UNPKG

12.5 kBJavaScriptView Raw
1import * as ejs from 'ejs';
2import * as util from 'node:util';
3const argTemplate = ' "%s")\n %s\n ;;\n';
4export default class ZshCompWithSpaces {
5 config;
6 _coTopics;
7 commands;
8 topics;
9 constructor(config) {
10 this.config = config;
11 this.topics = this.getTopics();
12 this.commands = this.getCommands();
13 }
14 generate() {
15 const firstArgs = [];
16 for (const t of this.topics) {
17 if (!t.name.includes(':'))
18 firstArgs.push({
19 id: t.name,
20 summary: t.description,
21 });
22 }
23 for (const c of this.commands) {
24 if (!firstArgs.some((a) => a.id === c.id) && !c.id.includes(':'))
25 firstArgs.push({
26 id: c.id,
27 summary: c.summary,
28 });
29 }
30 const mainArgsCaseBlock = () => {
31 let caseBlock = 'case $line[1] in\n';
32 for (const arg of firstArgs) {
33 if (this.coTopics.includes(arg.id)) {
34 // coTopics already have a completion function.
35 caseBlock += `${arg.id})\n _${this.config.bin}_${arg.id}\n ;;\n`;
36 }
37 else {
38 const cmd = this.commands.find((c) => c.id === arg.id);
39 if (cmd) {
40 // if it's a command and has flags, inline flag completion statement.
41 // skip it from the args statement if it doesn't accept any flag.
42 if (Object.keys(cmd.flags).length > 0) {
43 caseBlock += `${arg.id})\n${this.genZshFlagArgumentsBlock(cmd.flags)} ;; \n`;
44 }
45 }
46 else {
47 // it's a topic, redirect to its completion function.
48 caseBlock += `${arg.id})\n _${this.config.bin}_${arg.id}\n ;;\n`;
49 }
50 }
51 }
52 caseBlock += 'esac\n';
53 return caseBlock;
54 };
55 return `#compdef ${this.config.bin}
56${this.config.binAliases?.map((a) => `compdef ${a}=${this.config.bin}`).join('\n') ?? ''}
57
58${this.topics.map((t) => this.genZshTopicCompFun(t.name)).join('\n')}
59
60_${this.config.bin}() {
61 local context state state_descr line
62 typeset -A opt_args
63
64 _arguments -C "1: :->cmds" "*::arg:->args"
65
66 case "$state" in
67 cmds)
68 ${this.genZshValuesBlock(firstArgs)}
69 ;;
70 args)
71 ${mainArgsCaseBlock()}
72 ;;
73 esac
74}
75
76_${this.config.bin}
77`;
78 }
79 get coTopics() {
80 if (this._coTopics)
81 return this._coTopics;
82 const coTopics = [];
83 for (const topic of this.topics) {
84 for (const cmd of this.commands) {
85 if (topic.name === cmd.id) {
86 coTopics.push(topic.name);
87 }
88 }
89 }
90 this._coTopics = coTopics;
91 return this._coTopics;
92 }
93 genZshFlagArgumentsBlock(flags) {
94 // if a command doesn't have flags make it only complete files
95 // also add comp for the global `--help` flag.
96 if (!flags)
97 return '_arguments -S \\\n --help"[Show help for command]" "*: :_files';
98 const flagNames = Object.keys(flags);
99 // `-S`:
100 // Do not complete flags after a ‘--’ appearing on the line, and ignore the ‘--’. For example, with -S, in the line:
101 // foobar -x -- -y
102 // the ‘-x’ is considered a flag, the ‘-y’ is considered an argument, and the ‘--’ is considered to be neither.
103 let argumentsBlock = '_arguments -S \\\n';
104 for (const flagName of flagNames) {
105 const f = flags[flagName];
106 // skip hidden flags
107 if (f.hidden)
108 continue;
109 const flagSummary = this.sanitizeSummary(f.summary ?? f.description);
110 let flagSpec = '';
111 if (f.type === 'option') {
112 if (f.char) {
113 // eslint-disable-next-line unicorn/prefer-ternary
114 if (f.multiple) {
115 // this flag can be present multiple times on the line
116 flagSpec += `"*"{-${f.char},--${f.name}}`;
117 }
118 else {
119 flagSpec += `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}`;
120 }
121 flagSpec += `"[${flagSummary}]`;
122 flagSpec += f.options ? `:${f.name} options:(${f.options?.join(' ')})"` : ':file:_files"';
123 }
124 else {
125 if (f.multiple) {
126 // this flag can be present multiple times on the line
127 flagSpec += '"*"';
128 }
129 flagSpec += `--${f.name}"[${flagSummary}]:`;
130 flagSpec += f.options ? `${f.name} options:(${f.options.join(' ')})"` : 'file:_files"';
131 }
132 }
133 else if (f.char) {
134 // Flag.Boolean
135 flagSpec += `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}"[${flagSummary}]"`;
136 }
137 else {
138 // Flag.Boolean
139 flagSpec += `--${f.name}"[${flagSummary}]"`;
140 }
141 flagSpec += ' \\\n';
142 argumentsBlock += flagSpec;
143 }
144 // add global `--help` flag
145 argumentsBlock += '--help"[Show help for command]" \\\n';
146 // complete files if `-` is not present on the current line
147 argumentsBlock += '"*: :_files"';
148 return argumentsBlock;
149 }
150 genZshTopicCompFun(id) {
151 const coTopics = [];
152 for (const topic of this.topics) {
153 for (const cmd of this.commands) {
154 if (topic.name === cmd.id) {
155 coTopics.push(topic.name);
156 }
157 }
158 }
159 const flagArgsTemplate = ' "%s")\n %s\n ;;\n';
160 const underscoreSepId = id.replaceAll(':', '_');
161 const depth = id.split(':').length;
162 const isCotopic = coTopics.includes(id);
163 if (isCotopic) {
164 const compFuncName = `${this.config.bin}_${underscoreSepId}`;
165 const coTopicCompFunc = `_${compFuncName}() {
166 _${compFuncName}_flags() {
167 local context state state_descr line
168 typeset -A opt_args
169
170 ${this.genZshFlagArgumentsBlock(this.commands.find((c) => c.id === id)?.flags)}
171 }
172
173 local context state state_descr line
174 typeset -A opt_args
175
176 _arguments -C "1: :->cmds" "*: :->args"
177
178 case "$state" in
179 cmds)
180 if [[ "\${words[CURRENT]}" == -* ]]; then
181 _${compFuncName}_flags
182 else
183%s
184 fi
185 ;;
186 args)
187 case $line[1] in
188%s
189 *)
190 _${compFuncName}_flags
191 ;;
192 esac
193 ;;
194 esac
195}
196`;
197 const subArgs = [];
198 let argsBlock = '';
199 for (const t of this.topics.filter((t) => t.name.startsWith(id + ':') && t.name.split(':').length === depth + 1)) {
200 const subArg = t.name.split(':')[depth];
201 subArgs.push({
202 id: subArg,
203 summary: t.description,
204 });
205 argsBlock += util.format(argTemplate, subArg, `_${this.config.bin}_${underscoreSepId}_${subArg}`);
206 }
207 for (const c of this.commands.filter((c) => c.id.startsWith(id + ':') && c.id.split(':').length === depth + 1)) {
208 if (coTopics.includes(c.id))
209 continue;
210 const subArg = c.id.split(':')[depth];
211 subArgs.push({
212 id: subArg,
213 summary: c.summary,
214 });
215 argsBlock += util.format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags));
216 }
217 return util.format(coTopicCompFunc, this.genZshValuesBlock(subArgs), argsBlock);
218 }
219 let argsBlock = '';
220 const subArgs = [];
221 for (const t of this.topics.filter((t) => t.name.startsWith(id + ':') && t.name.split(':').length === depth + 1)) {
222 const subArg = t.name.split(':')[depth];
223 subArgs.push({
224 id: subArg,
225 summary: t.description,
226 });
227 argsBlock += util.format(argTemplate, subArg, `_${this.config.bin}_${underscoreSepId}_${subArg}`);
228 }
229 for (const c of this.commands.filter((c) => c.id.startsWith(id + ':') && c.id.split(':').length === depth + 1)) {
230 if (coTopics.includes(c.id))
231 continue;
232 const subArg = c.id.split(':')[depth];
233 subArgs.push({
234 id: subArg,
235 summary: c.summary,
236 });
237 argsBlock += util.format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags));
238 }
239 const topicCompFunc = `_${this.config.bin}_${underscoreSepId}() {
240 local context state state_descr line
241 typeset -A opt_args
242
243 _arguments -C "1: :->cmds" "*::arg:->args"
244
245 case "$state" in
246 cmds)
247%s
248 ;;
249 args)
250 case $line[1] in
251%s
252 esac
253 ;;
254 esac
255}
256`;
257 return util.format(topicCompFunc, this.genZshValuesBlock(subArgs), argsBlock);
258 }
259 genZshValuesBlock(subArgs) {
260 let valuesBlock = '_values "completions" \\\n';
261 for (const subArg of subArgs) {
262 valuesBlock += `"${subArg.id}[${subArg.summary}]" \\\n`;
263 }
264 return valuesBlock;
265 }
266 getCommands() {
267 const cmds = [];
268 for (const p of this.config.getPluginsList()) {
269 for (const c of p.commands) {
270 if (c.hidden)
271 continue;
272 const summary = this.sanitizeSummary(c.summary ?? c.description);
273 const { flags } = c;
274 cmds.push({
275 flags,
276 id: c.id,
277 summary,
278 });
279 for (const a of c.aliases) {
280 cmds.push({
281 flags,
282 id: a,
283 summary,
284 });
285 const split = a.split(':');
286 let topic = split[0];
287 // Completion funcs are generated from topics:
288 // `force` -> `force:org` -> `force:org:open|list`
289 //
290 // but aliases aren't guaranteed to follow the plugin command tree
291 // so we need to add any missing topic between the starting point and the alias.
292 for (let i = 0; i < split.length - 1; i++) {
293 if (!this.topics.some((t) => t.name === topic)) {
294 this.topics.push({
295 description: `${topic.replaceAll(':', ' ')} commands`,
296 name: topic,
297 });
298 }
299 topic += `:${split[i + 1]}`;
300 }
301 }
302 }
303 }
304 return cmds;
305 }
306 getTopics() {
307 const topics = this.config.topics
308 .filter((topic) => {
309 // it is assumed a topic has a child if it has children
310 const hasChild = this.config.topics.some((subTopic) => subTopic.name.includes(`${topic.name}:`));
311 return hasChild;
312 })
313 .sort((a, b) => {
314 if (a.name < b.name) {
315 return -1;
316 }
317 if (a.name > b.name) {
318 return 1;
319 }
320 return 0;
321 })
322 .map((t) => {
323 const description = t.description
324 ? this.sanitizeSummary(t.description)
325 : `${t.name.replaceAll(':', ' ')} commands`;
326 return {
327 description,
328 name: t.name,
329 };
330 });
331 return topics;
332 }
333 sanitizeSummary(summary) {
334 if (summary === undefined) {
335 return '';
336 }
337 return ejs
338 .render(summary, { config: this.config })
339 .replaceAll(/(["`])/g, '\\\\\\$1') // backticks and double-quotes require triple-backslashes
340 .replaceAll(/([[\]])/g, '\\\\$1') // square brackets require double-backslashes
341 .split('\n')[0]; // only use the first line
342 }
343}