@optique/core 1.1.12 → 1.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  const require_message = require('./message.cjs');
2
+ const require_joined_file_suggestion = require('./internal/joined-file-suggestion.cjs');
2
3
 
3
4
  //#region src/completion.ts
4
5
  /**
@@ -40,6 +41,17 @@ function validateProgramName(programName) {
40
41
  function encodePattern(pattern) {
41
42
  return pattern.replace(/%/g, "%25").replace(/:/g, "%3A");
42
43
  }
44
+ /** Encodes optional literal insertion metadata without changing old directives. */
45
+ function encodeFilePrefix(suggestion) {
46
+ const prefix = suggestion.prefix ?? "";
47
+ if (prefix === "" || !(suggestion.pattern ?? "").startsWith(prefix) || sanitizeForTransport(prefix) !== prefix) return "";
48
+ return `:${encodePattern(prefix)}`;
49
+ }
50
+ /** Escapes literal records that could otherwise be read as Bash directives. */
51
+ function encodeBashLiteral(value) {
52
+ const text = sanitizeForTransport(value);
53
+ return /^(?:__FILE__|__JOINED_FILE__|__LITERAL__):/.test(text) ? `__LITERAL__:${text}` : text;
54
+ }
43
55
  function encodeExtensions(extensions) {
44
56
  return extensions?.map((ext) => ext.replace(/^\./, "")).join(",") ?? "";
45
57
  }
@@ -56,6 +68,17 @@ function sanitizeForTransport(text) {
56
68
  return text.replace(/[\t\n\r\0]/g, " ");
57
69
  }
58
70
  /**
71
+ * Escapes literal values that would otherwise be interpreted as transport
72
+ * directives. Escape the escape prefix itself so decoding is unambiguous.
73
+ * @param text The literal suggestion text.
74
+ * @returns The sanitized and, if necessary, escaped literal text.
75
+ * @internal
76
+ */
77
+ function encodeLiteral(text) {
78
+ const sanitized = sanitizeForTransport(text);
79
+ return sanitized.startsWith("__FILE__:") || sanitized.startsWith("__LITERAL__:") ? `__LITERAL__:${sanitized}` : sanitized;
80
+ }
81
+ /**
59
82
  * The Bash shell completion generator.
60
83
  * @since 0.6.0
61
84
  */
