1
2
3
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
44
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")
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
96
97
98
99 @NonNull
100 public CLIProcessor getCLIProcessor() {
101 return cliProcessor;
102 }
103
104
105
106
107
108
109 @Nullable
110 public ICommand getTargetCommand() {
111 return targetCommand;
112 }
113
114
115
116
117
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
131
132
133
134 @NonNull
135 List<String> getExtraArgs() {
136 return extraArgs;
137 }
138
139
140
141
142
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
154
155
156
157
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
186
187
188
189
190
191
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
201
202
203
204
205
206
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
227
228
229
230
231
232
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
249
250
251
252
253
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
266
267
268
269 @NonNull
270 public ExitStatus processCommand() {
271
272 Optional<ExitStatus> earlyExit = checkHelpAndVersion();
273 if (earlyExit.isPresent()) {
274 return earlyExit.get();
275 }
276
277
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
287 Optional<ExitStatus> validationResult = validateExtraArguments(cmdLine)
288 .or(() -> validateCalledCommands(cmdLine));
289 if (validationResult.isPresent()) {
290 return validationResult.get();
291 }
292
293
294 applyGlobalOptions(cmdLine);
295 return invokeCommand(cmdLine);
296 }
297
298
299
300
301
302
303
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
343
344
345
346
347
348
349
350
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
366
367
368
369
370
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
384
385
386
387 @Nullable
388 private static String buildHelpHeader() {
389
390 return null;
391 }
392
393
394
395
396
397
398
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
425
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
452
453
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
467 ICommand targetCommand = getTargetCommand();
468 if (targetCommand == null) {
469 builder.append(" <command>");
470 } else {
471 builder.append(getSubCommands(targetCommand));
472 }
473
474
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
489 builder.append(" [<options>]");
490
491
492 if (targetCommand != null) {
493
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
550
551
552
553
554
555
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
567 }
568 }
569 return DEFAULT_TERMINAL_WIDTH;
570 }
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
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
618 int lineEnd = lineStart + effectiveWidth;
619 int lastSpace = text.lastIndexOf(' ', lineEnd);
620
621 if (lastSpace <= lineStart) {
622
623 result.append(text, lineStart, lineEnd);
624 lineStart = lineEnd;
625 } else {
626 result.append(text, lineStart, lastSpace);
627 lineStart = lastSpace + 1;
628 }
629 firstLine = false;
630 }
631
632 return ObjectUtils.notNull(result.toString());
633 }
634
635
636
637
638
639
640
641 public void showHelp() {
642 PrintStream out = cliProcessor.getOutputStream();
643
644
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
662 formatter.printHelp(
663 buildHelpCliSyntax(),
664 buildHelpHeader(),
665 toOptions(),
666 "",
667 false);
668 } catch (IOException ex) {
669 throw new UncheckedIOException("Failed to write help output", ex);
670 }
671
672
673
674 writer.print(buildHelpFooter(terminalWidth));
675 writer.flush();
676 }
677 }
678 }