@optique/core 1.0.15-dev.2849 → 1.0.15-dev.2873

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.
@@ -120,7 +120,7 @@ function _${programName} () {
120
120
  esac
121
121
  fi
122
122
  done
123
- local __tilde_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)$'
123
+ local __tilde_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)?$'
124
124
  local __tilde_glob_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)?$'
125
125
  local prev=("\${COMP_WORDS[@]:1:COMP_CWORD-1}")
126
126
  # Bash splits unquoted '=' at its default word breaks. Reassemble only
@@ -353,8 +353,9 @@ function _${programName} () {
353
353
 
354
354
  # path() echoes the raw shell word as its pattern. Once dequoted,
355
355
  # use the literal current-word prefix instead of the escaped pattern.
356
- if [[ ( "$pattern" == "$__raw_file_current" && "$__file_current" != "$__raw_file_current" ) ||
357
- ( -n "$__reply_prefix" && "$pattern" == "$__file_current" ) ]]; then
356
+ if [[ "$pattern" == "$__raw_file_current" && "$__file_current" != "$__raw_file_current" ]] ||
357
+ { [[ -n "$__reply_prefix" ]] && [ "$pattern" = "$__file_current" ] &&
358
+ { [[ "$pattern" != *[\*\?]* ]] || [ "$__raw_file_current" != "$__file_current" ]; }; }; then
358
359
  pattern=""
359
360
  fi
360
361
 
@@ -388,7 +389,8 @@ function _${programName} () {
388
389
  # However, if the user has already typed beyond the pattern (e.g.,
389
390
  # pattern="src/" and current="src/ma"), preserve the typed suffix
390
391
  # for incremental filtering.
391
- local __from_pattern=0
392
+ local __from_pattern=0 __filter_pattern=0 __pattern_prefix=""
393
+ local __pattern_tilde_prefix="" __pattern_tilde_expanded=""
392
394
  if [[ -n "$pattern" ]]; then
393
395
  # Normalize leading ./ so that ./src/ma matches pattern src/
394
396
  local __norm_current="\${__file_current#./}"
@@ -400,10 +402,27 @@ function _${programName} () {
400
402
  __compare_pattern="\${__compare_pattern%%[\*\?]*}"
401
403
  [[ "$__compare_pattern" == */* ]] && __compare_pattern="\${__compare_pattern%/*}/" || __compare_pattern=""
402
404
  fi
405
+ local __extends_pattern=0
403
406
  if [[ ( -n "$__compare_pattern" || -n "$__norm_current" ) && \${#__norm_current} -ge \${#__compare_pattern} && "\${__norm_current:0:\${#__compare_pattern}}" == "$__compare_pattern" && "$__file_current" != "$pattern" ]]; then
407
+ __extends_pattern=1
408
+ fi
409
+ if (( __extends_pattern )) && [[ "$__norm_pattern" != *[\*\?]* ]]; then
404
410
  # User has typed beyond or an equivalent form of the pattern
405
411
  true
406
412
  else
413
+ # Wildcards keep their file filter while the typed prefix narrows
414
+ # the resulting paths. Save the expanded prefix before using pattern.
415
+ if [[ "$__norm_pattern" == *[\*\?]* ]]; then
416
+ __filter_pattern=1
417
+ __pattern_prefix="$__glob_current"
418
+ # An unquoted value equal to its wildcard is a complete search
419
+ # pattern, not a literal filename prefix to filter against.
420
+ if [ "$__file_current" = "$pattern" ] && [ "$__raw_file_current" = "$__file_current" ]; then
421
+ __pattern_prefix=""
422
+ fi
423
+ __pattern_tilde_prefix="$__tilde_prefix"
424
+ __pattern_tilde_expanded="$__tilde_expanded"
425
+ fi
407
426
  __from_pattern=1
408
427
  # Reset tilde state from the current-word expansion so that a
409
428
  # non-tilde pattern is not rewritten through stale tilde state
@@ -432,36 +451,93 @@ function _${programName} () {
432
451
  # This runs after tilde expansion so that paths like ~/.config/ are
433
452
  # checked against the expanded path, not the literal ~ string.
434
453
  local __inside_hidden_path=0
435
- case "/\${__glob_current%/}/" in
436
- */.[!.]*/*|*/..?*/*) __inside_hidden_path=1 ;;
437
- esac
454
+ local __hidden_directory_pattern='/[.]([^./][^/]*|[.][^/]+)/'
455
+ if [[ "/\${__glob_current%/}/" =~ $__hidden_directory_pattern ||
456
+ "/\${__pattern_prefix%/}/" =~ $__hidden_directory_pattern ]]; then
457
+ __inside_hidden_path=1
458
+ fi
438
459
  # Also check if the current prefix explicitly targets hidden entries
439
460
  # (e.g., user typed "." or ".e" to complete .env)
440
461
  local __prefix_targets_hidden=0
441
462
  local __prefix_base="\${__glob_current##*/}"
463
+ if [[ -n "$__pattern_prefix" ]]; then __prefix_base="\${__pattern_prefix##*/}"; fi
442
464
  [[ "$__prefix_base" == .* ]] && __prefix_targets_hidden=1
443
465
  if [[ "$hidden" == "1" || "$__inside_hidden_path" == "1" || "$__prefix_targets_hidden" == "1" ]]; then shopt -s dotglob; fi
444
466
 
445
467
  # Pre-expand file candidates. When the glob base came from the
446
468
  # program's pattern and contains wildcard characters (* or ?),
447
- # use it as-is via compgen -G (safe — no command substitution).
469
+ # expand it as data with shell globbing. Empty IFS avoids splitting paths;
470
+ # NUL records preserve spaces and newlines without requiring mapfile.
471
+ # Shell expansion respects dotglob on older Bash versions as well.
448
472
  # Otherwise append * to treat it as a prefix.
449
- # Note: [ is NOT treated as a glob indicator because it commonly
450
- # appears in literal filenames like [draft] or foo[1].txt.
473
+ # Only * and ? are wildcard syntax; escape brackets, backslashes, and
474
+ # extended-glob operators so other pattern characters stay literal.
451
475
  local -a __candidates=()
452
476
  if [[ "$__from_pattern" == "1" && "$__glob_current" == *[\*\?]* ]]; then
453
- mapfile -t __candidates < <(compgen -G "$__glob_current" 2>/dev/null)
477
+ local __pathname_glob="" __glob_char __glob_index
478
+ for ((__glob_index=0; __glob_index<\${#__glob_current}; __glob_index++)); do
479
+ __glob_char="\${__glob_current:__glob_index:1}"
480
+ case "$__glob_char" in
481
+ '['|']'|'\\'|'('|')'|'|'|'+'|'@'|'!')
482
+ __pathname_glob+="\\\\$__glob_char" ;;
483
+ *) __pathname_glob+="$__glob_char" ;;
484
+ esac
485
+ done
486
+ [[ "$type" == "directory" ]] && __pathname_glob="\${__pathname_glob%/}"
487
+ local __glob_dir="\${__pathname_glob%/*}"
488
+ [[ "$__glob_dir" == "$__pathname_glob" ]] && __glob_dir="."
489
+ [[ -z "$__glob_dir" ]] && __glob_dir="/"
490
+ local __navigation_dir="" __basename_glob="\${__pathname_glob##*/}"
491
+ # Once an offered directory is entered, keep the basename filter but
492
+ # enumerate there. Only descendants of matching pattern bases qualify.
493
+ if [[ "$__pattern_prefix" == */* ]]; then
494
+ local __typed_dir="\${__pattern_prefix%/*}" __base_dir __norm_base __norm_dir
495
+ # Resolve directory ancestry in subshells, preserving the typed spelling
496
+ # for insertion. The trailing slash also preserves newline-ending names.
497
+ if __norm_dir=$(CDPATH= builtin cd -P -- "$__typed_dir" >/dev/null 2>&1 && printf '%s/' "\${PWD%/}"); then
498
+ while IFS= read -r -d '' __base_dir; do
499
+ [[ -d "$__base_dir" ]] || continue
500
+ __norm_base=$(CDPATH= builtin cd -P -- "$__base_dir" >/dev/null 2>&1 && printf '%s/' "\${PWD%/}") || continue
501
+ if [ "\${__norm_dir:0:\${#__norm_base}}" = "$__norm_base" ]; then
502
+ __navigation_dir="$__typed_dir"
503
+ break
504
+ fi
505
+ done < <(IFS=; printf '%s\\0' $__glob_dir)
506
+ fi
507
+ fi
508
+ local __candidate
509
+ while IFS= read -r -d '' __candidate; do
510
+ if [[ -e "$__candidate" || -L "$__candidate" ]]; then
511
+ [[ -d "$__candidate" ]] && __candidate="\${__candidate%/}"
512
+ __candidates+=("$__candidate")
513
+ fi
514
+ done < <(
515
+ IFS=
516
+ if [[ -n "$__navigation_dir" ]]; then
517
+ printf '%s\\0' "$__navigation_dir"/$__basename_glob
518
+ else
519
+ printf '%s\\0' $__pathname_glob
520
+ fi
521
+ )
454
522
  # For file/any modes, also include directories from the base
455
523
  # directory for navigation even when the glob itself does not
456
524
  # match directory names. Skip this for directory mode so that
457
525
  # the pattern's basename filter is respected.
458
526
  if [[ "$type" != "directory" ]]; then
459
- local __glob_dir="\${__glob_current%/*}"
460
- [[ "$__glob_dir" == "$__glob_current" ]] && __glob_dir="."
461
527
  local __d
462
- for __d in "$__glob_dir"/*/; do
463
- [[ -d "$__d" ]] && __candidates+=("\${__d%/}")
464
- done
528
+ local __directory_pattern="\${__glob_dir%/}/*/"
529
+ while IFS= read -r -d '' __d; do
530
+ [[ -d "$__d" ]] || continue
531
+ __d="\${__d%/}"
532
+ __candidates+=("$__d")
533
+ done < <(
534
+ IFS=
535
+ if [[ -n "$__navigation_dir" ]]; then
536
+ printf '%s\\0' "$__navigation_dir"/*/
537
+ else
538
+ printf '%s\\0' $__directory_pattern
539
+ fi
540
+ )
465
541
  fi
466
542
  else
467
543
  __candidates=("$__glob_current"*)
@@ -472,6 +548,26 @@ function _${programName} () {
472
548
  fi
473
549
  fi
474
550
 
551
+ if (( __filter_pattern )); then
552
+ local __nocasematch_was_set=0
553
+ shopt -q nocasematch && __nocasematch_was_set=1
554
+ if shopt -q nocaseglob; then shopt -s nocasematch; else shopt -u nocasematch; fi
555
+ local -a __filtered_candidates=()
556
+ for __candidate in "\${__candidates[@]}"; do
557
+ if [[ "$__candidate" == "$__pattern_prefix"* ||
558
+ "\${__candidate#./}" == "\${__pattern_prefix#./}"* ]]; then
559
+ if [[ "$__pattern_prefix" == ./* && "$__candidate" != ./* ]]; then
560
+ __candidate="./$__candidate"
561
+ elif [[ "$__pattern_prefix" != ./* && "$__pattern_prefix" != . ]]; then
562
+ __candidate="\${__candidate#./}"
563
+ fi
564
+ __filtered_candidates+=("$__candidate")
565
+ fi
566
+ done
567
+ if (( __nocasematch_was_set )); then shopt -s nocasematch; else shopt -u nocasematch; fi
568
+ __candidates=("\${__filtered_candidates[@]}")
569
+ fi
570
+
475
571
  # Generate file completions based on type
476
572
  case "$type" in
477
573
  file)
@@ -533,6 +629,10 @@ function _${programName} () {
533
629
  esac
534
630
 
535
631
  # Restore tilde prefix in completion results
632
+ if (( __filter_pattern )); then
633
+ __tilde_prefix="$__pattern_tilde_prefix"
634
+ __tilde_expanded="$__pattern_tilde_expanded"
635
+ fi
536
636
  if [[ -n "$__tilde_prefix" &&
537
637
  ( -z "$__quote" || $__file_quote_offset -gt 0 ) ]]; then
538
638
  local __i
@@ -632,6 +732,31 @@ function _${programName} () {
632
732
  COMPREPLY+=("\${__word:\${#__remove}}")
633
733
  fi
634
734
  done
735
+ if (( \${#COMPREPLY[@]} > 1 )) && command -v sort >/dev/null 2>&1; then
736
+ # Sort shell-escaped keys, not filenames: each key occupies one line even
737
+ # when a candidate contains tabs or newlines. Keep the first occurrence's
738
+ # index, then restore the original order without evaluating escaped data.
739
+ # This works without associative arrays or NUL-aware sort on Bash 3.2.
740
+ local -a __unique_replies=()
741
+ local __index __key __last_key=""
742
+ while IFS= read -r __index; do
743
+ __unique_replies+=("\${COMPREPLY[__index]}")
744
+ done < <(
745
+ for ((__index=0; __index<\${#COMPREPLY[@]}; __index++)); do
746
+ printf '%q\\t%d\\n' "\${COMPREPLY[__index]}" "$__index"
747
+ done |
748
+ LC_ALL=C sort -t $'\\t' -k1,1 -k2,2n |
749
+ {
750
+ while IFS=$'\\t' read -r __key __index; do
751
+ if [ "$__key" != "$__last_key" ]; then
752
+ printf '%s\\n' "$__index"
753
+ __last_key="$__key"
754
+ fi
755
+ done
756
+ } | LC_ALL=C sort -n
757
+ )
758
+ COMPREPLY=("\${__unique_replies[@]}")
759
+ fi
635
760
  if [[ $__file_mode == 1 && $(type -t compopt) == builtin ]]; then
636
761
  compopt -o filenames 2>/dev/null || true
637
762
  fi
@@ -657,7 +782,7 @@ fi
657
782
  }
658
783
  for (const [index, { prefix, file }] of joined.files.entries()) {
659
784
  if (index > 0) yield "\n";
660
- yield `__JOINED_FILE__:${encodePattern(prefix)}:${file.type}:${encodeExtensions(file.extensions)}::${file.includeHidden ? "1" : "0"}`;
785
+ yield `__JOINED_FILE__:${encodePattern(prefix)}:${file.type}:${encodeExtensions(file.extensions)}:${encodePattern(sanitizeForTransport(file.pattern ?? ""))}:${file.includeHidden ? "1" : "0"}`;
661
786
  }
662
787
  } else if (suggestion.kind === "literal") yield encodeBashLiteral(suggestion.text);
663
788
  else {
@@ -679,9 +804,14 @@ const zsh = {
679
804
  generateScript(programName, args = []) {
680
805
  validateProgramName(programName);
681
806
  const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`).join(" ");
807
+ const functionName = /[.-]/.test(programName) ? `_optique:${programName}` : `_${programName}`;
682
808
  return `#compdef ${programName}
683
- function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
809
+ function ${functionName} () {
684
810
  local current="\$words[CURRENT]"
811
+ # Let zsh match the suffix when completing in the middle of a word.
812
+ if [[ -o complete_in_word ]]; then
813
+ current="\$PREFIX"
814
+ fi
685
815
  local -a prev
686
816
  # Extract previous arguments, skipping empty ones
687
817
  prev=()
@@ -769,17 +899,60 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
769
899
  # Use zsh's native file completion
770
900
  case "\$type" in
771
901
  file)
772
- if [[ -n "\$ext_pattern" ]]; then
773
- # Route filtered files through zsh's standard files/directories
774
- # tags so files-tag styles and tag-order still apply, while
775
- # avoiding _files' all-files fallback for extension-filtered
776
- # matches.
777
- local file_pattern="\${ext_pattern}(#q-.)"
778
- _wanted files expl file _path_files -g "\${file_pattern}"
779
- _wanted directories expl directory _path_files -/
780
- else
781
- _files
902
+ # Use the same regular-file filter with or without extensions,
903
+ # keeping symlinked files and directory navigation available.
904
+ local file_pattern="\${ext_pattern:-*}(#q-.)"
905
+ local file_subtree_pattern="**/*(#q/N)"
906
+ local -a expl=() file_subtree=() file_recursive_patterns=()
907
+ local -a file_tags=(files directories)
908
+ local file_recursive_pattern file_directory file_prefix file_tag
909
+ if [[ -z "\$ext_pattern" ]] &&
910
+ zstyle -t ":completion:\${curcontext}:" list-dirs-first; then
911
+ file_tags=(directories files)
782
912
  fi
913
+ _tags "\${file_tags[@]}"
914
+ while _tags; do
915
+ local file_matched=0
916
+ for file_tag in "\${file_tags[@]}"; do
917
+ if [[ "\$file_tag" == directories ]]; then
918
+ while _next_label directories expl directory; do
919
+ _path_files -/ "\${expl[@]}" && file_matched=1
920
+ done
921
+ else
922
+ while _next_label files expl file; do
923
+ local -a file_ignore=() file_ignore_args=()
924
+ # Explicit globs bypass automatic fignore. Apply it only
925
+ # when the tag has not supplied its own ignore patterns.
926
+ if [[ -z "\$ext_pattern" && -z "\$_comp_no_ignore" && -n "\$FIGNORE" ]] && (( \${#_comp_ignore[@]} == 0 )); then
927
+ file_ignore=("?*\${^fignore[@]}")
928
+ file_ignore_args=(-F file_ignore)
929
+ fi
930
+ if _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}"; then
931
+ file_matched=1
932
+ elif [[ -z "\$ext_pattern" && "\$PREFIX\$SUFFIX" != */* ]] &&
933
+ zstyle -a ":completion:\${curcontext}:files" recursive-files file_recursive_patterns; then
934
+ # Match _files' recursive fallback, retaining the same
935
+ # file filter and tag-specific ignore options.
936
+ for file_recursive_pattern in "\${file_recursive_patterns[@]}"; do
937
+ if [[ "\$PWD/" = \${~file_recursive_pattern} ]]; then
938
+ if (( \${#file_subtree[@]} == 0 )); then
939
+ file_subtree=(\${~file_subtree_pattern})
940
+ fi
941
+ for file_directory in "\${file_subtree[@]}"; do
942
+ file_prefix="\$PREFIX"
943
+ PREFIX="\$file_directory/\$PREFIX"
944
+ _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}" && file_matched=1
945
+ PREFIX="\$file_prefix"
946
+ done
947
+ break
948
+ fi
949
+ done
950
+ fi
951
+ done
952
+ fi
953
+ done
954
+ (( file_matched )) && break
955
+ done
783
956
  ;;
784
957
  directory)
785
958
  _directories
@@ -829,7 +1002,13 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
829
1002
  }
830
1003
 
831
1004
  if (( $+functions[compdef] )); then
832
- compdef _${programName.replace(/[^a-zA-Z0-9]/g, "_")} ${programName}
1005
+ compdef ${functionName} ${programName}
1006
+ fi
1007
+
1008
+ # An autoloaded file runs as a function body. Dispatch its initial request;
1009
+ # sourcing the file only defines and registers the completion helper.
1010
+ if [[ \$ZSH_EVAL_CONTEXT == *:loadautofunc || \$ZSH_EVAL_CONTEXT == *:shfunc ]]; then
1011
+ ${functionName} "\$@"
833
1012
  fi
834
1013
  `;
835
1014
  },
@@ -855,7 +1034,7 @@ const fish = {
855
1034
  generateScript(programName, args = []) {
856
1035
  validateProgramName(programName);
857
1036
  const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "\\'")}'`).join(" ");
858
- const functionName = `__${programName.replace(/[^a-zA-Z0-9]/g, "_")}_complete`;
1037
+ const functionName = `__${programName}_complete`;
859
1038
  return `
860
1039
  function ${functionName}
861
1040
  set -l tokens (commandline -poc)
@@ -1544,8 +1723,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1544
1723
  let all_items = (do $list_entries $ls_pattern)
1545
1724
  let dirs = $all_items | where type == dir
1546
1725
  let files = $all_items | where type == file | where {|f|
1547
- let ext = ($f.name | path parse | get extension)
1548
- $ext in $ext_list
1726
+ $ext_list | any {|ext|
1727
+ $f.name | path basename | str ends-with ('.' + $ext)
1728
+ }
1549
1729
  }
1550
1730
  $dirs | append $files
1551
1731
  }
@@ -1561,8 +1741,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1561
1741
  let all_items = (do $list_entries $ls_pattern)
1562
1742
  let dirs = $all_items | where type == dir
1563
1743
  let files = $all_items | where type == file | where {|f|
1564
- let ext = ($f.name | path parse | get extension)
1565
- $ext in $ext_list
1744
+ $ext_list | any {|ext|
1745
+ $f.name | path basename | str ends-with ('.' + $ext)
1746
+ }
1566
1747
  }
1567
1748
  $dirs | append $files
1568
1749
  }
@@ -1730,6 +1911,15 @@ const pwsh = {
1730
1911
  Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1731
1912
  param(\$wordToComplete, \$commandAst, \$cursorPosition)
1732
1913
 
1914
+ # CompletionText is shell syntax; display labels remain unquoted.
1915
+ \$quoteArgument = {
1916
+ param([string]\$value)
1917
+ if (\$value -cmatch '\\A[a-zA-Z0-9_./:\\\\-]+\\z' -and \$value -cnotmatch '\\A-(?!-).*[.:]' -and \$value -cnotmatch '\\A-?(?:[0-9]|\\.[0-9])') {
1918
+ return \$value
1919
+ }
1920
+ return "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent(\$value) + "'"
1921
+ }
1922
+
1733
1923
  # Extract arguments from AST (handles quoted strings properly)
1734
1924
  \$arguments = @()
1735
1925
  \$commandElements = \$commandAst.CommandElements
@@ -1743,8 +1933,11 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1743
1933
  if (\$element.Extent.EndOffset -ge \$cursorPosition) {
1744
1934
  # wordToComplete has synthesized delimiters and decoded escapes.
1745
1935
  # Only the AST retains the literal value and original quote type.
1936
+ # PSReadLine leaves the cursor before a directory's closing quote.
1746
1937
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
1747
- \$element.Extent.EndOffset -eq \$cursorPosition) {
1938
+ (\$element.Extent.EndOffset -eq \$cursorPosition -or
1939
+ (\$element.Extent.EndOffset -eq (\$cursorPosition + 1) -and
1940
+ \$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')))) {
1748
1941
  if (\$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')) {
1749
1942
  \$valuePrefix = \$element.Value
1750
1943
  \$quoteType = \$element.StringConstantType
@@ -1777,7 +1970,7 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1777
1970
  } elseif (\$quoteType -eq 'DoubleQuoted') {
1778
1971
  '"' + (\$text -replace '[\\x60\$"\\u201c-\\u201e]', '\`\$0') + '"'
1779
1972
  } else {
1780
- \$text
1973
+ & \$quoteArgument \$text
1781
1974
  }
1782
1975
  }
1783
1976
 
@@ -1882,7 +2075,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1882
2075
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1883
2076
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1884
2077
  Where-Object {
1885
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -cin \$extList)
2078
+ \$fileName = \$_.Name
2079
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2080
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2081
+ }).Count -gt 0)
1886
2082
  }
1887
2083
  } else {
1888
2084
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1897,7 +2093,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1897
2093
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1898
2094
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1899
2095
  Where-Object {
1900
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -cin \$extList)
2096
+ \$fileName = \$_.Name
2097
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2098
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2099
+ }).Count -gt 0)
1901
2100
  }
1902
2101
  } else {
1903
2102
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1929,15 +2128,32 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1929
2128
  \$items | ForEach-Object {
1930
2129
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1931
2130
  \$itemType = if (\$_.PSIsContainer) { 'Directory' } else { 'File' }
2131
+ \$resultType = if (\$_.PSIsContainer) { 'ProviderContainer' } else { 'ParameterValue' }
2132
+ # PSReadLine recognizes the platform separator for containers.
2133
+ # Keep the display label's existing slash convention.
2134
+ \$directorySuffix = [IO.Path]::DirectorySeparatorChar
2135
+ \$pathText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)\$directorySuffix" } else { \$completionText }
1932
2136
  # Quoting disables native tilde expansion. A home pattern
1933
2137
  # supplied by the parser must insert the discovered path.
1934
2138
  \$replacementText = if (\$null -ne \$quoteType -and -not \$prefixFromInput -and \$homePattern) {
1935
- if (\$_.PSIsContainer) { \$_.FullName + '/' } else { \$_.FullName }
1936
- } else { \$completionText }
2139
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2140
+ } else { \$pathText }
2141
+ # Quoting suppresses native tilde expansion. Keep safe
2142
+ # ~/ paths bare; otherwise use the enumerated full path.
2143
+ \$insertionText = if (\$null -ne \$quoteType) {
2144
+ & \$quoteCompletion \$replacementText
2145
+ } elseif (\$completionText -cmatch '\\A~/[a-zA-Z0-9_./:\\\\-]*\\z') {
2146
+ \$pathText
2147
+ } else {
2148
+ \$argumentText = if (\$completionText.StartsWith('~/') -or \$completionText.StartsWith('~\\')) {
2149
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2150
+ } else { \$pathText }
2151
+ & \$quoteArgument \$argumentText
2152
+ }
1937
2153
  [System.Management.Automation.CompletionResult]::new(
1938
- (& \$quoteCompletion \$replacementText),
2154
+ \$insertionText,
1939
2155
  \$completionText,
1940
- 'ParameterValue',
2156
+ \$resultType,
1941
2157
  \$itemType
1942
2158
  )
1943
2159
  }
@@ -120,7 +120,7 @@ function _${programName} () {
120
120
  esac
121
121
  fi
122
122
  done
123
- local __tilde_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)$'
123
+ local __tilde_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)?$'
124
124
  local __tilde_glob_pattern='^(~[a-zA-Z0-9_.+-]*)(/.*)?$'
125
125
  local prev=("\${COMP_WORDS[@]:1:COMP_CWORD-1}")
126
126
  # Bash splits unquoted '=' at its default word breaks. Reassemble only
@@ -353,8 +353,9 @@ function _${programName} () {
353
353
 
354
354
  # path() echoes the raw shell word as its pattern. Once dequoted,
355
355
  # use the literal current-word prefix instead of the escaped pattern.
356
- if [[ ( "$pattern" == "$__raw_file_current" && "$__file_current" != "$__raw_file_current" ) ||
357
- ( -n "$__reply_prefix" && "$pattern" == "$__file_current" ) ]]; then
356
+ if [[ "$pattern" == "$__raw_file_current" && "$__file_current" != "$__raw_file_current" ]] ||
357
+ { [[ -n "$__reply_prefix" ]] && [ "$pattern" = "$__file_current" ] &&
358
+ { [[ "$pattern" != *[\*\?]* ]] || [ "$__raw_file_current" != "$__file_current" ]; }; }; then
358
359
  pattern=""
359
360
  fi
360
361
 
@@ -388,7 +389,8 @@ function _${programName} () {
388
389
  # However, if the user has already typed beyond the pattern (e.g.,
389
390
  # pattern="src/" and current="src/ma"), preserve the typed suffix
390
391
  # for incremental filtering.
391
- local __from_pattern=0
392
+ local __from_pattern=0 __filter_pattern=0 __pattern_prefix=""
393
+ local __pattern_tilde_prefix="" __pattern_tilde_expanded=""
392
394
  if [[ -n "$pattern" ]]; then
393
395
  # Normalize leading ./ so that ./src/ma matches pattern src/
394
396
  local __norm_current="\${__file_current#./}"
@@ -400,10 +402,27 @@ function _${programName} () {
400
402
  __compare_pattern="\${__compare_pattern%%[\*\?]*}"
401
403
  [[ "$__compare_pattern" == */* ]] && __compare_pattern="\${__compare_pattern%/*}/" || __compare_pattern=""
402
404
  fi
405
+ local __extends_pattern=0
403
406
  if [[ ( -n "$__compare_pattern" || -n "$__norm_current" ) && \${#__norm_current} -ge \${#__compare_pattern} && "\${__norm_current:0:\${#__compare_pattern}}" == "$__compare_pattern" && "$__file_current" != "$pattern" ]]; then
407
+ __extends_pattern=1
408
+ fi
409
+ if (( __extends_pattern )) && [[ "$__norm_pattern" != *[\*\?]* ]]; then
404
410
  # User has typed beyond or an equivalent form of the pattern
405
411
  true
406
412
  else
413
+ # Wildcards keep their file filter while the typed prefix narrows
414
+ # the resulting paths. Save the expanded prefix before using pattern.
415
+ if [[ "$__norm_pattern" == *[\*\?]* ]]; then
416
+ __filter_pattern=1
417
+ __pattern_prefix="$__glob_current"
418
+ # An unquoted value equal to its wildcard is a complete search
419
+ # pattern, not a literal filename prefix to filter against.
420
+ if [ "$__file_current" = "$pattern" ] && [ "$__raw_file_current" = "$__file_current" ]; then
421
+ __pattern_prefix=""
422
+ fi
423
+ __pattern_tilde_prefix="$__tilde_prefix"
424
+ __pattern_tilde_expanded="$__tilde_expanded"
425
+ fi
407
426
  __from_pattern=1
408
427
  # Reset tilde state from the current-word expansion so that a
409
428
  # non-tilde pattern is not rewritten through stale tilde state
@@ -432,36 +451,93 @@ function _${programName} () {
432
451
  # This runs after tilde expansion so that paths like ~/.config/ are
433
452
  # checked against the expanded path, not the literal ~ string.
434
453
  local __inside_hidden_path=0
435
- case "/\${__glob_current%/}/" in
436
- */.[!.]*/*|*/..?*/*) __inside_hidden_path=1 ;;
437
- esac
454
+ local __hidden_directory_pattern='/[.]([^./][^/]*|[.][^/]+)/'
455
+ if [[ "/\${__glob_current%/}/" =~ $__hidden_directory_pattern ||
456
+ "/\${__pattern_prefix%/}/" =~ $__hidden_directory_pattern ]]; then
457
+ __inside_hidden_path=1
458
+ fi
438
459
  # Also check if the current prefix explicitly targets hidden entries
439
460
  # (e.g., user typed "." or ".e" to complete .env)
440
461
  local __prefix_targets_hidden=0
441
462
  local __prefix_base="\${__glob_current##*/}"
463
+ if [[ -n "$__pattern_prefix" ]]; then __prefix_base="\${__pattern_prefix##*/}"; fi
442
464
  [[ "$__prefix_base" == .* ]] && __prefix_targets_hidden=1
443
465
  if [[ "$hidden" == "1" || "$__inside_hidden_path" == "1" || "$__prefix_targets_hidden" == "1" ]]; then shopt -s dotglob; fi
444
466
 
445
467
  # Pre-expand file candidates. When the glob base came from the
446
468
  # program's pattern and contains wildcard characters (* or ?),
447
- # use it as-is via compgen -G (safe — no command substitution).
469
+ # expand it as data with shell globbing. Empty IFS avoids splitting paths;
470
+ # NUL records preserve spaces and newlines without requiring mapfile.
471
+ # Shell expansion respects dotglob on older Bash versions as well.
448
472
  # Otherwise append * to treat it as a prefix.
449
- # Note: [ is NOT treated as a glob indicator because it commonly
450
- # appears in literal filenames like [draft] or foo[1].txt.
473
+ # Only * and ? are wildcard syntax; escape brackets, backslashes, and
474
+ # extended-glob operators so other pattern characters stay literal.
451
475
  local -a __candidates=()
452
476
  if [[ "$__from_pattern" == "1" && "$__glob_current" == *[\*\?]* ]]; then
453
- mapfile -t __candidates < <(compgen -G "$__glob_current" 2>/dev/null)
477
+ local __pathname_glob="" __glob_char __glob_index
478
+ for ((__glob_index=0; __glob_index<\${#__glob_current}; __glob_index++)); do
479
+ __glob_char="\${__glob_current:__glob_index:1}"
480
+ case "$__glob_char" in
481
+ '['|']'|'\\'|'('|')'|'|'|'+'|'@'|'!')
482
+ __pathname_glob+="\\\\$__glob_char" ;;
483
+ *) __pathname_glob+="$__glob_char" ;;
484
+ esac
485
+ done
486
+ [[ "$type" == "directory" ]] && __pathname_glob="\${__pathname_glob%/}"
487
+ local __glob_dir="\${__pathname_glob%/*}"
488
+ [[ "$__glob_dir" == "$__pathname_glob" ]] && __glob_dir="."
489
+ [[ -z "$__glob_dir" ]] && __glob_dir="/"
490
+ local __navigation_dir="" __basename_glob="\${__pathname_glob##*/}"
491
+ # Once an offered directory is entered, keep the basename filter but
492
+ # enumerate there. Only descendants of matching pattern bases qualify.
493
+ if [[ "$__pattern_prefix" == */* ]]; then
494
+ local __typed_dir="\${__pattern_prefix%/*}" __base_dir __norm_base __norm_dir
495
+ # Resolve directory ancestry in subshells, preserving the typed spelling
496
+ # for insertion. The trailing slash also preserves newline-ending names.
497
+ if __norm_dir=$(CDPATH= builtin cd -P -- "$__typed_dir" >/dev/null 2>&1 && printf '%s/' "\${PWD%/}"); then
498
+ while IFS= read -r -d '' __base_dir; do
499
+ [[ -d "$__base_dir" ]] || continue
500
+ __norm_base=$(CDPATH= builtin cd -P -- "$__base_dir" >/dev/null 2>&1 && printf '%s/' "\${PWD%/}") || continue
501
+ if [ "\${__norm_dir:0:\${#__norm_base}}" = "$__norm_base" ]; then
502
+ __navigation_dir="$__typed_dir"
503
+ break
504
+ fi
505
+ done < <(IFS=; printf '%s\\0' $__glob_dir)
506
+ fi
507
+ fi
508
+ local __candidate
509
+ while IFS= read -r -d '' __candidate; do
510
+ if [[ -e "$__candidate" || -L "$__candidate" ]]; then
511
+ [[ -d "$__candidate" ]] && __candidate="\${__candidate%/}"
512
+ __candidates+=("$__candidate")
513
+ fi
514
+ done < <(
515
+ IFS=
516
+ if [[ -n "$__navigation_dir" ]]; then
517
+ printf '%s\\0' "$__navigation_dir"/$__basename_glob
518
+ else
519
+ printf '%s\\0' $__pathname_glob
520
+ fi
521
+ )
454
522
  # For file/any modes, also include directories from the base
455
523
  # directory for navigation even when the glob itself does not
456
524
  # match directory names. Skip this for directory mode so that
457
525
  # the pattern's basename filter is respected.
458
526
  if [[ "$type" != "directory" ]]; then
459
- local __glob_dir="\${__glob_current%/*}"
460
- [[ "$__glob_dir" == "$__glob_current" ]] && __glob_dir="."
461
527
  local __d
462
- for __d in "$__glob_dir"/*/; do
463
- [[ -d "$__d" ]] && __candidates+=("\${__d%/}")
464
- done
528
+ local __directory_pattern="\${__glob_dir%/}/*/"
529
+ while IFS= read -r -d '' __d; do
530
+ [[ -d "$__d" ]] || continue
531
+ __d="\${__d%/}"
532
+ __candidates+=("$__d")
533
+ done < <(
534
+ IFS=
535
+ if [[ -n "$__navigation_dir" ]]; then
536
+ printf '%s\\0' "$__navigation_dir"/*/
537
+ else
538
+ printf '%s\\0' $__directory_pattern
539
+ fi
540
+ )
465
541
  fi
466
542
  else
467
543
  __candidates=("$__glob_current"*)
@@ -472,6 +548,26 @@ function _${programName} () {
472
548
  fi
473
549
  fi
474
550
 
551
+ if (( __filter_pattern )); then
552
+ local __nocasematch_was_set=0
553
+ shopt -q nocasematch && __nocasematch_was_set=1
554
+ if shopt -q nocaseglob; then shopt -s nocasematch; else shopt -u nocasematch; fi
555
+ local -a __filtered_candidates=()
556
+ for __candidate in "\${__candidates[@]}"; do
557
+ if [[ "$__candidate" == "$__pattern_prefix"* ||
558
+ "\${__candidate#./}" == "\${__pattern_prefix#./}"* ]]; then
559
+ if [[ "$__pattern_prefix" == ./* && "$__candidate" != ./* ]]; then
560
+ __candidate="./$__candidate"
561
+ elif [[ "$__pattern_prefix" != ./* && "$__pattern_prefix" != . ]]; then
562
+ __candidate="\${__candidate#./}"
563
+ fi
564
+ __filtered_candidates+=("$__candidate")
565
+ fi
566
+ done
567
+ if (( __nocasematch_was_set )); then shopt -s nocasematch; else shopt -u nocasematch; fi
568
+ __candidates=("\${__filtered_candidates[@]}")
569
+ fi
570
+
475
571
  # Generate file completions based on type
476
572
  case "$type" in
477
573
  file)
@@ -533,6 +629,10 @@ function _${programName} () {
533
629
  esac
534
630
 
535
631
  # Restore tilde prefix in completion results
632
+ if (( __filter_pattern )); then
633
+ __tilde_prefix="$__pattern_tilde_prefix"
634
+ __tilde_expanded="$__pattern_tilde_expanded"
635
+ fi
536
636
  if [[ -n "$__tilde_prefix" &&
537
637
  ( -z "$__quote" || $__file_quote_offset -gt 0 ) ]]; then
538
638
  local __i
@@ -632,6 +732,31 @@ function _${programName} () {
632
732
  COMPREPLY+=("\${__word:\${#__remove}}")
633
733
  fi
634
734
  done
735
+ if (( \${#COMPREPLY[@]} > 1 )) && command -v sort >/dev/null 2>&1; then
736
+ # Sort shell-escaped keys, not filenames: each key occupies one line even
737
+ # when a candidate contains tabs or newlines. Keep the first occurrence's
738
+ # index, then restore the original order without evaluating escaped data.
739
+ # This works without associative arrays or NUL-aware sort on Bash 3.2.
740
+ local -a __unique_replies=()
741
+ local __index __key __last_key=""
742
+ while IFS= read -r __index; do
743
+ __unique_replies+=("\${COMPREPLY[__index]}")
744
+ done < <(
745
+ for ((__index=0; __index<\${#COMPREPLY[@]}; __index++)); do
746
+ printf '%q\\t%d\\n' "\${COMPREPLY[__index]}" "$__index"
747
+ done |
748
+ LC_ALL=C sort -t $'\\t' -k1,1 -k2,2n |
749
+ {
750
+ while IFS=$'\\t' read -r __key __index; do
751
+ if [ "$__key" != "$__last_key" ]; then
752
+ printf '%s\\n' "$__index"
753
+ __last_key="$__key"
754
+ fi
755
+ done
756
+ } | LC_ALL=C sort -n
757
+ )
758
+ COMPREPLY=("\${__unique_replies[@]}")
759
+ fi
635
760
  if [[ $__file_mode == 1 && $(type -t compopt) == builtin ]]; then
636
761
  compopt -o filenames 2>/dev/null || true
637
762
  fi
@@ -657,7 +782,7 @@ fi
657
782
  }
658
783
  for (const [index, { prefix, file }] of joined.files.entries()) {
659
784
  if (index > 0) yield "\n";
660
- yield `__JOINED_FILE__:${encodePattern(prefix)}:${file.type}:${encodeExtensions(file.extensions)}::${file.includeHidden ? "1" : "0"}`;
785
+ yield `__JOINED_FILE__:${encodePattern(prefix)}:${file.type}:${encodeExtensions(file.extensions)}:${encodePattern(sanitizeForTransport(file.pattern ?? ""))}:${file.includeHidden ? "1" : "0"}`;
661
786
  }
662
787
  } else if (suggestion.kind === "literal") yield encodeBashLiteral(suggestion.text);
663
788
  else {
@@ -679,9 +804,14 @@ const zsh = {
679
804
  generateScript(programName, args = []) {
680
805
  validateProgramName(programName);
681
806
  const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`).join(" ");
807
+ const functionName = /[.-]/.test(programName) ? `_optique:${programName}` : `_${programName}`;
682
808
  return `#compdef ${programName}
683
- function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
809
+ function ${functionName} () {
684
810
  local current="\$words[CURRENT]"
811
+ # Let zsh match the suffix when completing in the middle of a word.
812
+ if [[ -o complete_in_word ]]; then
813
+ current="\$PREFIX"
814
+ fi
685
815
  local -a prev
686
816
  # Extract previous arguments, skipping empty ones
687
817
  prev=()
@@ -769,17 +899,60 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
769
899
  # Use zsh's native file completion
770
900
  case "\$type" in
771
901
  file)
772
- if [[ -n "\$ext_pattern" ]]; then
773
- # Route filtered files through zsh's standard files/directories
774
- # tags so files-tag styles and tag-order still apply, while
775
- # avoiding _files' all-files fallback for extension-filtered
776
- # matches.
777
- local file_pattern="\${ext_pattern}(#q-.)"
778
- _wanted files expl file _path_files -g "\${file_pattern}"
779
- _wanted directories expl directory _path_files -/
780
- else
781
- _files
902
+ # Use the same regular-file filter with or without extensions,
903
+ # keeping symlinked files and directory navigation available.
904
+ local file_pattern="\${ext_pattern:-*}(#q-.)"
905
+ local file_subtree_pattern="**/*(#q/N)"
906
+ local -a expl=() file_subtree=() file_recursive_patterns=()
907
+ local -a file_tags=(files directories)
908
+ local file_recursive_pattern file_directory file_prefix file_tag
909
+ if [[ -z "\$ext_pattern" ]] &&
910
+ zstyle -t ":completion:\${curcontext}:" list-dirs-first; then
911
+ file_tags=(directories files)
782
912
  fi
913
+ _tags "\${file_tags[@]}"
914
+ while _tags; do
915
+ local file_matched=0
916
+ for file_tag in "\${file_tags[@]}"; do
917
+ if [[ "\$file_tag" == directories ]]; then
918
+ while _next_label directories expl directory; do
919
+ _path_files -/ "\${expl[@]}" && file_matched=1
920
+ done
921
+ else
922
+ while _next_label files expl file; do
923
+ local -a file_ignore=() file_ignore_args=()
924
+ # Explicit globs bypass automatic fignore. Apply it only
925
+ # when the tag has not supplied its own ignore patterns.
926
+ if [[ -z "\$ext_pattern" && -z "\$_comp_no_ignore" && -n "\$FIGNORE" ]] && (( \${#_comp_ignore[@]} == 0 )); then
927
+ file_ignore=("?*\${^fignore[@]}")
928
+ file_ignore_args=(-F file_ignore)
929
+ fi
930
+ if _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}"; then
931
+ file_matched=1
932
+ elif [[ -z "\$ext_pattern" && "\$PREFIX\$SUFFIX" != */* ]] &&
933
+ zstyle -a ":completion:\${curcontext}:files" recursive-files file_recursive_patterns; then
934
+ # Match _files' recursive fallback, retaining the same
935
+ # file filter and tag-specific ignore options.
936
+ for file_recursive_pattern in "\${file_recursive_patterns[@]}"; do
937
+ if [[ "\$PWD/" = \${~file_recursive_pattern} ]]; then
938
+ if (( \${#file_subtree[@]} == 0 )); then
939
+ file_subtree=(\${~file_subtree_pattern})
940
+ fi
941
+ for file_directory in "\${file_subtree[@]}"; do
942
+ file_prefix="\$PREFIX"
943
+ PREFIX="\$file_directory/\$PREFIX"
944
+ _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}" && file_matched=1
945
+ PREFIX="\$file_prefix"
946
+ done
947
+ break
948
+ fi
949
+ done
950
+ fi
951
+ done
952
+ fi
953
+ done
954
+ (( file_matched )) && break
955
+ done
783
956
  ;;
784
957
  directory)
785
958
  _directories
@@ -829,7 +1002,13 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
829
1002
  }
830
1003
 
831
1004
  if (( $+functions[compdef] )); then
832
- compdef _${programName.replace(/[^a-zA-Z0-9]/g, "_")} ${programName}
1005
+ compdef ${functionName} ${programName}
1006
+ fi
1007
+
1008
+ # An autoloaded file runs as a function body. Dispatch its initial request;
1009
+ # sourcing the file only defines and registers the completion helper.
1010
+ if [[ \$ZSH_EVAL_CONTEXT == *:loadautofunc || \$ZSH_EVAL_CONTEXT == *:shfunc ]]; then
1011
+ ${functionName} "\$@"
833
1012
  fi
834
1013
  `;
835
1014
  },
@@ -855,7 +1034,7 @@ const fish = {
855
1034
  generateScript(programName, args = []) {
856
1035
  validateProgramName(programName);
857
1036
  const escapedArgs = args.map((arg) => `'${arg.replace(/'/g, "\\'")}'`).join(" ");
858
- const functionName = `__${programName.replace(/[^a-zA-Z0-9]/g, "_")}_complete`;
1037
+ const functionName = `__${programName}_complete`;
859
1038
  return `
860
1039
  function ${functionName}
861
1040
  set -l tokens (commandline -poc)
@@ -1544,8 +1723,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1544
1723
  let all_items = (do $list_entries $ls_pattern)
1545
1724
  let dirs = $all_items | where type == dir
1546
1725
  let files = $all_items | where type == file | where {|f|
1547
- let ext = ($f.name | path parse | get extension)
1548
- $ext in $ext_list
1726
+ $ext_list | any {|ext|
1727
+ $f.name | path basename | str ends-with ('.' + $ext)
1728
+ }
1549
1729
  }
1550
1730
  $dirs | append $files
1551
1731
  }
@@ -1561,8 +1741,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1561
1741
  let all_items = (do $list_entries $ls_pattern)
1562
1742
  let dirs = $all_items | where type == dir
1563
1743
  let files = $all_items | where type == file | where {|f|
1564
- let ext = ($f.name | path parse | get extension)
1565
- $ext in $ext_list
1744
+ $ext_list | any {|ext|
1745
+ $f.name | path basename | str ends-with ('.' + $ext)
1746
+ }
1566
1747
  }
1567
1748
  $dirs | append $files
1568
1749
  }
@@ -1730,6 +1911,15 @@ const pwsh = {
1730
1911
  Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1731
1912
  param(\$wordToComplete, \$commandAst, \$cursorPosition)
1732
1913
 
1914
+ # CompletionText is shell syntax; display labels remain unquoted.
1915
+ \$quoteArgument = {
1916
+ param([string]\$value)
1917
+ if (\$value -cmatch '\\A[a-zA-Z0-9_./:\\\\-]+\\z' -and \$value -cnotmatch '\\A-(?!-).*[.:]' -and \$value -cnotmatch '\\A-?(?:[0-9]|\\.[0-9])') {
1918
+ return \$value
1919
+ }
1920
+ return "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent(\$value) + "'"
1921
+ }
1922
+
1733
1923
  # Extract arguments from AST (handles quoted strings properly)
1734
1924
  \$arguments = @()
1735
1925
  \$commandElements = \$commandAst.CommandElements
@@ -1743,8 +1933,11 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1743
1933
  if (\$element.Extent.EndOffset -ge \$cursorPosition) {
1744
1934
  # wordToComplete has synthesized delimiters and decoded escapes.
1745
1935
  # Only the AST retains the literal value and original quote type.
1936
+ # PSReadLine leaves the cursor before a directory's closing quote.
1746
1937
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
1747
- \$element.Extent.EndOffset -eq \$cursorPosition) {
1938
+ (\$element.Extent.EndOffset -eq \$cursorPosition -or
1939
+ (\$element.Extent.EndOffset -eq (\$cursorPosition + 1) -and
1940
+ \$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')))) {
1748
1941
  if (\$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')) {
1749
1942
  \$valuePrefix = \$element.Value
1750
1943
  \$quoteType = \$element.StringConstantType
@@ -1777,7 +1970,7 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1777
1970
  } elseif (\$quoteType -eq 'DoubleQuoted') {
1778
1971
  '"' + (\$text -replace '[\\x60\$"\\u201c-\\u201e]', '\`\$0') + '"'
1779
1972
  } else {
1780
- \$text
1973
+ & \$quoteArgument \$text
1781
1974
  }
1782
1975
  }
1783
1976
 
@@ -1882,7 +2075,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1882
2075
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1883
2076
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1884
2077
  Where-Object {
1885
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -cin \$extList)
2078
+ \$fileName = \$_.Name
2079
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2080
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2081
+ }).Count -gt 0)
1886
2082
  }
1887
2083
  } else {
1888
2084
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1897,7 +2093,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1897
2093
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1898
2094
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1899
2095
  Where-Object {
1900
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -cin \$extList)
2096
+ \$fileName = \$_.Name
2097
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2098
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2099
+ }).Count -gt 0)
1901
2100
  }
1902
2101
  } else {
1903
2102
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1929,15 +2128,32 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1929
2128
  \$items | ForEach-Object {
1930
2129
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1931
2130
  \$itemType = if (\$_.PSIsContainer) { 'Directory' } else { 'File' }
2131
+ \$resultType = if (\$_.PSIsContainer) { 'ProviderContainer' } else { 'ParameterValue' }
2132
+ # PSReadLine recognizes the platform separator for containers.
2133
+ # Keep the display label's existing slash convention.
2134
+ \$directorySuffix = [IO.Path]::DirectorySeparatorChar
2135
+ \$pathText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)\$directorySuffix" } else { \$completionText }
1932
2136
  # Quoting disables native tilde expansion. A home pattern
1933
2137
  # supplied by the parser must insert the discovered path.
1934
2138
  \$replacementText = if (\$null -ne \$quoteType -and -not \$prefixFromInput -and \$homePattern) {
1935
- if (\$_.PSIsContainer) { \$_.FullName + '/' } else { \$_.FullName }
1936
- } else { \$completionText }
2139
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2140
+ } else { \$pathText }
2141
+ # Quoting suppresses native tilde expansion. Keep safe
2142
+ # ~/ paths bare; otherwise use the enumerated full path.
2143
+ \$insertionText = if (\$null -ne \$quoteType) {
2144
+ & \$quoteCompletion \$replacementText
2145
+ } elseif (\$completionText -cmatch '\\A~/[a-zA-Z0-9_./:\\\\-]*\\z') {
2146
+ \$pathText
2147
+ } else {
2148
+ \$argumentText = if (\$completionText.StartsWith('~/') -or \$completionText.StartsWith('~\\')) {
2149
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2150
+ } else { \$pathText }
2151
+ & \$quoteArgument \$argumentText
2152
+ }
1937
2153
  [System.Management.Automation.CompletionResult]::new(
1938
- (& \$quoteCompletion \$replacementText),
2154
+ \$insertionText,
1939
2155
  \$completionText,
1940
- 'ParameterValue',
2156
+ \$resultType,
1941
2157
  \$itemType
1942
2158
  )
1943
2159
  }
@@ -16,10 +16,13 @@ function joinedFileSuggestion(prefix, file, value) {
16
16
  text: prefix + (file.pattern ?? ""),
17
17
  description: file.description
18
18
  };
19
- if (file.pattern === void 0 || file.pattern === value) joinedFiles.set(literal, {
19
+ joinedFiles.set(literal, {
20
20
  files: [{
21
21
  prefix,
22
- file
22
+ file: file.pattern === value && !/[?*]/.test(value) ? {
23
+ ...file,
24
+ pattern: void 0
25
+ } : file
23
26
  }],
24
27
  literal: false
25
28
  });
@@ -15,10 +15,13 @@ function joinedFileSuggestion(prefix, file, value) {
15
15
  text: prefix + (file.pattern ?? ""),
16
16
  description: file.description
17
17
  };
18
- if (file.pattern === void 0 || file.pattern === value) joinedFiles.set(literal, {
18
+ joinedFiles.set(literal, {
19
19
  files: [{
20
20
  prefix,
21
- file
21
+ file: file.pattern === value && !/[?*]/.test(value) ? {
22
+ ...file,
23
+ pattern: void 0
24
+ } : file
22
25
  }],
23
26
  literal: false
24
27
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/core",
3
- "version": "1.0.15-dev.2849",
3
+ "version": "1.0.15-dev.2873",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",