@optique/core 1.0.0-dev.921 → 1.0.0

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.
Files changed (109) hide show
  1. package/dist/annotation-state.cjs +425 -0
  2. package/dist/annotation-state.d.cts +24 -0
  3. package/dist/annotation-state.d.ts +24 -0
  4. package/dist/annotation-state.js +414 -0
  5. package/dist/annotations.cjs +2 -248
  6. package/dist/annotations.d.cts +2 -137
  7. package/dist/annotations.d.ts +2 -137
  8. package/dist/annotations.js +2 -238
  9. package/dist/completion.cjs +611 -100
  10. package/dist/completion.d.cts +1 -1
  11. package/dist/completion.d.ts +1 -1
  12. package/dist/completion.js +611 -100
  13. package/dist/constructs.cjs +3338 -827
  14. package/dist/constructs.d.cts +48 -7
  15. package/dist/constructs.d.ts +48 -7
  16. package/dist/constructs.js +3338 -827
  17. package/dist/context.cjs +0 -23
  18. package/dist/context.d.cts +119 -53
  19. package/dist/context.d.ts +119 -53
  20. package/dist/context.js +0 -22
  21. package/dist/dependency-metadata.cjs +139 -0
  22. package/dist/dependency-metadata.d.cts +112 -0
  23. package/dist/dependency-metadata.d.ts +112 -0
  24. package/dist/dependency-metadata.js +138 -0
  25. package/dist/dependency-runtime.cjs +698 -0
  26. package/dist/dependency-runtime.d.cts +149 -0
  27. package/dist/dependency-runtime.d.ts +149 -0
  28. package/dist/dependency-runtime.js +687 -0
  29. package/dist/dependency.cjs +7 -928
  30. package/dist/dependency.d.cts +2 -794
  31. package/dist/dependency.d.ts +2 -794
  32. package/dist/dependency.js +2 -899
  33. package/dist/displaywidth.cjs +44 -0
  34. package/dist/displaywidth.js +43 -0
  35. package/dist/doc.cjs +285 -23
  36. package/dist/doc.d.cts +57 -2
  37. package/dist/doc.d.ts +57 -2
  38. package/dist/doc.js +283 -25
  39. package/dist/execution-context.cjs +56 -0
  40. package/dist/execution-context.js +53 -0
  41. package/dist/extension.cjs +87 -0
  42. package/dist/extension.d.cts +97 -0
  43. package/dist/extension.d.ts +97 -0
  44. package/dist/extension.js +76 -0
  45. package/dist/facade.cjs +718 -525
  46. package/dist/facade.d.cts +59 -15
  47. package/dist/facade.d.ts +59 -15
  48. package/dist/facade.js +718 -525
  49. package/dist/index.cjs +14 -29
  50. package/dist/index.d.cts +10 -10
  51. package/dist/index.d.ts +10 -10
  52. package/dist/index.js +7 -7
  53. package/dist/input-trace.cjs +56 -0
  54. package/dist/input-trace.d.cts +77 -0
  55. package/dist/input-trace.d.ts +77 -0
  56. package/dist/input-trace.js +55 -0
  57. package/dist/internal/annotations.cjs +316 -0
  58. package/dist/internal/annotations.d.cts +140 -0
  59. package/dist/internal/annotations.d.ts +140 -0
  60. package/dist/internal/annotations.js +306 -0
  61. package/dist/internal/dependency.cjs +984 -0
  62. package/dist/internal/dependency.d.cts +539 -0
  63. package/dist/internal/dependency.d.ts +539 -0
  64. package/dist/internal/dependency.js +964 -0
  65. package/dist/{mode-dispatch.cjs → internal/mode-dispatch.cjs} +1 -3
  66. package/dist/{mode-dispatch.d.cts → internal/mode-dispatch.d.cts} +3 -7
  67. package/dist/{mode-dispatch.d.ts → internal/mode-dispatch.d.ts} +3 -7
  68. package/dist/{mode-dispatch.js → internal/mode-dispatch.js} +1 -3
  69. package/dist/internal/parser.cjs +728 -0
  70. package/dist/internal/parser.d.cts +947 -0
  71. package/dist/internal/parser.d.ts +947 -0
  72. package/dist/internal/parser.js +711 -0
  73. package/dist/message.cjs +84 -26
  74. package/dist/message.d.cts +49 -9
  75. package/dist/message.d.ts +49 -9
  76. package/dist/message.js +84 -27
  77. package/dist/modifiers.cjs +1023 -240
  78. package/dist/modifiers.d.cts +42 -1
  79. package/dist/modifiers.d.ts +42 -1
  80. package/dist/modifiers.js +1023 -240
  81. package/dist/parser.cjs +11 -463
  82. package/dist/parser.d.cts +3 -537
  83. package/dist/parser.d.ts +3 -537
  84. package/dist/parser.js +2 -433
  85. package/dist/phase2-seed.cjs +59 -0
  86. package/dist/phase2-seed.js +56 -0
  87. package/dist/primitives.cjs +557 -208
  88. package/dist/primitives.d.cts +10 -14
  89. package/dist/primitives.d.ts +10 -14
  90. package/dist/primitives.js +557 -208
  91. package/dist/program.cjs +5 -1
  92. package/dist/program.d.cts +5 -3
  93. package/dist/program.d.ts +5 -3
  94. package/dist/program.js +6 -1
  95. package/dist/suggestion.cjs +22 -8
  96. package/dist/suggestion.js +22 -8
  97. package/dist/usage-internals.cjs +3 -2
  98. package/dist/usage-internals.js +4 -2
  99. package/dist/usage.cjs +195 -40
  100. package/dist/usage.d.cts +92 -11
  101. package/dist/usage.d.ts +92 -11
  102. package/dist/usage.js +194 -41
  103. package/dist/validate.cjs +170 -0
  104. package/dist/validate.js +164 -0
  105. package/dist/valueparser.cjs +1270 -187
  106. package/dist/valueparser.d.cts +320 -14
  107. package/dist/valueparser.d.ts +320 -14
  108. package/dist/valueparser.js +1269 -188
  109. package/package.json +9 -9
@@ -31,6 +31,31 @@ function validateProgramName(programName) {
31
31
  if (!SAFE_PROGRAM_NAME_PATTERN.test(programName)) throw new Error(`Invalid program name for shell completion: "${programName}". Program names must start with an alphanumeric character or underscore, and contain only alphanumeric characters, underscores, hyphens, and dots.`);
32
32
  }
