001/* 002 * SPDX-FileCopyrightText: none 003 * SPDX-License-Identifier: CC0-1.0 004 */ 005 006package dev.metaschema.cli.processor.command; 007 008import org.apache.commons.cli.CommandLine; 009import org.apache.commons.cli.Option; 010 011import java.io.File; 012import java.io.IOException; 013import java.io.OutputStreamWriter; 014import java.io.PrintWriter; 015import java.io.Writer; 016import java.nio.charset.StandardCharsets; 017import java.nio.file.Files; 018import java.nio.file.Path; 019import java.util.Collection; 020import java.util.List; 021import java.util.Locale; 022 023import dev.metaschema.cli.processor.CLIProcessor; 024import dev.metaschema.cli.processor.CallingContext; 025import dev.metaschema.cli.processor.ExitCode; 026import dev.metaschema.cli.processor.completion.CompletionScriptGenerator; 027import dev.metaschema.core.util.ObjectUtils; 028import edu.umd.cs.findbugs.annotations.NonNull; 029 030/** 031 * A command that generates shell completion scripts for Bash and Zsh. 032 * <p> 033 * This command introspects all registered commands and generates a completion 034 * script that provides intelligent tab-completion for the CLI tool. 035 */ 036public class ShellCompletionCommand 037 extends AbstractTerminalCommand { 038 039 @NonNull 040 private static final String COMMAND = "shell-completion"; 041 042 @NonNull 043 private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of( 044 ExtraArgument.newInstance("shell", true))); 045 046 @NonNull 047 private static final Option TO_OPTION = ObjectUtils.notNull( 048 Option.builder() 049 .longOpt("to") 050 .hasArg() 051 .argName("FILE") 052 .type(File.class) 053 .desc("write completion script to this file instead of stdout") 054 .get()); 055 056 /** 057 * Supported shell types. 058 */ 059 public enum Shell { 060 /** Bash shell. */ 061 BASH("bash"), 062 /** Zsh shell. */ 063 ZSH("zsh"); 064 065 @NonNull 066 private final String name; 067 068 Shell(@NonNull String name) { 069 this.name = name; 070 } 071 072 /** 073 * Get the shell name. 074 * 075 * @return the name 076 */ 077 @NonNull 078 public String getName() { 079 return name; 080 } 081 082 /** 083 * Parse a shell type from a string. 084 * 085 * @param value 086 * the string value to parse 087 * @return the shell type 088 * @throws IllegalArgumentException 089 * if the value is not a recognized shell type 090 */ 091 @NonNull 092 public static Shell fromString(@NonNull String value) { 093 String normalized = value.toLowerCase(Locale.ROOT); 094 for (Shell shell : values()) { 095 if (shell.name.equals(normalized)) { 096 return shell; 097 } 098 } 099 throw new IllegalArgumentException( 100 "Unknown shell: " + value + ". Supported shells: bash, zsh"); 101 } 102 } 103 104 @Override 105 public String getName() { 106 return COMMAND; 107 } 108 109 @Override 110 public String getDescription() { 111 return "Generate shell completion script for bash or zsh"; 112 } 113 114 @SuppressWarnings("null") 115 @Override 116 public Collection<? extends Option> gatherOptions() { 117 return List.of(TO_OPTION); 118 } 119 120 @Override 121 public List<ExtraArgument> getExtraArguments() { 122 return EXTRA_ARGUMENTS; 123 } 124 125 @Override 126 public ICommandExecutor newExecutor(CallingContext callingContext, CommandLine cmdLine) { 127 return ICommandExecutor.using(callingContext, cmdLine, this::executeCommand); 128 } 129 130 /** 131 * Execute the shell completion generation. 132 * 133 * @param callingContext 134 * the calling context 135 * @param cmdLine 136 * the parsed command line 137 * @throws CommandExecutionException 138 * if an error occurs during execution 139 */ 140 protected void executeCommand( 141 @NonNull CallingContext callingContext, 142 @NonNull CommandLine cmdLine) throws CommandExecutionException { 143 144 List<String> extraArgs = cmdLine.getArgList(); 145 if (extraArgs.isEmpty()) { 146 throw new CommandExecutionException( 147 ExitCode.INVALID_ARGUMENTS, 148 "Shell type is required. Supported shells: bash, zsh"); 149 } 150 151 Shell shell; 152 try { 153 shell = Shell.fromString(ObjectUtils.notNull(extraArgs.get(0))); 154 } catch (IllegalArgumentException ex) { 155 throw new CommandExecutionException(ExitCode.INVALID_ARGUMENTS, ex.getMessage()); 156 } 157 158 CLIProcessor processor = callingContext.getCLIProcessor(); 159 CompletionScriptGenerator generator = new CompletionScriptGenerator( 160 processor.getExec(), 161 processor.getTopLevelCommands()); 162 163 String script = shell == Shell.BASH 164 ? generator.generateBashCompletion() 165 : generator.generateZshCompletion(); 166 167 String outputFile = cmdLine.getOptionValue(TO_OPTION); 168 if (outputFile != null) { 169 writeToFile(outputFile, script); 170 } else { 171 writeToStdout(script); 172 } 173 } 174 175 private static void writeToFile(@NonNull String outputFile, @NonNull String script) 176 throws CommandExecutionException { 177 Path path = resolveAgainstCWD(ObjectUtils.notNull(Path.of(outputFile))); 178 try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { 179 writer.write(script); 180 } catch (IOException ex) { 181 throw new CommandExecutionException( 182 ExitCode.IO_ERROR, 183 "Failed to write completion script to: " + path, 184 ex); 185 } 186 } 187 188 private static void writeToStdout(@NonNull String script) { 189 PrintWriter writer = new PrintWriter( 190 new OutputStreamWriter(System.out, StandardCharsets.UTF_8), true); 191 writer.print(script); 192 writer.flush(); 193 } 194}