@optique/core 1.0.15-dev.2812 → 1.0.15-dev.2816

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,3 +1,4 @@
1
+ const require_joined_file_suggestion = require('./internal/joined-file-suggestion.cjs');
1
2
  const require_message = require('./message.cjs');
2
3
 
3
4
  //#region src/completion.ts
@@ -40,6 +41,11 @@ function validateProgramName(programName) {
40
41
  function encodePattern(pattern) {
41
42
  return pattern.replace(/%/g, "%25").replace(/:/g, "%3A");
42
43
  }
44
+ /** Escapes literal records that could otherwise be read as Bash directives. */
45
+ function encodeBashLiteral(value) {
46
+ const text = sanitizeForTransport(value);
47
+ return /^(?:__FILE__|__JOINED_FILE__|__LITERAL__):/.test(text) ? `__LITERAL__:${text}` : text;
48
+ }
43
49
  function encodeExtensions(extensions) {
44
50
  return extensions?.map((ext) => ext.replace(/^\./, "")).join(",") ?? "";
45
51
  }
@@ -106,8 +112,227 @@ function _${programName} () {
106
112
  local __tilde_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)$'
107
113
  local __tilde_glob_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)?$'
108
114
  local prev=("\${COMP_WORDS[@]:1:COMP_CWORD-1}")