33
33
  /**
34
+ * Percent-encodes colons and percent signs in a pattern string so that it
35
+ * can be safely embedded in the colon-delimited `__FILE__` transport format.
36
+ * @param pattern The raw pattern string.
37
+ * @returns The encoded pattern string.
38
+ * @internal
39
+ */
40
+ function encodePattern(pattern) {
41
+ return pattern.replace(/%/g, "%25").replace(/:/g, "%3A");
42
+ }
43
+ function encodeExtensions(extensions) {
44
+ return extensions?.map((ext) => ext.replace(/^\./, "")).join(",") ?? "";
45
+ }
46
+ /**
47
+ * Replaces control characters that would corrupt shell completion protocols.
48
+ * Shell completion formats use tabs as field delimiters and newlines as record
49
+ * delimiters. Null bytes are used as delimiters in zsh's format.
50
+ * This is used for both suggestion text and descriptions.
51
+ * @param text The string to sanitize.
52
+ * @returns The sanitized string with control characters replaced by spaces.
53
+ * @internal
54
+ */
55
+ function sanitizeForTransport(text) {
56
+ return text.replace(/[\t\n\r\0]/g, " ");
57
+ }
58
+ /**
34
59
  * The Bash shell completion generator.
35
60
  * @since 0.6.0
36
61
  */
@@ -48,29 +73,153 @@ function _${programName} () {
48
73
  if [[ "$line" == __FILE__:* ]]; then
49
74
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
50
75
  IFS=':' read -r _ type extensions pattern hidden <<< "$line"
76
+ extensions="\${extensions//,\\./,}"; extensions="\${extensions#.}"
77
+ pattern="\${pattern//%3A/:}"; pattern="\${pattern//%25/%}"
78
+
79
+ # Save and adjust glob/shell options for safe file completion
80
+ local __dotglob_was_set=0 __failglob_was_set=0 __noglob_was_set=0
81
+ local __globignore_was_set=0 __saved_globignore="\${GLOBIGNORE-}"
82
+ [[ \${GLOBIGNORE+x} == x ]] && __globignore_was_set=1
83
+ shopt -q dotglob && __dotglob_was_set=1
84
+ shopt -q failglob && __failglob_was_set=1
85
+ [[ $- == *f* ]] && __noglob_was_set=1
86
+ # Unset GLOBIGNORE before enabling dotglob because unsetting
87
+ # GLOBIGNORE implicitly clears dotglob in Bash
88
+ shopt -u failglob 2>/dev/null
89
+ set +f
90
+ unset GLOBIGNORE
91
+
92
+ # Expand tilde prefix for file globbing
93
+ local __glob_current="$current" __tilde_prefix="" __tilde_expanded=""
94
+ if [[ "$current" =~ ^(~[a-zA-Z0-9_.+-]*)(/.*)$ ]]; then
95
+ __tilde_prefix="\${BASH_REMATCH[1]}"
96
+ eval "__tilde_expanded=\$__tilde_prefix" 2>/dev/null || true
97
+ if [[ -n "$__tilde_expanded" && "$__tilde_expanded" != "$__tilde_prefix" ]]; then
98
+ __glob_current="\${__tilde_expanded}\${current#\$__tilde_prefix}"
99
+ else
100
+ __tilde_prefix=""
101
+ fi
102
+ fi
103
+
104
+ # When a pattern is specified, use it as the glob base instead of the
105
+ # current word so that completions enumerate the pattern's directory.
106
+ # However, if the user has already typed beyond the pattern (e.g.,
107
+ # pattern="src/" and current="src/ma"), preserve the typed suffix
108
+ # for incremental filtering.
109
+ local __from_pattern=0
110
+ if [[ -n "$pattern" ]]; then
111
+ # Normalize leading ./ so that ./src/ma matches pattern src/
112
+ local __norm_current="\${current#./}"
113
+ local __norm_pattern="\${pattern#./}"
114
+ # For wildcard patterns, compare only the directory prefix before
115
+ # any wildcards so that typing src/ma narrows src/*.ts correctly
116
+ local __compare_pattern="$__norm_pattern"
117
+ if [[ "$__compare_pattern" == *[\*\?]* ]]; then
118
+ __compare_pattern="\${__compare_pattern%%[\*\?]*}"
119
+ [[ "$__compare_pattern" == */* ]] && __compare_pattern="\${__compare_pattern%/*}/" || __compare_pattern=""
120
+ fi
121
+ if [[ ( -n "$__compare_pattern" || -n "$__norm_current" ) && \${#__norm_current} -ge \${#__compare_pattern} && "\${__norm_current:0:\${#__compare_pattern}}" == "$__compare_pattern" && "$current" != "$pattern" ]]; then
122
+ # User has typed beyond or an equivalent form of the pattern
123
+ true
124
+ else
125
+ __from_pattern=1
126
+ # Reset tilde state from the current-word expansion so that a
127
+ # non-tilde pattern is not rewritten through stale tilde state
128
+ __tilde_prefix=""
129
+ __tilde_expanded=""
130
+ __glob_current="$pattern"
131
+ if [[ "$pattern" =~ ^(~[a-zA-Z0-9_.+-]*)(/.*)?$ ]]; then
132
+ __tilde_prefix="\${BASH_REMATCH[1]}"
133
+ eval "__tilde_expanded=\$__tilde_prefix" 2>/dev/null || true
134
+ if [[ -n "$__tilde_expanded" && "$__tilde_expanded" != "$__tilde_prefix" ]]; then
135
+ __glob_current="\${__tilde_expanded}\${pattern#\$__tilde_prefix}"
136
+ else
137
+ __tilde_prefix=""
138
+ fi
139
+ fi
140
+ # If the glob base is a directory without a trailing slash,
141
+ # append one so that the glob enumerates its contents
142
+ if [[ -d "$__glob_current" && "$__glob_current" != */ ]]; then
143
+ __glob_current="$__glob_current/"
144
+ fi
145
+ fi
146
+ fi
147
+
148
+ # Enable dotglob when hidden files are requested, or when the user
149
+ # is already navigating inside a hidden directory (e.g., ~/.config/nvim/)
150
+ # This runs after tilde expansion so that paths like ~/.config/ are
151
+ # checked against the expanded path, not the literal ~ string.
152
+ local __inside_hidden_path=0
153
+ case "/\${__glob_current%/}/" in
154
+ */.[!.]*/*|*/..?*/*) __inside_hidden_path=1 ;;
155
+ esac
156
+ # Also check if the current prefix explicitly targets hidden entries
157
+ # (e.g., user typed "." or ".e" to complete .env)
158
+ local __prefix_targets_hidden=0
159
+ local __prefix_base="\${__glob_current##*/}"
160
+ [[ "$__prefix_base" == .* ]] && __prefix_targets_hidden=1
161
+ if [[ "$hidden" == "1" || "$__inside_hidden_path" == "1" || "$__prefix_targets_hidden" == "1" ]]; then shopt -s dotglob; fi
162
+
163
+ # Pre-expand file candidates. When the glob base came from the
164
+ # program's pattern and contains wildcard characters (* or ?),
165
+ # use it as-is via compgen -G (safe — no command substitution).
166
+ # Otherwise append * to treat it as a prefix.
167
+ # Note: [ is NOT treated as a glob indicator because it commonly
168
+ # appears in literal filenames like [draft] or foo[1].txt.
169
+ local -a __candidates=()
170
+ if [[ "$__from_pattern" == "1" && "$__glob_current" == *[\*\?]* ]]; then
171
+ mapfile -t __candidates < <(compgen -G "$__glob_current" 2>/dev/null)
172
+ # For file/any modes, also include directories from the base
173
+ # directory for navigation even when the glob itself does not
174
+ # match directory names. Skip this for directory mode so that
175
+ # the pattern's basename filter is respected.
176
+ if [[ "$type" != "directory" ]]; then
177
+ local __glob_dir="\${__glob_current%/*}"
178
+ [[ "$__glob_dir" == "$__glob_current" ]] && __glob_dir="."
179
+ local __d
180
+ for __d in "$__glob_dir"/*/; do
181
+ [[ -d "$__d" ]] && __candidates+=("\${__d%/}")
182
+ done
183
+ fi
184
+ else
185
+ __candidates=("$__glob_current"*)
186
+ # Remove no-match sentinel (bash returns the literal pattern).
187
+ # Also check -L for dangling symlinks which are valid candidates.
188
+ if [[ \${#__candidates[@]} -eq 1 && ! -e "\${__candidates[0]}" && ! -L "\${__candidates[0]}" ]]; then
189
+ __candidates=()
190
+ fi
191
+ fi
51
192
 
52
193
  # Generate file completions based on type
53
194
  case "$type" in
54
195
  file)
55
- # Complete files only
196
+ # Complete files and directories (directories for navigation)
56
197
  if [[ -n "$extensions" ]]; then
57
- # Complete with extension filtering
198
+ # Files with extension filtering + directories
58
199
  local ext_pattern="\${extensions//,/|}"
59
- for file in "$current"*; do
60
- [[ -e "$file" && "$file" =~ \\.($ext_pattern)$ ]] && COMPREPLY+=("$file")
200
+ for item in "\${__candidates[@]}"; do
201
+ if [[ -d "$item" ]]; then
202
+ COMPREPLY+=("$item/")
203
+ elif [[ -f "$item" && "$item" =~ \\.($ext_pattern)$ ]]; then
204
+ COMPREPLY+=("$item")
205
+ fi
61
206
  done
62
207
  else
63
- # Complete files only, exclude directories
64
- while IFS= read -r -d '' item; do
65
- [[ -f "$item" ]] && COMPREPLY+=("$item")
66
- done < <(compgen -f -z -- "$current")
208
+ # Complete files and directories for navigation
209
+ for item in "\${__candidates[@]}"; do
210
+ if [[ -d "$item" ]]; then
211
+ COMPREPLY+=("$item/")
212
+ elif [[ -f "$item" ]]; then
213
+ COMPREPLY+=("$item")
214
+ fi
215
+ done
67
216
  fi
68
217
  ;;
69
218
  directory)
70
219
  # Complete directories only
71
- while IFS= read -r -d '' dir; do
72
- COMPREPLY+=("$dir/")
73
- done < <(compgen -d -z -- "$current")
220
+ for dir in "\${__candidates[@]}"; do
221
+ [[ -d "$dir" ]] && COMPREPLY+=("$dir/")
222
+ done
74
223
  ;;
75
224
  any)
76
225
  # Complete both files and directories
@@ -78,31 +227,50 @@ function _${programName} () {
78
227
  # Files with extension filtering + directories
79
228
  # Files with extension filtering
80
229
  local ext_pattern="\${extensions//,/|}"
81
- for item in "$current"*; do
230
+ for item in "\${__candidates[@]}"; do
82
231
  if [[ -d "$item" ]]; then
83
232
  COMPREPLY+=("$item/")
84
- elif [[ -f "$item" && "$item" =~ \\.($ext_pattern)$ ]]; then
233
+ elif [[ ( -e "$item" || -L "$item" ) && "$item" =~ \\.($ext_pattern)$ ]]; then
85
234
  COMPREPLY+=("$item")
86
235
  fi
87
236
  done
88
237
  else
89
238
  # Complete files and directories, add slash to directories
90
- while IFS= read -r -d '' item; do
239
+ for item in "\${__candidates[@]}"; do
91
240
  if [[ -d "$item" ]]; then
92
241
  COMPREPLY+=("$item/")
93
- else
242
+ # Use -e || -L to include non-regular files (sockets, FIFOs, dangling symlinks)
243
+ elif [[ -e "$item" || -L "$item" ]]; then
94
244
  COMPREPLY+=("$item")
95
245
  fi
96
- done < <(compgen -f -z -- "$current")
246
+ done
97
247
  fi
98
248
  ;;
99
249
  esac
100
250
 
251
+ # Restore tilde prefix in completion results
252
+ if [[ -n "$__tilde_prefix" ]]; then
253
+ local __i
254
+ for __i in "\${!COMPREPLY[@]}"; do
255
+ COMPREPLY[\$__i]="\${COMPREPLY[\$__i]/#\$__tilde_expanded/\$__tilde_prefix}"
256
+ done
257
+ fi
258
+
259
+ # Restore glob/shell options
260
+ # Restore GLOBIGNORE before dotglob because assigning GLOBIGNORE
261
+ # implicitly enables dotglob in Bash
262
+ if [[ "$__globignore_was_set" == "1" ]]; then GLOBIGNORE="$__saved_globignore"; fi
263
+ if [[ "$__dotglob_was_set" == "0" ]]; then shopt -u dotglob; else shopt -s dotglob; fi
264
+ if [[ "$__failglob_was_set" == "1" ]]; then shopt -s failglob; fi
265
+ if [[ "$__noglob_was_set" == "1" ]]; then set -f; fi
266
+
101
267
  # Filter out hidden files unless requested
102
- if [[ "$hidden" != "1" && "$current" != .* ]]; then
268
+ if [[ "$hidden" != "1" && "$__inside_hidden_path" == "0" && "$__prefix_targets_hidden" == "0" ]]; then
103
269
  local filtered=()
270
+ local __name
104
271
  for item in "\${COMPREPLY[@]}"; do
105
- [[ "$(basename "$item")" != .* ]] && filtered+=("$item")
272
+ __name="\${item%/}"; __name="\${__name##*/}"
273
+ [[ "$__name" != .* ]] && filtered+=("$item")
106
274
  done