@@ -67,14 +90,297 @@ const bash = {
67
90
  return `
68
91
  function _${programName} () {
69
92
  COMPREPLY=()
70
- local current="\${COMP_WORDS[COMP_CWORD]}"
93
+ local __raw_current="\${COMP_WORDS[COMP_CWORD]}"
94
+ local current="" __quote="" __char __next __index __quote_offset=0
95
+ local __file_mode=0
96
+ if [[ $(type -t compopt) == builtin ]]; then
97
+ compopt +o filenames 2>/dev/null || true
98
+ fi
99
+ # Dequote the shell word without evaluating filename contents. Readline
100
+ # returns escaped words after Tab, and users may start with an open quote.
101
+ for ((__index=0; __index<\${#__raw_current}; __index++)); do
102
+ __char="\${__raw_current:__index:1}"
103
+ if [[ -z "$__quote" ]]; then
104
+ case "$__char" in
105
+ \\\\) ((++__index)); current+="\${__raw_current:__index:1}" ;;
106
+ \\'|\\") __quote="$__char"; __quote_offset=\${#current} ;;
107
+ *) current+="$__char" ;;
108
+ esac
109
+ elif [[ "$__quote" == "'" ]]; then
110
+ if [[ "$__char" == "'" ]]; then
111
+ __quote=""
112
+ else
113
+ current+="$__char"
114
+ fi
115
+ else
116
+ case "$__char" in
117
+ \\") __quote="" ;;
118
+ \\\\)
119
+ __next="\${__raw_current:__index+1:1}"
120
+ case "$__next" in
121
+ \\$|\\\`|\\"|\\\\)
122
+ ((++__index)); current+="$__next" ;;
123
+ *) current+="$__char" ;;
124
+ esac ;;
125
+ *) current+="$__char" ;;
126
+ esac
127
+ fi
128
+ done
129
+ local __tilde_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)?$'
130
+ local __tilde_glob_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)?$'
71
131
  local prev=("\${COMP_WORDS[@]:1:COMP_CWORD-1}")
72
- while IFS= read -r line; do
73
- if [[ "$line" == __FILE__:* ]]; then
74
- # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
75
- IFS=':' read -r _ type extensions pattern hidden <<< "$line"
132
+ # Bash splits unquoted '=' at its default word breaks. Reassemble only
133
+ # adjacent fragments, including further word breaks inside a joined value,
134
+ # leaving whitespace-separated arguments intact.
135
+ local __plain_current="$current" __remove="" __word __joined=0
136
+ local __raw_joined_current="$__raw_current"
137
+ local -a __raw_offsets=() __raw_quotes=()
138
+ if [[ \${COMP_LINE+x} ]]; then
139
+ local -a __words=()
140
+ local __rest="$COMP_LINE" __previous="" __space __valid=1 __i __j=0
141
+ local __shell_quote="" __shell_escape=0 __boundary=0 __previous_boundary=0 __scan __symbol __shell_dollar=0
142
+ # Reconstruct the prefix at the cursor, leaving any suffix to Readline.
143
+ if [[ \${COMP_POINT+x} ]]; then
144
+ __rest="\${COMP_LINE:0:COMP_POINT}"
145
+ fi
146
+ for ((__i=0; __i<=COMP_CWORD; __i++)); do
147
+ __word="\${COMP_WORDS[__i]}"
148
+ __space=0
149
+ while [[ "$__rest" == [[:space:]]* ]]; do
150
+ __space=1
151
+ __rest="\${__rest:1}"
152
+ done
153
+ if (( __i == COMP_CWORD && \${#__rest} < \${#__word} )); then
154
+ __word="\${__word:0:\${#__rest}}"
155
+ fi
156
+ if [[ "\${__rest:0:\${#__word}}" != "$__word" ]]; then
157
+ __valid=0
158
+ break
159
+ fi
160
+ # Quoted or escaped operators belong to words; bare operators separate
161
+ # shell syntax even when no whitespace surrounds a redirection.
162
+ __boundary=0
163
+ for ((__scan=0; __scan<\${#__word}; __scan++)); do
164
+ __symbol="\${__word:__scan:1}"
165
+ if (( __shell_escape )); then
166
+ __shell_escape=0
167
+ elif [[ "$__shell_quote" == ansi ]]; then
168
+ if [[ "$__symbol" == '\\' ]]; then __shell_escape=1
169
+ elif [[ "$__symbol" == "'" ]]; then __shell_quote=""; fi
170
+ elif [[ "$__shell_quote" == "'" ]]; then
171
+ [[ "$__symbol" == "'" ]] && __shell_quote=""
172
+ elif [[ "$__symbol" == '\\' ]]; then
173
+ __shell_escape=1
174
+ elif [[ -n "$__shell_quote" ]]; then
175
+ [[ "$__symbol" == "$__shell_quote" ]] && __shell_quote=""
176
+ else
177
+ case "$__symbol" in
178
+ "'")
179
+ if (( __shell_dollar )); then __shell_quote=ansi
180
+ else __shell_quote="'"; fi ;;
181
+ '"') __shell_quote='"' ;;
182
+ '$') __shell_dollar=1 ;;
183
+ '>'|'<'|'|'|'&'|';'|'('|')') __boundary=1 ;;
184
+ esac
185
+ fi
186
+ [[ "$__symbol" == '$' && -z "$__shell_quote" ]] || __shell_dollar=0
187
+ done
188
+ if (( __i > 1 && !__space && !__boundary && !__previous_boundary )) &&
189
+ { [[ -n "$__word" && -z "\${__word//=/}" ]] ||
190
+ [[ -n "$__previous" && -z "\${__previous//=/}" ]] ||
191
+ [[ "\${__words[__j-1]}" == [-/+]* ]] ||
192
+ [[ "$__word" == =* && "\${__words[__j-1]}" == [-/+]* ]]; }; then
193
+ __words[__j-1]+="$__word"
194
+ else
195
+ __words[__j]="$__word"
196
+ ((++__j))
197
+ fi
198
+ __rest="\${__rest:\${#__word}}"
199
+ __previous="$__word"
200
+ __previous_boundary=$__boundary
201
+ done
202
+ if (( __valid )); then
203
+ current="\${__words[__j-1]}"
204
+ __raw_joined_current="$current"
205
+ prev=("\${__words[@]:1:__j-2}")
206
+ # Bash passes the fragment Readline will replace as the second argument,
207
+ # which is empty when the cursor follows a word-break character.
208
+ if [[ \${2+x} ]]; then
209
+ __remove="\${current:0:\${#current}-\${#2}}"
210
+ elif [[ -n "$__raw_current" && -z "\${__raw_current//=/}" ]]; then
211
+ __remove="$current"
212
+ else
213
+ __remove="\${current:0:\${#current}-\${#__raw_current}}"
214
+ fi
215
+ # Decode joined shell words without evaluating substitutions. Keep quote
216
+ # state across the reassembled fragments and decode the preserved prefix
217
+ # separately so trimming uses the same representation as suggestions.
218
+ __words[__j]="$__remove"
219
+ local __decoded __decoded_option=0 __before_quote __join_quote __char __next __k __ansi __part __digits __code __join_quote_offset
220
+ for ((__i=1; __i<=__j; __i++)); do
221
+ __word="\${__words[__i]}"
222
+ __decoded=""; __join_quote=""; __join_quote_offset=0
223
+ for ((__k=0; __k<\${#__word}; __k++)); do
224
+ __char="\${__word:__k:1}"
225
+ __before_quote="$__join_quote"
226
+ if [[ -z "$__join_quote" ]]; then
227
+ case "$__char" in
228
+ '\\') ((++__k)); __decoded+="\${__word:__k:1}" ;;
229
+ '$')
230
+ if [[ "\${__word:__k+1:1}" == "'" ]]; then
231
+ __join_quote=ansi; __join_quote_offset=\${#__decoded}; __ansi=""; ((++__k))
232
+ elif [[ "\${__word:__k+1:1}" == '"' ]]; then
233
+ __join_quote='"'; __join_quote_offset=\${#__decoded}; ((++__k))
234
+ else __decoded+="$__char"; fi ;;
235
+ "'"|'"') __join_quote="$__char"; __join_quote_offset=\${#__decoded} ;;
236
+ *) __decoded+="$__char" ;;
237
+ esac
238
+ elif [[ "$__join_quote" == ansi ]]; then
239
+ if [[ "$__char" == "'" ]]; then
240
+ printf -v __part '%b' "$__ansi"
241
+ __decoded+="$__part"; __join_quote=""
242
+ elif [[ "$__char" == '\\' ]]; then
243
+ ((++__k)); __next="\${__word:__k:1}"
244
+ case "$__next" in
245
+ [0-7])
246
+ __digits="$__next"
247
+ while (( \${#__digits} < 3 )) && [[ "\${__word:__k+1:1}" == [0-7] ]]; do
248
+ ((++__k)); __digits+="\${__word:__k:1}"
249
+ done
250
+ __ansi+='\\0'"$__digits" ;;
251
+ c)
252
+ __next="\${__word:__k+1:1}"
253
+ if [[ -n "$__next" && "$__next" != "'" ]]; then
254
+ ((++__k)); printf -v __code '%d' "'$__next"
255
+ if [[ "$__next" == '?' ]]; then __code=127
256
+ else __code=$((__code & 31)); fi
257
+ printf -v __part '\\\\%03o' "$__code"
258
+ __ansi+="$__part"
259
+ else __ansi+='\\\\c'; fi ;;
260
+ "'"|'"'|'?') __ansi+="$__next" ;;
261
+ *) __ansi+='\\'"$__next" ;;
262
+ esac
263
+ else __ansi+="$__char"; fi
264
+ elif [[ "$__join_quote" == "'" ]]; then
265
+ if [[ "$__char" == "'" ]]; then __join_quote=""
266
+ else __decoded+="$__char"; fi
267
+ else
268
+ case "$__char" in
269
+ '"') __join_quote="" ;;
270
+ '\\')
271
+ __next="\${__word:__k+1:1}"
272
+ case "$__next" in
273
+ '$'|'\`'|'"'|'\\') ((++__k)); __decoded+="$__next" ;;
274
+ *) __decoded+="$__char" ;;
275
+ esac ;;
276
+ *) __decoded+="$__char" ;;
277
+ esac
278
+ fi
279
+ if (( __i == __j - 1 )) &&
280
+ { [[ ! \${__raw_offsets[\${#__decoded}]+x} ]] ||
281
+ [[ -n "$__before_quote" && -z "$__join_quote" ]]; }; then
282
+ __raw_offsets[\${#__decoded}]=$((__k + 1))
283
+ __raw_quotes[\${#__decoded}]="$__join_quote"
284
+ fi
285
+ done
286
+ if [[ "$__join_quote" == ansi ]]; then
287
+ printf -v __part '%b' "$__ansi"
288
+ __decoded+="$__part"
289
+ fi
290
+ # Shell syntax may precede the option prefix. Decide from decoded text,
291
+ # retaining the existing native-file handling for non-option words.
292
+ if [[ "$__decoded" == [-/+]* ]] || (( __i == __j && __decoded_option )); then
293
+ if (( __i == __j - 1 )); then
294
+ __decoded_option=1
295
+ __quote="$__join_quote"
296
+ [[ "$__quote" == ansi ]] && __quote="'"
297
+ __quote_offset=$__join_quote_offset
298
+ fi
299
+ __words[__i]="$__decoded"
300
+ fi
301
+ done
302
+ if [[ "\${__words[__j-1]}" == -*=* || "\${__words[__j-1]}" == /*:* || "\${__words[__j-1]}" != "$__raw_current" ]]; then
303
+ current="\${__words[__j-1]}"
304
+ __joined=1
305
+ else
306
+ current="$__plain_current"
307
+ __words[__j]=""
308
+ fi
309
+ prev=("\${__words[@]:1:__j-2}")
310
+ __remove="\${__words[__j]}"
311
+ fi
312
+ fi
313
+
314
+ # Joined file directives retain the exact option prefix and metadata chosen
315
+ # by the parser; do not infer option boundaries through speculative retries.
316
+ local __protocol_current="$__raw_current"
317
+ (( __joined )) && __protocol_current="$current"
318
+ local -a __lines=() __replies=()
319
+ local line __reply_prefix="" __file_current="$current" __literal=0
320
+ while IFS= read -r line || [[ -n "$line" ]]; do
321
+ [[ -n "$line" ]] && __lines+=("$line")
322
+ done < <(${programName} ${escapedArgs} "\${prev[@]}" "$__protocol_current" 2>/dev/null)
323
+ for line in "\${__lines[@]}"; do
324
+ COMPREPLY=()
325
+ __reply_prefix=""
326
+ __file_current="$current"
327
+ __literal=0
328
+ # Protocol tags are case-sensitive even when nocasematch is enabled.
329
+ if [ "\${line:0:12}" = '__LITERAL__:' ]; then
330
+ line="\${line#__LITERAL__:}"
331
+ __literal=1
332
+ elif [ "\${line:0:16}" = '__JOINED_FILE__:' ]; then
333
+ line="\${line#__JOINED_FILE__:}"
334
+ __reply_prefix="\${line%%:*}"
335
+ __reply_prefix="\${__reply_prefix//%3A/:}"
336
+ __reply_prefix="\${__reply_prefix//%25/%}"
337
+ line="__FILE__:\${line#*:}"
338
+ __file_current="\${current:\${#__reply_prefix}}"
339
+ fi
340
+ local __raw_file_current="$__raw_current" __file_quote_offset=$__quote_offset __file_tilde_quoted=0
341
+ if [[ -n "$__reply_prefix" ]]; then
342
+ local __raw_offset="\${__raw_offsets[\${#__reply_prefix}]:-\${#__reply_prefix}}"
343
+ __raw_file_current="\${__raw_joined_current:__raw_offset}"
344
+ # Quoting may start before the option name and remain active at the
345
+ # delimiter. Keep that context when deciding whether tilde is literal.
346
+ if [[ -n "\${__raw_quotes[\${#__reply_prefix}]-}" ]]; then
347
+ __file_tilde_quoted=1
348
+ fi
349
+ if (( __file_quote_offset >= \${#__reply_prefix} )); then
350
+ __file_quote_offset=$((__file_quote_offset - \${#__reply_prefix}))
351
+ fi
352
+ fi
353
+ if [ "$__literal" = 0 ] && [ "\${line:0:9}" = '__FILE__:' ]; then
354
+ # Parse file completion directive:
355
+ # __FILE__:type:extensions:pattern:hidden[:insertion_prefix]
356
+ local insertion_prefix="" __key_current="$__file_current"
357
+ IFS=':' read -r _ type extensions pattern hidden insertion_prefix <<< "$line"
76
358
  extensions="\${extensions//,\\./,}"; extensions="\${extensions#.}"
77
359
  pattern="\${pattern//%3A/:}"; pattern="\${pattern//%25/%}"
360
+ insertion_prefix="\${insertion_prefix//%3A/:}"; insertion_prefix="\${insertion_prefix//%25/%}"
361
+ local __reply_start=\${#COMPREPLY[@]}
362
+
363
+ # Inserted keys are not filesystem paths. Match only the value part,
364
+ # then restore the key once the file candidates have been filtered.
365
+ if [[ -n "$insertion_prefix" ]] &&
366
+ [ "\${pattern:0:\${#insertion_prefix}}" = "$insertion_prefix" ] &&
367
+ [ "\${__file_current:0:\${#insertion_prefix}}" = "$insertion_prefix" ]; then
368
+ pattern="\${pattern:\${#insertion_prefix}}"
369
+ __file_current="\${__file_current:\${#insertion_prefix}}"
370
+ if [ "\${__raw_file_current:0:\${#insertion_prefix}}" = "$insertion_prefix" ]; then
371
+ __raw_file_current="\${__raw_file_current:\${#insertion_prefix}}"
372
+ fi
373
+ else
374
+ insertion_prefix=""
375
+ fi
376
+
377
+ # path() echoes the raw shell word as its pattern. Once dequoted,
378
+ # use the literal current-word prefix instead of the escaped pattern.
379
+ if [[ "$pattern" == "$__raw_file_current" && "$__file_current" != "$__raw_file_current" ]] ||
380
+ { [[ -n "$__reply_prefix" ]] && [ "$pattern" = "$__file_current" ] &&
381
+ { [[ "$pattern" != *[\*\?]* ]] || [ "$__raw_file_current" != "$__file_current" ]; }; }; then
382
+ pattern=""
383
+ fi
78
384
 
79
385
  # Save and adjust glob/shell options for safe file completion
80
386
  local __dotglob_was_set=0 __failglob_was_set=0 __noglob_was_set=0
@@ -90,12 +396,12 @@ function _${programName} () {
90
396
  unset GLOBIGNORE
91
397
 
92
398
  # Expand tilde prefix for file globbing
93
- local __glob_current="$current" __tilde_prefix="" __tilde_expanded=""
94
- if [[ "$current" =~ ^(~[a-zA-Z0-9_.+-]*)(/.*)$ ]]; then
399
+ local __glob_current="$__file_current" __tilde_prefix="" __tilde_expanded=""
400
+ if [[ $__file_tilde_quoted == 0 && "$__raw_file_current" == "~"* && "$__file_current" =~ $__tilde_pattern ]]; then
95
401
  __tilde_prefix="\${BASH_REMATCH[1]}"
96
402
  eval "__tilde_expanded=\$__tilde_prefix" 2>/dev/null || true
97
403
  if [[ -n "$__tilde_expanded" && "$__tilde_expanded" != "$__tilde_prefix" ]]; then
98
- __glob_current="\${__tilde_expanded}\${current#\$__tilde_prefix}"
404
+ __glob_current="\${__tilde_expanded}\${__file_current#\$__tilde_prefix}"
99
405
  else
100
406
  __tilde_prefix=""
101
407
  fi
@@ -106,10 +412,11 @@ function _${programName} () {
106
412
  # However, if the user has already typed beyond the pattern (e.g.,
107
413
  # pattern="src/" and current="src/ma"), preserve the typed suffix
108
414
  # for incremental filtering.
109
- local __from_pattern=0
415
+ local __from_pattern=0 __filter_pattern=0 __pattern_prefix=""
416
+ local __pattern_tilde_prefix="" __pattern_tilde_expanded=""
110
417
  if [[ -n "$pattern" ]]; then
111
418
  # Normalize leading ./ so that ./src/ma matches pattern src/
112
- local __norm_current="\${current#./}"
419
+ local __norm_current="\${__file_current#./}"
113
420
  local __norm_pattern="\${pattern#./}"
114
421
  # For wildcard patterns, compare only the directory prefix before
115
422
  # any wildcards so that typing src/ma narrows src/*.ts correctly
@@ -118,17 +425,34 @@ function _${programName} () {
118
425
  __compare_pattern="\${__compare_pattern%%[\*\?]*}"
119
426
  [[ "$__compare_pattern" == */* ]] && __compare_pattern="\${__compare_pattern%/*}/" || __compare_pattern=""
120
427
  fi
121
- if [[ ( -n "$__compare_pattern" || -n "$__norm_current" ) && \${#__norm_current} -ge \${#__compare_pattern} && "\${__norm_current:0:\${#__compare_pattern}}" == "$__compare_pattern" && "$current" != "$pattern" ]]; then
428
+ local __extends_pattern=0
429
+ if [[ ( -n "$__compare_pattern" || -n "$__norm_current" ) && \${#__norm_current} -ge \${#__compare_pattern} && "\${__norm_current:0:\${#__compare_pattern}}" == "$__compare_pattern" && "$__file_current" != "$pattern" ]]; then
430
+ __extends_pattern=1
431
+ fi
432
+ if (( __extends_pattern )) && [[ "$__norm_pattern" != *[\*\?]* ]]; then
122
433
  # User has typed beyond or an equivalent form of the pattern
123
434
  true
124
435
  else
436
+ # Wildcards keep their file filter while the typed prefix narrows
437
+ # the resulting paths. Save the expanded prefix before using pattern.
438
+ if [[ "$__norm_pattern" == *[\*\?]* ]]; then
439
+ __filter_pattern=1
440
+ __pattern_prefix="$__glob_current"
441
+ # An unquoted value equal to its wildcard is a complete search
442
+ # pattern, not a literal filename prefix to filter against.
443
+ if [ "$__file_current" = "$pattern" ] && [ "$__raw_file_current" = "$__file_current" ]; then
444
+ __pattern_prefix=""
445
+ fi
446
+ __pattern_tilde_prefix="$__tilde_prefix"
447
+ __pattern_tilde_expanded="$__tilde_expanded"
448
+ fi
125
449
  __from_pattern=1
126
450
  # Reset tilde state from the current-word expansion so that a
127
451
  # non-tilde pattern is not rewritten through stale tilde state
128
452
  __tilde_prefix=""
129
453
  __tilde_expanded=""
130
454
  __glob_current="$pattern"
131
- if [[ "$pattern" =~ ^(~[a-zA-Z0-9_.+-]*)(/.*)?$ ]]; then
455
+ if [[ "$pattern" =~ $__tilde_glob_pattern ]]; then
132
456
  __tilde_prefix="\${BASH_REMATCH[1]}"
133
457
  eval "__tilde_expanded=\$__tilde_prefix" 2>/dev/null || true
134
458
  if [[ -n "$__tilde_expanded" && "$__tilde_expanded" != "$__tilde_prefix" ]]; then
@@ -150,36 +474,93 @@ function _${programName} () {
150
474
  # This runs after tilde expansion so that paths like ~/.config/ are
151
475
  # checked against the expanded path, not the literal ~ string.
152
476
  local __inside_hidden_path=0
153
- case "/\${__glob_current%/}/" in
154
- */.[!.]*/*|*/..?*/*) __inside_hidden_path=1 ;;
155
- esac
477
+ local __hidden_directory_pattern='/[.]([^./][^/]*|[.][^/]+)/'
478
+ if [[ "/\${__glob_current%/}/" =~ $__hidden_directory_pattern ||
479
+ "/\${__pattern_prefix%/}/" =~ $__hidden_directory_pattern ]]; then
480
+ __inside_hidden_path=1
481
+ fi
156
482
  # Also check if the current prefix explicitly targets hidden entries
157
483
  # (e.g., user typed "." or ".e" to complete .env)
158
484
  local __prefix_targets_hidden=0
159
485
  local __prefix_base="\${__glob_current##*/}"
486
+ if [[ -n "$__pattern_prefix" ]]; then __prefix_base="\${__pattern_prefix##*/}"; fi
160
487
  [[ "$__prefix_base" == .* ]] && __prefix_targets_hidden=1
161
488
  if [[ "$hidden" == "1" || "$__inside_hidden_path" == "1" || "$__prefix_targets_hidden" == "1" ]]; then shopt -s dotglob; fi
162
489
 
163
490
  # Pre-expand file candidates. When the glob base came from the
164
491
  # program's pattern and contains wildcard characters (* or ?),
165
- # use it as-is via compgen -G (safe—no command substitution).
492
+ # expand it as data with shell globbing. Empty IFS avoids splitting paths;
493
+ # NUL records preserve spaces and newlines without requiring mapfile.
494
+ # Shell expansion respects dotglob on older Bash versions as well.
166
495
  # Otherwise append * to treat it as a prefix.
167
- # Note: [ is NOT treated as a glob indicator because it commonly
168
- # appears in literal filenames like [draft] or foo[1].txt.
496
+ # Only * and ? are wildcard syntax; escape brackets, backslashes, and
497
+ # extended-glob operators so other pattern characters stay literal.
169
498
  local -a __candidates=()
170
499
  if [[ "$__from_pattern" == "1" && "$__glob_current" == *[\*\?]* ]]; then
171
- mapfile -t __candidates < <(compgen -G "$__glob_current" 2>/dev/null)
500
+ local __pathname_glob="" __glob_char __glob_index
501
+ for ((__glob_index=0; __glob_index<\${#__glob_current}; __glob_index++)); do
502
+ __glob_char="\${__glob_current:__glob_index:1}"
503
+ case "$__glob_char" in
504
+ '['|']'|'\\'|'('|')'|'|'|'+'|'@'|'!')
505
+ __pathname_glob+="\\\\$__glob_char" ;;
506
+ *) __pathname_glob+="$__glob_char" ;;
507
+ esac
508
+ done
509
+ [[ "$type" == "directory" ]] && __pathname_glob="\${__pathname_glob%/}"
510
+ local __glob_dir="\${__pathname_glob%/*}"
511
+ [[ "$__glob_dir" == "$__pathname_glob" ]] && __glob_dir="."
512
+ [[ -z "$__glob_dir" ]] && __glob_dir="/"
513
+ local __navigation_dir="" __basename_glob="\${__pathname_glob##*/}"
514
+ # Once an offered directory is entered, keep the basename filter but
515
+ # enumerate there. Only descendants of matching pattern bases qualify.
516
+ if [[ "$__pattern_prefix" == */* ]]; then
517
+ local __typed_dir="\${__pattern_prefix%/*}" __base_dir __norm_base __norm_dir
518
+ # Resolve directory ancestry in subshells, preserving the typed spelling
519
+ # for insertion. The trailing slash also preserves newline-ending names.
520
+ if __norm_dir=$(CDPATH= builtin cd -P -- "$__typed_dir" >/dev/null 2>&1 && printf '%s/' "\${PWD%/}"); then
521
+ while IFS= read -r -d '' __base_dir; do
522
+ [[ -d "$__base_dir" ]] || continue
523
+ __norm_base=$(CDPATH= builtin cd -P -- "$__base_dir" >/dev/null 2>&1 && printf '%s/' "\${PWD%/}") || continue
524
+ if [ "\${__norm_dir:0:\${#__norm_base}}" = "$__norm_base" ]; then
525
+ __navigation_dir="$__typed_dir"
526
+ break
527
+ fi
528
+ done < <(IFS=; printf '%s\\0' $__glob_dir)
529
+ fi
530
+ fi
531
+ local __candidate
532
+ while IFS= read -r -d '' __candidate; do
533
+ if [[ -e "$__candidate" || -L "$__candidate" ]]; then
534
+ [[ -d "$__candidate" ]] && __candidate="\${__candidate%/}"
535
+ __candidates+=("$__candidate")
536
+ fi
537
+ done < <(
538
+ IFS=
539
+ if [[ -n "$__navigation_dir" ]]; then
540
+ printf '%s\\0' "$__navigation_dir"/$__basename_glob
541
+ else
542
+ printf '%s\\0' $__pathname_glob
543
+ fi
544
+ )
172
545
  # For file/any modes, also include directories from the base
173
546
  # directory for navigation even when the glob itself does not
174
547
  # match directory names. Skip this for directory mode so that
175
548
  # the pattern's basename filter is respected.
176
549
  if [[ "$type" != "directory" ]]; then
177
- local __glob_dir="\${__glob_current%/*}"
178
- [[ "$__glob_dir" == "$__glob_current" ]] && __glob_dir="."
179
550
  local __d
180
- for __d in "$__glob_dir"/*/; do
181
- [[ -d "$__d" ]] && __candidates+=("\${__d%/}")
182
- done
551
+ local __directory_pattern="\${__glob_dir%/}/*/"
552
+ while IFS= read -r -d '' __d; do
553
+ [[ -d "$__d" ]] || continue
554
+ __d="\${__d%/}"
555
+ __candidates+=("$__d")
556
+ done < <(
557
+ IFS=
558
+ if [[ -n "$__navigation_dir" ]]; then
559
+ printf '%s\\0' "$__navigation_dir"/*/
560
+ else
561
+ printf '%s\\0' $__directory_pattern
562
+ fi
563
+ )
183
564
  fi
184
565
  else
185
566
  __candidates=("$__glob_current"*)
@@ -190,6 +571,26 @@ function _${programName} () {
190
571
  fi
191
572
  fi
192
573
 
574
+ if (( __filter_pattern )); then
575
+ local __nocasematch_was_set=0
576
+ shopt -q nocasematch && __nocasematch_was_set=1
577
+ if shopt -q nocaseglob; then shopt -s nocasematch; else shopt -u nocasematch; fi
578
+ local -a __filtered_candidates=()
579
+ for __candidate in "\${__candidates[@]}"; do
580
+ if [[ "$__candidate" == "$__pattern_prefix"* ||
581
+ "\${__candidate#./}" == "\${__pattern_prefix#./}"* ]]; then
582
+ if [[ "$__pattern_prefix" == ./* && "$__candidate" != ./* ]]; then
583
+ __candidate="./$__candidate"
584
+ elif [[ "$__pattern_prefix" != ./* && "$__pattern_prefix" != . ]]; then
585
+ __candidate="\${__candidate#./}"
586
+ fi
587
+ __filtered_candidates+=("$__candidate")
588
+ fi
589
+ done
590
+ if (( __nocasematch_was_set )); then shopt -s nocasematch; else shopt -u nocasematch; fi
591
+ __candidates=("\${__filtered_candidates[@]}")
592
+ fi
593
+
193
594
  # Generate file completions based on type
194
595
  case "$type" in
195
596
  file)
@@ -197,10 +598,11 @@ function _${programName} () {
197
598
  if [[ -n "$extensions" ]]; then
198
599
  # Files with extension filtering + directories
199
600
  local ext_pattern="\${extensions//,/|}"
601
+ ext_pattern='\\.('"$ext_pattern"')$'
200
602
  for item in "\${__candidates[@]}"; do
201
603
  if [[ -d "$item" ]]; then
202
604
  COMPREPLY+=("$item/")
203
- elif [[ -f "$item" && "$item" =~ \\.($ext_pattern)$ ]]; then
605
+ elif [[ -f "$item" && "$item" =~ $ext_pattern ]]; then
204
606
  COMPREPLY+=("$item")
205
607
  fi
206
608
  done
@@ -227,10 +629,11 @@ function _${programName} () {
227
629
  # Files with extension filtering + directories
228
630
  # Files with extension filtering
229
631
  local ext_pattern="\${extensions//,/|}"
632
+ ext_pattern='\\.('"$ext_pattern"')$'
230
633
  for item in "\${__candidates[@]}"; do
231
634
  if [[ -d "$item" ]]; then
232
635
  COMPREPLY+=("$item/")
233
- elif [[ ( -e "$item" || -L "$item" ) && "$item" =~ \\.($ext_pattern)$ ]]; then
636
+ elif [[ ( -e "$item" || -L "$item" ) && "$item" =~ $ext_pattern ]]; then
234
637
  COMPREPLY+=("$item")
235
638
  fi
236
639
  done
@@ -249,7 +652,12 @@ function _${programName} () {
249
652
  esac
250
653
 
251
654
  # Restore tilde prefix in completion results
252
- if [[ -n "$__tilde_prefix" ]]; then
655
+ if (( __filter_pattern )); then
656
+ __tilde_prefix="$__pattern_tilde_prefix"
657
+ __tilde_expanded="$__pattern_tilde_expanded"
658
+ fi
659
+ if [[ -n "$__tilde_prefix" &&
660
+ ( -z "$__quote" || $__file_quote_offset -gt 0 ) ]]; then
253
661
  local __i
254
662
  for __i in "\${!COMPREPLY[@]}"; do
255
663
  COMPREPLY[\$__i]="\${COMPREPLY[\$__i]/#\$__tilde_expanded/\$__tilde_prefix}"
@@ -264,36 +672,160 @@ function _${programName} () {
264
672
  if [[ "$__failglob_was_set" == "1" ]]; then shopt -s failglob; fi
265
673
  if [[ "$__noglob_was_set" == "1" ]]; then set -f; fi
266
674
 
675
+ # Readline appends directory separators when it receives the full path.
676
+ # Midword quotes still need ours because replies omit the typed prefix.
677
+ if [[ -n "$__quote" && $__file_quote_offset -eq 0 ]]; then
678
+ local __reply_index
679
+ for ((__reply_index=__reply_start; __reply_index<\${#COMPREPLY[@]}; __reply_index++)); do
680
+ COMPREPLY[__reply_index]="\${COMPREPLY[__reply_index]%/}"
681
+ done
682
+ fi
683
+
684
+ # Readline replaces only the text after an open quote. Remove the
685
+ # already typed prefix so it is not inserted a second time.
686
+ # Joined replies are trimmed once against __remove after restoring option=,
687
+ # and keyed replies once their key has been restored.
688
+ if [[ -n "$__quote" && $__file_quote_offset -gt 0 && -z "$__reply_prefix" && -z "$insertion_prefix" ]]; then
689
+ local __quote_prefix="\${__file_current:0:__file_quote_offset}"
690
+ local __reply_index
691
+ for ((__reply_index=__reply_start; __reply_index<\${#COMPREPLY[@]}; __reply_index++)); do
692
+ COMPREPLY[__reply_index]="\${COMPREPLY[__reply_index]#"$__quote_prefix"}"
693
+ done
694
+ fi
695
+
696
+ # Readline expands a leading tilde in a returned filename even when the
697
+ # user did not type one. Keep such local paths explicitly relative.
698
+ if [[ ( "$__raw_file_current" != "~"* || $__file_tilde_quoted == 1 ) && -z "$__tilde_prefix" ]]; then
699
+ local __reply_index
700
+ for ((__reply_index=__reply_start; __reply_index<\${#COMPREPLY[@]}; __reply_index++)); do
701
+ if [[ "\${COMPREPLY[__reply_index]}" =~ $__tilde_glob_pattern ]]; then
702
+ COMPREPLY[__reply_index]="./\${COMPREPLY[__reply_index]}"
703
+ fi
704
+ done
705
+ fi
706
+
707
+ # Bash 5.3 leaves substitutions unescaped inside an open double quote.
708
+ # Earlier versions already escape them during filename processing.
709
+ if [[ "$__quote" == '"' &&
710
+ ( \${BASH_VERSINFO[0]} -gt 5 ||
711
+ ( \${BASH_VERSINFO[0]} -eq 5 && \${BASH_VERSINFO[1]} -ge 3 ) ) &&
712
+ $(type -t compopt) == builtin ]]; then
713
+ local __escaped_reply __escape_index __reply_index
714
+ for ((__reply_index=__reply_start; __reply_index<\${#COMPREPLY[@]}; __reply_index++)); do
715
+ __escaped_reply=""
716
+ for ((__escape_index=0; __escape_index<\${#COMPREPLY[__reply_index]}; __escape_index++)); do
717
+ __char="\${COMPREPLY[__reply_index]:__escape_index:1}"
718
+ __next="\${COMPREPLY[__reply_index]:__escape_index+1:1}"
719
+ if [[ "$__char" == '\\' &&
720
+ ( "$__next" == '$' || "$__next" == '\`' ) ]]; then
721
+ __escaped_reply+="\\\\$__char"
722
+ elif [[ "$__char" == '$' || "$__char" == '\`' ]]; then
723
+ __escaped_reply+="\\\\$__char"
724
+ else
725
+ __escaped_reply+="$__char"
726
+ fi
727
+ done
728
+ COMPREPLY[__reply_index]="$__escaped_reply"
729
+ done
730
+ fi
731
+
267
732
  # Filter out hidden files unless requested
268
733
  if [[ "$hidden" != "1" && "$__inside_hidden_path" == "0" && "$__prefix_targets_hidden" == "0" ]]; then
269
734
  local filtered=()
270
735
  local __name
271
736
  for item in "\${COMPREPLY[@]}"; do
272
737
  __name="\${item%/}"; __name="\${__name##*/}"
273
- [[ "$__name" != .* ]] && filtered+=("$item")
738
+ if [[ "$__name" != .* ]]; then
739
+ filtered+=("$item")
740
+ fi
274
741
  done
275
742
  COMPREPLY=("\${filtered[@]}")
276
743
  fi
744
+ if [[ -n "$insertion_prefix" ]]; then
745
+ local __reply_index
746
+ for ((__reply_index=__reply_start; __reply_index<\${#COMPREPLY[@]}; __reply_index++)); do
747
+ COMPREPLY[__reply_index]="$insertion_prefix\${COMPREPLY[__reply_index]}"
748
+ done
749
+ if [[ -n "$__quote" && $__file_quote_offset -gt 0 && -z "$__reply_prefix" ]]; then
750
+ local __quote_prefix="\${__key_current:0:__file_quote_offset}"
751
+ for ((__reply_index=__reply_start; __reply_index<\${#COMPREPLY[@]}; __reply_index++)); do
752
+ COMPREPLY[__reply_index]="\${COMPREPLY[__reply_index]#"$__quote_prefix"}"
753
+ done
754
+ fi
755
+ fi
756
+ if [[ \${#COMPREPLY[@]} -gt $__reply_start ]]; then __file_mode=1; fi
277
757
  else
278
758
  # Regular literal completion
279
759
  COMPREPLY+=("$line")
280
760
  fi
281
- done < <(${programName} ${escapedArgs} "\${prev[@]}" "$current" 2>/dev/null)
761
+ for __word in "\${COMPREPLY[@]}"; do
762
+ __replies+=("$__reply_prefix$__word")
763
+ done
764
+ done
765
+ COMPREPLY=()
766
+ for __word in "\${__replies[@]}"; do
767
+ if [[ "\${__word:0:\${#__remove}}" == "$__remove" ]]; then
768
+ COMPREPLY+=("\${__word:\${#__remove}}")
769
+ fi
770
+ done
771
+ if (( \${#COMPREPLY[@]} > 1 )) && command -v sort >/dev/null 2>&1; then
772
+ # Sort shell-escaped keys, not filenames: each key occupies one line even
773
+ # when a candidate contains tabs or newlines. Keep the first occurrence's
774
+ # index, then restore the original order without evaluating escaped data.
775
+ # This works without associative arrays or NUL-aware sort on Bash 3.2.
776
+ local -a __unique_replies=()
777
+ local __index __key __last_key=""
778
+ while IFS= read -r __index; do
779
+ __unique_replies+=("\${COMPREPLY[__index]}")
780
+ done < <(
781
+ for ((__index=0; __index<\${#COMPREPLY[@]}; __index++)); do
782
+ printf '%q\\t%d\\n' "\${COMPREPLY[__index]}" "$__index"
783
+ done |
784
+ LC_ALL=C sort -t $'\\t' -k1,1 -k2,2n |
785
+ {
786
+ while IFS=$'\\t' read -r __key __index; do
787
+ if [ "$__key" != "$__last_key" ]; then
788
+ printf '%s\\n' "$__index"
789
+ __last_key="$__key"
790
+ fi
791
+ done
792
+ } | LC_ALL=C sort -n
793
+ )
794
+ COMPREPLY=("\${__unique_replies[@]}")
795
+ fi
796
+ if [[ $__file_mode == 1 && $(type -t compopt) == builtin ]]; then
797
+ compopt -o filenames 2>/dev/null || true
798
+ fi
282
799
  }
283
800
 
284
- complete -F _${programName} -- ${programName}
801
+ # Bash 3.2 has no compopt: use filename quoting for all candidates there.
802
+ if [[ $(type -t compopt) == builtin ]]; then
803
+ complete -F _${programName} -- ${programName}
804
+ else
805
+ complete -o filenames -F _${programName} -- ${programName}
806
+ fi
285
807
  `;
286
808
  },
