1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.cli.processor;
7   
8   import static dev.metaschema.cli.processor.ansi.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                 .a("   ")
435                 .bold()
436                 .format("%-" + commandColWidth + "s", command.getName())
437                 .boldOff()
438                 .a(' ')
439                 .a(wrappedDesc)
440                 .a(System.lineSeparator())
441                 .toString());
442       }
443       builder
444           .append(System.lineSeparator())
445           .append('\'')
446           .append(cliProcessor.getExec())
447           .append(" <command> --help' will show help on that specific command.")
448           .append(System.lineSeparator());
449       retval = builder.toString();
450       assert retval != null;
451     }
452     return retval;
453   }
454 
455   /**
456    * Get the CLI syntax.
457    *
458    * @return the CLI syntax to display in help output
459    */
460   private String buildHelpCliSyntax() {
461     StringBuilder builder = new StringBuilder(64);
462     builder.append(cliProcessor.getExec());
463 
464     List<ICommand> calledCommands = getCalledCommands();
465     if (!calledCommands.isEmpty()) {
466       builder.append(calledCommands.stream()
467           .map(ICommand::getName)
468           .collect(Collectors.joining(" ", " ", "")));
469     }
470 
471     // output calling commands
472     ICommand targetCommand = getTargetCommand();
473     if (targetCommand == null) {
474       builder.append(" <command>");
475     } else {
476       builder.append(getSubCommands(targetCommand));
477     }
478 
479     // output required options
480     getOptionsList().stream()
481         .filter(Option::isRequired)
482         .forEach(option -> {
483           builder
484               .append(' ')
485               .append(OptionUtils.toArgument(ObjectUtils.notNull(option)));
486           if (option.hasArg()) {
487             builder
488                 .append('=')
489                 .append(option.getArgName());
490           }
491         });
492 
493     // output non-required option placeholder
494     builder.append(" [<options>]");
495 
496     // output extra arguments
497     if (targetCommand != null) {
498       // handle extra arguments
499       builder.append(getExtraArguments(targetCommand));
500     }
501 
502     String retval = builder.toString();
503     assert retval != null;
504     return retval;
505   }
506 
507   @NonNull
508   private static CharSequence getSubCommands(@NonNull ICommand targetCommand) {
509     Collection<ICommand> subCommands = targetCommand.getSubCommands();
510 
511     StringBuilder builder = new StringBuilder();
512     if (!subCommands.isEmpty()) {
513       builder.append(' ');
514       if (!targetCommand.isSubCommandRequired()) {
515         builder.append('[');
516       }
517 
518       builder.append("<command>");
519 
520       if (!targetCommand.isSubCommandRequired()) {
521         builder.append(']');
522       }
523     }
524     return builder;
525   }
526 
527   @NonNull
528   private static CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
529     StringBuilder builder = new StringBuilder();
530     for (ExtraArgument argument : targetCommand.getExtraArguments()) {
531       builder.append(' ');
532       if (!argument.isRequired()) {
533         builder.append('[');
534       }
535 
536       builder.append('<')
537           .append(argument.getName())
538           .append('>');
539 
540       if (argument.getNumber() > 1) {
541         builder.append("...");
542       }
543 
544       if (!argument.isRequired()) {
545         builder.append(']');
546       }
547     }
548     return builder;
549   }
550 
551   private static final int DEFAULT_TERMINAL_WIDTH = 80;
552 
553   /**
554    * Get the terminal width from environment or use a default.
555    * <p>
556    * This method avoids native terminal detection which triggers Java 21+
557    * restricted method warnings. Instead, it uses the COLUMNS environment variable
558    * which is set by most shells.
559    *
560    * @return the terminal width in characters
561    */
562   private static int getTerminalWidth() {
563     String columns = System.getenv("COLUMNS");
564     if (columns != null) {
565       try {
566         int width = Integer.parseInt(columns);
567         if (width > 0) {
568           return width;
569         }
570       } catch (NumberFormatException e) {
571         // Ignore and use default
572       }
573     }
574     return DEFAULT_TERMINAL_WIDTH;
575   }
576 
577   /**
578    * Wrap text to fit within the specified width, with proper indentation for
579    * continuation lines.
580    *
581    * @param text
582    *          the text to wrap
583    * @param maxWidth
584    *          the maximum line width
585    * @param indent
586    *          the indentation string for continuation lines
587    * @return the wrapped text
588    * @throws IllegalArgumentException
589    *           if maxWidth is less than or equal to zero, or if the indent length
590    *           is greater than or equal to maxWidth
591    */
592   @NonNull
593   static String wrapText(@NonNull String text, int maxWidth, @NonNull String indent) {
594     if (maxWidth <= 0) {
595       throw new IllegalArgumentException("maxWidth must be positive, got: " + maxWidth);
596     }
597     if (indent.length() >= maxWidth) {
598       throw new IllegalArgumentException(
599           "indent length (" + indent.length() + ") must be less than maxWidth (" + maxWidth + ")");
600     }
601     if (text.length() <= maxWidth) {
602       return text;
603     }
604 
605     StringBuilder result = new StringBuilder(text.length() + 32);
606     int lineStart = 0;
607     boolean firstLine = true;
608     int effectiveWidth = maxWidth;
609 
610     while (lineStart < text.length()) {
611       if (!firstLine) {
612         result.append(System.lineSeparator()).append(indent);
613         effectiveWidth = maxWidth - indent.length();
614       }
615 
616       int remaining = text.length() - lineStart;
617       if (remaining <= effectiveWidth) {
618         result.append(text.substring(lineStart));
619         break;
620       }
621 
622       // Find last space within the width limit
623       int lineEnd = lineStart + effectiveWidth;
624       int lastSpace = text.lastIndexOf(' ', lineEnd);
625 
626       if (lastSpace <= lineStart) {
627         // No space found, force break at width
628         result.append(text, lineStart, lineEnd);
629         lineStart = lineEnd; // Continue from break point (no space to skip)
630       } else {
631         result.append(text, lineStart, lastSpace);
632         lineStart = lastSpace + 1; // Skip the space
633       }
634       firstLine = false;
635     }
636 
637     return ObjectUtils.notNull(result.toString());
638   }
639 
640   /**
641    * Output the help text to the console.
642    *
643    * @throws UncheckedIOException
644    *           if an error occurs while writing help output
645    */
646   public void showHelp() {
647     PrintStream out = cliProcessor.getOutputStream();
648     // Get terminal width from environment variable COLUMNS, or default to 80
649     // This avoids native terminal detection which triggers Java 21+ warnings
650     int terminalWidth = getTerminalWidth();
651 
652     try (PrintWriter writer = new PrintWriter(
653         AutoCloser.preventClose(out),
654         true,
655         StandardCharsets.UTF_8)) {
656       TextHelpAppendable appendable = new TextHelpAppendable(writer);
657       appendable.setMaxWidth(Math.max(terminalWidth, 50));
658 
659       HelpFormatter formatter = HelpFormatter.builder()
660           .setHelpAppendable(appendable)
661           .setOptionFormatBuilder(OptionFormatter.builder().setOptArgSeparator("="))
662           .setShowSince(false)
663           .get();
664 
665       try {
666         // Print main help (syntax, header, options) through the formatter
667         formatter.printHelp(
668             buildHelpCliSyntax(),
669             buildHelpHeader(),
670             toOptions(),
671             "", // Empty footer - we print it directly below
672             false);
673       } catch (IOException ex) {
674         throw new UncheckedIOException("Failed to write help output", ex);
675       }
676 
677       // Print footer directly to bypass TextHelpAppendable's text wrapping,
678       // which doesn't account for ANSI escape sequence lengths
679       writer.print(buildHelpFooter(terminalWidth));
680       writer.flush();
681     }
682   }
683 }