107
275
  COMPREPLY=("\${filtered[@]}")
108
276
  fi
@@ -113,18 +281,19 @@ function _${programName} () {
113
281
  done < <(${programName} ${escapedArgs} "\${prev[@]}" "$current" 2>/dev/null)
114
282
  }
115
283
 
116
- complete -F _${programName} ${programName}
284
+ complete -F _${programName} -- ${programName}
117
285
  `;
118
286
  },
119
287
  *encodeSuggestions(suggestions) {
120
288
  let i = 0;
121
289
  for (const suggestion of suggestions) {
122
290
  if (i > 0) yield "\n";
123
- if (suggestion.kind === "literal") yield `${suggestion.text}`;
291
+ if (suggestion.kind === "literal") yield sanitizeForTransport(suggestion.text);
124
292
  else {
125
- const extensions = suggestion.extensions?.join(",") || "";
293
+ const extensions = encodeExtensions(suggestion.extensions);
126
294
  const hidden = suggestion.includeHidden ? "1" : "0";
127
- yield `__FILE__:${suggestion.type}:${extensions}:${suggestion.pattern || ""}:${hidden}`;
295
+ const pattern = encodePattern(suggestion.pattern ?? "");
296
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}`;
128
297
  }
129
298
  i++;
130
299
  }