109
- while IFS= read -r line; do
110
- if [[ "$line" == __FILE__:* ]]; then
115
+ # Bash splits unquoted '=' at its default word breaks. Reassemble only
116
+ # adjacent fragments, including further word breaks inside a joined value,
117
+ # leaving whitespace-separated arguments intact.
118
+ local __plain_current="$current" __remove="" __word __joined=0
119
+ local __raw_joined_current="$__raw_current"
120
+ local -a __raw_offsets=() __raw_quotes=()
121
+ if [[ \${COMP_LINE+x} ]]; then
122
+ local -a __words=()
123
+ local __rest="$COMP_LINE" __previous="" __space __valid=1 __i __j=0
124
+ local __shell_quote="" __shell_escape=0 __boundary=0 __previous_boundary=0 __scan __symbol __shell_dollar=0
125
+ # Reconstruct the prefix at the cursor, leaving any suffix to Readline.
126
+ if [[ \${COMP_POINT+x} ]]; then
127
+ __rest="\${COMP_LINE:0:COMP_POINT}"
128
+ fi
129
+ for ((__i=0; __i<=COMP_CWORD; __i++)); do
130
+ __word="\${COMP_WORDS[__i]}"
131
+ __space=0
132
+ while [[ "$__rest" == [[:space:]]* ]]; do
133
+ __space=1
134
+ __rest="\${__rest:1}"
135
+ done
136
+ if (( __i == COMP_CWORD && \${#__rest} < \${#__word} )); then
137
+ __word="\${__word:0:\${#__rest}}"
138
+ fi
139
+ if [[ "\${__rest:0:\${#__word}}" != "$__word" ]]; then
140
+ __valid=0
141
+ break
142
+ fi
143
+ # Quoted or escaped operators belong to words; bare operators separate
144
+ # shell syntax even when no whitespace surrounds a redirection.
145
+ __boundary=0
146
+ for ((__scan=0; __scan<\${#__word}; __scan++)); do
147
+ __symbol="\${__word:__scan:1}"
148
+ if (( __shell_escape )); then
149
+ __shell_escape=0
150
+ elif [[ "$__shell_quote" == ansi ]]; then
151
+ if [[ "$__symbol" == '\\' ]]; then __shell_escape=1
152
+ elif [[ "$__symbol" == "'" ]]; then __shell_quote=""; fi
153
+ elif [[ "$__shell_quote" == "'" ]]; then
154
+ [[ "$__symbol" == "'" ]] && __shell_quote=""
155
+ elif [[ "$__symbol" == '\\' ]]; then
156
+ __shell_escape=1
157
+ elif [[ -n "$__shell_quote" ]]; then
158
+ [[ "$__symbol" == "$__shell_quote" ]] && __shell_quote=""
159
+ else
160
+ case "$__symbol" in
161
+ "'")
162
+ if (( __shell_dollar )); then __shell_quote=ansi
163
+ else __shell_quote="'"; fi ;;
164
+ '"') __shell_quote='"' ;;
165
+ '$') __shell_dollar=1 ;;
166
+ '>'|'<'|'|'|'&'|';'|'('|')') __boundary=1 ;;
167
+ esac
168
+ fi
169
+ [[ "$__symbol" == '$' && -z "$__shell_quote" ]] || __shell_dollar=0
170
+ done
171
+ if (( __i > 1 && !__space && !__boundary && !__previous_boundary )) &&
172
+ { [[ -n "$__word" && -z "\${__word//=/}" ]] ||
173
+ [[ -n "$__previous" && -z "\${__previous//=/}" ]] ||
174
+ [[ "\${__words[__j-1]}" == [-/+]* ]] ||
175
+ [[ "$__word" == =* && "\${__words[__j-1]}" == [-/+]* ]]; }; then
176
+ __words[__j-1]+="$__word"
177
+ else
178
+ __words[__j]="$__word"
179
+ ((++__j))
180
+ fi
181
+ __rest="\${__rest:\${#__word}}"
182
+ __previous="$__word"
183
+ __previous_boundary=$__boundary
184
+ done
185
+ if (( __valid )); then
186
+ current="\${__words[__j-1]}"
187
+ __raw_joined_current="$current"
188
+ prev=("\${__words[@]:1:__j-2}")
189
+ # Bash passes the fragment Readline will replace as the second argument,
190
+ # which is empty when the cursor follows a word-break character.
191
+ if [[ \${2+x} ]]; then
192
+ __remove="\${current:0:\${#current}-\${#2}}"
193
+ elif [[ -n "$__raw_current" && -z "\${__raw_current//=/}" ]]; then
194
+ __remove="$current"
195
+ else
196
+ __remove="\${current:0:\${#current}-\${#__raw_current}}"
197
+ fi
198
+ # Decode joined shell words without evaluating substitutions. Keep quote
199
+ # state across the reassembled fragments and decode the preserved prefix
200
+ # separately so trimming uses the same representation as suggestions.
201
+ __words[__j]="$__remove"
202
+ local __decoded __decoded_option=0 __before_quote __join_quote __char __next __k __ansi __part __digits __code __join_quote_offset
203
+ for ((__i=1; __i<=__j; __i++)); do
204
+ __word="\${__words[__i]}"
205
+ __decoded=""; __join_quote=""; __join_quote_offset=0
206
+ for ((__k=0; __k<\${#__word}; __k++)); do
207
+ __char="\${__word:__k:1}"
208
+ __before_quote="$__join_quote"
209
+ if [[ -z "$__join_quote" ]]; then
210
+ case "$__char" in
211
+ '\\') ((++__k)); __decoded+="\${__word:__k:1}" ;;
212
+ '$')
213
+ if [[ "\${__word:__k+1:1}" == "'" ]]; then
214
+ __join_quote=ansi; __join_quote_offset=\${#__decoded}; __ansi=""; ((++__k))
215
+ elif [[ "\${__word:__k+1:1}" == '"' ]]; then
216
+ __join_quote='"'; __join_quote_offset=\${#__decoded}; ((++__k))
217
+ else __decoded+="$__char"; fi ;;
218
+ "'"|'"') __join_quote="$__char"; __join_quote_offset=\${#__decoded} ;;
219
+ *) __decoded+="$__char" ;;
220
+ esac
221
+ elif [[ "$__join_quote" == ansi ]]; then
222
+ if [[ "$__char" == "'" ]]; then
223
+ printf -v __part '%b' "$__ansi"
224
+ __decoded+="$__part"; __join_quote=""
225
+ elif [[ "$__char" == '\\' ]]; then
226
+ ((++__k)); __next="\${__word:__k:1}"
227
+ case "$__next" in
228
+ [0-7])
229
+ __digits="$__next"
230
+ while (( \${#__digits} < 3 )) && [[ "\${__word:__k+1:1}" == [0-7] ]]; do
231
+ ((++__k)); __digits+="\${__word:__k:1}"
232
+ done
233
+ __ansi+='\\0'"$__digits" ;;
234
+ c)
235
+ __next="\${__word:__k+1:1}"
236
+ if [[ -n "$__next" && "$__next" != "'" ]]; then
237
+ ((++__k)); printf -v __code '%d' "'$__next"
238
+ if [[ "$__next" == '?' ]]; then __code=127
239
+ else __code=$((__code & 31)); fi
240
+ printf -v __part '\\\\%03o' "$__code"
241
+ __ansi+="$__part"
242
+ else __ansi+='\\\\c'; fi ;;
243
+ "'"|'"'|'?') __ansi+="$__next" ;;
244
+ *) __ansi+='\\'"$__next" ;;
245
+ esac
246
+ else __ansi+="$__char"; fi
247
+ elif [[ "$__join_quote" == "'" ]]; then
248
+ if [[ "$__char" == "'" ]]; then __join_quote=""
249
+ else __decoded+="$__char"; fi
250
+ else
251
+ case "$__char" in
252
+ '"') __join_quote="" ;;
253
+ '\\')
254
+ __next="\${__word:__k+1:1}"
255
+ case "$__next" in
256
+ '$'|'\`'|'"'|'\\') ((++__k)); __decoded+="$__next" ;;
257
+ *) __decoded+="$__char" ;;
258
+ esac ;;
259
+ *) __decoded+="$__char" ;;
260
+ esac
261
+ fi
262
+ if (( __i == __j - 1 )) &&
263
+ { [[ ! \${__raw_offsets[\${#__decoded}]+x} ]] ||
264
+ [[ -n "$__before_quote" && -z "$__join_quote" ]]; }; then
265
+ __raw_offsets[\${#__decoded}]=$((__k + 1))
266
+ __raw_quotes[\${#__decoded}]="$__join_quote"
267
+ fi
268
+ done
269
+ if [[ "$__join_quote" == ansi ]]; then
270
+ printf -v __part '%b' "$__ansi"
271
+ __decoded+="$__part"
272
+ fi
273
+ # Shell syntax may precede the option prefix. Decide from decoded text,
274
+ # retaining the existing native-file handling for non-option words.
275
+ if [[ "$__decoded" == [-/+]* ]] || (( __i == __j && __decoded_option )); then
276
+ if (( __i == __j - 1 )); then
277
+ __decoded_option=1
278
+ __quote="$__join_quote"
279
+ [[ "$__quote" == ansi ]] && __quote="'"
280
+ __quote_offset=$__join_quote_offset
281
+ fi
282
+ __words[__i]="$__decoded"
283
+ fi
284
+ done
285
+ if [[ "\${__words[__j-1]}" == -*=* || "\${__words[__j-1]}" == /*:* || "\${__words[__j-1]}" != "$__raw_current" ]]; then
286
+ current="\${__words[__j-1]}"
287
+ __joined=1
288
+ else
289
+ current="$__plain_current"
290
+ __words[__j]=""
291
+ fi
292
+ prev=("\${__words[@]:1:__j-2}")
293
+ __remove="\${__words[__j]}"
294
+ fi
295
+ fi
296
+
297
+ # Joined file directives retain the exact option prefix and metadata chosen
298
+ # by the parser; do not infer option boundaries through speculative retries.
299
+ local __protocol_current="$__raw_current"
300
+ (( __joined )) && __protocol_current="$current"
301
+ local -a __lines=() __replies=()
302
+ local line __reply_prefix="" __file_current="$current" __literal=0
303
+ while IFS= read -r line || [[ -n "$line" ]]; do
304
+ [[ -n "$line" ]] && __lines+=("$line")
305
+ done < <(${programName} ${escapedArgs} "\${prev[@]}" "$__protocol_current" 2>/dev/null)
306
+ for line in "\${__lines[@]}"; do
307
+ COMPREPLY=()
308
+ __reply_prefix=""
309
+ __file_current="$current"
310
+ __literal=0
311
+ if [[ "$line" == __LITERAL__:* ]]; then
312
+ line="\${line#__LITERAL__:}"
313
+ __literal=1
314
+ elif [[ "$line" == __JOINED_FILE__:* ]]; then
315
+ line="\${line#__JOINED_FILE__:}"
316
+ __reply_prefix="\${line%%:*}"
317
+ __reply_prefix="\${__reply_prefix//%3A/:}"
318
+ __reply_prefix="\${__reply_prefix//%25/%}"
319
+ line="__FILE__:\${line#*:}"
320
+ __file_current="\${current:\${#__reply_prefix}}"
321
+ fi
322
+ local __raw_file_current="$__raw_current" __file_quote_offset=$__quote_offset __file_tilde_quoted=0
323
+ if [[ -n "$__reply_prefix" ]]; then
324
+ local __raw_offset="\${__raw_offsets[\${#__reply_prefix}]:-\${#__reply_prefix}}"
325
+ __raw_file_current="\${__raw_joined_current:__raw_offset}"
326
+ # Quoting may start before the option name and remain active at the
327
+ # delimiter. Keep that context when deciding whether tilde is literal.
328
+ if [[ -n "\${__raw_quotes[\${#__reply_prefix}]-}" ]]; then
329
+ __file_tilde_quoted=1
330
+ fi
331
+ if (( __file_quote_offset >= \${#__reply_prefix} )); then
332
+ __file_quote_offset=$((__file_quote_offset - \${#__reply_prefix}))
333
+ fi
334
+ fi
335
+ if [[ $__literal == 0 && "$line" == __FILE__:* ]]; then
111
336
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
112
337
  IFS=':' read -r _ type extensions pattern hidden <<< "$line"
113
338
  extensions="\${extensions//,\\./,}"; extensions="\${extensions#.}"
@@ -116,7 +341,8 @@ function _${programName} () {
116
341
 
117
342
  # path() echoes the raw shell word as its pattern. Once dequoted,
118
343
  # use the literal current-word prefix instead of the escaped pattern.
119
- if [[ "$pattern" == "$__raw_current" && "$current" != "$__raw_current" ]]; then
344
+ if [[ ( "$pattern" == "$__raw_file_current" && "$__file_current" != "$__raw_file_current" ) ||
345
+ ( -n "$__reply_prefix" && "$pattern" == "$__file_current" ) ]]; then
120
346
  pattern=""
121
347
  fi
122
348
 
@@ -134,12 +360,12 @@ function _${programName} () {
134
360
  unset GLOBIGNORE
135
361
 
136
362
  # Expand tilde prefix for file globbing
137
- local __glob_current="$current" __tilde_prefix="" __tilde_expanded=""
138
- if [[ "$__raw_current" == "~"* && "$current" =~ $__tilde_pattern ]]; then
363
+ local __glob_current="$__file_current" __tilde_prefix="" __tilde_expanded=""
364
+ if [[ $__file_tilde_quoted == 0 && "$__raw_file_current" == "~"* && "$__file_current" =~ $__tilde_pattern ]]; then
139
365
  __tilde_prefix="\${BASH_REMATCH[1]}"
140
366
  eval "__tilde_expanded=\$__tilde_prefix" 2>/dev/null || true
141
367
  if [[ -n "$__tilde_expanded" && "$__tilde_expanded" != "$__tilde_prefix" ]]; then
142
- __glob_current="\${__tilde_expanded}\${current#\$__tilde_prefix}"
368
+ __glob_current="\${__tilde_expanded}\${__file_current#\$__tilde_prefix}"
143
369
  else
144
370
  __tilde_prefix=""
145
371
  fi
@@ -153,7 +379,7 @@ function _${programName} () {
153
379
  local __from_pattern=0
154
380
  if [[ -n "$pattern" ]]; then
155
381
  # Normalize leading ./ so that ./src/ma matches pattern src/
156
- local __norm_current="\${current#./}"
382
+ local __norm_current="\${__file_current#./}"
157
383
  local __norm_pattern="\${pattern#./}"
158
384
  # For wildcard patterns, compare only the directory prefix before
159
385
  # any wildcards so that typing src/ma narrows src/*.ts correctly
@@ -162,7 +388,7 @@ function _${programName} () {
162
388
  __compare_pattern="\${__compare_pattern%%[\*\?]*}"
163
389
  [[ "$__compare_pattern" == */* ]] && __compare_pattern="\${__compare_pattern%/*}/" || __compare_pattern=""
164
390
  fi
165
- if [[ ( -n "$__compare_pattern" || -n "$__norm_current" ) && \${#__norm_current} -ge \${#__compare_pattern} && "\${__norm_current:0:\${#__compare_pattern}}" == "$__compare_pattern" && "$current" != "$pattern" ]]; then
391
+ if [[ ( -n "$__compare_pattern" || -n "$__norm_current" ) && \${#__norm_current} -ge \${#__compare_pattern} && "\${__norm_current:0:\${#__compare_pattern}}" == "$__compare_pattern" && "$__file_current" != "$pattern" ]]; then
166
392
  # User has typed beyond or an equivalent form of the pattern
167
393
  true
168
394
  else
@@ -296,7 +522,7 @@ function _${programName} () {
296
522
 
297
523
  # Restore tilde prefix in completion results
298
524
  if [[ -n "$__tilde_prefix" &&
299
- ( -z "$__quote" || $__quote_offset -gt 0 ) ]]; then
525
+ ( -z "$__quote" || $__file_quote_offset -gt 0 ) ]]; then
300
526
  local __i
301
527
  for __i in "\${!COMPREPLY[@]}"; do
302
528
  COMPREPLY[\$__i]="\${COMPREPLY[\$__i]/#\$__tilde_expanded/\$__tilde_prefix}"
@@ -313,7 +539,7 @@ function _${programName} () {
313
539
 
314
540
  # Readline appends directory separators when it receives the full path.
315
541
  # Midword quotes still need ours because replies omit the typed prefix.
316
- if [[ -n "$__quote" && $__quote_offset -eq 0 ]]; then
542
+ if [[ -n "$__quote" && $__file_quote_offset -eq 0 ]]; then
317
543
  local __reply_index
318
544
  for ((__reply_index=__reply_start; __reply_index<\${#COMPREPLY[@]}; __reply_index++)); do
319
545
  COMPREPLY[__reply_index]="\${COMPREPLY[__reply_index]%/}"
@@ -322,8 +548,9 @@ function _${programName} () {
322
548
 
323
549
  # Readline replaces only the text after an open quote. Remove the
324
550
  # already typed prefix so it is not inserted a second time.
325
- if [[ -n "$__quote" && $__quote_offset -gt 0 ]]; then
326
- local __quote_prefix="\${current:0:__quote_offset}"
551
+ # Joined replies are trimmed once against __remove after restoring option=.
552
+ if [[ -n "$__quote" && $__file_quote_offset -gt 0 && -z "$__reply_prefix" ]]; then
553
+ local __quote_prefix="\${__file_current:0:__file_quote_offset}"
327
554
  local __reply_index
328
555
  for ((__reply_index=__reply_start; __reply_index<\${#COMPREPLY[@]}; __reply_index++)); do
329
556
  COMPREPLY[__reply_index]="\${COMPREPLY[__reply_index]#"$__quote_prefix"}"
@@ -332,7 +559,7 @@ function _${programName} () {
332
559
 
333
560
  # Readline expands a leading tilde in a returned filename even when the
334
561
  # user did not type one. Keep such local paths explicitly relative.
335
- if [[ "$__raw_current" != "~"* && -z "$__tilde_prefix" ]]; then
562
+ if [[ ( "$__raw_file_current" != "~"* || $__file_tilde_quoted == 1 ) && -z "$__tilde_prefix" ]]; then
336
563
  local __reply_index
337
564
  for ((__reply_index=__reply_start; __reply_index<\${#COMPREPLY[@]}; __reply_index++)); do
338
565
  if [[ "\${COMPREPLY[__reply_index]}" =~ $__tilde_glob_pattern ]]; then
@@ -369,27 +596,30 @@ function _${programName} () {
369
596
  # Filter out hidden files unless requested
370
597
  if [[ "$hidden" != "1" && "$__inside_hidden_path" == "0" && "$__prefix_targets_hidden" == "0" ]]; then
371
598
  local filtered=()
372
- local __name __filter_index __kept_before=0
373
- __filter_index=0
599
+ local __name
374
600
  for item in "\${COMPREPLY[@]}"; do
375
601
  __name="\${item%/}"; __name="\${__name##*/}"
376
602
  if [[ "$__name" != .* ]]; then
377
603
  filtered+=("$item")
378
- if [[ $__filter_index -lt $__reply_start ]]; then
379
- __kept_before=$((__kept_before + 1))
380
- fi
381
604
  fi
382
- __filter_index=$((__filter_index + 1))
383
605
  done
384
606
  COMPREPLY=("\${filtered[@]}")
385
- __reply_start=$__kept_before
386
607
  fi
387
608
  if [[ \${#COMPREPLY[@]} -gt $__reply_start ]]; then __file_mode=1; fi
388
609
  else
389
610
  # Regular literal completion
390
611
  COMPREPLY+=("$line")
391
612
  fi
392
- done < <(${programName} ${escapedArgs} "\${prev[@]}" "$__raw_current" 2>/dev/null)
613
+ for __word in "\${COMPREPLY[@]}"; do
614
+ __replies+=("$__reply_prefix$__word")
615
+ done
616
+ done
617
+ COMPREPLY=()
618
+ for __word in "\${__replies[@]}"; do
619
+ if [[ "\${__word:0:\${#__remove}}" == "$__remove" ]]; then
620
+ COMPREPLY+=("\${__word:\${#__remove}}")
621
+ fi
622
+ done
393
623
  if [[ $__file_mode == 1 && $(type -t compopt) == builtin ]]; then
394
624
  compopt -o filenames 2>/dev/null || true
395
625
  fi
@@ -407,7 +637,17 @@ fi
407
637
  let i = 0;
408
638
  for (const suggestion of suggestions) {
409
639
  if (i > 0) yield "\n";
410
- if (suggestion.kind === "literal") yield sanitizeForTransport(suggestion.text);
640
+ const joined = require_joined_file_suggestion.getJoinedFileSuggestion(suggestion);
641
+ if (joined !== void 0) {
642
+ if (joined.literal && suggestion.kind === "literal") {
643
+ yield encodeBashLiteral(suggestion.text);
644
+ yield "\n";
645
+ }
646
+ for (const [index, { prefix, file }] of joined.files.entries()) {
647
+ if (index > 0) yield "\n";
648
+ yield `__JOINED_FILE__:${encodePattern(prefix)}:${file.type}:${encodeExtensions(file.extensions)}::${file.includeHidden ? "1" : "0"}`;
649
+ }
650
+ } else if (suggestion.kind === "literal") yield encodeBashLiteral(suggestion.text);
411
651
  else {
412
652
  const extensions = encodeExtensions(suggestion.extensions);
413
653
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -946,8 +1186,10 @@ def args-split []: string -> list<string> {
946
1186
  mut current_token = ""
947
1187
  mut result: list<string> = []
948
1188
  mut prev_state = $STATE_NORMAL
1189
+ mut ends_with_delimiter = false
949
1190
 
950
1191
  for char in ($in | split chars) {
1192
+ $ends_with_delimiter = false
951
1193
  if $state == $STATE_ESCAPE {
952
1194
  $current_token = $current_token + $char
953
1195
  $state = $prev_state
@@ -960,6 +1202,7 @@ def args-split []: string -> list<string> {
960
1202
  } else if $char == '"' {
961
1203
  $state = $STATE_IN_DOUBLE_QUOTE
962
1204
  } else if ($char in $WHITESPACES) {
1205
+ $ends_with_delimiter = true
963
1206
  if ($current_token | is-not-empty) {
964
1207
  $result = $result | append $current_token
965
1208
  $current_token = ""
@@ -981,12 +1224,52 @@ def args-split []: string -> list<string> {
981
1224
  }
982
1225
  }
983
1226
  }
984
- if ($current_token | is-not-empty) {
1227
+ # A quote left open at the end starts the argument being completed, even
1228
+ # when nothing follows it yet
1229
+ if ($current_token | is-not-empty) or ($state in [$STATE_IN_SINGLE_QUOTE $STATE_IN_DOUBLE_QUOTE]) {
985
1230
  $result = $result | append $current_token
986
1231
  }
1232
+ # Whitespace outside quotes at the end starts a new, still empty argument
1233
+ if $ends_with_delimiter {
1234
+ $result = $result | append ''
1235
+ }
987
1236
  $result
988
1237
  }
989
1238
 
1239
+ # Helper to tell whether Nushell would not read a value back verbatim as a
1240
+ # single argument. A leading ! is included because the line editor expands
1241
+ # history (e.g., !!) there. With --allow-expansion, a leading tilde and n-dot
1242
+ # path segments (e.g., ...) are allowed to expand.
1243
+ def needs-quote [--allow-expansion]: string -> bool {
1244
+ let value = $in
1245
+ let special = ["'" '"' '\`' "#" "(" ")" "[" "]" "{" "}" "|" ";" "&" "$" "*" "?" "<" ">"]
1246
+ (
1247
+ ($value =~ '\\s')
1248
+ or ($special | any {|c| $value | str contains $c })
1249
+ or ($value =~ '^(\\+\\+|[-+*/])?=$')
1250
+ or ($value | str starts-with '!')
1251
+ or ((not $allow_expansion) and (
1252
+ ($value | str starts-with '~')
1253
+ or ($value | split row -r '[/\\\\]' | any {|s| $s =~ '^\\.{3,}$' })
1254
+ ))
1255
+ )
1256
+ }
1257
+
1258
+ # Helper to quote a value so that Nushell reads it back verbatim as a
1259
+ # single argument. The line editor expands history at a ! that follows
1260
+ # whitespace even inside quotes, so such a ! is put between two quoted parts,
1261
+ # right after a quote, where it is left alone.
1262
+ def quote-arg []: string -> string {
1263
+ let value = $in
1264
+ if ($value | is-empty) or not ($value | needs-quote) {
1265
+ $value
1266
+ } else if ($value | str contains "'") {
1267
+ '"' + ($value | str replace -a '\\' '\\\\' | str replace -a '"' '\\"' | str replace -ar '(\\s)!' '\${1}"!"') + '"'
1268
+ } else {
1269
+ "'" + ($value | str replace -ar '(\\s)!' "\${1}'!'") + "'"
1270
+ }
1271
+ }
1272
+
990
1273
  # Completion command that calls back to the CLI
991
1274
  export def "${functionName}" [context: string] {
992
1275
  # Split context into tokens, handling quotes properly
@@ -999,18 +1282,11 @@ export def "${functionName}" [context: string] {
999
1282
  []
1000
1283
  }
1001
1284
 
1002
- # If context ends with whitespace, add empty string to represent the new argument
1003
- let args_with_empty = if ($context | str ends-with ' ') {
1004
- $args | append ''
1005
- } else {
1006
- $args
1007
- }
1008
-
1009
1285
  # Ensure at least one argument (empty string) to get completions not script
1010
- let final_args = if ($args_with_empty | is-empty) {
1286
+ let final_args = if ($args | is-empty) {
1011
1287
  ['']
1012
1288
  } else {
1013
- $args_with_empty
1289
+ $args
1014
1290
  }
1015
1291
 
1016
1292
  let output = try {
@@ -1192,19 +1468,45 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1192
1468
  if ($parsed.parent | is-empty) { "" } else if ($parsed.parent | str ends-with "/") { $parsed.parent } else { $parsed.parent + "/" }
1193
1469
  }
1194
1470
 
1195
- # Format file completions
1471
+ # Format file completions, quoting names that Nushell would not read
1472
+ # back as a single argument
1196
1473
  $filtered | each {|item|
1197
- let name = if $item.type == dir {
1198
- $dir_prefix + ($item.name | path basename) + "/"
1474
+ let base = if $item.type == dir {
1475
+ ($item.name | path basename) + "/"
1476
+ } else {
1477
+ $item.name | path basename
1478
+ }
1479
+ let name = $dir_prefix + $base
1480
+ # Nushell expands a leading tilde even in a file name, but not after ./
1481
+ let spelled = if ($dir_prefix | is-empty) and ($base | str starts-with '~') {
1482
+ './' + $base
1483
+ } else {
1484
+ $name
1485
+ }
1486
+ # A tilde or n-dot prefix typed by the user is meant to be expanded
1487
+ # by Nushell, which quoting would prevent, so expand such a prefix
1488
+ # here when the value has to be quoted
1489
+ let literal_ndots = ($base | str replace -r '[/\\\\]$' '') =~ '^\\.{3,}$'
1490
+ let expandable = ($dir_prefix | str starts-with '~') or ($dir_prefix | split row -r '[/\\\\]' | any {|s| $s =~ '^\\.{3,}$' })
1491
+ let value = if not ($literal_ndots or ($spelled | needs-quote --allow-expansion)) {
1492
+ $spelled
1493
+ } else if $expandable {
1494
+ let expanded_prefix = ($dir_prefix | path expand --no-symlink)
1495
+ let expanded_prefix = if ($expanded_prefix | str ends-with "/") { $expanded_prefix } else { $expanded_prefix + "/" }
1496
+ ($expanded_prefix + $base) | quote-arg
1199
1497
  } else {
1200
- $dir_prefix + ($item.name | path basename)
1498
+ $spelled | quote-arg
1499
+ }
1500
+ if $value == $name {
1501
+ { value: $value }
1502
+ } else {
1503
+ { value: $value, display_override: $name }
1201
1504
  }
1202
- { value: $name }
1203
1505
  }
1204
1506
  } else {
1205
1507
  # Regular literal completion - split by tab
1206
1508
  let parts = ($line | split row "\t")
1207
- if ($parts | length) >= 2 {
1509
+ let record = if ($parts | length) >= 2 {
1208
1510
  # value\\tdescription format
1209
1511
  { value: ($parts | get 0), description: ($parts | get 1) }
1210
1512
  } else if ($parts | length) == 1 and ($parts | get 0 | is-not-empty) {
@@ -1213,6 +1515,18 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1213
1515
  } else {
1214
1516
  null
1215
1517
  }
1518
+ # Quote values that Nushell would not read back as a single argument,
1519
+ # while the menu keeps showing them as they are
1520
+ if $record == null {
1521
+ null
1522
+ } else {
1523
+ let value = ($record.value | quote-arg)
1524
+ if $value == $record.value {
1525
+ $record
1526
+ } else {
1527
+ $record | upsert value $value | insert display_override $record.value
1528
+ }
1529
+ }
1216
1530
  }
1217
1531
  } | flatten | compact
1218
1532
  }