001/* 002 * SPDX-FileCopyrightText: none 003 * SPDX-License-Identifier: CC0-1.0 004 */ 005 006package dev.metaschema.cli.processor; 007 008import static org.jline.jansi.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 .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}