@@ -139,7 +308,7 @@ const zsh = {
139
308
  generateScript(programName, args = []) {
140
309
  validateProgramName(programName);
141
310
  const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`).join(" ");
142
- return `
311
+ return `#compdef ${programName}
143
312
  function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
144
313
  local current="\$words[CURRENT]"
145
314
  local -a prev
@@ -176,17 +345,52 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
176
345
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
177
346
  local type extensions pattern hidden
178
347
  IFS=':' read -r _ type extensions pattern hidden <<< "\$value"
348
+ pattern="\${pattern//%3A/:}"; pattern="\${pattern//%25/%}"
179
349
  has_file_completion=1
180
350
 
351
+ # Native completion helpers expect normal zsh glob grouping even if
352
+ # the caller enabled SH_GLOB in their shell options.
353
+ local __was_sh_glob=0
354
+ [[ -o sh_glob ]] && __was_sh_glob=1
355
+ unsetopt sh_glob
356
+
357
+ # Enable glob_dots when hidden files are requested so that
358
+ # _files and _directories include dot-prefixed entries
359
+ local __was_glob_dots=0
360
+ [[ -o glob_dots ]] && __was_glob_dots=1
361
+ if [[ "\$hidden" == "1" ]]; then setopt glob_dots; fi
362
+
363
+ # When a pattern is specified, override PREFIX so that _files and
364
+ # _directories enumerate the pattern's directory instead of the
365
+ # current word. If the user has already typed beyond the pattern,
366
+ # keep PREFIX unchanged for incremental narrowing.
367
+ local __saved_prefix="\$PREFIX"
368
+ if [[ -n "\$pattern" ]]; then
369
+ # Normalize leading ./ so that ./src/ma matches pattern src/
370
+ local __norm_prefix="\${PREFIX#./}"
371
+ local __norm_pattern="\${pattern#./}"
372
+ local __compare_pattern="\$__norm_pattern"
373
+ if [[ "\$__compare_pattern" == *[\*\?]* ]]; then
374
+ __compare_pattern="\${__compare_pattern%%[\*\?]*}"
375
+ [[ "\$__compare_pattern" == */* ]] && __compare_pattern="\${__compare_pattern%/*}/" || __compare_pattern=""
376
+ fi
377
+ if [[ ( -n "\$__compare_pattern" || -n "\$__norm_prefix" ) && \${#__norm_prefix} -ge \${#__compare_pattern} && "\${__norm_prefix[1,\${#__compare_pattern}]}" == "\$__compare_pattern" && "\$PREFIX" != "\$pattern" ]]; then
378
+ # User typed an equivalent or extended form — keep PREFIX
379
+ true
380
+ else
381
+ PREFIX="\$pattern"
382
+ fi
383
+ fi
384
+
181
385
  # Use zsh's native file completion
182
386
  case "\$type" in
183
387
  file)
184
388
  if [[ -n "\$extensions" ]]; then
185
- # Complete files with extension filtering
186
- local ext_pattern="*.(\\$\{extensions//,/|\})"
187
- _files -g "\\$ext_pattern"
389
+ # Complete files with extension filtering + directories for navigation
390
+ local ext_pattern="*.(\$\{extensions//,/|\})"
391
+ _files -g "\$ext_pattern"; _directories
188
392
  else
189
- _files -g "*"
393
+ _files
190
394
  fi
191
395
  ;;
192
396
  directory)
@@ -195,16 +399,18 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
195
399
  any)
196
400
  if [[ -n "\$extensions" ]]; then
197
401
  # Complete both files and directories, with extension filtering for files
198
- local ext_pattern="*.(\\$\{extensions//,/|\})"
199
- _files -g "\\$ext_pattern" && _directories
402
+ local ext_pattern="*.(\$\{extensions//,/|\})"
403
+ _files -g "\$ext_pattern"; _directories
200
404
  else
201
405
  _files
202
406
  fi
203
407
  ;;
204
408
  esac
205
409
 
206
- # Note: zsh's _files and _directories handle hidden file filtering automatically
207
- # based on the completion context and user settings
410
+ # Restore PREFIX and glob_dots to their previous state
411
+ PREFIX="\$__saved_prefix"
412
+ if [[ "\$__was_sh_glob" == "1" ]]; then setopt sh_glob; else unsetopt sh_glob; fi
413
+ if [[ "\$__was_glob_dots" == "1" ]]; then setopt glob_dots; else unsetopt glob_dots; fi
208
414
  else
209
415
  # Regular literal completion
210
416
  if [[ -n "\$value" ]]; then
@@ -236,13 +442,14 @@ compdef _${programName.replace(/[^a-zA-Z0-9]/g, "_")} ${programName}
236
442
  },
237
443
  *encodeSuggestions(suggestions) {
238
444
  for (const suggestion of suggestions) if (suggestion.kind === "literal") {
239
- const description = suggestion.description == null ? "" : require_message.formatMessage(suggestion.description, { colors: false });
240
- yield `${suggestion.text}\0${description}\0`;
445
+ const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
446
+ yield `${sanitizeForTransport(suggestion.text)}\0${description}\0`;
241
447
  } else {
242
- const extensions = suggestion.extensions?.join(",") || "";
448
+ const extensions = encodeExtensions(suggestion.extensions);
243
449
  const hidden = suggestion.includeHidden ? "1" : "0";
244
- const description = suggestion.description == null ? "" : require_message.formatMessage(suggestion.description, { colors: false });
245
- yield `__FILE__:${suggestion.type}:${extensions}:${suggestion.pattern || ""}:${hidden}\0${description}\0`;
450
+ const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
451
+ const pattern = encodePattern(suggestion.pattern ?? "");
452
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}\0${description}\0`;
246
453
  }
247
454
  }
248
455
  };
@@ -274,44 +481,190 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
274
481
  for line in $output
275
482
  if string match -q '__FILE__:*' -- $line
276
483
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
277
- set -l parts (string split ':' -- $line)
484
+ set -l directive (string split \\t -- $line)[1]
485
+ set -l parts (string split ':' -- $directive)
278
486
  set -l type $parts[2]
279
487
  set -l extensions $parts[3]
280
- set -l pattern $parts[4]
488
+ set -l pattern (string replace -a '%25' '%' -- (string replace -a '%3A' ':' -- $parts[4]))
281
489
  set -l hidden $parts[5]
282
490
 
