1
2
3
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
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 .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
457
458
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
472 ICommand targetCommand = getTargetCommand();
473 if (targetCommand == null) {
474 builder.append(" <command>");
475 } else {
476 builder.append(getSubCommands(targetCommand));
477 }
478
479
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
494 builder.append(" [<options>]");
495
496
497 if (targetCommand != null) {
498
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
555
556
557
558
559
560
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
572 }
573 }
574 return DEFAULT_TERMINAL_WIDTH;
575 }
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
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
623 int lineEnd = lineStart + effectiveWidth;
624 int lastSpace = text.lastIndexOf(' ', lineEnd);
625
626 if (lastSpace <= lineStart) {
627
628 result.append(text, lineStart, lineEnd);
629 lineStart = lineEnd;
630 } else {
631 result.append(text, lineStart, lastSpace);
632 lineStart = lastSpace + 1;
633 }
634 firstLine = false;
635 }
636
637 return ObjectUtils.notNull(result.toString());
638 }
639
640
641
642
643
644
645
646 public void showHelp() {
647 PrintStream out = cliProcessor.getOutputStream();
648
649
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
667 formatter.printHelp(
668 buildHelpCliSyntax(),
669 buildHelpHeader(),
670 toOptions(),
671 "",
672 false);
673 } catch (IOException ex) {
674 throw new UncheckedIOException("Failed to write help output", ex);
675 }
676
677
678
679 writer.print(buildHelpFooter(terminalWidth));
680 writer.flush();
681 }
682 }
683 }