1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.cli.processor;
7   
8   import static org.jline.jansi.Ansi.ansi;
9   
10  import org.apache.commons.cli.CommandLine;
11  import org.apache.commons.cli.DefaultParser;
12  import org.apache.commons.cli.Option;
13  import org.apache.commons.cli.Options;
14  import org.apache.commons.cli.ParseException;
15  import org.apache.commons.cli.help.HelpFormatter;
16  import org.apache.commons.cli.help.OptionFormatter;
17  import org.apache.commons.cli.help.TextHelpAppendable;
18  
19  import java.io.IOException;
20  import java.io.PrintStream;
21  import java.io.PrintWriter;
22  import java.io.UncheckedIOException;
23  import java.nio.charset.StandardCharsets;
24  import java.util.Collection;
25  import java.util.LinkedList;
26  import java.util.List;
27  import java.util.Optional;
28  import java.util.concurrent.atomic.AtomicBoolean;
29  import java.util.stream.Collectors;
30  
31  import dev.metaschema.cli.processor.command.CommandExecutionException;
32  import dev.metaschema.cli.processor.command.ExtraArgument;
33  import dev.metaschema.cli.processor.command.ICommand;
34  import dev.metaschema.cli.processor.command.ICommandExecutor;
35  import dev.metaschema.core.util.AutoCloser;
36  import dev.metaschema.core.util.CollectionUtil;
37  import dev.metaschema.core.util.ObjectUtils;
38  import edu.umd.cs.findbugs.annotations.NonNull;
39  import edu.umd.cs.findbugs.annotations.Nullable;
40  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
41  
42  /**
43   * Records information about the command line options and called command
44   * hierarchy.
45   */
46  public class CallingContext {
47    @NonNull
48    private final CLIProcessor cliProcessor;
49    @NonNull
50    private final List<Option> options;
51    @NonNull
52    private final List<ICommand> calledCommands;
53    @Nullable
54    private final ICommand targetCommand;
55    @NonNull
56    private final List<String> extraArgs;
57  
58    @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields")
59    CallingContext(@NonNull CLIProcessor cliProcessor, @NonNull List<String> args) {
60      this.cliProcessor = cliProcessor;
61  
62      @SuppressWarnings("PMD.LooseCoupling") // needed to support getLast
63      LinkedList<ICommand> calledCommands = new LinkedList<>();
64      List<Option> options = new LinkedList<>(CLIProcessor.OPTIONS);
65      List<String> extraArgs = new LinkedList<>();
66  
67      AtomicBoolean endArgs = new AtomicBoolean();
68      args.forEach(arg -> {
69        if (endArgs.get() || arg.startsWith("-")) {
70          extraArgs.add(arg);
71        } else if ("--".equals(arg)) {
72          endArgs.set(true);
73        } else {
74          ICommand command = calledCommands.isEmpty()
75              ? cliProcessor.getTopLevelCommandsByName().get(arg)
76              : calledCommands.getLast().getSubCommandByName(arg);
77  
78          if (command == null) {
79            extraArgs.add(arg);
80            endArgs.set(true);
81          } else {
82            calledCommands.add(command);
83            options.addAll(command.gatherOptions());
84          }
85        }
86      });
87  
88      this.calledCommands = CollectionUtil.unmodifiableList(calledCommands);
89      this.targetCommand = calledCommands.peekLast();
90      this.options = CollectionUtil.unmodifiableList(options);
91      this.extraArgs = CollectionUtil.unmodifiableList(extraArgs);
92    }
93  
94    /**
95     * Get the command line processor instance that generated this calling context.
96     *
97     * @return the instance
98     */
99    @NonNull
100   public CLIProcessor getCLIProcessor() {
101     return cliProcessor;
102   }
103 
104   /**
105    * Get the command that was triggered by the CLI arguments.
106    *
107    * @return the command or {@code null} if no command was triggered
108    */
109   @Nullable
110   public ICommand getTargetCommand() {
111     return targetCommand;
112   }
113 
114   /**
115    * Get the options that are in scope for the current command context.
116    *
117    * @return the list of options
118    */
119   @NonNull
120   List<Option> getOptionsList() {
121     return options;
122   }
123 
124   @NonNull
125   List<ICommand> getCalledCommands() {
126     return calledCommands;
127   }
128 
129   /**
130    * Get any left over arguments that were not consumed by CLI options.
131    *
132    * @return the list of remaining arguments, which may be empty
133    */
134   @NonNull
135   List<String> getExtraArgs() {
136     return extraArgs;
137   }
138 
139   /**
140    * Get the collections of in scope options as an options group.
141    *
142    * @return the options group
143    */
144   Options toOptions() {
145     Options retval = new Options();
146     for (Option option : getOptionsList()) {
147       retval.addOption(option);
148     }
149     return retval;
150   }
151 
152   /**
153    * Check for --help and --version options before full parsing.
154    * <p>
155    * This is phase 1 of command processing.
156    *
157    * @return an exit status if help or version was requested, or empty to continue
158    */
159   @NonNull
160   protected Optional<ExitStatus> checkHelpAndVersion() {
161     Options phase1Options = new Options();
162     phase1Options.addOption(CLIProcessor.HELP_OPTION);
163     phase1Options.addOption(CLIProcessor.VERSION_OPTION);
164 
165     try {
166       CommandLine cmdLine = new DefaultParser()
167           .parse(phase1Options, getExtraArgs().toArray(new String[0]), true);
168 
169       if (cmdLine.hasOption(CLIProcessor.VERSION_OPTION)) {
170         cliProcessor.showVersion();
171         return ObjectUtils.notNull(Optional.of(ExitCode.OK.exit()));
172       }
173       if (cmdLine.hasOption(CLIProcessor.HELP_OPTION)) {
174         showHelp();
175         return ObjectUtils.notNull(Optional.of(ExitCode.OK.exit()));
176       }
177     } catch (ParseException ex) {
178       String msg = ex.getMessage();
179       return ObjectUtils.notNull(Optional.of(handleInvalidCommand(msg != null ? msg : "Invalid command")));
180     }
181     return ObjectUtils.notNull(Optional.empty());
182   }
183 
184   /**
185    * Parse all command line options.
186    * <p>
187    * This is phase 2 of command processing.
188    *
189    * @return the parsed command line
190    * @throws ParseException
191    *           if parsing fails
192    */
193   @NonNull
194   protected CommandLine parseOptions() throws ParseException {
195     return ObjectUtils.notNull(
196         new DefaultParser().parse(toOptions(), getExtraArgs().toArray(new String[0])));
197   }
198 
199   /**
200    * Validate extra arguments for the target command.
201    * <p>
202    * This is phase 3 of command processing.
203    *
204    * @param cmdLine
205    *          the parsed command line
206    * @return an exit status if validation failed, or empty to continue
207    */
208   @NonNull
209   protected Optional<ExitStatus> validateExtraArguments(@NonNull CommandLine cmdLine) {
210     ICommand target = getTargetCommand();
211     if (target == null) {
212       return ObjectUtils.notNull(Optional.empty());
213     }
214     try {
215       target.validateExtraArguments(this, cmdLine);
216       return ObjectUtils.notNull(Optional.empty());
217     } catch (InvalidArgumentException ex) {
218       return ObjectUtils.notNull(Optional.of(handleError(
219           ExitCode.INVALID_ARGUMENTS.exitMessage(ex.getLocalizedMessage()),
220           cmdLine,
221           true)));
222     }
223   }
224 
225   /**
226    * Validate options for all called commands in the chain.
227    * <p>
228    * This is phase 4 of command processing.
229    *
230    * @param cmdLine
231    *          the parsed command line
232    * @return an exit status if validation failed, or empty to continue
233    */
234   @NonNull
235   protected Optional<ExitStatus> validateCalledCommands(@NonNull CommandLine cmdLine) {
236     for (ICommand cmd : getCalledCommands()) {
237       try {
238         cmd.validateOptions(this, cmdLine);
239       } catch (InvalidArgumentException ex) {
240         String msg = ex.getMessage();
241         return ObjectUtils.notNull(Optional.of(handleInvalidCommand(msg != null ? msg : "Invalid argument")));
242       }
243     }
244     return ObjectUtils.notNull(Optional.empty());
245   }
246 
247   /**
248    * Apply global options like --no-color and --quiet.
249    * <p>
250    * This is phase 5 of command processing.
251    *
252    * @param cmdLine
253    *          the parsed command line
254    */
255   protected void applyGlobalOptions(@NonNull CommandLine cmdLine) {
256     if (cmdLine.hasOption(CLIProcessor.NO_COLOR_OPTION)) {
257       CLIProcessor.handleNoColor();
258     }
259     if (cmdLine.hasOption(CLIProcessor.QUIET_OPTION)) {
260       CLIProcessor.handleQuiet();
261     }
262   }
263 
264   /**
265    * Process the command identified by the CLI arguments.
266    *
267    * @return the result of processing the command
268    */
269   @NonNull
270   public ExitStatus processCommand() {
271     // Phase 1: Check help/version before full parsing
272     Optional<ExitStatus> earlyExit = checkHelpAndVersion();
273     if (earlyExit.isPresent()) {
274       return earlyExit.get();
275     }
276 
277     // Phase 2: Parse all options
278     CommandLine cmdLine;
279     try {
280       cmdLine = parseOptions();
281     } catch (ParseException ex) {
282       String msg = ex.getMessage();
283       return handleInvalidCommand(msg != null ? msg : "Parse error");
284     }
285 
286     // Phase 3-4: Validate arguments and options
287     Optional<ExitStatus> validationResult = validateExtraArguments(cmdLine)
288         .or(() -> validateCalledCommands(cmdLine));
289     if (validationResult.isPresent()) {
290       return validationResult.get();
291     }
292 
293     // Phase 5: Apply global options and execute
294     applyGlobalOptions(cmdLine);
295     return invokeCommand(cmdLine);
296   }
297 
298   /**
299    * Directly execute the logic associated with the command.
300    *
301    * @param cmdLine
302    *          the command line information
303    * @return the result of executing the command
304    */
305   @NonNull
306   private ExitStatus invokeCommand(@NonNull CommandLine cmdLine) {
307     ExitStatus retval;
308     try {
309       ICommand targetCommand = getTargetCommand();
310       if (targetCommand == null) {
311         retval = ExitCode.INVALID_COMMAND.exit();
312       } else {
313         ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine);
314         try {
315           executor.execute();
316           retval = ExitCode.OK.exit();
317         } catch (CommandExecutionException ex) {
318           retval = ex.toExitStatus();
319         } catch (RuntimeException ex) {
320           retval = ExitCode.RUNTIME_ERROR
321               .exitMessage("Unexpected error occurred: " + ex.getLocalizedMessage())
322               .withThrowable(ex);
323         }
324       }
325     } catch (RuntimeException ex) {
326       retval = ExitCode.RUNTIME_ERROR
327           .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage()))
328           .withThrowable(ex);
329     }
330 
331     if (!ExitCode.OK.equals(retval.getExitCode())) {
332       retval.generateMessage(cmdLine.hasOption(CLIProcessor.SHOW_STACK_TRACE_OPTION));
333 
334       if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) {
335         showHelp();
336       }
337     }
338     return retval;
339   }
340 
341   /**
342    * Handle an error that occurred while executing the command.
343    *
344    * @param exitStatus
345    *          the execution result
346    * @param cmdLine
347    *          the command line information
348    * @param showHelp
349    *          if {@code true} show the help information
350    * @return the resulting exit status
351    */
352   @NonNull
353   public ExitStatus handleError(
354       @NonNull ExitStatus exitStatus,
355       @NonNull CommandLine cmdLine,
356       boolean showHelp) {
357     exitStatus.generateMessage(cmdLine.hasOption(CLIProcessor.SHOW_STACK_TRACE_OPTION));
358     if (showHelp) {
359       showHelp();
360     }
361     return exitStatus;
362   }
363 
364   /**
365    * Generate the help message and exit status for an invalid command using the
366    * provided message.
367    *
368    * @param message
369    *          the error message
370    * @return the resulting exit status
371    */
372   @NonNull
373   public ExitStatus handleInvalidCommand(
374       @NonNull String message) {
375     showHelp();
376 
377     ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message);
378     retval.generateMessage(false);
379     return retval;
380   }
381 
382   /**
383    * Callback for providing a help header.
384    *
385    * @return the header or {@code null}
386    */
387   @Nullable
388   private static String buildHelpHeader() {
389     // TODO: build a suitable header
390     return null;
391   }
392 
393   /**
394    * Callback for providing a help footer.
395    *
396    * @param terminalWidth
397    *          the terminal width for text wrapping
398    * @return the footer or an empty string if no subcommands
399    */
400   @NonNull
401   private String buildHelpFooter(int terminalWidth) {
402     ICommand targetCommand = getTargetCommand();
403     Collection<ICommand> subCommands;
404     if (targetCommand == null) {
405       subCommands = cliProcessor.getTopLevelCommands();
406     } else {
407       subCommands = targetCommand.getSubCommands();
408     }
409 
410     String retval;
411     if (subCommands.isEmpty()) {
412       retval = "";
413     } else {
414       StringBuilder builder = new StringBuilder(128);
415       builder
416           .append(System.lineSeparator())
417           .append("The following are available commands:")
418           .append(System.lineSeparator());
419 
420       int commandColWidth = subCommands.stream()
421           .mapToInt(command -> command.getName().length())
422           .max().orElse(0);
423 
424       // Calculate description column width: terminal - 3 (leading spaces) -
425       // commandCol - 1 (space)
426       int prefixWidth = 3 + commandColWidth + 1;
427       int descWidth = Math.max(terminalWidth - prefixWidth, 20);
428       String continuationIndent = " ".repeat(prefixWidth);
429 
430       for (ICommand command : subCommands) {
431         String wrappedDesc = wrapText(command.getDescription(), descWidth, continuationIndent);
432         builder.append(
433             ansi()
434                 .render(String.format("   @|bold %-" + commandColWidth + "s|@ %s%n",
435                     command.getName(),
436                     wrappedDesc)));
437       }
438       builder
439           .append(System.lineSeparator())
440           .append('\'')
441           .append(cliProcessor.getExec())
442           .append(" <command> --help' will show help on that specific command.")
443           .append(System.lineSeparator());
444       retval = builder.toString();
445       assert retval != null;
446     }
447     return retval;
448   }
449 
450   /**
451    * Get the CLI syntax.
452    *
453    * @return the CLI syntax to display in help output
454    */
455   private String buildHelpCliSyntax() {
456     StringBuilder builder = new StringBuilder(64);
457     builder.append(cliProcessor.getExec());
458 
459     List<ICommand> calledCommands = getCalledCommands();
460     if (!calledCommands.isEmpty()) {
461       builder.append(calledCommands.stream()
462           .map(ICommand::getName)
463           .collect(Collectors.joining(" ", " ", "")));
464     }
465 
466     // output calling commands
467     ICommand targetCommand = getTargetCommand();
468     if (targetCommand == null) {
469       builder.append(" <command>");
470     } else {
471       builder.append(getSubCommands(targetCommand));
472     }
473 
474     // output required options
475     getOptionsList().stream()
476         .filter(Option::isRequired)
477         .forEach(option -> {
478           builder
479               .append(' ')
480               .append(OptionUtils.toArgument(ObjectUtils.notNull(option)));
481           if (option.hasArg()) {
482             builder
483                 .append('=')
484                 .append(option.getArgName());
485           }
486         });
487 
488     // output non-required option placeholder
489     builder.append(" [<options>]");
490 
491     // output extra arguments
492     if (targetCommand != null) {
493       // handle extra arguments
494       builder.append(getExtraArguments(targetCommand));
495     }
496 
497     String retval = builder.toString();
498     assert retval != null;
499     return retval;
500   }
501 
502   @NonNull
503   private static CharSequence getSubCommands(@NonNull ICommand targetCommand) {
504     Collection<ICommand> subCommands = targetCommand.getSubCommands();
505 
506     StringBuilder builder = new StringBuilder();
507     if (!subCommands.isEmpty()) {
508       builder.append(' ');
509       if (!targetCommand.isSubCommandRequired()) {
510         builder.append('[');
511       }
512 
513       builder.append("<command>");
514 
515       if (!targetCommand.isSubCommandRequired()) {
516         builder.append(']');
517       }
518     }
519     return builder;
520   }
521 
522   @NonNull
523   private static CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
524     StringBuilder builder = new StringBuilder();
525     for (ExtraArgument argument : targetCommand.getExtraArguments()) {
526       builder.append(' ');
527       if (!argument.isRequired()) {
528         builder.append('[');
529       }
530 
531       builder.append('<')
532           .append(argument.getName())
533           .append('>');
534 
535       if (argument.getNumber() > 1) {
536         builder.append("...");
537       }
538 
539       if (!argument.isRequired()) {
540         builder.append(']');
541       }
542     }
543     return builder;
544   }
545 
546   private static final int DEFAULT_TERMINAL_WIDTH = 80;
547 
548   /**
549    * Get the terminal width from environment or use a default.
550    * <p>
551    * This method avoids native terminal detection which triggers Java 21+
552    * restricted method warnings. Instead, it uses the COLUMNS environment variable
553    * which is set by most shells.
554    *
555    * @return the terminal width in characters
556    */
557   private static int getTerminalWidth() {
558     String columns = System.getenv("COLUMNS");
559     if (columns != null) {
560       try {
561         int width = Integer.parseInt(columns);
562         if (width > 0) {
563           return width;
564         }
565       } catch (NumberFormatException e) {
566         // Ignore and use default
567       }
568     }
569     return DEFAULT_TERMINAL_WIDTH;
570   }
571 
572   /**
573    * Wrap text to fit within the specified width, with proper indentation for
574    * continuation lines.
575    *
576    * @param text
577    *          the text to wrap
578    * @param maxWidth
579    *          the maximum line width
580    * @param indent
581    *          the indentation string for continuation lines
582    * @return the wrapped text
583    * @throws IllegalArgumentException
584    *           if maxWidth is less than or equal to zero, or if the indent length
585    *           is greater than or equal to maxWidth
586    */
587   @NonNull
588   static String wrapText(@NonNull String text, int maxWidth, @NonNull String indent) {
589     if (maxWidth <= 0) {
590       throw new IllegalArgumentException("maxWidth must be positive, got: " + maxWidth);
591     }
592     if (indent.length() >= maxWidth) {
593       throw new IllegalArgumentException(
594           "indent length (" + indent.length() + ") must be less than maxWidth (" + maxWidth + ")");
595     }
596     if (text.length() <= maxWidth) {
597       return text;
598     }
599 
600     StringBuilder result = new StringBuilder(text.length() + 32);
601     int lineStart = 0;
602     boolean firstLine = true;
603     int effectiveWidth = maxWidth;
604 
605     while (lineStart < text.length()) {
606       if (!firstLine) {
607         result.append(System.lineSeparator()).append(indent);
608         effectiveWidth = maxWidth - indent.length();
609       }
610 
611       int remaining = text.length() - lineStart;
612       if (remaining <= effectiveWidth) {
613         result.append(text.substring(lineStart));
614         break;
615       }
616 
617       // Find last space within the width limit
618       int lineEnd = lineStart + effectiveWidth;
619       int lastSpace = text.lastIndexOf(' ', lineEnd);
620 
621       if (lastSpace <= lineStart) {
622         // No space found, force break at width
623         result.append(text, lineStart, lineEnd);
624         lineStart = lineEnd; // Continue from break point (no space to skip)
625       } else {
626         result.append(text, lineStart, lastSpace);
627         lineStart = lastSpace + 1; // Skip the space
628       }
629       firstLine = false;
630     }
631 
632     return ObjectUtils.notNull(result.toString());
633   }
634 
635   /**
636    * Output the help text to the console.
637    *
638    * @throws UncheckedIOException
639    *           if an error occurs while writing help output
640    */
641   public void showHelp() {
642     PrintStream out = cliProcessor.getOutputStream();
643     // Get terminal width from environment variable COLUMNS, or default to 80
644     // This avoids native terminal detection which triggers Java 21+ warnings
645     int terminalWidth = getTerminalWidth();
646 
647     try (PrintWriter writer = new PrintWriter(
648         AutoCloser.preventClose(out),
649         true,
650         StandardCharsets.UTF_8)) {
651       TextHelpAppendable appendable = new TextHelpAppendable(writer);
652       appendable.setMaxWidth(Math.max(terminalWidth, 50));
653 
654       HelpFormatter formatter = HelpFormatter.builder()
655           .setHelpAppendable(appendable)
656           .setOptionFormatBuilder(OptionFormatter.builder().setOptArgSeparator("="))
657           .setShowSince(false)
658           .get();
659 
660       try {
661         // Print main help (syntax, header, options) through the formatter
662         formatter.printHelp(
663             buildHelpCliSyntax(),
664             buildHelpHeader(),
665             toOptions(),
666             "", // Empty footer - we print it directly below
667             false);
668       } catch (IOException ex) {
669         throw new UncheckedIOException("Failed to write help output", ex);
670       }
671 
672       // Print footer directly to bypass TextHelpAppendable's text wrapping,
673       // which doesn't account for ANSI escape sequence lengths
674       writer.print(buildHelpFooter(terminalWidth));
675       writer.flush();
676     }
677   }
678 }