491
+ # When a pattern is specified, use it as the glob base instead
492
+ # of the current word. If the user has already typed beyond the
493
+ # pattern (e.g., pattern="src/" and current="src/ma"), keep the
494
+ # current word for incremental narrowing.
495
+ set -l glob_base $current
496
+ set -l __tilde_prefix ""
497
+ set -l __from_pattern 0
498
+ if test -n "$pattern"
499
+ # Normalize leading ./ so that ./src/ma matches pattern src/
500
+ set -l __norm_current (string replace -r '^\\./' '' -- "$current")
501
+ set -l __norm_pattern (string replace -r '^\\./' '' -- "$pattern")
502
+ # For wildcard patterns, compare only the directory prefix
503
+ set -l __compare_pattern "$__norm_pattern"
504
+ if string match -q '*[*?]*' -- "$__compare_pattern"
505
+ set __compare_pattern (string replace -r '[*?].*' '' -- "$__compare_pattern")
506
+ if string match -q '*/*' -- "$__compare_pattern"
507
+ set __compare_pattern (string replace -r '/[^/]*$' '/' -- "$__compare_pattern")
508
+ else
509
+ set __compare_pattern ""
510
+ end
511
+ end
512
+ set -l __cp_len (string length -- "$__compare_pattern")
513
+ set -l __nc_len (string length -- "$__norm_current")
514
+ if begin; test -n "$__compare_pattern"; or test -n "$__norm_current"; end
515
+ and test $__nc_len -ge $__cp_len
516
+ and test (string sub -l $__cp_len -- "$__norm_current") = "$__compare_pattern"
517
+ and test "$current" != "$pattern"
518
+ set glob_base $current
519
+ else
520
+ set glob_base $pattern
521
+ set __from_pattern 1
522
+ end
523
+ end
524
+
525
+ # Expand tilde prefix for globbing — fish does not expand ~
526
+ # inside variable substitutions, so replace it with $HOME
527
+ if string match -q '~/*' -- "$glob_base"
528
+ set __tilde_prefix "~"
529
+ set glob_base (string replace -r '^~' "$HOME" -- "$glob_base")
530
+ else if string match -q '~' -- "$glob_base"
531
+ set __tilde_prefix "~"
532
+ set glob_base "$HOME/"
533
+ end
534
+
535
+ # If the glob base is a directory without a trailing slash,
536
+ # append one so that the glob enumerates its contents
537
+ if test -d "$glob_base"; and not string match -q '*/' -- "$glob_base"
538
+ set glob_base "$glob_base/"
539
+ end
540
+
541
+ # Pre-expand file candidates. When the glob base came from
542
+ # the program's pattern and contains * or ?, use it as the
543
+ # complete glob expression. Otherwise append * as a prefix.
544
+ # Note: [ is NOT treated as a glob because it commonly
545
+ # appears in literal filenames like [draft] or foo[1].txt.
546
+ set -l __has_glob 0
547
+ if test $__from_pattern -eq 1
548
+ and string match -q '*[*?]*' -- "$glob_base"
549
+ set __has_glob 1
550
+ end
551
+ set -l __candidates
552
+ if test $__has_glob -eq 1
553
+ # Safe glob expansion without eval: split the pattern into
554
+ # directory and filter parts, list the directory, then use
555
+ # string match for wildcard filtering
556
+ set -l __glob_dir (string replace -r '/[^/]*$' '' -- "$glob_base")
557
+ set -l __glob_filter (string replace -r '.*/' '' -- "$glob_base")
558
+ if test -z "$__glob_dir"; or test "$__glob_dir" = "$glob_base"
559
+ set __glob_dir "."
560
+ end
561
+ # Match files by the glob filter. Fish's * does not match
562
+ # dotfiles, so also scan .* when the filter targets them.
563
+ for __item in $__glob_dir/*
564
+ if test -e "$__item"
565
+ set -l __bn (basename "$__item")
566
+ if string match -q "$__glob_filter" -- "$__bn"
567
+ set -a __candidates "$__item"
568
+ end
569
+ end
570
+ end
571
+ if string match -q '.*' -- "$__glob_filter"
572
+ for __item in $__glob_dir/.*
573
+ if test -e "$__item"
574
+ set -l __bn (basename "$__item")
575
+ if test "$__bn" = "." -o "$__bn" = ".."
576
+ continue
577
+ end
578
+ if string match -q "$__glob_filter" -- "$__bn"
579
+ set -a __candidates "$__item"
580
+ end
581
+ end
582
+ end
583
+ end
584
+ # For file/any modes, also include directories for navigation.
585
+ # Skip for directory mode so the pattern filter is respected.
586
+ if test "$type" != "directory"
587
+ for __item in $__glob_dir/*/
588
+ if test -d "$__item"
589
+ set -a __candidates (string replace -r '/$' '' -- "$__item")
590
+ end
591
+ end
592
+ end
593
+ else
594
+ set __candidates $glob_base*
595
+ end
596
+
283
597
  # Generate file completions based on type
284
598
  set -l items
285
599
  switch $type
286
600
  case file
287
- # Complete files only
288
- for item in $current*
289
- if test -f $item
601
+ # Complete files and directories (directories for navigation)
602
+ for item in $__candidates
603
+ if test -d $item
604
+ set -a items $item/
605
+ else if test -f $item
290
606
  set -a items $item
291
607
  end
292
608
  end
609
+ # Fish's * glob does not match dotfiles; add them
610
+ # explicitly when the basename is empty (i.e., $glob_base
611
+ # is "" or ends with "/"), because only then are * and
612
+ # .* complementary. When a non-empty basename is present
613
+ # (e.g., "foo"), foo* already covers foo.txt, so foo.*
614
+ # would just produce duplicates.
615
+ if test "$hidden" = "1" -a $__has_glob -eq 0
616
+ if test -z "$glob_base"; or string match -q '*/' -- "$glob_base"
617
+ for item in $glob_base.*
618
+ if test -d $item
619
+ set -a items $item/
620
+ else if test -f $item
621
+ set -a items $item
622
+ end
623
+ end
624
+ end
625
+ end
293
626
  case directory
294
627
  # Complete directories only
295
- for item in $current*
628
+ for item in $__candidates
296
629
  if test -d $item
297
630
  set -a items $item/
298
631
  end
299
632
  end
633
+ if test "$hidden" = "1" -a $__has_glob -eq 0
634
+ if test -z "$glob_base"; or string match -q '*/' -- "$glob_base"
635
+ for item in $glob_base.*
636
+ if test -d $item
637
+ set -a items $item/
638
+ end
639
+ end
640
+ end
641
+ end
300
642
  case any
301
643
  # Complete both files and directories
302
- for item in $current*
644
+ for item in $__candidates
303
645
  if test -d $item
304
646
  set -a items $item/
305
647
  else if test -f $item
306
648
  set -a items $item
307
649
  end
308
650
  end
651
+ if test "$hidden" = "1" -a $__has_glob -eq 0
652
+ if test -z "$glob_base"; or string match -q '*/' -- "$glob_base"
653
+ for item in $glob_base.*
654
+ if test -d $item
655
+ set -a items $item/
656
+ else if test -f $item
657
+ set -a items $item
658
+ end
659
+ end
660
+ end
661
+ end
309
662
  end
310
663
 
311
664
  # Filter by extensions if specified
312
665
  if test -n "$extensions" -a "$type" != directory
