1
2
3
4
5
6 package dev.metaschema.cli.processor.completion;
7
8 import org.apache.commons.cli.Option;
9
10 import java.util.Collection;
11 import java.util.List;
12 import java.util.stream.Collectors;
13
14 import dev.metaschema.cli.processor.command.ExtraArgument;
15 import dev.metaschema.cli.processor.command.ICommand;
16 import dev.metaschema.core.util.ObjectUtils;
17 import edu.umd.cs.findbugs.annotations.NonNull;
18
19
20
21
22
23
24
25
26 public class CompletionScriptGenerator {
27
28
29
30
31 public enum Shell {
32
33 BASH,
34
35 ZSH
36 }
37
38 @NonNull
39 private final String programName;
40 @NonNull
41 private final List<ICommand> commands;
42
43
44
45
46
47
48
49
50
51 public CompletionScriptGenerator(
52 @NonNull String programName,
53 @NonNull List<ICommand> commands) {
54 this.programName = programName;
55 this.commands = commands;
56 }
57
58
59
60
61
62
63 @NonNull
64 public String getProgramName() {
65 return programName;
66 }
67
68
69
70
71
72
73 @NonNull
74 public List<ICommand> getCommands() {
75 return commands;
76 }
77
78
79
80
81
82
83 @NonNull
84 public String generateBashCompletion() {
85 StringBuilder sb = new StringBuilder();
86 String funcName = "_" + sanitizeFunctionName(programName);
87
88
89 sb.append("# Bash completion for ").append(programName).append("\n");
90 sb.append("# Generated by metaschema cli-processor\n\n");
91
92
93 sb.append("# Provide fallback if _init_completion is not available\n");
94 sb.append("if ! type _init_completion &>/dev/null; then\n");
95 sb.append(" _init_completion() {\n");
96 sb.append(" COMPREPLY=()\n");
97 sb.append(" cur=\"${COMP_WORDS[COMP_CWORD]}\"\n");
98 sb.append(" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n");
99 sb.append(" words=(\"${COMP_WORDS[@]}\")\n");
100 sb.append(" cword=$COMP_CWORD\n");
101 sb.append(" }\n");
102 sb.append("fi\n\n");
103
104
105 sb.append("# Provide fallback if _filedir is not available\n");
106 sb.append("if ! type _filedir &>/dev/null; then\n");
107 sb.append(" _filedir() {\n");
108 sb.append(" COMPREPLY=($(compgen -f -- \"$cur\"))\n");
109 sb.append(" }\n");
110 sb.append("fi\n\n");
111
112
113 sb.append(funcName).append("() {\n");
114 sb.append(" local cur prev words cword\n");
115 sb.append(" _init_completion || return\n\n");
116
117
118 String commandNames = commands.stream()
119 .map(ICommand::getName)
120 .collect(Collectors.joining(" "));
121 sb.append(" local commands=\"").append(commandNames).append("\"\n\n");
122
123
124 sb.append(" case \"${words[1]}\" in\n");
125 for (ICommand cmd : commands) {
126 generateBashCommandCase(sb, cmd, 2);
127 }
128 sb.append(" *)\n");
129 sb.append(" COMPREPLY=($(compgen -W \"$commands\" -- \"$cur\"))\n");
130 sb.append(" ;;\n");
131 sb.append(" esac\n");
132 sb.append("}\n\n");
133
134
135 sb.append("complete -F ").append(funcName).append(" ").append(programName).append("\n");
136
137 return ObjectUtils.notNull(sb.toString());
138 }
139
140
141
142
143
144
145 @NonNull
146 public String generateZshCompletion() {
147 StringBuilder sb = new StringBuilder();
148 String funcName = "_" + sanitizeFunctionName(programName);
149
150
151 sb.append("#compdef ").append(programName).append("\n\n");
152 sb.append("# Zsh completion for ").append(programName).append("\n");
153 sb.append("# Generated by metaschema cli-processor\n\n");
154
155
156 sb.append(funcName).append("() {\n");
157 sb.append(" local -a commands\n");
158 sb.append(" commands=(\n");
159 for (ICommand cmd : commands) {
160 String escaped = cmd.getDescription().replace("'", "'\\''");
161 sb.append(" '").append(cmd.getName()).append(":").append(escaped).append("'\n");
162 }
163 sb.append(" )\n\n");
164
165 sb.append(" _arguments -C \\\n");
166 sb.append(" '1:command:->command' \\\n");
167 sb.append(" '*::arg:->args'\n\n");
168
169 sb.append(" case $state in\n");
170 sb.append(" command)\n");
171 sb.append(" _describe 'command' commands\n");
172 sb.append(" ;;\n");
173 sb.append(" args)\n");
174 sb.append(" case $words[1] in\n");
175 for (ICommand cmd : commands) {
176 generateZshCommandCase(sb, cmd);
177 }
178 sb.append(" esac\n");
179 sb.append(" ;;\n");
180 sb.append(" esac\n");
181 sb.append("}\n\n");
182
183 sb.append(funcName).append(" \"$@\"\n");
184
185 return ObjectUtils.notNull(sb.toString());
186 }
187
188 private void generateBashCommandCase(StringBuilder sb, ICommand cmd, int depth) {
189 String indent = " ".repeat(Math.max(1, depth - 1));
190 sb.append(indent).append(cmd.getName()).append(")\n");
191
192 Collection<? extends Option> options = cmd.gatherOptions();
193 List<ExtraArgument> extraArgs = cmd.getExtraArguments();
194 Collection<ICommand> subCommands = cmd.getSubCommands();
195
196
197 if (!subCommands.isEmpty()) {
198 String subCmdNames = subCommands.stream()
199 .map(ICommand::getName)
200 .collect(Collectors.joining(" "));
201
202 sb.append(indent).append(" if [[ ${#words[@]} -eq ").append(depth).append(" ]]; then\n");
203 sb.append(indent).append(" COMPREPLY=($(compgen -W \"").append(subCmdNames).append("\" -- \"$cur\"))\n");
204 sb.append(indent).append(" else\n");
205 sb.append(indent).append(" case \"${words[").append(depth).append("]}\" in\n");
206 for (ICommand subCmd : subCommands) {
207 generateBashCommandCase(sb, subCmd, depth + 1);
208 }
209 sb.append(indent).append(" esac\n");
210 sb.append(indent).append(" fi\n");
211 } else {
212
213 String optionNames = options.stream()
214 .map(CompletionScriptGenerator::getBashOptionName)
215 .collect(Collectors.joining(" "));
216
217 sb.append(indent).append(" if [[ \"$cur\" == -* ]]; then\n");
218 sb.append(indent).append(" COMPREPLY=($(compgen -W \"").append(optionNames).append("\" -- \"$cur\"))\n");
219 sb.append(indent).append(" else\n");
220
221
222 boolean hasOptionCompletion = false;
223 for (Option opt : options) {
224 if (opt.hasArg()) {
225 String completion = getCompletionForOption(opt, Shell.BASH);
226 if (!completion.isEmpty()) {
227 if (!hasOptionCompletion) {
228 sb.append(indent).append(" case \"$prev\" in\n");
229 hasOptionCompletion = true;
230 }
231 String optNames = getBashOptionName(opt);
232 sb.append(indent).append(" ").append(optNames.replace(" ", "|")).append(")\n");
233 sb.append(indent).append(" COMPREPLY=($(").append(completion).append(" -- \"$cur\"))\n");
234 sb.append(indent).append(" ;;\n");
235 }
236 }
237 }
238 if (hasOptionCompletion) {
239 sb.append(indent).append(" *)\n");
240 }
241
242
243 String extraArgCompletion = getDefaultExtraArgumentCompletion(extraArgs, Shell.BASH);
244 if (!extraArgCompletion.isEmpty()) {
245 sb.append(indent).append(" ").append(extraArgCompletion).append("\n");
246 }
247
248 if (hasOptionCompletion) {
249 sb.append(indent).append(" ;;\n");
250 sb.append(indent).append(" esac\n");
251 }
252 sb.append(indent).append(" fi\n");
253 }
254 sb.append(indent).append(" ;;\n");
255 }
256
257 private static void generateZshCommandCase(StringBuilder sb, ICommand cmd) {
258 sb.append(" ").append(cmd.getName()).append(")\n");
259
260 Collection<? extends Option> options = cmd.gatherOptions();
261 List<ExtraArgument> extraArgs = cmd.getExtraArguments();
262
263 sb.append(" _arguments \\\n");
264
265
266 for (Option opt : options) {
267 String optSpec = getZshOptionSpec(opt);
268 sb.append(" ").append(optSpec).append(" \\\n");
269 }
270
271
272 int argNum = 1;
273 for (ExtraArgument arg : extraArgs) {
274 String completion = getCompletionForExtraArgument(arg, Shell.ZSH);
275 String required = arg.isRequired() ? "" : "::";
276 if (completion.isEmpty()) {
277 completion = " ";
278 }
279 sb.append(" '").append(required.isEmpty() ? argNum : "").append(required)
280 .append(arg.getName()).append(":").append(completion).append("' \\\n");
281 argNum++;
282 }
283
284
285 int lastBackslash = sb.lastIndexOf(" \\");
286 if (lastBackslash > 0) {
287 sb.delete(lastBackslash, lastBackslash + 2);
288 }
289 sb.append("\n");
290
291 sb.append(" ;;\n");
292 }
293
294 @NonNull
295 private static String getBashOptionName(Option opt) {
296 StringBuilder sb = new StringBuilder();
297 if (opt.getOpt() != null) {
298 sb.append("-").append(opt.getOpt());
299 }
300 if (opt.getLongOpt() != null) {
301 if (sb.length() > 0) {
302 sb.append(" ");
303 }
304 sb.append("--").append(opt.getLongOpt());
305 }
306 return ObjectUtils.notNull(sb.toString());
307 }
308
309 @NonNull
310 private static String getZshOptionSpec(Option opt) {
311 StringBuilder sb = new StringBuilder("'");
312 if (opt.getLongOpt() != null) {
313 sb.append("--").append(opt.getLongOpt());
314 } else if (opt.getOpt() != null) {
315 sb.append("-").append(opt.getOpt());
316 }
317
318 String desc = opt.getDescription();
319 if (desc != null) {
320 desc = desc.replace("'", "'\\''");
321 sb.append("[").append(desc).append("]");
322 }
323
324 if (opt.hasArg()) {
325 String argName = opt.getArgName();
326 if (argName == null) {
327 argName = "arg";
328 }
329 String completion = getCompletionForOption(opt, Shell.ZSH);
330 sb.append(":").append(argName).append(":").append(completion);
331 }
332
333 sb.append("'");
334 return ObjectUtils.notNull(sb.toString());
335 }
336
337 @NonNull
338 private static String getCompletionForOption(Option opt, Shell shell) {
339 Object typeObj = opt.getType();
340 Class<?> type = typeObj instanceof Class ? (Class<?>) typeObj : null;
341 ICompletionType completion = CompletionTypeRegistry.lookup(type);
342 if (completion != null) {
343 return shell == Shell.BASH ? completion.getBashCompletion() : completion.getZshCompletion();
344 }
345 return "";
346 }
347
348 @NonNull
349 private static String getCompletionForExtraArgument(ExtraArgument arg, Shell shell) {
350 Class<?> type = arg.getType();
351 ICompletionType completion = CompletionTypeRegistry.lookup(type);
352 if (completion != null) {
353 return shell == Shell.BASH ? completion.getBashCompletion() : completion.getZshCompletion();
354 }
355 return "";
356 }
357
358 @NonNull
359 private static String getDefaultExtraArgumentCompletion(List<ExtraArgument> args, Shell shell) {
360
361 if (!args.isEmpty()) {
362 String completion = getCompletionForExtraArgument(args.get(0), shell);
363 if (!completion.isEmpty()) {
364 return completion;
365 }
366 }
367
368 if (shell == Shell.BASH) {
369 return "_filedir";
370 }
371 return "_files";
372 }
373
374 @NonNull
375 private static String sanitizeFunctionName(String name) {
376 return ObjectUtils.notNull(name.replaceAll("[^a-zA-Z0-9_]", "_"));
377 }
378 }