@optique/core 1.1.13-dev.2823 → 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
  import { formatMessage } from "./message.js";
2
+ import { getJoinedFileSuggestion } from "./internal/joined-file-suggestion.js";
2
3
 
3
4
  //#region src/completion.ts
4
5
  /**
@@ -46,6 +47,11 @@ function encodeFilePrefix(suggestion) {
46
47
  if (prefix === "" || !(suggestion.pattern ?? "").startsWith(prefix) || sanitizeForTransport(prefix) !== prefix) return "";
47
48
  return `:${encodePattern(prefix)}`;
48
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
+ }
49
55
  function encodeExtensions(extensions) {
50
56
  return extensions?.map((ext) => ext.replace(/^\./, "")).join(",") ?? "";
51
57
  }
@@ -62,6 +68,17 @@ function sanitizeForTransport(text) {
62
68
  return text.replace(/[\t\n\r\0]/g, " ");
63
69
  }
64
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
+ /**
65
82
  * The Bash shell completion generator.
66
83
  * @since 0.6.0
67
84
  */
@@ -73,32 +90,296 @@ const bash = {
73
90
  return `
74
91
  function _${programName} () {
75
92
  COMPREPLY=()
76
- 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_.+-]*)(/.*)?$'
77
131
  local prev=("\${COMP_WORDS[@]:1:COMP_CWORD-1}")
78
- local __prefix_aware=0
79
- while IFS= read -r line; do
80
- if [[ "$line" == __FILE__:* ]]; then
81
- # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
82
- local insertion_prefix
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"
83
357
  IFS=':' read -r _ type extensions pattern hidden insertion_prefix <<< "$line"
84
358
  extensions="\${extensions//,\\./,}"; extensions="\${extensions#.}"
85
359
  pattern="\${pattern//%3A/:}"; pattern="\${pattern//%25/%}"
86
-
87
360
  insertion_prefix="\${insertion_prefix//%3A/:}"; insertion_prefix="\${insertion_prefix//%25/%}"
88
- local __saved_current="$current"
89
- local -a __saved_reply=()
90
- if [[ -n "$insertion_prefix" && "$pattern" == "$insertion_prefix"* && "$current" == "$insertion_prefix"* ]]; then
91
- pattern="\${pattern#"$insertion_prefix"}"
92
- current="\${current#"$insertion_prefix"}"
93
- __prefix_aware=1
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
94
373
  else
95
374
  insertion_prefix=""
96
375
  fi
97
- # Inserted keys are not filesystem paths. Once present, keep earlier
98
- # replies out of later file filters; retain legacy-only aggregation.
99
- if [[ "$__prefix_aware" == "1" ]]; then
100
- __saved_reply=("\${COMPREPLY[@]}")
101
- COMPREPLY=()
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=""
102
383
  fi
103
384
 
104
385
  # Save and adjust glob/shell options for safe file completion
@@ -115,12 +396,12 @@ function _${programName} () {
115
396
  unset GLOBIGNORE
116
397
 
117
398
  # Expand tilde prefix for file globbing
118
- local __glob_current="$current" __tilde_prefix="" __tilde_expanded=""
119
- 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
120
401
  __tilde_prefix="\${BASH_REMATCH[1]}"
121
402
  eval "__tilde_expanded=\$__tilde_prefix" 2>/dev/null || true
122
403
  if [[ -n "$__tilde_expanded" && "$__tilde_expanded" != "$__tilde_prefix" ]]; then
123
- __glob_current="\${__tilde_expanded}\${current#\$__tilde_prefix}"
404
+ __glob_current="\${__tilde_expanded}\${__file_current#\$__tilde_prefix}"
124
405
  else
125
406
  __tilde_prefix=""
126
407
  fi
@@ -131,10 +412,11 @@ function _${programName} () {
131
412
  # However, if the user has already typed beyond the pattern (e.g.,
132
413
  # pattern="src/" and current="src/ma"), preserve the typed suffix
133
414
  # for incremental filtering.
134
- local __from_pattern=0
415
+ local __from_pattern=0 __filter_pattern=0 __pattern_prefix=""
416
+ local __pattern_tilde_prefix="" __pattern_tilde_expanded=""
135
417
  if [[ -n "$pattern" ]]; then
136
418
  # Normalize leading ./ so that ./src/ma matches pattern src/
137
- local __norm_current="\${current#./}"
419
+ local __norm_current="\${__file_current#./}"
138
420
  local __norm_pattern="\${pattern#./}"
139
421
  # For wildcard patterns, compare only the directory prefix before
140
422
  # any wildcards so that typing src/ma narrows src/*.ts correctly
@@ -143,17 +425,34 @@ function _${programName} () {
143
425
  __compare_pattern="\${__compare_pattern%%[\*\?]*}"
144
426
  [[ "$__compare_pattern" == */* ]] && __compare_pattern="\${__compare_pattern%/*}/" || __compare_pattern=""
145
427
  fi
146
- 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
147
433
  # User has typed beyond or an equivalent form of the pattern
148
434
  true
149
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
150
449
  __from_pattern=1
151
450
  # Reset tilde state from the current-word expansion so that a
152
451
  # non-tilde pattern is not rewritten through stale tilde state
153
452
  __tilde_prefix=""
154
453
  __tilde_expanded=""
155
454
  __glob_current="$pattern"
156
- if [[ "$pattern" =~ ^(~[a-zA-Z0-9_.+-]*)(/.*)?$ ]]; then
455
+ if [[ "$pattern" =~ $__tilde_glob_pattern ]]; then
157
456
  __tilde_prefix="\${BASH_REMATCH[1]}"
158
457
  eval "__tilde_expanded=\$__tilde_prefix" 2>/dev/null || true
159
458
  if [[ -n "$__tilde_expanded" && "$__tilde_expanded" != "$__tilde_prefix" ]]; then
@@ -175,36 +474,93 @@ function _${programName} () {
175
474
  # This runs after tilde expansion so that paths like ~/.config/ are
176
475
  # checked against the expanded path, not the literal ~ string.
177
476
  local __inside_hidden_path=0
178
- case "/\${__glob_current%/}/" in
179
- */.[!.]*/*|*/..?*/*) __inside_hidden_path=1 ;;
180
- 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
181
482
  # Also check if the current prefix explicitly targets hidden entries
182
483
  # (e.g., user typed "." or ".e" to complete .env)
183
484
  local __prefix_targets_hidden=0
184
485
  local __prefix_base="\${__glob_current##*/}"
486
+ if [[ -n "$__pattern_prefix" ]]; then __prefix_base="\${__pattern_prefix##*/}"; fi
185
487
  [[ "$__prefix_base" == .* ]] && __prefix_targets_hidden=1
186
488
  if [[ "$hidden" == "1" || "$__inside_hidden_path" == "1" || "$__prefix_targets_hidden" == "1" ]]; then shopt -s dotglob; fi
187
489
 
188
490
  # Pre-expand file candidates. When the glob base came from the
189
491
  # program's pattern and contains wildcard characters (* or ?),
190
- # 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.
191
495
  # Otherwise append * to treat it as a prefix.
192
- # Note: [ is NOT treated as a glob indicator because it commonly
193
- # 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.
194
498
  local -a __candidates=()
195
499
  if [[ "$__from_pattern" == "1" && "$__glob_current" == *[\*\?]* ]]; then
196
- 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
+ )
197
545
  # For file/any modes, also include directories from the base
198
546
  # directory for navigation even when the glob itself does not
199
547
  # match directory names. Skip this for directory mode so that
200
548
  # the pattern's basename filter is respected.
201
549
  if [[ "$type" != "directory" ]]; then
202
- local __glob_dir="\${__glob_current%/*}"
203
- [[ "$__glob_dir" == "$__glob_current" ]] && __glob_dir="."
204
550
  local __d
205
- for __d in "$__glob_dir"/*/; do
206
- [[ -d "$__d" ]] && __candidates+=("\${__d%/}")
207
- 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
+ )
208
564
  fi
209
565
  else
210
566
  __candidates=("$__glob_current"*)
@@ -215,6 +571,26 @@ function _${programName} () {
215
571
  fi
216
572
  fi
217
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
+
218
594
  # Generate file completions based on type
219
595
  case "$type" in
220
596
  file)
@@ -222,10 +598,11 @@ function _${programName} () {
222
598
  if [[ -n "$extensions" ]]; then
223
599
  # Files with extension filtering + directories
224
600
  local ext_pattern="\${extensions//,/|}"
601
+ ext_pattern='\\.('"$ext_pattern"')$'
225
602
  for item in "\${__candidates[@]}"; do
226
603
  if [[ -d "$item" ]]; then
227
604
  COMPREPLY+=("$item/")
228
- elif [[ -f "$item" && "$item" =~ \\.($ext_pattern)$ ]]; then
605
+ elif [[ -f "$item" && "$item" =~ $ext_pattern ]]; then
229
606
  COMPREPLY+=("$item")
230
607
  fi
231
608
  done
@@ -252,10 +629,11 @@ function _${programName} () {
252
629
  # Files with extension filtering + directories
253
630
  # Files with extension filtering
254
631
  local ext_pattern="\${extensions//,/|}"
632
+ ext_pattern='\\.('"$ext_pattern"')$'
255
633
  for item in "\${__candidates[@]}"; do
256
634
  if [[ -d "$item" ]]; then
257
635
  COMPREPLY+=("$item/")
258
- elif [[ ( -e "$item" || -L "$item" ) && "$item" =~ \\.($ext_pattern)$ ]]; then
636
+ elif [[ ( -e "$item" || -L "$item" ) && "$item" =~ $ext_pattern ]]; then
259
637
  COMPREPLY+=("$item")
260
638
  fi
261
639
  done
@@ -274,7 +652,12 @@ function _${programName} () {
274
652
  esac
275
653
 
276
654
  # Restore tilde prefix in completion results
277
- 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
278
661
  local __i
279
662
  for __i in "\${!COMPREPLY[@]}"; do
280
663
  COMPREPLY[\$__i]="\${COMPREPLY[\$__i]/#\$__tilde_expanded/\$__tilde_prefix}"
@@ -289,41 +672,155 @@ function _${programName} () {
289
672
  if [[ "$__failglob_was_set" == "1" ]]; then shopt -s failglob; fi
290
673
  if [[ "$__noglob_was_set" == "1" ]]; then set -f; fi
291
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
+
292
732
  # Filter out hidden files unless requested
293
733
  if [[ "$hidden" != "1" && "$__inside_hidden_path" == "0" && "$__prefix_targets_hidden" == "0" ]]; then
294
734
  local filtered=()
295
735
  local __name
296
736
  for item in "\${COMPREPLY[@]}"; do
297
737
  __name="\${item%/}"; __name="\${__name##*/}"
298
- [[ "$__name" != .* ]] && filtered+=("$item")
738
+ if [[ "$__name" != .* ]]; then
739
+ filtered+=("$item")
740
+ fi
299
741
  done
300
742
  COMPREPLY=("\${filtered[@]}")
301
743
  fi
302
744
  if [[ -n "$insertion_prefix" ]]; then
303
- local __prefix_index
304
- for __prefix_index in "\${!COMPREPLY[@]}"; do
305
- COMPREPLY[$__prefix_index]="$insertion_prefix\${COMPREPLY[$__prefix_index]}"
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]}"
306
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
307
755
  fi
308
- if [[ "$__prefix_aware" == "1" ]]; then
309
- COMPREPLY=("\${__saved_reply[@]}" "\${COMPREPLY[@]}")
310
- fi
311
- current="$__saved_current"
756
+ if [[ \${#COMPREPLY[@]} -gt $__reply_start ]]; then __file_mode=1; fi
312
757
  else
313
758
  # Regular literal completion
314
759
  COMPREPLY+=("$line")
315
760
  fi
316
- 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
317
799
  }
318
800
 
319
- 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
320
807
  `;
321
808
  },