313
666
  set -l filtered
314
- set -l ext_list (string split ',' -- $extensions)
667
+ set -l ext_list (string replace -r '^\\.' '' -- (string split ',' -- $extensions))
315
668
  for item in $items
316
669
  # Skip directories, they don't have extensions
317
670
  if string match -q '*/' -- $item
@@ -329,8 +682,11 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
329
682
  set items $filtered
330
683
  end
331
684
 
332
- # Filter out hidden files unless requested
333
- if test "$hidden" != "1" -a (string sub -l 1 -- $current) != "."
685
+ # Filter out hidden files unless requested.
686
+ # Check the basename of glob_base so that patterns like
687
+ # "src/.e" correctly target hidden entries.
688
+ set -l __glob_basename (string replace -r '.*/(.*)' '$1' -- $glob_base)
689
+ if test "$hidden" != "1" -a (string sub -l 1 -- $__glob_basename) != "."
334
690
  set -l filtered
335
691
  for item in $items
336
692
  set -l basename (basename $item)
@@ -341,6 +697,15 @@ ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current
341
697
  set items $filtered
342
698
  end
343
699
 
700
+ # Restore tilde prefix in completion results
701
+ if test -n "$__tilde_prefix"
702
+ set -l restored
703
+ for item in $items
704
+ set -a restored (string replace "$HOME" "~" -- $item)
705
+ end
706
+ set items $restored
707
+ end
708
+
344
709
  # Output file completions
345
710
  for item in $items
346
711
  echo $item
@@ -367,13 +732,14 @@ complete -c ${programName} -f -a '(${functionName})'
367
732
  for (const suggestion of suggestions) {
368
733
  if (i > 0) yield "\n";
369
734
  if (suggestion.kind === "literal") {
370
- const description = suggestion.description == null ? "" : require_message.formatMessage(suggestion.description, { colors: false });
371
- yield `${suggestion.text}\t${description}`;
735
+ const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
736
+ yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
372
737
  } else {
373
- const extensions = suggestion.extensions?.join(",") || "";
738
+ const extensions = encodeExtensions(suggestion.extensions);
374
739
  const hidden = suggestion.includeHidden ? "1" : "0";
375
- const description = suggestion.description == null ? "" : require_message.formatMessage(suggestion.description, { colors: false });
376
- yield `__FILE__:${suggestion.type}:${extensions}:${suggestion.pattern || ""}:${hidden}\t${description}`;
740
+ const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
741
+ const pattern = encodePattern(suggestion.pattern ?? "");
742
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}\t${description}`;
377
743
  }
378
744
  i++;
379
745
  }
@@ -479,10 +845,11 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
479
845
  $output | lines | each {|line|
480
846
  if ($line | str starts-with '__FILE__:') {
481
847
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
482
- let parts = ($line | split row ':')
848
+ let directive = ($line | split row "\t" | first)
849
+ let parts = ($directive | split row ':')
483
850
  let type = ($parts | get 1)
484
851
  let extensions = ($parts | get 2)
485
- let pattern = ($parts | get 3)
852
+ let pattern = ($parts | get 3 | str replace -a '%3A' ':' | str replace -a '%25' '%')
486
853
  let hidden = ($parts | get 4) == '1'
487
854
 
488
855
  # Extract prefix from the last argument if it exists
@@ -492,33 +859,96 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
492
859
  ""
493
860
  }
494
861
 
495
- # Generate file completions based on type
496
- # Use current directory if prefix is empty
497
- let ls_pattern = if ($prefix | is-empty) { "." } else { $prefix + "*" }
862
+ # When a pattern is specified, use it as the glob base instead of
863
+ # the user-typed prefix. If the user has already typed beyond the
864
+ # pattern (e.g., pattern="src/" and prefix="src/ma"), keep the
865
+ # prefix for incremental narrowing. Normalize path separators
866
+ # before comparing so that Windows backslashes match forward slashes
867
+ # in the transported pattern.
868
+ let glob_base = if ($pattern | is-not-empty) {
869
+ # Normalize separators and leading ./; downcase only on Windows
870
+ # where filesystems are typically case-insensitive
871
+ let norm_prefix_raw = ($prefix | str replace -a '\\' '/' | str replace -r '^\\./' '')
872
+ let norm_pattern_raw = ($pattern | str replace -a '\\' '/' | str replace -r '^\\./' '')
873
+ let is_win = (($nu.os-info.name | str downcase) == "windows")
874
+ let norm_prefix = (if $is_win { $norm_prefix_raw | str downcase } else { $norm_prefix_raw })
875
+ let norm_pattern = (if $is_win { $norm_pattern_raw | str downcase } else { $norm_pattern_raw })
876
+ # For wildcard patterns, compare only the directory prefix
877
+ let compare_pattern = if ($norm_pattern =~ '[*?]') {
878
+ let before_wild = ($norm_pattern | str replace -r '[*?].*' '')
879
+ if ($before_wild =~ '/') { $before_wild | str replace -r '/[^/]*$' '/' } else { "" }
880
+ } else {
881
+ $norm_pattern
882
+ }
883
+ if (($compare_pattern | is-not-empty) or ($norm_prefix | is-not-empty)) and ($norm_prefix | str starts-with $compare_pattern) and (($norm_prefix | str length) >= ($compare_pattern | str length)) and ($prefix != $pattern) {
884
+ $prefix
885
+ } else {
886
+ $pattern
887
+ }
888
+ } else {
889
+ $prefix
890
+ }
891
+
892
+ # If the glob base is a directory without a trailing slash (e.g., "src"),
893
+ # append one so that ls enumerates its contents instead of matching
894
+ # siblings like "src-old"
895
+ let glob_base = if ($glob_base | is-not-empty) and ($glob_base | path type) == "dir" and (not ($glob_base | str ends-with "/")) {
896
+ $glob_base + "/"
897
+ } else {
898
+ $glob_base
899
+ }
900
+
901
+ # Generate file completions based on type.
902
+ # When the glob base contains wildcard characters, use it as-is;
903
+ # otherwise append * to treat it as a prefix.
904
+ # Note: into glob is required so that ls expands wildcards from a variable
905
+ let has_glob = ($glob_base =~ '[*?]')
906
+ let ls_pattern = if ($glob_base | is-empty) {
907
+ "."
908
+ } else if $has_glob {
909
+ ($glob_base | into glob)
910
+ } else {
911
+ ($glob_base + "*" | into glob)
912
+ }
498
913
 
914
+ # When using a glob pattern, also compute a directory listing pattern
915
+ # so that file/any modes can include directories for navigation
916
+ let glob_dir_pattern = if $has_glob {
917
+ let dir_part = ($glob_base | path dirname)
918
+ let dir = if ($dir_part | is-empty) or ($dir_part == $glob_base) { "." } else { $dir_part }
919
+ ($dir + "/*" | into glob)
920
+ } else {
921
+ null
922
+ }
923
+
924
+ # Use ls -a to include hidden files when requested
499
925
  let items = try {
500
926
  match $type {
501
927
  "file" => {
502
928
  if ($extensions | is-empty) {
503
- ls $ls_pattern | where type == file
929
+ (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == file or type == dir
504
930
  } else {
505
- let ext_list = ($extensions | split row ',')
506
- ls $ls_pattern | where type == file | where {|f|
931
+ let ext_list = ($extensions | split row ',' | each {|e| $e | str replace -r '^\\.' '' })
932
+ let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
933
+ let dirs = $all_items | where type == dir
934
+ let files = $all_items | where type == file | where {|f|
507
935
  let ext = ($f.name | path parse | get extension)
508
936
  $ext in $ext_list
509
937
  }
938
+ $dirs | append $files
510
939
  }
511
940
  },
512
941
  "directory" => {
513
- ls $ls_pattern | where type == dir
942
+ (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }) | where type == dir
514
943
  },
515
944
  "any" => {
516
945
  if ($extensions | is-empty) {
517
- ls $ls_pattern
946
+ if $hidden { ls -a $ls_pattern } else { ls $ls_pattern }
518
947
  } else {
519
- let ext_list = ($extensions | split row ',')
520
- let dirs = ls $ls_pattern | where type == dir
521
- let files = ls $ls_pattern | where type == file | where {|f|
948
+ let ext_list = ($extensions | split row ',' | each {|e| $e | str replace -r '^\\.' '' })
949
+ let all_items = (if $hidden { ls -a $ls_pattern } else { ls $ls_pattern })
950
+ let dirs = $all_items | where type == dir
951
+ let files = $all_items | where type == file | where {|f|
522
952
  let ext = ($f.name | path parse | get extension)
523
953
  $ext in $ext_list
524
954
  }
@@ -530,8 +960,25 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
530
960
  []
531
961
  }
532
962
 
533
- # Filter out hidden files unless requested
534
- let filtered = if $hidden or ($prefix | str starts-with '.') {
963
+ # When using a glob pattern, also add directories from the base
964
+ # directory for navigation (file/any modes should list directories
965
+ # even when the glob itself does not match directory names)
966
+ let items = if ($glob_dir_pattern != null) and ($type != "directory") {
967
+ let extra_dirs = try {
968
+ (if $hidden { ls -a $glob_dir_pattern } else { ls $glob_dir_pattern }) | where type == dir
969
+ } catch {
970
+ []
971
+ }
972
+ $items | append $extra_dirs | uniq-by name
973
+ } else {
974
+ $items
975
+ }
976
+
977
+ # Filter out hidden files unless requested.
978
+ # Check the basename of glob_base so that patterns like "src/.e"
979
+ # correctly target hidden entries.
980
+ let glob_basename = ($glob_base | path basename)
981
+ let filtered = if $hidden or ($glob_basename | str starts-with '.') {
535
982
  $items
536
983
  } else {
537
984
  $items | where {|item|
@@ -540,12 +987,22 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
540
987
  }
541
988
  }
542
989
 
990
+ # Extract directory prefix to preserve in completion text
991
+ let dir_prefix = if ($glob_base | is-empty) {
992
+ ""
993
+ } else if ($glob_base | str ends-with "/") {
994
+ $glob_base
995
+ } else {
996
+ let parsed = ($glob_base | path parse)
997
+ if ($parsed.parent | is-empty) { "" } else if ($parsed.parent | str ends-with "/") { $parsed.parent } else { $parsed.parent + "/" }
998
+ }
999
+
543
1000
  # Format file completions
544
1001
  $filtered | each {|item|
545
1002
  let name = if $item.type == dir {
546
- ($item.name | path basename) + "/"
1003
+ $dir_prefix + ($item.name | path basename) + "/"
547
1004
  } else {
548
- $item.name | path basename
1005
+ $dir_prefix + ($item.name | path basename)
549
1006
  }
550
1007
  { value: $name }
551
1008
  }
@@ -597,13 +1054,14 @@ ${functionName}-external
597
1054
  for (const suggestion of suggestions) {
598
1055
  if (i > 0) yield "\n";
599
1056
  if (suggestion.kind === "literal") {
600
- const description = suggestion.description == null ? "" : require_message.formatMessage(suggestion.description, { colors: false });
601
- yield `${suggestion.text}\t${description}`;
1057
+ const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1058
+ yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
602
1059
  } else {
603
- const extensions = suggestion.extensions?.join(",") || "";
1060
+ const extensions = encodeExtensions(suggestion.extensions);
604
1061
  const hidden = suggestion.includeHidden ? "1" : "0";
605
- const description = suggestion.description == null ? "" : require_message.formatMessage(suggestion.description, { colors: false });
606
- yield `__FILE__:${suggestion.type}:${extensions}:${suggestion.pattern || ""}:${hidden}\t${description}`;
1062
+ const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1063
+ const pattern = encodePattern(suggestion.pattern ?? "");
1064
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}\t${description}`;
607
1065
  }
