001/* 002 * SPDX-FileCopyrightText: none 003 * SPDX-License-Identifier: CC0-1.0 004 */ 005 006package dev.metaschema.cli.processor; 007 008import static dev.metaschema.cli.processor.ansi.Ansi.ansi; 009 010import org.apache.commons.cli.CommandLine; 011import org.apache.commons.cli.DefaultParser; 012import org.apache.commons.cli.Option; 013import org.apache.commons.cli.Options; 014import org.apache.commons.cli.ParseException; 015import org.apache.commons.cli.help.HelpFormatter; 016import org.apache.commons.cli.help.OptionFormatter; 017import org.apache.commons.cli.help.TextHelpAppendable; 018 019import java.io.IOException; 020import java.io.PrintStream; 021import java.io.PrintWriter; 022import java.io.UncheckedIOException; 023import java.nio.charset.StandardCharsets; 024import java.util.Collection; 025import java.util.LinkedList; 026import java.util.List; 027import java.util.Optional; 028import java.util.concurrent.atomic.AtomicBoolean; 029import java.util.stream.Collectors; 030 031import dev.metaschema.cli.processor.command.CommandExecutionException; 032import dev.metaschema.cli.processor.command.ExtraArgument; 033import dev.metaschema.cli.processor.command.ICommand; 034import dev.metaschema.cli.processor.command.ICommandExecutor; 035import dev.metaschema.core.util.AutoCloser; 036import dev.metaschema.core.util.CollectionUtil; 037import dev.metaschema.core.util.ObjectUtils; 038import edu.umd.cs.findbugs.annotations.NonNull; 039import edu.umd.cs.findbugs.annotations.Nullable; 040import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 041 042/** 043 * Records information about the command line options and called command 044 * hierarchy. 045 */ 046public class CallingContext { 047 @NonNull 048 private final CLIProcessor cliProcessor; 049 @NonNull 050 private final List<Option> options; 051 @NonNull 052 private final List<ICommand> calledCommands; 053 @Nullable 054 private final ICommand targetCommand; 055 @NonNull 056 private final List<String> extraArgs; 057 058 @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields") 059 CallingContext(@NonNull CLIProcessor cliProcessor, @NonNull List<String> args) { 060 this.cliProcessor = cliProcessor; 061 062 @SuppressWarnings("PMD.LooseCoupling") // needed to support getLast 063 LinkedList<ICommand> calledCommands = new LinkedList<>(); 064 List<Option> options = new LinkedList<>(CLIProcessor.OPTIONS); 065 List<String> extraArgs = new LinkedList<>(); 066 067 AtomicBoolean endArgs = new AtomicBoolean(); 068 args.forEach(arg -> { 069 if (endArgs.get() || arg.startsWith("-")) { 070 extraArgs.add(arg); 071 } else if ("--".equals(arg)) { 072 endArgs.set(true); 073 } else { 074 ICommand command = calledCommands.isEmpty() 075 ? cliProcessor.getTopLevelCommandsByName().get(arg) 076 : calledCommands.getLast().getSubCommandByName(arg); 077 078 if (command == null) { 079 extraArgs.add(arg); 080 endArgs.set(true); 081 } else { 082 calledCommands.add(command); 083 options.addAll(command.gatherOptions()); 084 } 085 } 086 }); 087 088 this.calledCommands = CollectionUtil.unmodifiableList(calledCommands); 089 this.targetCommand = calledCommands.peekLast(); 090 this.options = CollectionUtil.unmodifiableList(options); 091 this.extraArgs = CollectionUtil.unmodifiableList(extraArgs); 092 } 093 094 /** 095 * Get the command line processor instance that generated this calling context. 096 * 097 * @return the instance 098 */ 099 @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}