287
809
  *encodeSuggestions(suggestions) {
288
810
  let i = 0;
289
811
  for (const suggestion of suggestions) {
290
812
  if (i > 0) yield "\n";
291
- if (suggestion.kind === "literal") yield sanitizeForTransport(suggestion.text);
813
+ const joined = require_joined_file_suggestion.getJoinedFileSuggestion(suggestion);
814
+ if (joined !== void 0) {
815
+ if (joined.literal && suggestion.kind === "literal") {
816
+ yield encodeBashLiteral(suggestion.text);
817
+ yield "\n";
818
+ }
819
+ for (const [index, { prefix, file }] of joined.files.entries()) {
820
+ if (index > 0) yield "\n";
821
+ yield `__JOINED_FILE__:${encodePattern(prefix)}:${file.type}:${encodeExtensions(file.extensions)}:${encodePattern(sanitizeForTransport(file.pattern ?? ""))}:${file.includeHidden ? "1" : "0"}${encodeFilePrefix(file)}`;
822
+ }
823
+ } else if (suggestion.kind === "literal") yield encodeBashLiteral(suggestion.text);
292
824
  else {
293
825
  const extensions = encodeExtensions(suggestion.extensions);
294
826
  const hidden = suggestion.includeHidden ? "1" : "0";
295
827
  const pattern = encodePattern(suggestion.pattern ?? "");
296
- yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}`;
828
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}${encodeFilePrefix(suggestion)}`;
297
829
  }
298
830
  i++;
299
831
  }