608
1066
  i++;
609
1067
  }
@@ -669,59 +1127,110 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
669
1127
 
670
1128
  if (\$line -match '^__FILE__:') {
671
1129
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
672
- \$parts = \$line -split ':', 5
1130
+ \$directive = (\$line -split "\`t")[0]
1131
+ \$parts = \$directive -split ':', 5
673
1132
  \$type = \$parts[1]
674
1133
  \$extensions = \$parts[2]
675
- \$pattern = \$parts[3]
1134
+ \$pattern = \$parts[3] -replace '%3A', ':' -replace '%25', '%'
676
1135
  \$hidden = \$parts[4] -eq '1'
677
1136
 
678
- # Determine current prefix for file matching
679
- \$prefix = if (\$wordToComplete) { \$wordToComplete } else { '' }
1137
+ # When a pattern is specified, use it as the file matching
1138
+ # prefix instead of the current word. If the user has
1139
+ # already typed beyond the pattern, keep their input for
1140
+ # incremental narrowing. Normalize path separators before
1141
+ # comparing so that Windows backslashes match forward slashes
1142
+ # in the transported pattern.
1143
+ \$normalizedPattern = if (\$pattern) { \$pattern.Replace('\\', '/') -replace '^\\./','' } else { '' }
1144
+ \$normalizedWord = if (\$wordToComplete) { \$wordToComplete.Replace('\\', '/') -replace '^\\./','' } else { '' }
1145
+ \$comparison = if (\$IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
1146
+ # For wildcard patterns, compare only the directory prefix
1147
+ \$comparePattern = \$normalizedPattern
1148
+ if (\$comparePattern -match '[\*\?]') {
1149
+ \$beforeWild = \$comparePattern -replace '[\*\?].*', ''
1150
+ if (\$beforeWild.Contains('/')) {
1151
+ \$comparePattern = \$beforeWild.Substring(0, \$beforeWild.LastIndexOf('/') + 1)
1152
+ } else {
1153
+ \$comparePattern = ''
1154
+ }
1155
+ }
1156
+ \$prefix = if ((\$comparePattern -or \$normalizedWord) -and \$normalizedWord -and \$normalizedWord.StartsWith(\$comparePattern, \$comparison) -and \$normalizedWord.Length -ge \$comparePattern.Length -and \$wordToComplete -ne \$pattern) {
1157
+ \$wordToComplete
1158
+ } elseif (\$pattern) {
1159
+ \$pattern
1160
+ } elseif (\$wordToComplete) {
1161
+ \$wordToComplete
1162
+ } else { '' }
1163
+
1164
+ # Use -Force to include hidden files when requested, or when
1165
+ # the prefix basename targets dotfiles (e.g., src/.e)
1166
+ \$prefixBasename = if (\$prefix) { Split-Path -Leaf \$prefix } else { '' }
1167
+ \$forceParam = if (\$hidden -or (\$prefixBasename -and \$prefixBasename.StartsWith('.'))) { @{Force = \$true} } else { @{} }
1168
+
1169
+ # If prefix is a directory without trailing slash, append
1170
+ # one so Get-ChildItem lists its contents
1171
+ if (\$prefix -and (Test-Path -Path \$prefix -PathType Container) -and -not \$prefix.EndsWith('/') -and -not \$prefix.EndsWith('\\')) {
1172
+ \$prefix = \$prefix + '/'
1173
+ }
1174
+
1175
+ # Build the glob path — when the prefix contains wildcard
1176
+ # characters, use it as-is; otherwise append *
1177
+ \$globPath = if (\$prefix -match '[\*\?]') { \$prefix } else { "\${prefix}*" }
680
1178
 
681
1179
  # Get file system items based on type
682
1180
  \$items = @()
683
1181
  switch (\$type) {
684
1182
  'file' {
685
1183
  if (\$extensions) {
686
- # Filter by extensions
687
- \$extList = \$extensions -split ','
688
- \$items = Get-ChildItem -File -Path "\${prefix}*" -ErrorAction SilentlyContinue |
1184
+ # Filter by extensions, always include directories
1185
+ \$extList = (\$extensions -split ',') -replace '^\\.',''
1186
+ \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
689
1187
  Where-Object {
690
- \$ext = \$_.Extension
691
- \$extList | ForEach-Object { if (\$ext -eq ".\$_") { return \$true } }
1188
+ \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -in \$extList)
692
1189
  }
693
1190
  } else {
694
- \$items = Get-ChildItem -File -Path "\${prefix}*" -ErrorAction SilentlyContinue
1191
+ \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
695
1192
  }
696
1193
  }
697
1194
  'directory' {
698
- \$items = Get-ChildItem -Directory -Path "\${prefix}*" -ErrorAction SilentlyContinue
1195
+ \$items = Get-ChildItem @forceParam -Directory -Path \$globPath -ErrorAction SilentlyContinue
699
1196
  }
700
1197
  'any' {
701
1198
  if (\$extensions) {
702
- # Get directories and filtered files
703
- \$dirs = Get-ChildItem -Directory -Path "\${prefix}*" -ErrorAction SilentlyContinue
704
- \$extList = \$extensions -split ','
705
- \$files = Get-ChildItem -File -Path "\${prefix}*" -ErrorAction SilentlyContinue |
1199
+ # Filter by extensions, always include directories
1200
+ \$extList = (\$extensions -split ',') -replace '^\\.',''
1201
+ \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
706
1202
  Where-Object {
707
- \$ext = \$_.Extension
708
- \$extList | ForEach-Object { if (\$ext -eq ".\$_") { return \$true } }
1203
+ \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -in \$extList)
709
1204
  }
710
- \$items = \$dirs + \$files
711
1205
  } else {
712
- \$items = Get-ChildItem -Path "\${prefix}*" -ErrorAction SilentlyContinue
1206
+ \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
713
1207
  }
714
1208
  }
715
1209
  }