322
809
  *encodeSuggestions(suggestions) {
323
810
  let i = 0;
324
811
  for (const suggestion of suggestions) {
325
812
  if (i > 0) yield "\n";
326
- if (suggestion.kind === "literal") yield sanitizeForTransport(suggestion.text);
813
+ const joined = 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);
327
824
  else {
328
825
  const extensions = encodeExtensions(suggestion.extensions);
329
826
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -343,16 +840,36 @@ const zsh = {
343
840
  generateScript(programName, args = []) {
344
841
  validateProgramName(programName);
345
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("");
346
845
  return `#compdef ${programName}
347
- function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
348
- 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
349
866
  local -a prev
350
867
  # Extract previous arguments, skipping empty ones
351
868
  prev=()
352
869
  local i
353
870
  for (( i=2; i < CURRENT; i++ )); do
354
871
  if [[ -n "\$words[i]" ]]; then
355
- prev+=("\$words[i]")
872
+ prev+=("\${(Q)words[i]}")
356
873
  fi
357
874
  done
358
875
 
@@ -376,7 +893,12 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
376
893
  desc=\${line#*\$'\\0'}
377
894
  desc=\${desc%%\$'\\0'*}
378
895
 
379
- 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
380
902
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
381
903
  local type extensions pattern hidden insertion_prefix
382
904
  IFS=':' read -r _ type extensions pattern hidden insertion_prefix <<< "\$value"
@@ -384,10 +906,17 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
384
906
  __decoded_pattern="\${__decoded_pattern//\\%3A/:}"; __decoded_pattern="\${__decoded_pattern//\\%25/%}"
385
907
  insertion_prefix="\${insertion_prefix//\\%3A/:}"; insertion_prefix="\${insertion_prefix//\\%25/%}"
386
908
  local __saved_prefix="\$PREFIX" __saved_iprefix="\$IPREFIX"
387
- if [[ -n "\$insertion_prefix" && "\$__decoded_pattern" == "\$insertion_prefix"* && "\$PREFIX" == "\$insertion_prefix"* ]]; then
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
388
914
  pattern="\${__decoded_pattern#"\$insertion_prefix"}"
389
915
  PREFIX="\${PREFIX#"\$insertion_prefix"}"
390
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"}"
391
920
  else
392
921
  # Preserve legacy decoding for directives without active metadata.
393
922
  pattern="\${pattern//%3A/:}"; pattern="\${pattern//%25/%}"
@@ -399,6 +928,11 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
399
928
  local __was_sh_glob=0
400
929
  [[ -o sh_glob ]] && __was_sh_glob=1
401
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
402
936
 
403
937
  # Enable glob_dots when hidden files are requested so that
404
938
  # _files and _directories include dot-prefixed entries
@@ -410,20 +944,351 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
410
944
  # _directories enumerate the pattern's directory instead of the
411
945
  # current word. If the user has already typed beyond the pattern,
412
946
  # keep PREFIX unchanged for incremental narrowing.
413
- 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
414
981
  # Normalize leading ./ so that ./src/ma matches pattern src/
415
- local __norm_prefix="\${PREFIX#./}"
982
+ local __norm_prefix="\${__logical_prefix#./}"
416
983
  local __norm_pattern="\${pattern#./}"
417
984
  local __compare_pattern="\$__norm_pattern"
418
985
  if [[ "\$__compare_pattern" == *[\*\?]* ]]; then
419
986
  __compare_pattern="\${__compare_pattern%%[\*\?]*}"
420
987
  [[ "\$__compare_pattern" == */* ]] && __compare_pattern="\${__compare_pattern%/*}/" || __compare_pattern=""
421
988
  fi
422
- if [[ ( -n "\$__compare_pattern" || -n "\$__norm_prefix" ) && \${#__norm_prefix} -ge \${#__compare_pattern} && "\${__norm_prefix[1,\${#__compare_pattern}]}" == "\$__compare_pattern" && "\$PREFIX" != "\$pattern" ]]; then
423
- # User typed an equivalent or extended form—keep PREFIX
424
- 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
425
1054
  else
426
- 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
427
1292
  fi
428
1293
  fi
429
1294
 
@@ -435,15 +1300,114 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
435
1300
  ext_pattern="*.\$extensions"
436
1301
  fi
437
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=()
438
1306
  # Use zsh's native file completion
439
1307
  case "\$type" in
440
- file)
441
- if [[ -n "\$ext_pattern" ]]; then
442
- # Route filtered files through zsh's standard files/directories
443
- # tags so files-tag styles and tag-order still apply, while
444
- # avoiding _files' all-files fallback for extension-filtered
445
- # matches.
446
- 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
447
1411
  _wanted files expl file _path_files -g "\${file_pattern}"
448
1412
  _wanted directories expl directory _path_files -/
449
1413
  else
@@ -451,23 +1415,25 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
451
1415
  fi
452
1416
  ;;
453
1417
  directory)
454
- _directories
455
- ;;
456
- any)
457
- if [[ -n "\$ext_pattern" ]]; then
458
- local file_pattern="\${ext_pattern}(#q^-/)"
459
- _wanted files expl file _path_files -g "\${file_pattern}"
460
- _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-/)"
461
1421
  else
462
- _files
1422
+ _directories
463
1423
  fi
464
1424
  ;;
465
1425
  esac
466
-
467
1426
  # Restore PREFIX and glob_dots to their previous state
468
1427
  PREFIX="\$__saved_prefix"
469
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
470
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
471
1437
  if [[ "\$__was_glob_dots" == "1" ]]; then setopt glob_dots; else unsetopt glob_dots; fi
472
1438
  else
473
1439
  # Regular literal completion
@@ -484,26 +1450,46 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
484
1450
  # Prepare completion with descriptions for _describe
485
1451
  local -a matches
486
1452
  local -i i
1453
+ local __match
487
1454
  for (( i=1; i <= \${#completions[@]}; i++ )); do
1455
+ # _describe uses backslashes to escape its colon separator.
1456
+ __match="\${completions[i]//\\\\/\\\\\\\\}"
1457
+ __match="\${__match//:/\\\\:}"
488
1458
  if [[ -n "\${descriptions[i]}" ]]; then
489
- matches+=("\${completions[i]}:\${descriptions[i]}")
1459
+ matches+=("\$__match:\${descriptions[i]}")
490
1460
  else
491
- matches+=("\${completions[i]}")
1461
+ matches+=("\$__match")
492
1462
  fi
493
1463
  done
494
- _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
495
1470
  fi
496
1471
  }
497
1472
 
1473
+ function ${fixedGlobFunction} () {
1474
+ local __fixed_candidate="\${REPLY:a}"
1475
+ (( \${__fixed_matches[(Ie)\$__fixed_candidate]} ))
1476
+ }
1477
+
498
1478
  if (( $+functions[compdef] )); then
499
- 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} "\$@"
500
1486
  fi
501
1487
  `;
502
1488
  },
503
1489
  *encodeSuggestions(suggestions) {
504
1490
  for (const suggestion of suggestions) if (suggestion.kind === "literal") {
505
1491
  const description = suggestion.description == null ? "" : sanitizeForTransport(formatMessage(suggestion.description, { colors: false }));
506
- yield `${sanitizeForTransport(suggestion.text)}\0${description}\0`;
1492
+ yield `${encodeLiteral(suggestion.text)}\0${description}\0`;
507
1493
  } else {
508
1494
  const extensions = encodeExtensions(suggestion.extensions);
509
1495
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -522,12 +1508,35 @@ const fish = {
522
1508
  generateScript(programName, args = []) {
523
1509
  validateProgramName(programName);
524
1510
  const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "\\'")}'`).join(" ");
525
- const functionName = `__${programName.replace(/[^a-zA-Z0-9]/g, "_")}_complete`;
1511
+ const functionName = `__${programName}_complete`;
526
1512
  return `
527
1513
  function ${functionName}
528
1514
  set -l tokens (commandline -poc)
529
1515
  set -l current (commandline -ct)
530
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
+
531
1540
  # Extract previous arguments (skip the command name)
532
1541
  set -l prev
533
1542
  set -l count (count $tokens)
@@ -539,7 +1548,12 @@ function ${functionName}
539
1548
  ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current 2>/dev/null)\n` : ` set -l output (${programName} $prev $current 2>/dev/null)\n`}
540
1549
  # Process each line of output
541
1550
  for line in $output
542
- 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
543
1557
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
544
1558
  set -l directive (string split \\t -- $line)[1]
545
1559
  set -l parts (string split ':' -- $directive)
@@ -552,6 +1566,7 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
552
1566
  set insertion_prefix (string replace -a '%25' '%' -- (string replace -a '%3A' ':' -- $parts[6]))
553
1567
  end
554
1568
  set -l saved_current "$current"
1569
+ set -l saved_raw_current "$__raw_current"
555
1570
  set -l prefix_length (string length -- "$insertion_prefix")
556
1571
  if test $prefix_length -gt 0
557
1572
  and test (string sub -l $prefix_length -- "$pattern") = "$insertion_prefix"
@@ -559,10 +1574,22 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
559
1574
  set -l start (math $prefix_length + 1)
560
1575
  set pattern (string sub -s $start -- "$pattern")
561
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
562
1582
  else
563
1583
  set insertion_prefix ""
564
1584
  end
565
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
1592
+
566
1593
  # When a pattern is specified, use it as the glob base instead
567
1594
  # of the current word. If the user has already typed beyond the
568
1595
  # pattern (e.g., pattern="src/" and current="src/ma"), keep the
@@ -574,15 +1601,13 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
574
1601
  # Normalize leading ./ so that ./src/ma matches pattern src/
575
1602
  set -l __norm_current (string replace -r '^\\./' '' -- "$current")
576
1603
  set -l __norm_pattern (string replace -r '^\\./' '' -- "$pattern")
577
- # 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.
578
1608
  set -l __compare_pattern "$__norm_pattern"
579
- if string match -q '*[*?]*' -- "$__compare_pattern"
580
- set __compare_pattern (string replace -r '[*?].*' '' -- "$__compare_pattern")
581
- if string match -q '*/*' -- "$__compare_pattern"
582
- set __compare_pattern (string replace -r '/[^/]*$' '/' -- "$__compare_pattern")
583
- else
584
- set __compare_pattern ""
585
- end
1609
+ if string match -qr '[*?][^/]*$' -- "$__compare_pattern"
1610
+ set __compare_pattern (string replace -r '[^/]*$' '' -- "$__compare_pattern")
586
1611
  end
587
1612
  set -l __cp_len (string length -- "$__compare_pattern")
588
1613
  set -l __nc_len (string length -- "$__norm_current")
@@ -597,14 +1622,17 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
597
1622
  end
598
1623
  end
599
1624
 
600
- # Expand tilde prefix for globbing—fish does not expand ~
601
- # inside variable substitutions, so replace it with $HOME
602
- if string match -q '~/*' -- "$glob_base"
603
- set __tilde_prefix "~"
604
- set glob_base (string replace -r '^~' "$HOME" -- "$glob_base")
605
- else if string match -q '~' -- "$glob_base"
606
- set __tilde_prefix "~"
607
- 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
608
1636
  end
609
1637
 
610
1638
  # If the glob base is a directory without a trailing slash,
@@ -614,54 +1642,82 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
614
1642
  end
615
1643
 
616
1644
  # Pre-expand file candidates. When the glob base came from
617
- # the program's pattern and contains * or ?, use it as the
618
- # 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.
619
1651
  # Note: [ is NOT treated as a glob because it commonly
620
1652
  # appears in literal filenames like [draft] or foo[1].txt.
621
1653
  set -l __has_glob 0
622
1654
  if test $__from_pattern -eq 1
623
- and string match -q '*[*?]*' -- "$glob_base"
1655
+ and string match -qr '[*?][^/]*$' -- "$glob_base"
1656
+ and test "$pattern" != "$current"
624
1657
  set __has_glob 1
625
1658
  end
626
1659
  set -l __candidates
627
1660
  if test $__has_glob -eq 1
628
1661
  # Safe glob expansion without eval: split the pattern into
629
- # directory and filter parts, list the directory, then use
630
- # string match for wildcard filtering
631
- 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
632
1670
  set -l __glob_filter (string replace -r '.*/' '' -- "$glob_base")
633
- if test -z "$__glob_dir"; or test "$__glob_dir" = "$glob_base"
634
- 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
635
1684
  end
1685
+ set __glob_regex "$__glob_regex"'$'
636
1686
  # Match files by the glob filter. Fish's * does not match
637
- # dotfiles, so also scan .* when the filter targets them.
638
- 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
639
1694
  if test -e "$__item"
640
- set -l __bn (basename "$__item")
641
- if string match -q "$__glob_filter" -- "$__bn"
642
- set -a __candidates "$__item"
1695
+ set -l __bn (string replace -r '.*/' '' -- "$__item")
1696
+ if contains -- "$__bn" . ..
1697
+ continue
643
1698
  end
644
- end
645
- end
646
- if string match -q '.*' -- "$__glob_filter"
647
- for __item in $__glob_dir/.*
648
- if test -e "$__item"
649
- set -l __bn (basename "$__item")
650
- if test "$__bn" = "." -o "$__bn" = ".."
651
- continue
652
- end
653
- if string match -q "$__glob_filter" -- "$__bn"
654
- set -a __candidates "$__item"
655
- end
1699
+ if string match -qr -- "$__glob_regex" "$__bn"
1700
+ set -a __candidates "$__item"
656
1701
  end
657
1702
  end
658
1703
  end
659
1704
  # For file/any modes, also include directories for navigation.
660
1705
  # Skip for directory mode so the pattern filter is respected.
1706
+ # Directories the filter already matched are not repeated.
661
1707
  if test "$type" != "directory"
662
- 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
663
1718
  if test -d "$__item"
664
- set -a __candidates (string replace -r '/$' '' -- "$__item")
1719
+ and not string match -qr -- "$__glob_regex" "$__bn"
1720
+ set -a __candidates "$__item"
665
1721
  end
666
1722
  end
667
1723
  end
@@ -764,7 +1820,7 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
764
1820
  if test "$hidden" != "1" -a (string sub -l 1 -- $__glob_basename) != "."
765
1821
  set -l filtered
766
1822
  for item in $items
767
- set -l basename (basename $item)
1823
+ set -l basename (basename -- "$item")
768
1824
  if not string match -q '.*' -- $basename
769
1825
  set -a filtered $item
770
1826
  end
@@ -781,11 +1837,13 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
781
1837
  set items $restored
782
1838
  end
783
1839
 
784
- # 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.
785
1842
  for item in $items
786
- echo "$insertion_prefix$item"
1843
+ printf '%s\\n' "$insertion_prefix$item"
787
1844
  end
788
1845
  set current "$saved_current"
1846
+ set __raw_current "$saved_raw_current"
789
1847
  else
790
1848
  # Regular literal completion - split by tab
791
1849
  set -l parts (string split \\t -- $line)
@@ -809,7 +1867,7 @@ complete -c ${programName} -f -a '(${functionName})'
809
1867
  if (i > 0) yield "\n";
810
1868
  if (suggestion.kind === "literal") {
811
1869
  const description = suggestion.description == null ? "" : sanitizeForTransport(formatMessage(suggestion.description, { colors: false }));
812
- yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
1870
+ yield `${encodeLiteral(suggestion.text)}\t${description}`;
813
1871
  } else {
814
1872
  const extensions = encodeExtensions(suggestion.extensions);
815
1873
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -822,6 +1880,16 @@ complete -c ${programName} -f -a '(${functionName})'
822
1880
  }
823
1881
  };
824
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
+ /**
825
1893
  * The Nushell completion generator.
826
1894
  * @since 0.6.0
827
1895
  */
@@ -829,66 +1897,193 @@ const nu = {
829
1897
  name: "nu",
830
1898
  generateScript(programName, args = []) {
831
1899
  validateProgramName(programName);
832
- const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "''")}'`).join(" ");
1900
+ const escapedArgs = args.map(quoteNuString).join(" ");
833
1901
  const safeName = programName.replace(/[^a-zA-Z0-9]+/g, "-");
834
1902
  const functionName = `nu-complete-${safeName}`;
835
1903
  return `
836
- # 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.
837
1909
  def args-split []: string -> list<string> {
838
- let STATE_NORMAL = 0
839
- let STATE_IN_SINGLE_QUOTE = 1
840
- let STATE_IN_DOUBLE_QUOTE = 2
841
- let STATE_ESCAPE = 3
842
1910
  let WHITESPACES = [" " "\\t" "\\n" "\\r"]
1911
+ let QUOTES = ["'" '"' "\`"]
843
1912
 
844
- mut state = $STATE_NORMAL
845
- mut current_token = ""
846
- mut result: list<string> = []
847
- mut prev_state = $STATE_NORMAL
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
+ }
1974
+
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
848
1984
 
849
1985
  for char in ($in | split chars) {
850
- if $state == $STATE_ESCAPE {
851
- $current_token = $current_token + $char
852
- $state = $prev_state
853
- } else if $char == '\\\\' {
854
- $prev_state = $state
855
- $state = $STATE_ESCAPE
856
- } else if $state == $STATE_NORMAL {
857
- if $char == "'" {
858
- $state = $STATE_IN_SINGLE_QUOTE
859
- } else if $char == '"' {
860
- $state = $STATE_IN_DOUBLE_QUOTE
861
- } else if ($char in $WHITESPACES) {
862
- if ($current_token | is-not-empty) {
863
- $result = $result | append $current_token
864
- $current_token = ""
865
- }
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
866
1997
  } else {
867
- $current_token = $current_token + $char
1998
+ $raw_pending = ""
1999
+ }
2000
+ if $raw_pending == ("'" + $raw_hashes) {
2001
+ $quote = ""
2002
+ $raw_pending = ""
868
2003
  }
869
- } else if $state == $STATE_IN_SINGLE_QUOTE {
870
- if $char == "'" {
871
- $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
872
2010
  } else {
873
- $current_token = $current_token + $char
2011
+ $quote = "r"
874
2012
  }
875
- } else if $state == $STATE_IN_DOUBLE_QUOTE {
876
- if $char == '"' {
877
- $state = $STATE_NORMAL
2013
+ } else if $quote != "" {
2014
+ $word = $word + $char
2015
+ if $escaped {
2016
+ $escaped = false
2017
+ } else if $char == $quote {
2018
+ $quote = ""
878
2019
  } else {
879
- $current_token = $current_token + $char
2020
+ $escaped = ($quote == '"' and $char == '\\')
880
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
881
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 ""
882
2045
  }
883
- if ($current_token | is-not-empty) {
884
- $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}'!'") + "'"
885
2080
  }
886
- $result
887
2081
  }
888
2082
 
889
2083
  # Completion command that calls back to the CLI
890
2084
  export def "${functionName}" [context: string] {
891
- # 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
892
2087
  let tokens = $context | args-split
893
2088
 
894
2089
  # Remove the command name (first token) to get arguments
@@ -898,18 +2093,11 @@ export def "${functionName}" [context: string] {
898
2093
  []
899
2094
  }
900
2095
 
901
- # If context ends with whitespace, add empty string to represent the new argument
902
- let args_with_empty = if ($context | str ends-with ' ') {
903
- $args | append ''
904
- } else {
905
- $args
906
- }
907
-
908
2096
  # Ensure at least one argument (empty string) to get completions not script
909
- let final_args = if ($args_with_empty | is-empty) {
2097
+ let final_args = if ($args | is-empty) {
910
2098
  ['']
911
2099
  } else {
912
- $args_with_empty
2100
+ $args
913
2101
  }
914
2102
 
915
2103
  let output = try {
@@ -918,8 +2106,10 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
918
2106
  }
919
2107
 
920
2108
  # Process each line of output
921
- $output | lines | each {|line|
922
- 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__:') {
923
2113
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
924
2114
  let directive = ($line | split row "\t" | first)
925
2115
  let parts = ($directive | split row ':')
@@ -950,12 +2140,12 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
950
2140
  # prefix for incremental narrowing. Normalize path separators
951
2141
  # before comparing so that Windows backslashes match forward slashes
952
2142
  # in the transported pattern.
2143
+ let is_win = (($nu.os-info.name | str downcase) == "windows")
953
2144
  let glob_base = if ($pattern | is-not-empty) {
954
2145
  # Normalize separators and leading ./; downcase only on Windows
955
2146
  # where filesystems are typically case-insensitive
956
2147
  let norm_prefix_raw = ($prefix | str replace -a '\\' '/' | str replace -r '^\\./' '')
957
2148
  let norm_pattern_raw = ($pattern | str replace -a '\\' '/' | str replace -r '^\\./' '')
958
- let is_win = (($nu.os-info.name | str downcase) == "windows")
959
2149
  let norm_prefix = (if $is_win { $norm_prefix_raw | str downcase } else { $norm_prefix_raw })
960
2150
  let norm_pattern = (if $is_win { $norm_pattern_raw | str downcase } else { $norm_pattern_raw })
961
2151
  # For wildcard patterns, compare only the directory prefix
@@ -974,10 +2164,27 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
974
2164
  $prefix
975
2165
  }
976
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
+
977
2182
  # If the glob base is a directory without a trailing slash (e.g., "src"),
978
2183
  # append one so that ls enumerates its contents instead of matching
979
- # siblings like "src-old"
980
- 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 "/")) {
981
2188
  $glob_base + "/"
982
2189
  } else {
983
2190
  $glob_base
@@ -986,56 +2193,78 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
986
2193
  # Generate file completions based on type.
987
2194
  # When the glob base contains wildcard characters, use it as-is;
988
2195
  # otherwise append * to treat it as a prefix.
989
- # 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.
990
2200
  let has_glob = ($glob_base =~ '[*?]')
991
2201
  let ls_pattern = if ($glob_base | is-empty) {
992
2202
  "."
993
2203
  } else if $has_glob {
994
2204
  ($glob_base | into glob)
995
2205
  } else {
996
- ($glob_base + "*" | into glob)
2206
+ (($glob_base | str replace -a -r '([\\[\\]])' '[$1]') + "*" | into glob)
997
2207
  }
998
2208
 
999
2209
  # When using a glob pattern, also compute a directory listing pattern
1000
- # 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
1001
2212
  let glob_dir_pattern = if $has_glob {
1002
2213
  let dir_part = ($glob_base | path dirname)
1003
2214
  let dir = if ($dir_part | is-empty) or ($dir_part == $glob_base) { "." } else { $dir_part }
1004
- ($dir + "/*" | into glob)
2215
+ (($dir | str replace -a -r '([\\[\\]])' '[$1]') + "/*" | into glob)
1005
2216
  } else {
1006
2217
  null
1007
2218
  }
1008
2219
 
1009
- # 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
+
1010
2237
  let items = try {
1011
2238
  match $type {
1012
2239
  "file" => {
1013
2240
  if ($extensions | is-empty) {
1014
- (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
1015
2242
  } else {
1016
2243
  let ext_list = ($extensions | split row ',' | each {|e| $e | str replace -r '^\\.' '' })
1017
- let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
2244
+ let all_items = (do $list_entries $ls_pattern)
1018
2245
  let dirs = $all_items | where type == dir
1019
2246
  let files = $all_items | where type == file | where {|f|
1020
- let ext = ($f.name | path parse | get extension)
1021
- $ext in $ext_list
2247
+ $ext_list | any {|ext|
2248
+ $f.name | path basename | str ends-with ('.' + $ext)
2249
+ }
1022
2250
  }
1023
2251
  $dirs | append $files
1024
2252
  }
1025
2253
  },
1026
2254
  "directory" => {
1027
- (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == dir
2255
+ (do $list_entries $ls_pattern) | where type == dir
1028
2256
  },
1029
2257
  "any" => {
1030
2258
  if ($extensions | is-empty) {
1031
- if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }
2259
+ do $list_entries $ls_pattern
1032
2260
  } else {
1033
2261
  let ext_list = ($extensions | split row ',' | each {|e| $e | str replace -r '^\\.' '' })
1034
- let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
2262
+ let all_items = (do $list_entries $ls_pattern)
1035
2263
  let dirs = $all_items | where type == dir
1036
2264
  let files = $all_items | where type == file | where {|f|
1037
- let ext = ($f.name | path parse | get extension)
1038
- $ext in $ext_list
2265
+ $ext_list | any {|ext|
2266
+ $f.name | path basename | str ends-with ('.' + $ext)
2267
+ }
1039
2268
  }
1040
2269
  $dirs | append $files
1041
2270
  }
@@ -1050,7 +2279,7 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1050
2279
  # even when the glob itself does not match directory names)
1051
2280
  let items = if ($glob_dir_pattern != null) and ($type != "directory") {
1052
2281
  let extra_dirs = try {
1053
- (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
1054
2283
  } catch {
1055
2284
  []
1056
2285
  }
@@ -1072,6 +2301,17 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1072
2301
  }
1073
2302
  }
1074
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
+
1075
2315
  # Extract directory prefix to preserve in completion text
1076
2316
  let dir_prefix = if ($glob_base | is-empty) {
1077
2317
  ""
@@ -1082,19 +2322,56 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1082
2322
  if ($parsed.parent | is-empty) { "" } else if ($parsed.parent | str ends-with "/") { $parsed.parent } else { $parsed.parent + "/" }
1083
2323
  }
1084
2324
 
1085
- # Format file completions
2325
+ # Format file completions, quoting names that Nushell would not read
2326
+ # back as a single argument
1086
2327
  $filtered | each {|item|
1087
- let name = if $item.type == dir {
1088
- $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 }
1089
2367
  } else {
1090
- $dir_prefix + ($item.name | path basename)
2368
+ { value: $value, display_override: $name }
1091
2369
  }
1092
- { value: ($insertion_prefix + $name) }
1093
2370
  }
1094
2371
  } else {
1095
2372
  # Regular literal completion - split by tab
1096
2373
  let parts = ($line | split row "\t")
1097
- if ($parts | length) >= 2 {
2374
+ let record = if ($parts | length) >= 2 {
1098
2375
  # value\\tdescription format
1099
2376
  { value: ($parts | get 0), description: ($parts | get 1) }
1100
2377
  } else if ($parts | length) == 1 and ($parts | get 0 | is-not-empty) {
@@ -1103,6 +2380,18 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1103
2380
  } else {
1104
2381
  null
1105
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
+ }
1106
2395
  }
1107
2396
  } | flatten | compact
1108
2397
  }
@@ -1140,7 +2429,7 @@ ${functionName}-external
1140
2429
  if (i > 0) yield "\n";
1141
2430
  if (suggestion.kind === "literal") {
1142
2431
  const description = suggestion.description == null ? "" : sanitizeForTransport(formatMessage(suggestion.description, { colors: false }));
1143
- yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
2432
+ yield `${encodeLiteral(suggestion.text)}\t${description}`;
1144
2433
  } else {
1145
2434
  const extensions = encodeExtensions(suggestion.extensions);
1146
2435
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -1165,27 +2454,48 @@ const pwsh = {
1165
2454
  Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1166
2455
  param(\$wordToComplete, \$commandAst, \$cursorPosition)
1167
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
+
1168
2466
  # Extract arguments from AST (handles quoted strings properly)
1169
2467
  \$arguments = @()
1170
2468
  \$commandElements = \$commandAst.CommandElements
2469
+ \$valuePrefix = \$wordToComplete
2470
+ \$quoteType = \$null
1171
2471
 
1172
- # Determine the range of elements to extract
1173
- # Exclude the last element if it matches wordToComplete (partial input case)
1174
- \$maxIndex = \$commandElements.Count - 1
1175
- if (\$commandElements.Count -gt 1) {
1176
- \$lastElement = \$commandElements[\$commandElements.Count - 1]
1177
- \$lastText = if (\$lastElement -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
1178
- \$lastElement.Value
1179
- } else {
1180
- \$lastElement.Extent.Text
1181
- }
1182
- if (\$lastText -eq \$wordToComplete) {
1183
- \$maxIndex = \$commandElements.Count - 2
1184
- }
1185
- }
1186
-
1187
- 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++) {
1188
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
+ }
1189
2499
 
1190
2500
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
1191
2501
  \$arguments += \$element.Value
@@ -1194,23 +2504,41 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1194
2504
  }
1195
2505
  }
1196
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
+
1197
2520
  # Build arguments array for completion command
1198
2521
  \$completionArgs = @()
1199
2522
  ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1200
2523
  ` : ""} \$completionArgs += \$arguments
1201
- \$completionArgs += \$wordToComplete
1202
2524
 
1203
2525
  # Call completion command and capture output
1204
2526
  try {
1205
- \$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
1206
2530
  if (-not \$output) { return }
1207
2531
 
1208
2532
  # Parse tab-separated output and create CompletionResult objects
1209
2533
  \$output -split "\`n" | ForEach-Object {
1210
- \$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() }
1211
2538
  if (-not \$line) { return }
2539
+ if (\$literal) { \$line = \$line.Substring(12) }
1212
2540
 
1213
- if (\$line -match '^__FILE__:') {
2541
+ if (-not \$literal -and \$_.StartsWith('__FILE__:', [System.StringComparison]::Ordinal)) {
1214
2542
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
1215
2543
  \$directive = (\$line -split "\`t")[0]
1216
2544
  \$parts = \$directive -split ':', 6
@@ -1219,7 +2547,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1219
2547
  \$pattern = \$parts[3] -replace '%3A', ':' -replace '%25', '%'
1220
2548
  \$hidden = \$parts[4] -eq '1'
1221
2549
  \$insertionPrefix = if (\$parts.Length -gt 5) { \$parts[5] -replace '%3A', ':' -replace '%25', '%' } else { '' }
1222
- \$matchingWord = \$wordToComplete
2550
+ \$matchingWord = \$valuePrefix
1223
2551
  if (\$insertionPrefix -and \$pattern.StartsWith(\$insertionPrefix, [System.StringComparison]::Ordinal) -and \$matchingWord.StartsWith(\$insertionPrefix, [System.StringComparison]::Ordinal)) {
1224
2552
  \$pattern = \$pattern.Substring(\$insertionPrefix.Length)
1225
2553
  \$matchingWord = \$matchingWord.Substring(\$insertionPrefix.Length)
@@ -1244,6 +2572,19 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1244
2572
  \$comparePattern = ''
1245
2573
  }
1246
2574
  }
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
+ }
1247
2588
  \$prefix = if ((\$comparePattern -or \$normalizedWord) -and \$normalizedWord -and \$normalizedWord.StartsWith(\$comparePattern, \$comparison) -and \$normalizedWord.Length -ge \$comparePattern.Length -and \$matchingWord -ne \$pattern) {
1248
2589
  \$matchingWord
1249
2590
  } elseif (\$pattern) {
@@ -1257,15 +2598,35 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1257
2598
  \$prefixBasename = if (\$prefix) { Split-Path -Leaf \$prefix } else { '' }
1258
2599
  \$forceParam = if (\$hidden -or (\$prefixBasename -and \$prefixBasename.StartsWith('.'))) { @{Force = \$true} } else { @{} }
1259
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
+
1260
2610
  # If prefix is a directory without trailing slash, append
1261
2611
  # one so Get-ChildItem lists its contents
1262
- 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('\\')) {
1263
2613
  \$prefix = \$prefix + '/'
2614
+ \$lookupPrefix = \$lookupPrefix + '/'
1264
2615
  }
1265
2616
 
1266
2617
  # Build the glob path—when the prefix contains wildcard
1267
2618
  # characters, use it as-is; otherwise append *
1268
- \$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
+ }
1269
2630
 
1270
2631
  # Get file system items based on type
1271
2632
  \$items = @()
@@ -1276,7 +2637,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1276
2637
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1277
2638
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1278
2639
  Where-Object {
1279
- \$_.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)
1280
2644
  }
1281
2645
  } else {
1282
2646
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1291,7 +2655,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1291
2655
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1292
2656
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1293
2657
  Where-Object {
1294
- \$_.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)
1295
2662
  }