@@ -308,16 +840,36 @@ const zsh = {
308
840
  generateScript(programName, args = []) {
309
841
  validateProgramName(programName);
310
842
  const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`).join(" ");
843
+ const functionName = /[.-]/.test(programName) ? `_optique:${programName}` : `_${programName}`;
844
+ const fixedGlobFunction = "__optique-fixed-glob-" + Array.from(programName, (character) => character.charCodeAt(0).toString(16).padStart(2, "0")).join("");
311
845
  return `#compdef ${programName}
312
- function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
313
- local current="\$words[CURRENT]"
846
+ function ${functionName} () {
847
+ # Completion fields omit surrounding quotes, unlike words[CURRENT].
848
+ # Single-quoted fields are already literal; other fields retain escapes.
849
+ local current="\$IPREFIX\$PREFIX\$SUFFIX\$ISUFFIX"
850
+ local __logical_prefix="\$IPREFIX\$PREFIX"
851
+ # Let zsh match the suffix when completing in the middle of a word.
852
+ if [[ -o complete_in_word ]]; then
853
+ current="\$__logical_prefix"
854
+ fi
855
+ if [[ "\$compstate[quote]" == '\"' ]]; then
856
+ # Restore the omitted quoting context before removing shell escapes.
857
+ # In double quotes, a backslash before an ordinary character is literal.
858
+ current="\$QIPREFIX\$current\${QISUFFIX:-\$compstate[quote]}"
859
+ __logical_prefix="\$QIPREFIX\$__logical_prefix\$compstate[quote]"
860
+ current="\${(Q)current}"
861
+ __logical_prefix="\${(Q)__logical_prefix}"
862
+ elif [[ "\$compstate[quote]" != "'" ]]; then
863
+ current="\${(Q)current}"
864
+ __logical_prefix="\${(Q)__logical_prefix}"
865
+ fi
314
866
  local -a prev
315
867
  # Extract previous arguments, skipping empty ones
316
868
  prev=()
317
869
  local i
318
870
  for (( i=2; i < CURRENT; i++ )); do
319
871
  if [[ -n "\$words[i]" ]]; then
320
- prev+=("\$words[i]")
872
+ prev+=("\${(Q)words[i]}")
321
873
  fi
322
874
  done
323
875
 
@@ -341,11 +893,34 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
341
893
  desc=\${line#*\$'\\0'}
342
894
  desc=\${desc%%\$'\\0'*}
343
895
 
344
- if [[ "\$value" == __FILE__:* ]]; then
896
+ local __literal=0
897
+ if [[ "\$value" == __LITERAL__:* ]]; then
898
+ value="\${value#__LITERAL__:}"
899
+ __literal=1
900
+ fi
901
+ if [[ \$__literal == 0 && "\$value" == __FILE__:* ]]; then
345
902
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
346
- local type extensions pattern hidden
347
- IFS=':' read -r _ type extensions pattern hidden <<< "\$value"
348
- pattern="\${pattern//%3A/:}"; pattern="\${pattern//%25/%}"
903
+ local type extensions pattern hidden insertion_prefix
904
+ IFS=':' read -r _ type extensions pattern hidden insertion_prefix <<< "\$value"
905
+ local __decoded_pattern="\$pattern"
906
+ __decoded_pattern="\${__decoded_pattern//\\%3A/:}"; __decoded_pattern="\${__decoded_pattern//\\%25/%}"
907
+ insertion_prefix="\${insertion_prefix//\\%3A/:}"; insertion_prefix="\${insertion_prefix//\\%25/%}"
908
+ local __saved_prefix="\$PREFIX" __saved_iprefix="\$IPREFIX"
909
+ local __saved_current="\$current" __saved_logical_prefix="\$__logical_prefix"
910
+ if [[ -n "\$insertion_prefix" && "\$__decoded_pattern" == "\$insertion_prefix"* &&
911
+ "\$PREFIX" == "\$insertion_prefix"* &&
912
+ "\$__logical_prefix" == "\$insertion_prefix"* &&
913
+ "\$current" == "\$insertion_prefix"* ]]; then
914
+ pattern="\${__decoded_pattern#"\$insertion_prefix"}"
915
+ PREFIX="\${PREFIX#"\$insertion_prefix"}"
916
+ IPREFIX="\$IPREFIX\$insertion_prefix"
917
+ # Compare file patterns with the value part, not the inserted key.
918
+ current="\${current#"\$insertion_prefix"}"
919
+ __logical_prefix="\${__logical_prefix#"\$insertion_prefix"}"
920
+ else
921
+ # Preserve legacy decoding for directives without active metadata.
922
+ pattern="\${pattern//%3A/:}"; pattern="\${pattern//%25/%}"
923
+ fi
349
924
  has_file_completion=1
350
925
 
351
926
  # Native completion helpers expect normal zsh glob grouping even if
@@ -353,6 +928,11 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
353
928
  local __was_sh_glob=0
354
929
  [[ -o sh_glob ]] && __was_sh_glob=1
355
930
  unsetopt sh_glob
931
+ local __was_extended_glob=0
932
+ [[ -o extended_glob ]] && __was_extended_glob=1
933
+ local __was_sh_word_split=0
934
+ [[ -o sh_word_split ]] && __was_sh_word_split=1
935
+ unsetopt sh_word_split
356
936
 
357
937
  # Enable glob_dots when hidden files are requested so that
358
938
  # _files and _directories include dot-prefixed entries
@@ -364,21 +944,351 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
364
944
  # _directories enumerate the pattern's directory instead of the
365
945
  # current word. If the user has already typed beyond the pattern,
366
946
  # keep PREFIX unchanged for incremental narrowing.
367
- local __saved_prefix="\$PREFIX"
368
- if [[ -n "\$pattern" ]]; then
947
+ local __fixed_glob=0
948
+ local -a __fixed_matches
949
+ local __initial_file_pattern="" __initial_directory="" __initial_leaf=""
950
+ if [[ ( "\$type" == "file" || "\$type" == "any" || "\$type" == "directory" ) &&
951
+ -z "\$current\$PREFIX\$SUFFIX" &&
952
+ -z "\${compstate[pattern_match]}" &&
953
+ "\$pattern" != /* && "\$pattern" != "~"* ]]; then
954
+ if [[ "\$pattern" == */* ]]; then
955
+ __initial_directory="\${pattern%/*}/"
956
+ fi
957
+ __initial_leaf="\${pattern##*/}"
958
+ # Native -g filters are split as shell words and use extended glob
959
+ # syntax. Keep unsupported filename forms on the existing path.
960
+ if [[ "\$__initial_leaf" == *[\*\?]* &&
961
+ "\$__initial_directory" != *[\*\?]* &&
962
+ "\$__initial_directory" != *["[]()"]* &&
963
+ "\$__initial_leaf" != *["[]"]* &&
964
+ "\$__initial_leaf" != *'\\'* &&
965
+ "\$__initial_leaf" != *[[:space:]]* &&
966
+ "\$__initial_leaf" != *["#~^()|<>;&"]* &&
967
+ "\$__initial_leaf" != *"'"* && "\$__initial_leaf" != *'"'* ]]; then
968
+ __initial_file_pattern="\$__initial_leaf"
969
+ # Only the literal directory belongs in PREFIX. Quote it so
970
+ # spaces and shell metacharacters remain literal path text.
971
+ if [[ -n "\$__initial_directory" ]]; then
972
+ PREFIX="\${(q)__initial_directory}"
973
+ fi
974
+ fi
975
+ fi
976
+ local __saved_pattern_match="\$compstate[pattern_match]"
977
+ local __pattern_match_changed=0
978
+ # path() echoes current as its pattern. Keep zsh's escaped PREFIX
979
+ # in that case instead of reinterpreting logical text as shell syntax.
980
+ if [[ -n "\$pattern" && "\$pattern" != "\$current" && -z "\$__initial_file_pattern" ]]; then
369
981
  # Normalize leading ./ so that ./src/ma matches pattern src/
370
- local __norm_prefix="\${PREFIX#./}"
982
+ local __norm_prefix="\${__logical_prefix#./}"
371
983
  local __norm_pattern="\${pattern#./}"
372
984
  local __compare_pattern="\$__norm_pattern"
373
985
  if [[ "\$__compare_pattern" == *[\*\?]* ]]; then
374
986
  __compare_pattern="\${__compare_pattern%%[\*\?]*}"
375
987
  [[ "\$__compare_pattern" == */* ]] && __compare_pattern="\${__compare_pattern%/*}/" || __compare_pattern=""
376
988
  fi
377
- if [[ ( -n "\$__compare_pattern" || -n "\$__norm_prefix" ) && \${#__norm_prefix} -ge \${#__compare_pattern} && "\${__norm_prefix[1,\${#__compare_pattern}]}" == "\$__compare_pattern" && "\$PREFIX" != "\$pattern" ]]; then
378
- # User typed an equivalent or extended form—keep PREFIX
379
- true
989
+ if [[ ( -n "\$__compare_pattern" || -n "\$__norm_prefix" ) && \${#__norm_prefix} -ge \${#__compare_pattern} && "\${__norm_prefix[1,\${#__compare_pattern}]}" == "\$__compare_pattern" && "\$__logical_prefix" != "\$pattern" ]]; then
990
+ # Keep the typed prefix, but retain a fixed file glob separately.
991
+ if [[ "\$type" != "directory" && "\$__norm_pattern" == *[\*\?]* ]]; then
992
+ __fixed_glob=1
993
+ local __fixed_was_glob_dots=0
994
+ [[ -o glob_dots ]] && __fixed_was_glob_dots=1
995
+ if [[ "\$PREFIX" == .* || "\$PREFIX" == */.* ]]; then setopt glob_dots; fi
996
+ # Keep qualifiers in a string so sourcing also works with SH_GLOB.
997
+ local __fixed_pattern="" __glob_char="" __glob_previous=""
998
+ local __glob_escaped=0
999
+ local __glob_class=0 __glob_class_start=0 __glob_class_nested=""
1000
+ local -i __glob_index
1001
+ for (( __glob_index=1; __glob_index <= \${#pattern}; __glob_index++ )); do
1002
+ __glob_char="\${pattern[__glob_index]}"
1003
+ if [[ "\$__glob_class" == "1" ]]; then
1004
+ # Class members are already literal. Preserve leading ]
1005
+ # and nested POSIX classes without adding backslashes.
1006
+ __fixed_pattern+="\$__glob_char"
1007
+ if [[ -n "\$__glob_class_nested" ]]; then
1008
+ if [[ "\$__glob_char" == "]" && "\$__glob_previous" == "\$__glob_class_nested" ]]; then
1009
+ __glob_class_nested=""
1010
+ fi
1011
+ elif [[ "\$__glob_char" == "[" && "\${pattern[__glob_index+1]}" == ":" ]]; then
1012
+ __glob_class_nested="\${pattern[__glob_index+1]}"
1013
+ __glob_class_start=0
1014
+ elif [[ "\$__glob_char" == "]" && "\$__glob_class_start" == "0" ]]; then
1015
+ __glob_class=0
1016
+ elif [[ "\$__glob_class_start" == "1" && ( "\$__glob_char" == "!" || "\$__glob_char" == "^" ) &&
1017
+ ( "\${pattern[__glob_index+1]}" != "]" || "\${pattern[__glob_index+2,-1]}" == *']'* ) ]]; then
1018
+ __glob_class_start=2
1019
+ else
1020
+ __glob_class_start=0
1021
+ fi
1022
+ __glob_previous="\$__glob_char"
1023
+ continue
1024
+ fi
1025
+ if [[ "\$__glob_escaped" == "1" ]]; then
1026
+ __fixed_pattern+="\$__glob_char"
1027
+ __glob_escaped=0
1028
+ __glob_previous=""
1029
+ continue
1030
+ fi
1031
+ if [[ "\$__glob_char" == "\\\\" ]]; then
1032
+ __fixed_pattern+="\$__glob_char"
1033
+ __glob_escaped=1
1034
+ elif [[ "\$__glob_char" == "[" ]]; then
1035
+ __fixed_pattern+="\$__glob_char"
1036
+ __glob_class=1
1037
+ __glob_class_start=1
1038
+ elif [[ "\$__glob_char" == [#^~\\(\\)\\|\\<\\>] &&
1039
+ ! ( "\$__glob_char" == "~" && "\$__glob_index" == "1" ) ]]; then
1040
+ # Keep class negation and leading home expansion; escape
1041
+ # extended-glob operators in literal pathname components.
1042
+ __fixed_pattern+="\\\\\$__glob_char"
1043
+ else
1044
+ __fixed_pattern+="\$__glob_char"
1045
+ fi
1046
+ __glob_previous="\$__glob_char"
1047
+ done
1048
+ __fixed_pattern+="(#qN)"
1049
+ __fixed_matches=( \${~__fixed_pattern} )
1050
+ __fixed_matches=( "\${(@)__fixed_matches:a}" )
1051
+ # Hidden parents widen fixed matches, not native child suggestions.
1052
+ if [[ "\$__fixed_was_glob_dots" == "0" && "\${PREFIX##*/}" != .* ]]; then unsetopt glob_dots; fi
1053
+ fi
380
1054
  else
381
- PREFIX="\$pattern"
1055
+ local __native_pattern="\$pattern"
1056
+ # Initial globs use the conservative native filters above.
1057
+ # Retained input and protected markers still need pattern matching.
1058
+ if [[ -n "\$compstate[context]" && "\$pattern" == *[\\*\\?\\[]* &&
1059
+ ( -n "\$current" || "\$pattern" == *'$'* || "\$pattern" == *'\`'* ) ]]; then
1060
+ compstate[pattern_match]='*'
1061
+ __pattern_match_changed=1
1062
+ fi
1063
+ # Native helpers expand substitutions, tilde names and executable
1064
+ # glob qualifiers. Input-derived patterns must keep these literal.
1065
+ # Ordinary fixed glob/tilde patterns retain their native behavior.
1066
+ local __tilde_name="\${pattern#\\~}"
1067
+ __tilde_name="\${__tilde_name%%/*}"
1068
+ local -a match mbegin mend
1069
+ local __code_qualifier=0
1070
+ local __fixed_qualifier_base=""
1071
+ local __safe_qualifiers='([-/F.@=p*rwxAIERWXsStUG^MTNDn,]|[ugdl]<->|[amc]([Mwhmsd]|)([-+]|)<->|L([kKmMgGtTpP]|)([-+]|)<->|[oO][nLlamcdN])##'
1072
+ local __qualifier_pattern="\$pattern" __qualifier
1073
+ while _have_glob_qual "\$__qualifier_pattern" complete; do
1074
+ __qualifier="\${match[5]#\\#q}"
1075
+ __qualifier_pattern="\$match[2]"
1076
+ # A numeric sense (+1) is not a function qualifier (+name).
1077
+ # Accept only bounded attribute/sort syntax in this exception.
1078
+ # Expand the pattern at runtime after unsetting SH_GLOB.
1079
+ if [[ "\$__qualifier" == \${~__safe_qualifiers} ]]; then
1080
+ __fixed_qualifier_base="\$__qualifier_pattern"
1081
+ elif [[ "\$__qualifier" == *[e+]* ]]; then
1082
+ __code_qualifier=1
1083
+ fi
1084
+ done
1085
+ # Partial overlaps with complete backend classes/groups remain
1086
+ # syntax. A complete input-provided expression can be literal.
1087
+ local -a __syntax_starts=() __syntax_ends=() __group_stack=()
1088
+ local -i __scan_index __class_begin=0 __member_begin=0
1089
+ local __scan_char __nested_class=""
1090
+ for (( __scan_index=1; __scan_index <= \${#pattern}; __scan_index++ )); do
1091
+ __scan_char="\${pattern[__scan_index]}"
1092
+ if [[ "\$__scan_char" == '\\' ]]; then
1093
+ (( __scan_index++ ))
1094
+ elif (( __class_begin )); then
1095
+ if [[ "\$__scan_char" == '[' && "\${pattern[__scan_index+1]}" == [:.=] ]]; then
1096
+ __nested_class="\${pattern[__scan_index+1]}"
1097
+ elif [[ -n "\$__nested_class" && "\$__scan_char" == "\$__nested_class" && "\${pattern[__scan_index+1]}" == ']' ]]; then
1098
+ (( __scan_index++ ))
1099
+ __nested_class=""
1100
+ elif [[ -z "\$__nested_class" && "\$__scan_char" == ']' ]] && (( __scan_index > __member_begin )); then
1101
+ __syntax_starts+=("\$__class_begin")
1102
+ __syntax_ends+=("\$__scan_index")
1103
+ __class_begin=0
1104
+ elif (( __scan_index == __member_begin )) &&
1105
+ [[ "\$__scan_char" == '!' || "\$__scan_char" == '^' ]] &&
1106
+ [[ "\${pattern[__scan_index+1]}" != ']' || "\${pattern[__scan_index+2,-1]}" == *']'* ]]; then
1107
+ (( __member_begin++ ))
1108
+ fi
1109
+ elif [[ "\$__scan_char" == '[' ]]; then
1110
+ __class_begin=\$__scan_index
1111
+ __member_begin=\$(( __scan_index + 1 ))
1112
+ elif [[ "\$__scan_char" == '(' ]]; then
1113
+ __group_stack+=("\$__scan_index")
1114
+ elif [[ "\$__scan_char" == ')' ]] && (( \${#__group_stack} )); then
1115
+ __syntax_starts+=("\${__group_stack[-1]}")
1116
+ __syntax_ends+=("\$__scan_index")
1117
+ __group_stack[-1]=()
1118
+ fi
1119
+ done
1120
+ # Search retained input only where it cannot split fixed syntax.
1121
+ local __input_part="\$current"
1122
+ local __backend_prefix="\$pattern" __backend_suffix=""
1123
+ local __search_pattern __candidate_prefix
1124
+ local -i __search_offset __overlap_start __overlap_end __range_index __syntax_overlap
1125
+ local -i __fixed_tilde_end=0
1126
+ local __tilde_expression='([+-]|[+-]<->|[[:alnum:]_.-]#)'
1127
+ if [[ "\$pattern" == '~'* &&
1128
+ "\$__tilde_name" == \${~__tilde_expression} ]]; then
1129
+ __fixed_tilde_end=\$(( \${#__tilde_name} + 1 ))
1130
+ fi
1131
+ while [[ -n "\$__input_part" ]]; do
1132
+ __search_pattern="\$pattern"
1133
+ __search_offset=0
1134
+ while [[ "\$__search_pattern" == *"\$__input_part"* ]]; do
1135
+ __candidate_prefix="\${__search_pattern%%"\$__input_part"*}"
1136
+ __overlap_start=\$(( __search_offset + \${#__candidate_prefix} + 1 ))
1137
+ __overlap_end=\$(( __overlap_start + \${#__input_part} - 1 ))
1138
+ __syntax_overlap=0
1139
+ # Leading home/directory-stack/named-directory syntax belongs
1140
+ # to the fixed pattern, even when the input overlaps it.
1141
+ if (( __fixed_tilde_end && __overlap_start <= __fixed_tilde_end )); then
1142
+ __syntax_overlap=1
1143
+ fi
1144
+ for (( __range_index=1; __range_index <= \${#__syntax_starts}; __range_index++ )); do
1145
+ if (( __overlap_start <= __syntax_ends[__range_index] &&
1146
+ __overlap_end >= __syntax_starts[__range_index] &&
1147
+ ( __overlap_start > __syntax_starts[__range_index] ||
1148
+ __overlap_end < __syntax_ends[__range_index] ) )); then
1149
+ __syntax_overlap=1
1150
+ break
1151
+ fi
1152
+ done
1153
+ # Every position in a recognized fixed attribute qualifier
1154
+ # is backend syntax, including its closing parenthesis.
1155
+ if [[ "\$__fixed_qualifier_base" == *[\\*\\?\\]] ]] &&
1156
+ (( __overlap_end > \${#__fixed_qualifier_base} )); then
1157
+ __syntax_overlap=1
1158
+ fi
1159
+ if (( ! __syntax_overlap )); then
1160
+ if (( __overlap_start == 1 )); then
1161
+ __backend_prefix=""
1162
+ else
1163
+ __backend_prefix="\${pattern[1,__overlap_start-1]}"
1164
+ fi
1165
+ __backend_suffix="\${pattern[__overlap_end+1,-1]}"
1166
+ break 2
1167
+ fi
1168
+ __search_offset=\$__overlap_start
1169
+ __search_pattern="\${pattern[__overlap_start+1,-1]}"
1170
+ done
1171
+ __input_part="\${__input_part[2,-1]}"
1172
+ done
1173
+ local __quoted_input="\${(q)__input_part}"
1174
+ # Pattern matching removes quoting differently from normal
1175
+ # completion. Character classes keep these characters literal.
1176
+ if [[ -n "\$compstate[pattern_match]" ]]; then
1177
+ __quoted_input="\${(b)__input_part}"
1178
+ __quoted_input="\${__quoted_input//\\\$/[\\\$]}"
1179
+ __quoted_input="\${__quoted_input//\\\`/[\\\`]}"
1180
+ fi
1181
+ if [[ "\$pattern" == *'$'* || "\$pattern" == *'\`'* ||
1182
+ ( "\$pattern" == '~'* && "\$__tilde_name" == *[^[:alnum:]_.+-]* ) ]] ||
1183
+ (( __code_qualifier )); then
1184
+ __native_pattern="\${(q)pattern}"
1185
+ local __backend_pattern="\$__backend_prefix\$__backend_suffix"
1186
+ local __backend_tilde="\${__backend_pattern#\\~}"
1187
+ __backend_tilde="\${__backend_tilde%%/*}"
1188
+ local __unsafe_backend_qualifier=0
1189
+ __qualifier_pattern="\$__backend_pattern"
1190
+ while _have_glob_qual "\$__qualifier_pattern" complete; do
1191
+ __qualifier="\${match[5]#\\#q}"
1192
+ __qualifier_pattern="\$match[2]"
1193
+ if [[ "\$__qualifier" != \${~__safe_qualifiers} ]]; then
1194
+ __unsafe_backend_qualifier=1
1195
+ fi
1196
+ done
1197
+ # Retain trusted wildcards and safe attribute qualifiers when
1198
+ # all executable syntax belongs to the quoted input.
1199
+ if [[ -n "\$__input_part" &&
1200
+ "\$__backend_pattern" != *'$'* &&
1201
+ "\$__backend_pattern" != *'\`'* &&
1202
+ ( "\$__backend_pattern" != '~'* ||
1203
+ "\$__backend_tilde" != *[^[:alnum:]_.+-]* ) ]] &&
1204
+ (( ! __unsafe_backend_qualifier )); then
1205
+ __native_pattern="\$__backend_prefix\$__quoted_input\$__backend_suffix"
1206
+ elif [[ "\$__backend_pattern" != *'$('* &&
1207
+ "\$__backend_pattern" != *'\${'* &&
1208
+ "\$__backend_pattern" != *'$['* &&
1209
+ ( "\$pattern" != '~'* ||
1210
+ "\$__tilde_name" != *[^[:alnum:]_.+-]* ) ]] &&
1211
+ (( ! __unsafe_backend_qualifier )); then
1212
+ # Fixed patterns also need literal substitution markers,
1213
+ # without quoting their wildcards or safe qualifiers.
1214
+ local __dollar='\\\$'
1215
+ local __backtick='\\\`'
1216
+ if [[ -n "\$compstate[pattern_match]" ]]; then
1217
+ __dollar='[\$]'
1218
+ __backtick='[\`]'
1219
+ fi
1220
+ # Existing glob classes already protect their markers.
1221
+ local -a __backend_parts=()
1222
+ local __part __escaped __char __class_kind
1223
+ local -i __index __in_class __class_start
1224
+ for __part in "\$__backend_prefix" "\$__backend_suffix"; do
1225
+ __escaped=""
1226
+ __in_class=0
1227
+ __class_kind=""
1228
+ for (( __index=1; __index <= \${#__part}; __index++ )); do
1229
+ __char="\${__part[__index]}"
1230
+ if [[ "\$__char" == '\\' ]]; then
1231
+ __escaped+="\$__char\${__part[__index+1]}"
1232
+ (( __index++ ))
1233
+ continue
1234
+ elif (( __in_class )); then
1235
+ if [[ "\$__char" == '[' && "\${__part[__index+1]}" == [:.=] ]]; then
1236
+ __class_kind="\${__part[__index+1]}"
1237
+ elif [[ -n "\$__class_kind" && "\$__char" == "\$__class_kind" && "\${__part[__index+1]}" == ']' ]]; then
1238
+ __escaped+="\$__char]"
1239
+ (( __index++ ))
1240
+ __class_kind=""
1241
+ continue
1242
+ elif [[ -z "\$__class_kind" && "\$__char" == ']' ]] && (( __index > __class_start )); then
1243
+ __in_class=0
1244
+ elif (( __index == __class_start )) && [[ "\$__char" == '!' || "\$__char" == '^' ]]; then
1245
+ (( __class_start++ ))
1246
+ fi
1247
+ elif [[ "\$__char" == '[' ]]; then
1248
+ __in_class=1
1249
+ __class_start=$(( __index + 1 ))
1250
+ elif [[ "\$__char" == '$' ]]; then
1251
+ __char="\$__dollar"
1252
+ elif [[ "\$__char" == '\`' ]]; then
1253
+ __char="\$__backtick"
1254
+ fi
1255
+ __escaped+="\$__char"
1256
+ done
1257
+ __backend_parts+=("\$__escaped")
1258
+ done
1259
+ __backend_prefix="\${__backend_parts[1]}"
1260
+ __backend_suffix="\${__backend_parts[2]}"
1261
+ __native_pattern="\$__backend_prefix\$__quoted_input\$__backend_suffix"
1262
+ fi
1263
+ else
1264
+ # Quote retained metacharacters, including segments made only
1265
+ # of them. Wildcard-only overlap remains backend syntax.
1266
+ if [[ "\$__input_part" == *[\\\\\\(\\)\\[\\#\\^\\|\\~\\<\\>\\*\\?]* &&
1267
+ "\$__input_part" != [\\*\\?]## ]]; then
1268
+ __native_pattern="\$__backend_prefix\$__quoted_input\$__backend_suffix"
1269
+ fi
1270
+ fi
1271
+ PREFIX="\$__native_pattern"
1272
+ # Match simple fixed tilde globs through native -g filters. The
1273
+ # pattern matcher otherwise treats the leading ~ as glob syntax,
1274
+ # even after _path_files has expanded it for filesystem lookup.
1275
+ if (( __fixed_tilde_end )) && [[ -z "\$__input_part" &&
1276
+ "\$__native_pattern" == "\$pattern" && "\$pattern" == */* ]]; then
1277
+ __initial_directory="\${pattern%/*}/"
1278
+ __initial_leaf="\${pattern##*/}"
1279
+ if [[ "\$__initial_leaf" == *[\*\?]* &&
1280
+ "\$__initial_directory" != *[\*\?]* &&
1281
+ "\$__initial_directory" != *["[]()"]* &&
1282
+ "\$__initial_leaf" != *["[]()#~^|<>;&"]* &&
1283
+ "\$__initial_leaf" != *'\\'* &&
1284
+ "\$__initial_leaf" != *[[:space:]]* &&
1285
+ "\$__initial_leaf" != *"'"* && "\$__initial_leaf" != *'"'* ]]; then
1286
+ __initial_file_pattern="\$__initial_leaf"
1287
+ PREFIX="\$__initial_directory"
1288
+ compstate[pattern_match]=""
1289
+ __pattern_match_changed=1
1290
+ fi
1291
+ fi
382
1292
  fi
383
1293
  fi
384
1294
 
@@ -390,15 +1300,114 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
390
1300
  ext_pattern="*.\$extensions"
391
1301
  fi
392
1302
  fi
1303
+ # _wanted/_next_label write to the named explanation parameter.
1304
+ # Keep it local for every file directive, including directory/any.
1305
+ local -a expl=()
393
1306
  # Use zsh's native file completion
394
1307
  case "\$type" in
395
- file)
396
- if [[ -n "\$ext_pattern" ]]; then
397
- # Route filtered files through zsh's standard files/directories
398
- # tags so files-tag styles and tag-order still apply, while
399
- # avoiding _files' all-files fallback for extension-filtered
400
- # matches.
401
- local file_pattern="\${ext_pattern}(#q-.)"
1308
+ file|any)
1309
+ if [[ "\$type" == "file" || -n "\$__initial_file_pattern" ]]; then
1310
+ # Regular-file suggestions retain symlinked files; initial
1311
+ # any globs use a non-directory qualifier instead.
1312
+ local file_pattern="\${ext_pattern:-*}(#q-.)"
1313
+ if [[ "\$__fixed_glob" == "1" ]]; then
1314
+ file_pattern="\${ext_pattern:-*}(#q-.e:${fixedGlobFunction}:)"
1315
+ fi
1316
+ if [[ -n "\$__initial_file_pattern" ]]; then
1317
+ local __file_qualifier="-."
1318
+ [[ "\$type" == "any" ]] && __file_qualifier="^-/"
1319
+ local -a __initial_file_patterns=()
1320
+ if [[ -n "\$extensions" ]]; then
1321
+ setopt extended_glob
1322
+ local __initial_extension
1323
+ for __initial_extension in "\${(@s:,:)extensions}"; do
1324
+ __initial_file_patterns+=("(\${__initial_file_pattern}~^*.\${__initial_extension})(#q\${__file_qualifier})")
1325
+ done
1326
+ else
1327
+ __initial_file_patterns=("\${__initial_file_pattern}(#q\${__file_qualifier})")
1328
+ fi
1329
+ file_pattern="\${(j: :)__initial_file_patterns}"
1330
+ fi
1331
+ local file_pattern_prefix="\$PREFIX"
1332
+ local -a match mbegin mend
1333
+ # Native qualifier copying concatenates flags with the explicit
1334
+ # regular-file group. Keep backend attributes in their own group.
1335
+ if [[ -n "\$compstate[pattern_match]" && -z "\$SUFFIX" ]]; then
1336
+ local file_qualifier file_qualifiers=""
1337
+ while _have_glob_qual "\$file_pattern_prefix" complete; do
1338
+ file_qualifier="\${match[5]#\\#q}"
1339
+ file_pattern_prefix="\$match[2]"
1340
+ file_qualifiers="(#q\$file_qualifier)\$file_qualifiers"
1341
+ done
1342
+ if [[ -n "\$file_qualifiers" ]]; then
1343
+ file_pattern+="\$file_qualifiers"
1344
+ fi
1345
+ fi
1346
+ local file_subtree_pattern="**/*(#q/N)"
1347
+ local -a file_subtree=() file_recursive_patterns=()
1348
+ local -a file_tags=(files directories)
1349
+ local file_recursive_pattern file_directory file_prefix file_tag
1350
+ if [[ -n "\$__initial_file_pattern" || -z "\$ext_pattern" ]] &&
1351
+ zstyle -t ":completion:\${curcontext}:" list-dirs-first; then
1352
+ file_tags=(directories files)
1353
+ fi
1354
+ _tags "\${file_tags[@]}"
1355
+ while _tags; do
1356
+ local file_matched=0
1357
+ for file_tag in "\${file_tags[@]}"; do
1358
+ if [[ "\$file_tag" == directories ]]; then
1359
+ local file_tag_prefix="\$PREFIX"
1360
+ PREFIX="\$file_pattern_prefix"
1361
+ while _next_label directories expl directory; do
1362
+ # File qualifiers constrain final results, not the
1363
+ # directories needed to navigate to those results.
1364
+ _path_files -/ "\${expl[@]}" && file_matched=1
1365
+ done
1366
+ PREFIX="\$file_tag_prefix"
1367
+ else
1368
+ local file_tag_prefix="\$PREFIX"
1369
+ PREFIX="\$file_pattern_prefix"
1370
+ while _next_label files expl file; do
1371
+ local -a file_ignore=() file_ignore_args=()
1372
+ # Explicit globs bypass automatic fignore. Apply it only
1373
+ # when the tag has not supplied its own ignore patterns.
1374
+ if [[ -z "\$__initial_file_pattern" && -z "\$ext_pattern" && ( "\$__fixed_glob" != "1" || "\$pattern" == "*" ) && -z "\$_comp_no_ignore" && -n "\$FIGNORE" ]] && (( \${#_comp_ignore[@]} == 0 )); then
1375
+ file_ignore=("?*\${^fignore[@]}")
1376
+ file_ignore_args=(-F file_ignore)
1377
+ fi
1378
+ if _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}"; then
1379
+ file_matched=1
1380
+ elif [[ ( -n "\$__initial_file_pattern" || -z "\$ext_pattern" ) && "\$PREFIX\$SUFFIX" != */* ]] &&
1381
+ zstyle -a ":completion:\${curcontext}:files" recursive-files file_recursive_patterns; then
1382
+ # Match _files' recursive fallback, retaining the same
1383
+ # file filter and tag-specific ignore options.
1384
+ for file_recursive_pattern in "\${file_recursive_patterns[@]}"; do
1385
+ if [[ "\$PWD/" = \${~file_recursive_pattern} ]]; then
1386
+ if (( \${#file_subtree[@]} == 0 )); then
1387
+ file_subtree=(\${~file_subtree_pattern})
1388
+ fi
1389
+ for file_directory in "\${file_subtree[@]}"; do
1390
+ file_prefix="\$PREFIX"
1391
+ PREFIX="\$file_directory/\$PREFIX"
1392
+ _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}" && file_matched=1
1393
+ PREFIX="\$file_prefix"
1394
+ done
1395
+ break
1396
+ fi
1397
+ done
1398
+ fi
1399
+ done
1400
+ PREFIX="\$file_tag_prefix"
1401
+ fi
1402
+ done
1403
+ (( file_matched )) && break
1404
+ done
1405
+ elif [[ -n "\$ext_pattern" || "\$__fixed_glob" == "1" ]]; then
1406
+ local file_pattern="\${ext_pattern}(#q^-/)"
1407
+ if [[ "\$__fixed_glob" == "1" ]]; then
1408
+ # Put the predicate before ^, which negates later qualifiers.
1409
+ file_pattern="\${ext_pattern:-*}(#qe:${fixedGlobFunction}:^-/)"
1410
+ fi
402
1411
  _wanted files expl file _path_files -g "\${file_pattern}"
403
1412
  _wanted directories expl directory _path_files -/
404
1413
  else
@@ -406,22 +1415,25 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
406
1415
  fi
407
1416
  ;;
408
1417
  directory)
409
- _directories
410
- ;;
411
- any)
412
- if [[ -n "\$ext_pattern" ]]; then
413
- local file_pattern="\${ext_pattern}(#q^-/)"
414
- _wanted files expl file _path_files -g "\${file_pattern}"
415
- _wanted directories expl directory _path_files -/
1418
+ if [[ -n "\$__initial_file_pattern" ]]; then
1419
+ # -/ would add every directory alongside the filtered glob.
1420
+ _wanted directories expl directory _path_files -g "\${__initial_file_pattern}(#q-/)"
416
1421
  else
417
- _files
1422
+ _directories
418
1423
  fi
419
1424
  ;;
420
1425
  esac
421
-
422
1426
  # Restore PREFIX and glob_dots to their previous state
423
1427
  PREFIX="\$__saved_prefix"
1428
+ IPREFIX="\$__saved_iprefix"
1429
+ current="\$__saved_current"
1430
+ __logical_prefix="\$__saved_logical_prefix"
1431
+ if [[ "\$__was_extended_glob" == "1" ]]; then setopt extended_glob; else unsetopt extended_glob; fi
1432
+ if (( __pattern_match_changed )); then
1433
+ compstate[pattern_match]="\$__saved_pattern_match"
1434
+ fi
424
1435
  if [[ "\$__was_sh_glob" == "1" ]]; then setopt sh_glob; else unsetopt sh_glob; fi
1436
+ if [[ "\$__was_sh_word_split" == "1" ]]; then setopt sh_word_split; else unsetopt sh_word_split; fi
425
1437
  if [[ "\$__was_glob_dots" == "1" ]]; then setopt glob_dots; else unsetopt glob_dots; fi
426
1438
  else
427
1439
  # Regular literal completion
@@ -438,32 +1450,52 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
438
1450
  # Prepare completion with descriptions for _describe
439
1451
  local -a matches
440
1452
  local -i i
1453
+ local __match
441
1454
  for (( i=1; i <= \${#completions[@]}; i++ )); do
1455
+ # _describe uses backslashes to escape its colon separator.
1456
+ __match="\${completions[i]//\\\\/\\\\\\\\}"
1457
+ __match="\${__match//:/\\\\:}"
442
1458
  if [[ -n "\${descriptions[i]}" ]]; then
443
- matches+=("\${completions[i]}:\${descriptions[i]}")
1459
+ matches+=("\$__match:\${descriptions[i]}")
444
1460
  else
445
- matches+=("\${completions[i]}")
1461
+ matches+=("\$__match")
446
1462
  fi
447
1463
  done
448
- _describe 'commands' matches
1464
+ if [[ -n "\$QISUFFIX" ]]; then
1465
+ # A closing quote already exists; do not append a space inside it.
1466
+ _describe 'commands' matches -S ''
1467
+ else
1468
+ _describe 'commands' matches
1469
+ fi
449
1470
  fi
450
1471
  }
451
1472
 
1473
+ function ${fixedGlobFunction} () {
1474
+ local __fixed_candidate="\${REPLY:a}"
1475
+ (( \${__fixed_matches[(Ie)\$__fixed_candidate]} ))
1476
+ }
1477
+
452
1478
  if (( $+functions[compdef] )); then
453
- compdef _${programName.replace(/[^a-zA-Z0-9]/g, "_")} ${programName}
1479
+ compdef ${functionName} ${programName}
1480
+ fi
1481
+
1482
+ # An autoloaded file runs as a function body. Dispatch its initial request;
1483
+ # sourcing the file only defines and registers the completion helper.
1484
+ if [[ \$ZSH_EVAL_CONTEXT == *:loadautofunc || \$ZSH_EVAL_CONTEXT == *:shfunc ]]; then
1485
+ ${functionName} "\$@"
454
1486
  fi
455
1487
  `;
456
1488
  },
457
1489
  *encodeSuggestions(suggestions) {
458
1490
  for (const suggestion of suggestions) if (suggestion.kind === "literal") {
459
1491
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
460
- yield `${sanitizeForTransport(suggestion.text)}\0${description}\0`;
1492
+ yield `${encodeLiteral(suggestion.text)}\0${description}\0`;
461
1493
  } else {
462
1494
  const extensions = encodeExtensions(suggestion.extensions);
463
1495
  const hidden = suggestion.includeHidden ? "1" : "0";
464
1496
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
465
1497
  const pattern = encodePattern(suggestion.pattern ?? "");
466
- yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}\0${description}\0`;
1498
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}${encodeFilePrefix(suggestion)}\0${description}\0`;
467
1499
  }
468
1500
  }
469
1501
  };
@@ -476,12 +1508,35 @@ const fish = {
476
1508
  generateScript(programName, args = []) {
477
1509
  validateProgramName(programName);
478
1510
  const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "\\'")}'`).join(" ");
479
- const functionName = `__${programName.replace(/[^a-zA-Z0-9]/g, "_")}_complete`;
1511
+ const functionName = `__${programName}_complete`;
480
1512
  return `
481
1513
  function ${functionName}
482
1514
  set -l tokens (commandline -poc)
483
1515
  set -l current (commandline -ct)
484
1516
 
1517
+ # commandline -poc decodes previous tokens, but commandline -ct returns
1518
+ # the current token as typed. Decode its quotes and escapes as well.
1519
+ # Keep the token as typed if decoding fails or yields a newline or tab,
1520
+ # which completion output cannot carry.
1521
+ set -l __raw_current "$current"
1522
+ set -l __decoded (string unescape -- "$current")
1523
+ or begin
1524
+ # string unescape rejects an unfinished trailing backslash. fish
1525
+ # keeps it literally inside single quotes, which also keep a " as
1526
+ # is, and otherwise waits for the character it escapes.
1527
+ set -l __prefix (string replace -r '\\\\\\\\$' '' -- "$current")
1528
+ set -l __probe (string unescape -- "$__prefix\\"")
1529
+ set -l __plain (string unescape -- "$__prefix")
1530
+ if test "$__probe" = "$__plain\\""
1531
+ set __decoded (string unescape -- "$current\\\\")
1532
+ else
1533
+ set __decoded (string unescape -- "$__prefix")
1534
+ end
1535
+ end
1536
+ and test (count $__decoded) -eq 1
1537
+ and not string match -qr '\\t' -- "$__decoded"
1538
+ and set current "$__decoded"
1539
+
485
1540
  # Extract previous arguments (skip the command name)
486
1541
  set -l prev
487
1542
  set -l count (count $tokens)
@@ -493,7 +1548,12 @@ function ${functionName}
493
1548
  ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current 2>/dev/null)\n` : ` set -l output (${programName} $prev $current 2>/dev/null)\n`}
494
1549
  # Process each line of output
495
1550
  for line in $output
496
- if string match -q '__FILE__:*' -- $line
1551
+ set -l literal 0
1552
+ if string match -q '__LITERAL__:*' -- $line
1553
+ set line (string sub -s 13 -- $line)
1554
+ set literal 1
1555
+ end
1556
+ if test $literal -eq 0; and string match -q '__FILE__:*' -- $line
497
1557
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
498
1558
  set -l directive (string split \\t -- $line)[1]
499
1559
  set -l parts (string split ':' -- $directive)
@@ -501,6 +1561,34 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
501
1561
  set -l extensions $parts[3]
502
1562
  set -l pattern (string replace -a '%25' '%' -- (string replace -a '%3A' ':' -- $parts[4]))
503
1563
  set -l hidden $parts[5]
1564
+ set -l insertion_prefix ""
1565
+ if test (count $parts) -ge 6
1566
+ set insertion_prefix (string replace -a '%25' '%' -- (string replace -a '%3A' ':' -- $parts[6]))
1567
+ end
1568
+ set -l saved_current "$current"
1569
+ set -l saved_raw_current "$__raw_current"
1570
+ set -l prefix_length (string length -- "$insertion_prefix")
1571
+ if test $prefix_length -gt 0
1572
+ and test (string sub -l $prefix_length -- "$pattern") = "$insertion_prefix"
1573
+ and test (string sub -l $prefix_length -- "$current") = "$insertion_prefix"
1574
+ set -l start (math $prefix_length + 1)
1575
+ set pattern (string sub -s $start -- "$pattern")
1576
+ set current (string sub -s $start -- "$current")
1577
+ # Keep the raw word aligned so an unquoted key is not mistaken
1578
+ # for quoting when comparing it with the decoded value.
1579
+ if test (string sub -l $prefix_length -- "$__raw_current") = "$insertion_prefix"
1580
+ set __raw_current (string sub -s $start -- "$__raw_current")
1581
+ end
1582
+ else
1583
+ set insertion_prefix ""
1584
+ end
1585
+
1586
+ # path() echoes the current word as its pattern. When that word
1587
+ # was quoted or escaped, treat the echo as the typed word rather
1588
+ # than as a pattern from the program.
1589
+ if test "$current" != "$__raw_current"; and test "$pattern" = "$current"
1590
+ set pattern ""
1591
+ end
504
1592
 
505
1593
  # When a pattern is specified, use it as the glob base instead
506
1594
  # of the current word. If the user has already typed beyond the
@@ -513,15 +1601,13 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
513
1601
  # Normalize leading ./ so that ./src/ma matches pattern src/
514
1602
  set -l __norm_current (string replace -r '^\\./' '' -- "$current")
515
1603
  set -l __norm_pattern (string replace -r '^\\./' '' -- "$pattern")
516
- # For wildcard patterns, compare only the directory prefix
1604
+ # For wildcard patterns, compare only the directory prefix.
1605
+ # Use a regex: fish wildcards have no character classes.
1606
+ # Only the basename can hold wildcards; a ? or * in a
1607
+ # directory name such as What?/ is literal.
517
1608
  set -l __compare_pattern "$__norm_pattern"
518
- if string match -q '*[*?]*' -- "$__compare_pattern"
519
- set __compare_pattern (string replace -r '[*?].*' '' -- "$__compare_pattern")
520
- if string match -q '*/*' -- "$__compare_pattern"
521
- set __compare_pattern (string replace -r '/[^/]*$' '/' -- "$__compare_pattern")
522
- else
523
- set __compare_pattern ""
524
- end
1609
+ if string match -qr '[*?][^/]*$' -- "$__compare_pattern"
1610
+ set __compare_pattern (string replace -r '[^/]*$' '' -- "$__compare_pattern")
525
1611
  end
526
1612
  set -l __cp_len (string length -- "$__compare_pattern")
527
1613
  set -l __nc_len (string length -- "$__norm_current")
@@ -536,14 +1622,17 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
536
1622
  end
537
1623
  end
538
1624
 
539
- # Expand tilde prefix for globbing—fish does not expand ~
540
- # inside variable substitutions, so replace it with $HOME
541
- if string match -q '~/*' -- "$glob_base"
542
- set __tilde_prefix "~"
543
- set glob_base (string replace -r '^~' "$HOME" -- "$glob_base")
544
- else if string match -q '~' -- "$glob_base"
545
- set __tilde_prefix "~"
546
- set glob_base "$HOME/"
1625
+ # Expand tilde prefix for globbing — fish does not expand ~
1626
+ # inside variable substitutions, so replace it with $HOME. Like
1627
+ # fish itself, keep a quoted or escaped tilde in the word literal.
1628
+ if test $__from_pattern -eq 1; or string match -q '~*' -- "$__raw_current"
1629
+ if string match -q '~/*' -- "$glob_base"
1630
+ set __tilde_prefix "~"
1631
+ set glob_base (string replace -r '^~' "$HOME" -- "$glob_base")
1632
+ else if string match -q '~' -- "$glob_base"
1633
+ set __tilde_prefix "~"
1634
+ set glob_base "$HOME/"
1635
+ end
547
1636
  end
548
1637
 
549
1638
  # If the glob base is a directory without a trailing slash,
@@ -553,54 +1642,82 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
553
1642
  end
554
1643
 
555
1644
  # Pre-expand file candidates. When the glob base came from
556
- # the program's pattern and contains * or ?, use it as the
557
- # complete glob expression. Otherwise append * as a prefix.
1645
+ # the program's pattern and its basename contains * or ?, use
1646
+ # it as the complete glob expression. Otherwise append * as a
1647
+ # prefix, so a literal ? in a directory name such as What?/
1648
+ # still lists that directory. A pattern equal to the current
1649
+ # word is the typed text echoed back (as path() does), so its
1650
+ # ? and * are literal characters, not wildcards.
558
1651
  # Note: [ is NOT treated as a glob because it commonly
559
1652
  # appears in literal filenames like [draft] or foo[1].txt.
560
1653
  set -l __has_glob 0
561
1654
  if test $__from_pattern -eq 1
562
- and string match -q '*[*?]*' -- "$glob_base"
1655
+ and string match -qr '[*?][^/]*$' -- "$glob_base"
1656
+ and test "$pattern" != "$current"
563
1657
  set __has_glob 1
564
1658
  end
565
1659
  set -l __candidates
566
1660
  if test $__has_glob -eq 1
567
1661
  # Safe glob expansion without eval: split the pattern into
568
- # directory and filter parts, list the directory, then use
569
- # string match for wildcard filtering
570
- set -l __glob_dir (string replace -r '/[^/]*$' '' -- "$glob_base")
1662
+ # a directory prefix and a basename filter, list the
1663
+ # directory, then match basenames against a regex built
1664
+ # from the filter. The prefix keeps one empty element for
1665
+ # bare patterns so that $__glob_prefix* still expands.
1666
+ set -l __glob_prefix ""
1667
+ if string match -q '*/*' -- "$glob_base"
1668
+ set __glob_prefix (string replace -r '[^/]*$' '' -- "$glob_base")
1669
+ end
571
1670
  set -l __glob_filter (string replace -r '.*/' '' -- "$glob_base")
572
- if test -z "$__glob_dir"; or test "$__glob_dir" = "$glob_base"
573
- set __glob_dir "."
1671
+ # Translate the filter into an anchored regex: * and ? are
1672
+ # wildcards and every other character is literal. Fish's
1673
+ # own wildcards cannot be used here because ? is literal
1674
+ # when the qmark-noglob feature is enabled (fish 4 default).
1675
+ set -l __glob_regex '^'
1676
+ for __ch in (string split '' -- "$__glob_filter")
1677
+ if test "$__ch" = '*'
1678
+ set __glob_regex "$__glob_regex.*"
1679
+ else if test "$__ch" = '?'
1680
+ set __glob_regex "$__glob_regex."
1681
+ else
1682
+ set __glob_regex "$__glob_regex"(string escape --style=regex -- "$__ch")
1683
+ end
574
1684
  end
1685
+ set __glob_regex "$__glob_regex"'$'
575
1686
  # Match files by the glob filter. Fish's * does not match
576
- # dotfiles, so also scan .* when the filter targets them.
577
- for __item in $__glob_dir/*
1687
+ # dotfiles, so also scan .* when the filter targets them or
1688
+ # hidden files are requested.
1689
+ set -l __entries $__glob_prefix*
1690
+ if test "$hidden" = "1"; or string match -q '.*' -- "$__glob_filter"
1691
+ set -a __entries $__glob_prefix.*
1692
+ end
1693
+ for __item in $__entries
578
1694
  if test -e "$__item"
579
- set -l __bn (basename "$__item")
580
- if string match -q "$__glob_filter" -- "$__bn"
581
- set -a __candidates "$__item"
1695
+ set -l __bn (string replace -r '.*/' '' -- "$__item")
1696
+ if contains -- "$__bn" . ..
1697
+ continue
582
1698
  end
583
- end
584
- end
585
- if string match -q '.*' -- "$__glob_filter"
586
- for __item in $__glob_dir/.*
587
- if test -e "$__item"
588
- set -l __bn (basename "$__item")
589
- if test "$__bn" = "." -o "$__bn" = ".."
590
- continue
591
- end
592
- if string match -q "$__glob_filter" -- "$__bn"
593
- set -a __candidates "$__item"
594
- end
1699
+ if string match -qr -- "$__glob_regex" "$__bn"
1700
+ set -a __candidates "$__item"
595
1701
  end
596
1702
  end
597
1703
  end
598
1704
  # For file/any modes, also include directories for navigation.
599
1705
  # Skip for directory mode so the pattern filter is respected.
1706
+ # Directories the filter already matched are not repeated.
600
1707
  if test "$type" != "directory"
601
- for __item in $__glob_dir/*/
1708
+ set -l __nav_dirs $__glob_prefix*/
1709
+ if test "$hidden" = "1"
1710
+ set -a __nav_dirs $__glob_prefix.*/
1711
+ end
1712
+ for __item in $__nav_dirs
1713
+ set __item (string replace -r '/$' '' -- "$__item")
1714
+ set -l __bn (string replace -r '.*/' '' -- "$__item")
1715
+ if contains -- "$__bn" . ..
1716
+ continue
1717
+ end
602
1718
  if test -d "$__item"
603
- set -a __candidates (string replace -r '/$' '' -- "$__item")
1719
+ and not string match -qr -- "$__glob_regex" "$__bn"
1720
+ set -a __candidates "$__item"
604
1721
  end
605
1722
  end
606
1723
  end
@@ -703,7 +1820,7 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
703
1820
  if test "$hidden" != "1" -a (string sub -l 1 -- $__glob_basename) != "."
704
1821
  set -l filtered
705
1822
  for item in $items
706
- set -l basename (basename $item)
1823
+ set -l basename (basename -- "$item")
707
1824
  if not string match -q '.*' -- $basename
708
1825
  set -a filtered $item
709
1826
  end
@@ -720,10 +1837,13 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
720
1837
  set items $restored
721
1838
  end
722
1839
 
723
- # Output file completions
1840
+ # Output file completions. Use printf rather than echo, which
1841
+ # would take filenames like -n or -e as its own options.
724
1842
  for item in $items
725
- echo $item
1843
+ printf '%s\\n' "$insertion_prefix$item"
726
1844
  end
1845
+ set current "$saved_current"
1846
+ set __raw_current "$saved_raw_current"
727
1847
  else
728
1848
  # Regular literal completion - split by tab
729
1849
  set -l parts (string split \\t -- $line)
@@ -747,19 +1867,29 @@ complete -c ${programName} -f -a '(${functionName})'
747
1867
  if (i > 0) yield "\n";
748
1868
  if (suggestion.kind === "literal") {
749
1869
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
750
- yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
1870
+ yield `${encodeLiteral(suggestion.text)}\t${description}`;
751
1871
  } else {
752
1872
  const extensions = encodeExtensions(suggestion.extensions);
753
1873
  const hidden = suggestion.includeHidden ? "1" : "0";
754
1874
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
755
1875
  const pattern = encodePattern(suggestion.pattern ?? "");
756
- yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}\t${description}`;
1876
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}${encodeFilePrefix(suggestion)}\t${description}`;
757
1877
  }
758
1878
  i++;
759
1879
  }
760
1880
  }
761
1881
  };
762
1882
  /**
1883
+ * Quotes a string as a Nushell string literal. Nushell single-quoted strings
1884
+ * have no escape sequences, so a value containing an apostrophe is written as
1885
+ * a double-quoted string, in which only backslashes and double quotes need
1886
+ * escaping.
1887
+ */
1888
+ function quoteNuString(value) {
1889
+ if (!value.includes("'")) return `'${value}'`;
1890
+ return `"${value.replace(/["\\]/g, "\\$&")}"`;
1891
+ }
1892
+ /**
763
1893
  * The Nushell completion generator.
764
1894
  * @since 0.6.0
765
1895
  */
@@ -767,66 +1897,193 @@ const nu = {
767
1897
  name: "nu",
768
1898
  generateScript(programName, args = []) {
769
1899
  validateProgramName(programName);
770
- const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "''")}'`).join(" ");
1900
+ const escapedArgs = args.map(quoteNuString).join(" ");
771
1901
  const safeName = programName.replace(/[^a-zA-Z0-9]+/g, "-");
772
1902
  const functionName = `nu-complete-${safeName}`;
773
1903
  return `
774
- # Helper to split args respecting quotes and whitespace
1904
+ # Helper to split a command line into the arguments Nushell would pass to
1905
+ # an external command. Words are found the way Nushell's lexer finds them
1906
+ # (quotes and raw strings may contain whitespace), then each word is decoded
1907
+ # like an external argument. A final empty argument is added when the line
1908
+ # ends in unquoted whitespace, for the word about to be completed.
775
1909
  def args-split []: string -> list<string> {
776
- let STATE_NORMAL = 0
777
- let STATE_IN_SINGLE_QUOTE = 1
778
- let STATE_IN_DOUBLE_QUOTE = 2
779
- let STATE_ESCAPE = 3
780
1910
  let WHITESPACES = [" " "\\t" "\\n" "\\r"]
1911
+ let QUOTES = ["'" '"' "\`"]
1912
+
1913
+ # Decodes one word. An unterminated word is the one still being typed,
1914
+ # so its open quote is taken as if it were closed.
1915
+ let decode = {|word: string, unterminated: bool|
1916
+ let hashes = if ($word | str starts-with "r#") {
1917
+ $word | str replace -r '(?s)^r(#+).*$' '$1'
1918
+ } else {
1919
+ ""
1920
+ }
1921
+ if ($hashes | is-not-empty) {
1922
+ let prefix = "r" + $hashes + "'"
1923
+ let suffix = "'" + $hashes
1924
+ let size = ($hashes | str length)
1925
+ if ($word | str starts-with $prefix) and ($word | str ends-with $suffix) and (($word | str length) >= ($size * 2 + 3)) {
1926
+ $word | str replace $prefix "" | str replace -r ($suffix + '$') ""
1927
+ } else if $unterminated and ($word | str starts-with $prefix) {
1928
+ $word | str replace $prefix ""
1929
+ } else {
1930
+ $word
1931
+ }
1932
+ } else {
1933
+ mut result = ""
1934
+ mut quote = ""
1935
+ mut quoted = ""
1936
+ mut escaped = false
1937
+ for char in ($word | split chars) {
1938
+ if $quote == "" {
1939
+ if ($char in $QUOTES) {
1940
+ $quote = $char
1941
+ $quoted = ""
1942
+ } else {
1943
+ $result = $result + $char
1944
+ }
1945
+ } else if $escaped {
1946
+ $quoted = $quoted + $char
1947
+ $escaped = false
1948
+ } else if $char == $quote {
1949
+ let body = $quoted
1950
+ $result = $result + (if $quote == '"' {
1951
+ try { '"' + $body + '"' | from nuon } catch { $body }
1952
+ } else {
1953
+ $body
1954
+ })
1955
+ $quote = ""
1956
+ } else {
1957
+ $quoted = $quoted + $char
1958
+ $escaped = ($quote == '"' and $char == '\\')
1959
+ }
1960
+ }
1961
+ if $quote != "" {
1962
+ let body = $quoted
1963
+ $result = $result + (if not $unterminated {
1964
+ $quote + $body
1965
+ } else if $quote == '"' {
1966
+ try { '"' + $body + '"' | from nuon } catch { $body }
1967
+ } else {
1968
+ $body
1969
+ })
1970
+ }
1971
+ $result
1972
+ }
1973
+ }
781
1974
 
782
- mut state = $STATE_NORMAL
783
- mut current_token = ""
784
- mut result: list<string> = []
785
- mut prev_state = $STATE_NORMAL
1975
+ mut words: list<string> = []
1976
+ mut word = ""
1977
+ mut in_word = false
1978
+ mut quote = ""
1979
+ mut escaped = false
1980
+ mut raw_hashes = ""
1981
+ mut raw_pending = ""
1982
+ mut prev = ""
1983
+ mut trailing_space = false
786
1984
 
787
1985
  for char in ($in | split chars) {
788
- if $state == $STATE_ESCAPE {
789
- $current_token = $current_token + $char
790
- $state = $prev_state
791
- } else if $char == '\\\\' {
792
- $prev_state = $state
793
- $state = $STATE_ESCAPE
794
- } else if $state == $STATE_NORMAL {
795
- if $char == "'" {
796
- $state = $STATE_IN_SINGLE_QUOTE
797
- } else if $char == '"' {
798
- $state = $STATE_IN_DOUBLE_QUOTE
799
- } else if ($char in $WHITESPACES) {
800
- if ($current_token | is-not-empty) {
801
- $result = $result | append $current_token
802
- $current_token = ""
803
- }
1986
+ $trailing_space = false
1987
+ if $quote == "#" and $char != "#" and $char != "'" {
1988
+ # Not a raw string after all; lex this character normally
1989
+ $quote = ""
1990
+ }
1991
+ if $quote == "r" {
1992
+ # Inside a raw string, which ends at ' followed by the same number of #
1993
+ if ($raw_pending | is-not-empty) and $char == "#" {
1994
+ $raw_pending = $raw_pending + $char
1995
+ } else if $char == "'" {
1996
+ $raw_pending = $char
804
1997
  } else {
805
- $current_token = $current_token + $char
1998
+ $raw_pending = ""
1999
+ }
2000
+ if $raw_pending == ("'" + $raw_hashes) {
2001
+ $quote = ""
2002
+ $raw_pending = ""
806
2003
  }
807
- } else if $state == $STATE_IN_SINGLE_QUOTE {
808
- if $char == "'" {
809
- $state = $STATE_NORMAL
2004
+ $word = $word + $char
2005
+ } else if $quote == "#" {
2006
+ # Between r# and the opening ' of a raw string
2007
+ $word = $word + $char
2008
+ if $char == "#" {
2009
+ $raw_hashes = $raw_hashes + $char
810
2010
  } else {
811
- $current_token = $current_token + $char
2011
+ $quote = "r"
812
2012
  }
813
- } else if $state == $STATE_IN_DOUBLE_QUOTE {
814
- if $char == '"' {
815
- $state = $STATE_NORMAL
2013
+ } else if $quote != "" {
2014
+ $word = $word + $char
2015
+ if $escaped {
2016
+ $escaped = false
2017
+ } else if $char == $quote {
2018
+ $quote = ""
816
2019
  } else {
817
- $current_token = $current_token + $char
2020
+ $escaped = ($quote == '"' and $char == '\\')
818
2021
  }
2022
+ } else if ($char in $WHITESPACES) {
2023
+ if $in_word {
2024
+ $words = $words | append (do $decode $word false)
2025
+ $word = ""
2026
+ $in_word = false
2027
+ }
2028
+ $trailing_space = true
2029
+ } else {
2030
+ if ($char in $QUOTES) {
2031
+ $quote = $char
2032
+ } else if $char == "#" and $prev == "r" {
2033
+ $quote = "#"
2034
+ $raw_hashes = "#"
2035
+ }
2036
+ $word = $word + $char
2037
+ $in_word = true
819
2038
  }
2039
+ $prev = $char
2040
+ }
2041
+ if $in_word {
2042
+ $words = $words | append (do $decode $word ($quote != ""))
2043
+ } else if $trailing_space {
2044
+ $words = $words | append ""
820
2045
  }
821
- if ($current_token | is-not-empty) {
822
- $result = $result | append $current_token
2046
+ $words
2047
+ }
2048
+
2049
+ # Helper to tell whether Nushell would not read a value back verbatim as a
2050
+ # single argument. A leading ! is included because the line editor expands
2051
+ # history (e.g., !!) there. With --allow-expansion, a leading tilde and n-dot
2052
+ # path segments (e.g., ...) are allowed to expand.
2053
+ def needs-quote [--allow-expansion]: string -> bool {
2054
+ let value = $in
2055
+ let special = ["'" '"' '\`' "#" "(" ")" "[" "]" "{" "}" "|" ";" "&" "$" "*" "?" "<" ">"]
2056
+ (
2057
+ ($value =~ '\\s')
2058
+ or ($special | any {|c| $value | str contains $c })
2059
+ or ($value =~ '^(\\+\\+|[-+*/])?=$')
2060
+ or ($value | str starts-with '!')
2061
+ or ((not $allow_expansion) and (
2062
+ ($value | str starts-with '~')
2063
+ or ($value | split row -r '[/\\\\]' | any {|s| $s =~ '^\\.{3,}$' })
2064
+ ))
2065
+ )
2066
+ }
2067
+
2068
+ # Helper to quote a value so that Nushell reads it back verbatim as a
2069
+ # single argument. The line editor expands history at a ! that follows
2070
+ # whitespace even inside quotes, so such a ! is put between two quoted parts,
2071
+ # right after a quote, where it is left alone.
2072
+ def quote-arg []: string -> string {
2073
+ let value = $in
2074
+ if ($value | is-empty) or not ($value | needs-quote) {
2075
+ $value
2076
+ } else if ($value | str contains "'") {
2077
+ '"' + ($value | str replace -a '\\' '\\\\' | str replace -a '"' '\\"' | str replace -ar '(\\s)!' '\${1}"!"') + '"'
2078
+ } else {
2079
+ "'" + ($value | str replace -ar '(\\s)!' "\${1}'!'") + "'"
823
2080
  }
824
- $result
825
2081
  }
826
2082
 
827
2083
  # Completion command that calls back to the CLI
828
2084
  export def "${functionName}" [context: string] {
829
- # Split context into tokens, handling quotes properly
2085
+ # Split context into the arguments Nushell would pass, including an empty
2086
+ # one for a new word when the context ends in unquoted whitespace
830
2087
  let tokens = $context | args-split
831
2088
 
832
2089
  # Remove the command name (first token) to get arguments
@@ -836,18 +2093,11 @@ export def "${functionName}" [context: string] {
836
2093
  []
837
2094
  }
838
2095
 
839
- # If context ends with whitespace, add empty string to represent the new argument
840
- let args_with_empty = if ($context | str ends-with ' ') {
841
- $args | append ''
842
- } else {
843
- $args
844
- }
845
-
846
2096
  # Ensure at least one argument (empty string) to get completions not script
847
- let final_args = if ($args_with_empty | is-empty) {
2097
+ let final_args = if ($args | is-empty) {
848
2098
  ['']
849
2099
  } else {
850
- $args_with_empty
2100
+ $args
851
2101
  }
852
2102
 
853
2103
  let output = try {
@@ -856,8 +2106,10 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
856
2106
  }
857
2107
 
858
2108
  # Process each line of output
859
- $output | lines | each {|line|
860
- if ($line | str starts-with '__FILE__:') {
2109
+ $output | lines | each {|raw_line|
2110
+ let literal = ($raw_line | str starts-with '__LITERAL__:')
2111
+ let line = if $literal { $raw_line | str substring 12.. } else { $raw_line }
2112
+ if (not $literal) and ($line | str starts-with '__FILE__:') {
861
2113
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
862
2114
  let directive = ($line | split row "\t" | first)
863
2115
  let parts = ($directive | split row ':')
@@ -866,6 +2118,10 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
866
2118
  let pattern = ($parts | get 3 | str replace -a '%3A' ':' | str replace -a '%25' '%')
867
2119
  let hidden = ($parts | get 4) == '1'
868
2120
 
2121
+ let insertion_prefix = if ($parts | length) > 5 {
2122
+ $parts | get 5 | str replace -a '%3A' ':' | str replace -a '%25' '%'
2123
+ } else { "" }
2124
+
869
2125
  # Extract prefix from the last argument if it exists
870
2126
  let prefix = if ($final_args | length) > 0 {
871
2127
  $final_args | last
@@ -873,18 +2129,23 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
873
2129
  ""
874
2130
  }
875
2131
 
2132
+ let insertion_prefix = if ($insertion_prefix | is-not-empty) and ($pattern | str starts-with $insertion_prefix) and ($prefix | str starts-with $insertion_prefix) { $insertion_prefix } else { "" }
2133
+ let prefix_length = ($insertion_prefix | str length)
2134
+ let pattern = if $prefix_length > 0 { $pattern | str substring $prefix_length.. } else { $pattern }
2135
+ let prefix = if $prefix_length > 0 { $prefix | str substring $prefix_length.. } else { $prefix }
2136
+
876
2137
  # When a pattern is specified, use it as the glob base instead of
877
2138
  # the user-typed prefix. If the user has already typed beyond the
878
2139
  # pattern (e.g., pattern="src/" and prefix="src/ma"), keep the
879
2140
  # prefix for incremental narrowing. Normalize path separators
880
2141
  # before comparing so that Windows backslashes match forward slashes
881
2142
  # in the transported pattern.
2143
+ let is_win = (($nu.os-info.name | str downcase) == "windows")
882
2144
  let glob_base = if ($pattern | is-not-empty) {
883
2145
  # Normalize separators and leading ./; downcase only on Windows
884
2146
  # where filesystems are typically case-insensitive
885
2147
  let norm_prefix_raw = ($prefix | str replace -a '\\' '/' | str replace -r '^\\./' '')
886
2148
  let norm_pattern_raw = ($pattern | str replace -a '\\' '/' | str replace -r '^\\./' '')
887
- let is_win = (($nu.os-info.name | str downcase) == "windows")
888
2149
  let norm_prefix = (if $is_win { $norm_prefix_raw | str downcase } else { $norm_prefix_raw })
889
2150
  let norm_pattern = (if $is_win { $norm_pattern_raw | str downcase } else { $norm_pattern_raw })
890
2151
  # For wildcard patterns, compare only the directory prefix
@@ -903,10 +2164,27 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
903
2164
  $prefix
904
2165
  }
905
2166
 
2167
+ # Intersect fixed glob matches with the narrowed prefix candidates.
2168
+ # Directory-only suggestions retain their existing navigation behavior.
2169
+ let narrow_fixed_glob = ($type != "directory") and ($pattern =~ '[*?]') and ($prefix != $pattern) and ($glob_base == $prefix)
2170
+ let fixed_matches = if $narrow_fixed_glob {
2171
+ try {
2172
+ let fixed_pattern = ($pattern | into glob)
2173
+ let include_hidden = $hidden or ($prefix | path basename | str starts-with '.')
2174
+ let matches = if $include_hidden { ls -a --directory $fixed_pattern } else { ls --directory $fixed_pattern }
2175
+ $matches | each {|item|
2176
+ let name = ($env.PWD | path join $item.name | path expand --no-symlink)
2177
+ if $is_win { $name | str downcase } else { $name }
2178
+ }
2179
+ } catch { [] }
2180
+ } else { [] }
2181
+
906
2182
  # If the glob base is a directory without a trailing slash (e.g., "src"),
907
2183
  # append one so that ls enumerates its contents instead of matching
908
- # siblings like "src-old"
909
- let glob_base = if ($glob_base | is-not-empty) and ($glob_base | path type) == "dir" and (not ($glob_base | str ends-with "/")) {
2184
+ # siblings like "src-old". path expand follows symlinks, so that
2185
+ # a symlink to a directory is treated the same way; --no-symlink first
2186
+ # resolves .. against the logical $env.PWD as ls does
2187
+ let glob_base = if ($glob_base | is-not-empty) and ($glob_base | path expand --no-symlink | path expand | path type) == "dir" and (not ($glob_base | str ends-with "/")) {
910
2188
  $glob_base + "/"
911
2189
  } else {
912
2190
  $glob_base
@@ -915,56 +2193,78 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
915
2193
  # Generate file completions based on type.
916
2194
  # When the glob base contains wildcard characters, use it as-is;
917
2195
  # otherwise append * to treat it as a prefix.
918
- # Note: into glob is required so that ls expands wildcards from a variable
2196
+ # Note: into glob is required so that ls expands wildcards from a variable.
2197
+ # A literal prefix has its brackets wrapped as [[] and []] so that path
2198
+ # text like "report[1]" is not parsed as a glob character class; a
2199
+ # wildcard pattern is a glob as a whole, so its brackets stay classes.
919
2200
  let has_glob = ($glob_base =~ '[*?]')
920
2201
  let ls_pattern = if ($glob_base | is-empty) {
921
2202
  "."
922
2203
  } else if $has_glob {
923
2204
  ($glob_base | into glob)
924
2205
  } else {
925
- ($glob_base + "*" | into glob)
2206
+ (($glob_base | str replace -a -r '([\\[\\]])' '[$1]') + "*" | into glob)
926
2207
  }
927
2208
 
928
2209
  # When using a glob pattern, also compute a directory listing pattern
929
- # so that file/any modes can include directories for navigation
2210
+ # so that file/any modes can include directories for navigation; its
2211
+ # directory part is a literal path
930
2212
  let glob_dir_pattern = if $has_glob {
931
2213
  let dir_part = ($glob_base | path dirname)
932
2214
  let dir = if ($dir_part | is-empty) or ($dir_part == $glob_base) { "." } else { $dir_part }
933
- ($dir + "/*" | into glob)
2215
+ (($dir | str replace -a -r '([\\[\\]])' '[$1]') + "/*" | into glob)
934
2216
  } else {
935
2217
  null
936
2218
  }
937
2219
 
938
- # Use ls -a to include hidden files when requested
2220
+ # List entries matching a pattern, using ls -a to include hidden
2221
+ # files when requested. ls reports every symlink as type "symlink",
2222
+ # so classify each one by its target's type instead (path expand
2223
+ # follows symlinks); broken or looping symlinks stay "symlink".
2224
+ # Join the name to $env.PWD first so that a leading ~ in an entry
2225
+ # name is not expanded to a home directory, and resolve .. against
2226
+ # the logical $env.PWD (--no-symlink) before following the symlink
2227
+ let list_entries = {|pattern|
2228
+ (if $hidden { ls -a $pattern } else { ls $pattern }) | each {|item|
2229
+ if $item.type == symlink {
2230
+ $item | update type ($env.PWD | path join $item.name | path expand --no-symlink | path expand | path type)
2231
+ } else {
2232
+ $item
2233
+ }
2234
+ }
2235
+ }
2236
+
939
2237
  let items = try {
940
2238
  match $type {
941
2239
  "file" => {
942
2240
  if ($extensions | is-empty) {
943
- (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file or type == dir
2241
+ (do $list_entries $ls_pattern) | where type == file or type == dir
944
2242
  } else {
945
2243
  let ext_list = ($extensions | split row ',' | each {|e| $e | str replace -r '^\\.' '' })
946
- let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
2244
+ let all_items = (do $list_entries $ls_pattern)
947
2245
  let dirs = $all_items | where type == dir
948
2246
  let files = $all_items | where type == file | where {|f|
949
- let ext = ($f.name | path parse | get extension)
950
- $ext in $ext_list
2247
+ $ext_list | any {|ext|
2248
+ $f.name | path basename | str ends-with ('.' + $ext)
2249
+ }
951
2250
  }
952
2251
  $dirs | append $files
953
2252
  }
954
2253
  },
955
2254
  "directory" => {
956
- (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == dir
2255
+ (do $list_entries $ls_pattern) | where type == dir
957
2256
  },
958
2257
  "any" => {
959
2258
  if ($extensions | is-empty) {
960
- if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }
2259
+ do $list_entries $ls_pattern
961
2260
  } else {
962
2261
  let ext_list = ($extensions | split row ',' | each {|e| $e | str replace -r '^\\.' '' })
963
- let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
2262
+ let all_items = (do $list_entries $ls_pattern)
964
2263
  let dirs = $all_items | where type == dir
965
2264
  let files = $all_items | where type == file | where {|f|
966
- let ext = ($f.name | path parse | get extension)
967
- $ext in $ext_list
2265
+ $ext_list | any {|ext|
2266
+ $f.name | path basename | str ends-with ('.' + $ext)
2267
+ }
968
2268
  }
969
2269
  $dirs | append $files
970
2270
  }
@@ -979,7 +2279,7 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
979
2279
  # even when the glob itself does not match directory names)
980
2280
  let items = if ($glob_dir_pattern != null) and ($type != "directory") {
981
2281
  let extra_dirs = try {
982
- (if $hidden { ls -a $glob_dir_pattern } else { ls $glob_dir_pattern }) | where type == dir
2282
+ (do $list_entries $glob_dir_pattern) | where type == dir
983
2283
  } catch {
984
2284
  []
985
2285
  }
@@ -1001,6 +2301,17 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1001
2301
  }
1002
2302
  }
1003
2303
 
2304
+ let filtered = if $narrow_fixed_glob {
2305
+ $filtered | where {|item|
2306
+ # Any-mode listings already include directory symlinks; keep them
2307
+ # navigable without changing their existing completion rendering.
2308
+ let navigation_dir = ($item.type == dir) or ($item.type == symlink and (try { ($item.name | path expand | path type) == "dir" } catch { false }))
2309
+ let name = ($env.PWD | path join $item.name | path expand --no-symlink)
2310
+ let normalized_name = if $is_win { $name | str downcase } else { $name }
2311
+ $navigation_dir or ($normalized_name in $fixed_matches)
2312
+ }
2313
+ } else { $filtered }
2314
+
1004
2315
  # Extract directory prefix to preserve in completion text
1005
2316
  let dir_prefix = if ($glob_base | is-empty) {
1006
2317
  ""
@@ -1011,19 +2322,56 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1011
2322
  if ($parsed.parent | is-empty) { "" } else if ($parsed.parent | str ends-with "/") { $parsed.parent } else { $parsed.parent + "/" }
1012
2323
  }
1013
2324
 
1014
- # Format file completions
2325
+ # Format file completions, quoting names that Nushell would not read
2326
+ # back as a single argument
1015
2327
  $filtered | each {|item|
1016
- let name = if $item.type == dir {
1017
- $dir_prefix + ($item.name | path basename) + "/"
2328
+ let base = if $item.type == dir {
2329
+ ($item.name | path basename) + "/"
2330
+ } else {
2331
+ $item.name | path basename
2332
+ }
2333
+ let name = $dir_prefix + $base
2334
+ # Nushell expands a leading tilde even in a file name, but not after ./
2335
+ let spelled = if ($dir_prefix | is-empty) and ($base | str starts-with '~') {
2336
+ './' + $base
2337
+ } else {
2338
+ $name
2339
+ }
2340
+ # A tilde or n-dot prefix typed by the user is meant to be expanded
2341
+ # by Nushell, which quoting would prevent, so expand such a prefix
2342
+ # here when the value has to be quoted
2343
+ let literal_ndots = ($base | str replace -r '[/\\\\]$' '') =~ '^\\.{3,}$'
2344
+ let expandable = ($dir_prefix | str starts-with '~') or ($dir_prefix | split row -r '[/\\\\]' | any {|s| $s =~ '^\\.{3,}$' })
2345
+ let value = if not ($literal_ndots or ($spelled | needs-quote --allow-expansion)) {
2346
+ $spelled
2347
+ } else if $expandable {
2348
+ let expanded_prefix = ($dir_prefix | path expand --no-symlink)
2349
+ let expanded_prefix = if ($expanded_prefix | str ends-with "/") { $expanded_prefix } else { $expanded_prefix + "/" }
2350
+ ($expanded_prefix + $base) | quote-arg
2351
+ } else {
2352
+ $spelled | quote-arg
2353
+ }
2354
+ if ($insertion_prefix | is-not-empty) {
2355
+ # A key before the path keeps any tilde or n-dot segment from
2356
+ # being expanded, so quote the whole argument as written and show
2357
+ # the key in the menu as well.
2358
+ let keyed = $insertion_prefix + $name
2359
+ let keyed_value = ($keyed | quote-arg)
2360
+ if $keyed_value == $keyed {
2361
+ { value: $keyed_value }
2362
+ } else {
2363
+ { value: $keyed_value, display_override: $keyed }
2364
+ }
2365
+ } else if $value == $name {
2366
+ { value: $value }
1018
2367
  } else {
1019
- $dir_prefix + ($item.name | path basename)
2368
+ { value: $value, display_override: $name }
1020
2369
  }
1021
- { value: $name }
1022
2370
  }
1023
2371
  } else {
1024
2372
  # Regular literal completion - split by tab
1025
2373
  let parts = ($line | split row "\t")
1026
- if ($parts | length) >= 2 {
2374
+ let record = if ($parts | length) >= 2 {
1027
2375
  # value\\tdescription format
1028
2376
  { value: ($parts | get 0), description: ($parts | get 1) }
1029
2377
  } else if ($parts | length) == 1 and ($parts | get 0 | is-not-empty) {
@@ -1032,6 +2380,18 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1032
2380
  } else {
1033
2381
  null
1034
2382
  }
2383
+ # Quote values that Nushell would not read back as a single argument,
2384
+ # while the menu keeps showing them as they are
2385
+ if $record == null {
2386
+ null
2387
+ } else {
2388
+ let value = ($record.value | quote-arg)
2389
+ if $value == $record.value {
2390
+ $record
2391
+ } else {
2392
+ $record | upsert value $value | insert display_override $record.value
2393
+ }
2394
+ }
1035
2395
  }
1036
2396
  } | flatten | compact
1037
2397
  }
@@ -1069,13 +2429,13 @@ ${functionName}-external
1069
2429
  if (i > 0) yield "\n";
1070
2430
  if (suggestion.kind === "literal") {
1071
2431
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1072
- yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
2432
+ yield `${encodeLiteral(suggestion.text)}\t${description}`;
1073
2433
  } else {
1074
2434
  const extensions = encodeExtensions(suggestion.extensions);
1075
2435
  const hidden = suggestion.includeHidden ? "1" : "0";
1076
2436
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1077
2437
  const pattern = encodePattern(suggestion.pattern ?? "");
1078
- yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}\t${description}`;
2438
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}${encodeFilePrefix(suggestion)}\t${description}`;
1079
2439
  }
1080
2440
  i++;
1081
2441
  }
@@ -1094,27 +2454,48 @@ const pwsh = {
1094
2454
  Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1095
2455
  param(\$wordToComplete, \$commandAst, \$cursorPosition)
1096
2456
 
2457
+ # CompletionText is shell syntax; display labels remain unquoted.
2458
+ \$quoteArgument = {
2459
+ param([string]\$value)
2460
+ if (\$value -cmatch '\\A[a-zA-Z0-9_./:\\\\-]+\\z' -and \$value -cnotmatch '\\A-(?!-).*[.:]' -and \$value -cnotmatch '\\A-?(?:[0-9]|\\.[0-9])') {
2461
+ return \$value
2462
+ }
2463
+ return "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent(\$value) + "'"
2464
+ }
2465
+
1097
2466
  # Extract arguments from AST (handles quoted strings properly)
1098
2467
  \$arguments = @()
1099
2468
  \$commandElements = \$commandAst.CommandElements
2469
+ \$valuePrefix = \$wordToComplete
2470
+ \$quoteType = \$null
1100
2471
 
1101
- # Determine the range of elements to extract
1102
- # Exclude the last element if it matches wordToComplete (partial input case)
1103
- \$maxIndex = \$commandElements.Count - 1
1104
- if (\$commandElements.Count -gt 1) {
1105
- \$lastElement = \$commandElements[\$commandElements.Count - 1]
1106
- \$lastText = if (\$lastElement -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
1107
- \$lastElement.Value
1108
- } else {
1109
- \$lastElement.Extent.Text
1110
- }
1111
- if (\$lastText -eq \$wordToComplete) {
1112
- \$maxIndex = \$commandElements.Count - 2
1113
- }
1114
- }
1115
-
1116
- for (\$i = 1; \$i -le \$maxIndex; \$i++) {
2472
+ # Keep only complete arguments before the cursor. The current word
2473
+ # (including a token ending exactly at the cursor) is appended below.
2474
+ for (\$i = 1; \$i -lt \$commandElements.Count; \$i++) {
1117
2475
  \$element = \$commandElements[\$i]
2476
+ if (\$element.Extent.EndOffset -ge \$cursorPosition) {
2477
+ # wordToComplete has synthesized delimiters and decoded escapes.
2478
+ # Only the AST retains the literal value and original quote type.
2479
+ # PSReadLine leaves the cursor before a directory's closing quote.
2480
+ if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
2481
+ (\$element.Extent.EndOffset -eq \$cursorPosition -or
2482
+ (\$element.Extent.EndOffset -eq (\$cursorPosition + 1) -and
2483
+ \$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')))) {
2484
+ if (\$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')) {
2485
+ \$valuePrefix = \$element.Value
2486
+ \$quoteType = \$element.StringConstantType
2487
+ } elseif (\$element.Extent.Text -match "^[^=]+=['\\u2018-\\u201b]") {
2488
+ # Joined values are BareWord constants despite their quotes.
2489
+ # Quote the entire replacement to keep it one argument.
2490
+ \$valuePrefix = \$element.Value
2491
+ \$quoteType = 'SingleQuoted'
2492
+ } elseif (\$element.Extent.Text -match '^[^=]+=["\\u201c-\\u201e]') {
2493
+ \$valuePrefix = \$element.Value
2494
+ \$quoteType = 'DoubleQuoted'
2495
+ }
2496
+ }
2497
+ break
2498
+ }
1118
2499
 
1119
2500
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
1120
2501
  \$arguments += \$element.Value
@@ -1123,30 +2504,54 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1123
2504
  }
1124
2505
  }
1125
2506
 
2507
+ # CompletionText replaces the whole token, including its opening quote.
2508
+ # Escape literals without evaluating variables or expressions.
2509
+ \$quoteCompletion = {
2510
+ param(\$text)
2511
+ if (\$quoteType -eq 'SingleQuoted') {
2512
+ "'" + (\$text -replace "['\\u2018-\\u201b]", '\$0\$0') + "'"
2513
+ } elseif (\$quoteType -eq 'DoubleQuoted') {
2514
+ '"' + (\$text -replace '[\\x60\$"\\u201c-\\u201e]', '\`\$0') + '"'
2515
+ } else {
2516
+ & \$quoteArgument \$text
2517
+ }
2518
+ }
2519
+
1126
2520
  # Build arguments array for completion command
1127
2521
  \$completionArgs = @()
1128
2522
  ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1129
2523
  ` : ""} \$completionArgs += \$arguments
1130
- \$completionArgs += \$wordToComplete
1131
2524
 
1132
2525
  # Call completion command and capture output
1133
2526
  try {
1134
- \$output = & ${programName} \$completionArgs 2>\$null
2527
+ # Quote the prefix separately: array arguments expand a literal tilde
2528
+ # when passed to native programs on Unix.
2529
+ \$output = & ${programName} \$completionArgs "\$valuePrefix" 2>\$null
1135
2530
  if (-not \$output) { return }
1136
2531
 
1137
2532
  # Parse tab-separated output and create CompletionResult objects
1138
2533
  \$output -split "\`n" | ForEach-Object {
1139
- \$line = \$_.Trim()
2534
+ \$literal = \$_.StartsWith('__LITERAL__:', [System.StringComparison]::Ordinal)
2535
+ # Quoted values preserve whitespace; only remove the CR of CRLF.
2536
+ # Keep the existing trimming for unquoted completions.
2537
+ \$line = if (\$null -ne \$quoteType) { \$_.TrimEnd([char]13) } else { \$_.Trim() }
1140
2538
  if (-not \$line) { return }
2539
+ if (\$literal) { \$line = \$line.Substring(12) }
1141
2540
 
1142
- if (\$line -match '^__FILE__:') {
2541
+ if (-not \$literal -and \$_.StartsWith('__FILE__:', [System.StringComparison]::Ordinal)) {
1143
2542
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
1144
2543
  \$directive = (\$line -split "\`t")[0]
1145
- \$parts = \$directive -split ':', 5
2544
+ \$parts = \$directive -split ':', 6
1146
2545
  \$type = \$parts[1]
1147
2546
  \$extensions = \$parts[2]
1148
2547
  \$pattern = \$parts[3] -replace '%3A', ':' -replace '%25', '%'
1149
2548
  \$hidden = \$parts[4] -eq '1'
2549
+ \$insertionPrefix = if (\$parts.Length -gt 5) { \$parts[5] -replace '%3A', ':' -replace '%25', '%' } else { '' }
2550
+ \$matchingWord = \$valuePrefix
2551
+ if (\$insertionPrefix -and \$pattern.StartsWith(\$insertionPrefix, [System.StringComparison]::Ordinal) -and \$matchingWord.StartsWith(\$insertionPrefix, [System.StringComparison]::Ordinal)) {
2552
+ \$pattern = \$pattern.Substring(\$insertionPrefix.Length)
2553
+ \$matchingWord = \$matchingWord.Substring(\$insertionPrefix.Length)
2554
+ } else { \$insertionPrefix = '' }
1150
2555
 
1151
2556
  # When a pattern is specified, use it as the file matching
1152
2557
  # prefix instead of the current word. If the user has
@@ -1155,7 +2560,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1155
2560
  # comparing so that Windows backslashes match forward slashes
1156
2561
  # in the transported pattern.
1157
2562
  \$normalizedPattern = if (\$pattern) { \$pattern.Replace('\\', '/') -replace '^\\./','' } else { '' }
1158
- \$normalizedWord = if (\$wordToComplete) { \$wordToComplete.Replace('\\', '/') -replace '^\\./','' } else { '' }
2563
+ \$normalizedWord = if (\$matchingWord) { \$matchingWord.Replace('\\', '/') -replace '^\\./','' } else { '' }
1159
2564
  \$comparison = if (\$IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
1160
2565
  # For wildcard patterns, compare only the directory prefix
1161
2566
  \$comparePattern = \$normalizedPattern
@@ -1167,12 +2572,25 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1167
2572
  \$comparePattern = ''
1168
2573
  }
1169
2574
  }
1170
- \$prefix = if ((\$comparePattern -or \$normalizedWord) -and \$normalizedWord -and \$normalizedWord.StartsWith(\$comparePattern, \$comparison) -and \$normalizedWord.Length -ge \$comparePattern.Length -and \$wordToComplete -ne \$pattern) {
1171
- \$wordToComplete
2575
+ # A quoted home-pattern directory inserts an absolute path.
2576
+ # Compare subsequent input against the same expanded prefix.
2577
+ # Detect home syntax before removing ./, which can target a
2578
+ # literal local tilde directory rather than the home directory.
2579
+ \$homePattern = \$pattern -and (\$pattern -eq '~' -or \$pattern.Replace('\\', '/').StartsWith('~/'))
2580
+ if (\$homePattern) {
2581
+ # Preserve the home root when the pattern is exactly ~.
2582
+ \$expandedPattern = if (\$comparePattern -eq '~') { \$HOME } else { \$HOME.Replace('\\', '/').TrimEnd('/') + \$comparePattern.Substring(1) }
2583
+ \$expandedPattern = [IO.Path]::GetFullPath(\$expandedPattern).Replace('\\', '/')
2584
+ if (\$normalizedWord.StartsWith(\$expandedPattern, \$comparison)) {
2585
+ \$comparePattern = \$expandedPattern
2586
+ }
2587
+ }
2588
+ \$prefix = if ((\$comparePattern -or \$normalizedWord) -and \$normalizedWord -and \$normalizedWord.StartsWith(\$comparePattern, \$comparison) -and \$normalizedWord.Length -ge \$comparePattern.Length -and \$matchingWord -ne \$pattern) {
2589
+ \$matchingWord
1172
2590
  } elseif (\$pattern) {
1173
2591
  \$pattern
1174
- } elseif (\$wordToComplete) {
1175
- \$wordToComplete
2592
+ } elseif (\$matchingWord) {
2593
+ \$matchingWord
1176
2594
  } else { '' }
1177
2595
 
1178
2596
  # Use -Force to include hidden files when requested, or when
@@ -1180,15 +2598,35 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1180
2598
  \$prefixBasename = if (\$prefix) { Split-Path -Leaf \$prefix } else { '' }
1181
2599
  \$forceParam = if (\$hidden -or (\$prefixBasename -and \$prefixBasename.StartsWith('.'))) { @{Force = \$true} } else { @{} }
1182
2600
 
2601
+ # A quoted tilde is literal when passed to a native command.
2602
+ # Prevent PowerShell's file provider from expanding it to home.
2603
+ # Parser-provided home patterns still need home expansion.
2604
+ \$prefixFromInput = \$matchingWord -and \$prefix -ceq \$matchingWord
2605
+ \$lookupPrefix = if (\$prefixFromInput -and \$null -ne \$quoteType -and \$prefix.StartsWith('~')) { './' + \$prefix } else { \$prefix }
2606
+
2607
+ # Detect narrowing before directory normalization changes prefix.
2608
+ \$narrowFixedGlob = \$type -ne 'directory' -and \$pattern -match '[\*\?]' -and \$prefix -ceq \$matchingWord -and \$pattern -cne \$matchingWord
2609
+
1183
2610
  # If prefix is a directory without trailing slash, append
1184
2611
  # one so Get-ChildItem lists its contents
1185
- if (\$prefix -and (Test-Path -Path \$prefix -PathType Container) -and -not \$prefix.EndsWith('/') -and -not \$prefix.EndsWith('\\')) {
2612
+ if (\$prefix -and (Test-Path -Path \$lookupPrefix -PathType Container) -and -not \$prefix.EndsWith('/') -and -not \$prefix.EndsWith('\\')) {
1186
2613
  \$prefix = \$prefix + '/'
2614
+ \$lookupPrefix = \$lookupPrefix + '/'
1187
2615
  }
1188
2616
 
1189
2617
  # Build the glob path—when the prefix contains wildcard
1190
2618
  # characters, use it as-is; otherwise append *
1191
- \$globPath = if (\$prefix -match '[\*\?]') { \$prefix } else { "\${prefix}*" }
2619
+ \$globPath = if (\$lookupPrefix -match '[\*\?]') { \$lookupPrefix } else { "\${lookupPrefix}*" }
2620
+
2621
+ # Keep the original native glob as an independent file filter
2622
+ # when the current word narrows its candidates.
2623
+ \$fixedMatches = @()
2624
+ if (\$narrowFixedGlob) {
2625
+ # Include hidden parents here; candidate enumeration still
2626
+ # controls which hidden children can be suggested.
2627
+ \$fixedMatches = @(Get-Item -Force -Path \$pattern -ErrorAction SilentlyContinue |
2628
+ Select-Object -ExpandProperty FullName)
2629
+ }
1192
2630
 
1193
2631
  # Get file system items based on type
1194
2632
  \$items = @()
@@ -1199,7 +2637,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1199
2637
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1200
2638
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1201
2639
  Where-Object {
1202
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -in \$extList)
2640
+ \$fileName = \$_.Name
2641
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2642
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2643
+ }).Count -gt 0)
1203
2644
  }
1204
2645
  } else {
1205
2646
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1214,7 +2655,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1214
2655
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1215
2656
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1216
2657
  Where-Object {
1217
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -in \$extList)
2658
+ \$fileName = \$_.Name
2659
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2660
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2661
+ }).Count -gt 0)
1218
2662
  }
1219
2663
  } else {
1220
2664
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1225,7 +2669,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1225
2669
  # For file/any modes with glob patterns, also add directories
1226
2670
  # from the base directory for navigation
1227
2671
  if (\$prefix -match '[\*\?]' -and \$type -ne 'directory') {
1228
- \$globDir = Split-Path -Parent \$prefix
2672
+ \$globDir = Split-Path -Parent \$lookupPrefix
1229
2673
  if (-not \$globDir) { \$globDir = '.' }
1230
2674
  \$extraDirs = Get-ChildItem @forceParam -Directory -Path "\$globDir/*" -ErrorAction SilentlyContinue
1231
2675
  if (\$extraDirs) { \$items = @(\$items) + @(\$extraDirs) | Select-Object -Unique }
@@ -1236,6 +2680,14 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1236
2680
  \$items = \$items | Where-Object { -not \$_.Attributes.HasFlag([System.IO.FileAttributes]::Hidden) }
1237
2681
  }
1238
2682
 
2683
+ if (\$narrowFixedGlob) {
2684
+ \$items = \$items | Where-Object {
2685
+ if (\$_.PSIsContainer) { \$true }
2686
+ elseif (\$IsWindows) { \$fixedMatches -contains \$_.FullName }
2687
+ else { \$fixedMatches -ccontains \$_.FullName }
2688
+ }
2689
+ }
2690
+
1239
2691
  # Extract directory prefix to preserve in completion text
1240
2692
  \$dirPrefix = if (\$prefix -and (\$prefix.Contains('/') -or \$prefix.Contains('\\'))) {
1241
2693
  \$slashIdx = [Math]::Max(\$prefix.LastIndexOf('/'), \$prefix.LastIndexOf('\\'))
@@ -1246,10 +2698,34 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1246
2698
  \$items | ForEach-Object {
1247
2699
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1248
2700
  \$itemType = if (\$_.PSIsContainer) { 'Directory' } else { 'File' }
2701
+ \$resultType = if (\$_.PSIsContainer) { 'ProviderContainer' } else { 'ParameterValue' }
2702
+ # PSReadLine recognizes the platform separator for containers.
2703
+ # Keep the display label's existing slash convention.
2704
+ \$directorySuffix = [IO.Path]::DirectorySeparatorChar
2705
+ \$pathText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)\$directorySuffix" } else { \$completionText }
2706
+ # Quoting disables native tilde expansion. A home pattern
2707
+ # supplied by the parser must insert the discovered path.
2708
+ \$replacementText = if (\$null -ne \$quoteType -and -not \$prefixFromInput -and \$homePattern) {
2709
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2710
+ } else { \$pathText }
2711
+ # Quoting suppresses native tilde expansion. Keep safe
2712
+ # ~/ paths bare; otherwise use the enumerated full path.
2713
+ # Keyed paths keep the typed spelling, including ~/, and
2714
+ # always take the quoted argument form.
2715
+ \$insertionText = if (\$null -ne \$quoteType) {
2716
+ & \$quoteCompletion (\$insertionPrefix + \$replacementText)
2717
+ } elseif (-not \$insertionPrefix -and \$completionText -cmatch '\\A~/[a-zA-Z0-9_./:\\\\-]*\\z') {
2718
+ \$pathText
2719
+ } else {
2720
+ \$argumentText = if (-not \$insertionPrefix -and (\$completionText.StartsWith('~/') -or \$completionText.StartsWith('~\\'))) {
2721
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2722
+ } else { \$pathText }
2723
+ & \$quoteArgument (\$insertionPrefix + \$argumentText)
2724
+ }
1249
2725
  [System.Management.Automation.CompletionResult]::new(
2726
+ \$insertionText,
1250
2727
  \$completionText,
1251
- \$completionText,
1252
- 'ParameterValue',
2728
+ \$resultType,
1253
2729
  \$itemType
1254
2730
  )
1255
2731
  }
@@ -1257,11 +2733,14 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1257
2733
  # Parse literal completion: text\\tlistItemText\\tdescription
1258
2734
  \$parts = \$line -split "\`t", 3
1259
2735
  \$completionText = \$parts[0]
2736
+ # Empty display text is invalid for CompletionResult. Keep
2737
+ # ignoring empty suggestions without dropping later records.
2738
+ if (-not \$completionText) { return }
1260
2739
  \$listItemText = if (\$parts.Length -gt 1 -and \$parts[1]) { \$parts[1] } else { \$completionText }
1261
2740
  \$toolTip = if (\$parts.Length -gt 2 -and \$parts[2]) { \$parts[2] } else { \$completionText }
1262
2741
 
1263
2742
  [System.Management.Automation.CompletionResult]::new(
1264
- \$completionText,
2743
+ (& \$quoteCompletion \$completionText),
1265
2744
  \$listItemText,
1266
2745
  'ParameterValue',
1267
2746
  \$toolTip
@@ -1281,13 +2760,13 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1281
2760
  if (suggestion.kind === "literal") {
1282
2761
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1283
2762
  const text = sanitizeForTransport(suggestion.text);
1284
- yield `${text}\t${text}\t${description}`;
2763
+ yield `${encodeLiteral(suggestion.text)}\t${text}\t${description}`;
1285
2764
  } else {
1286
2765
  const extensions = encodeExtensions(suggestion.extensions);
1287
2766
  const hidden = suggestion.includeHidden ? "1" : "0";
1288
2767
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1289
2768
  const pattern = encodePattern(suggestion.pattern ?? "");
1290
- yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}\t[file]\t${description}`;
2769
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}${encodeFilePrefix(suggestion)}\t[file]\t${description}`;
1291
2770
  }
1292
2771
  i++;
1293
2772
  }