716
1210
 
717
- # Filter hidden files unless requested
718
- if (-not \$hidden) {
1211
+ # For file/any modes with glob patterns, also add directories
1212
+ # from the base directory for navigation
1213
+ if (\$prefix -match '[\*\?]' -and \$type -ne 'directory') {
1214
+ \$globDir = Split-Path -Parent \$prefix
1215
+ if (-not \$globDir) { \$globDir = '.' }
1216
+ \$extraDirs = Get-ChildItem @forceParam -Directory -Path "\$globDir/*" -ErrorAction SilentlyContinue
1217
+ if (\$extraDirs) { \$items = @(\$items) + @(\$extraDirs) | Select-Object -Unique }
1218
+ }
1219
+
1220
+ # Filter hidden files unless requested or the prefix targets dotfiles
1221
+ if (-not \$hidden -and -not (\$prefixBasename -and \$prefixBasename.StartsWith('.'))) {
719
1222
  \$items = \$items | Where-Object { -not \$_.Attributes.HasFlag([System.IO.FileAttributes]::Hidden) }
720
1223
  }
721
1224
 
1225
+ # Extract directory prefix to preserve in completion text
1226
+ \$dirPrefix = if (\$prefix -and (\$prefix.Contains('/') -or \$prefix.Contains('\\'))) {
1227
+ \$slashIdx = [Math]::Max(\$prefix.LastIndexOf('/'), \$prefix.LastIndexOf('\\'))
1228
+ \$prefix.Substring(0, \$slashIdx + 1)
1229
+ } else { '' }
1230
+
722
1231
  # Create completion results for files
723
1232
  \$items | ForEach-Object {
724
- \$completionText = if (\$_.PSIsContainer) { "\$(\$_.Name)/" } else { \$_.Name }
1233
+ \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
725
1234
  \$itemType = if (\$_.PSIsContainer) { 'Directory' } else { 'File' }
726
1235
  [System.Management.Automation.CompletionResult]::new(
727
1236
  \$completionText,
@@ -756,13 +1265,15 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
756
1265
  for (const suggestion of suggestions) {
757
1266
  if (i > 0) yield "\n";
758
1267
  if (suggestion.kind === "literal") {
759
- const description = suggestion.description == null ? "" : require_message.formatMessage(suggestion.description, { colors: false });
760
- yield `${suggestion.text}\t${suggestion.text}\t${description}`;
1268
+ const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1269
+ const text = sanitizeForTransport(suggestion.text);
1270
+ yield `${text}\t${text}\t${description}`;
761
1271
  } else {
762
- const extensions = suggestion.extensions?.join(",") || "";
1272
+ const extensions = encodeExtensions(suggestion.extensions);
763
1273
  const hidden = suggestion.includeHidden ? "1" : "0";
764
- const description = suggestion.description == null ? "" : require_message.formatMessage(suggestion.description, { colors: false });
765
- yield `__FILE__:${suggestion.type}:${extensions}:${suggestion.pattern || ""}:${hidden}\t[file]\t${description}`;
1274
+ const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1275
+ const pattern = encodePattern(suggestion.pattern ?? "");
1276
+ yield `__FILE__:${suggestion.type}:${extensions}:${pattern}:${hidden}\t[file]\t${description}`;
766
1277
  }
767
1278
  i++;
768
1279
  }