1296
2663
  } else {
1297
2664
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1302,7 +2669,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1302
2669
  # For file/any modes with glob patterns, also add directories
1303
2670
  # from the base directory for navigation
1304
2671
  if (\$prefix -match '[\*\?]' -and \$type -ne 'directory') {
1305
- \$globDir = Split-Path -Parent \$prefix
2672
+ \$globDir = Split-Path -Parent \$lookupPrefix
1306
2673
  if (-not \$globDir) { \$globDir = '.' }
1307
2674
  \$extraDirs = Get-ChildItem @forceParam -Directory -Path "\$globDir/*" -ErrorAction SilentlyContinue
1308
2675
  if (\$extraDirs) { \$items = @(\$items) + @(\$extraDirs) | Select-Object -Unique }
@@ -1313,6 +2680,14 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1313
2680
  \$items = \$items | Where-Object { -not \$_.Attributes.HasFlag([System.IO.FileAttributes]::Hidden) }
1314
2681
  }
1315
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
+
1316
2691
  # Extract directory prefix to preserve in completion text
1317
2692
  \$dirPrefix = if (\$prefix -and (\$prefix.Contains('/') -or \$prefix.Contains('\\'))) {
1318
2693
  \$slashIdx = [Math]::Max(\$prefix.LastIndexOf('/'), \$prefix.LastIndexOf('\\'))
@@ -1323,10 +2698,34 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1323
2698
  \$items | ForEach-Object {
1324
2699
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1325
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
+ }
1326
2725
  [System.Management.Automation.CompletionResult]::new(
1327
- (\$insertionPrefix + \$completionText),
2726
+ \$insertionText,
1328
2727
  \$completionText,
1329
- 'ParameterValue',
2728
+ \$resultType,
1330
2729
  \$itemType
1331
2730
  )
1332
2731
  }
@@ -1334,11 +2733,14 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1334
2733
  # Parse literal completion: text\\tlistItemText\\tdescription
1335
2734
  \$parts = \$line -split "\`t", 3
1336
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 }
1337
2739
  \$listItemText = if (\$parts.Length -gt 1 -and \$parts[1]) { \$parts[1] } else { \$completionText }
1338
2740
  \$toolTip = if (\$parts.Length -gt 2 -and \$parts[2]) { \$parts[2] } else { \$completionText }
1339
2741
 
1340
2742
  [System.Management.Automation.CompletionResult]::new(
1341
- \$completionText,
2743
+ (& \$quoteCompletion \$completionText),
1342
2744
  \$listItemText,
1343
2745
  'ParameterValue',
1344
2746
  \$toolTip
@@ -1358,7 +2760,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1358
2760
  if (suggestion.kind === "literal") {
1359
2761
  const description = suggestion.description == null ? "" : sanitizeForTransport(formatMessage(suggestion.description, { colors: false }));
1360
2762
  const text = sanitizeForTransport(suggestion.text);
1361
- yield `${text}\t${text}\t${description}`;
2763
+ yield `${encodeLiteral(suggestion.text)}\t${text}\t${description}`;
1362
2764
  } else {
1363
2765
  const extensions = encodeExtensions(suggestion.extensions);
1364
2766
  const hidden = suggestion.includeHidden ? "1" : "0";