@optique/core 1.0.15-dev.2854 → 1.0.15-dev.2880

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 {
@@ -683,6 +808,10 @@ const zsh = {
683
808
  return `#compdef ${programName}
684
809
  function ${functionName} () {
685
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
686
815
  local -a prev
687
816
  # Extract previous arguments, skipping empty ones
688
817
  prev=()
@@ -730,6 +859,8 @@ function ${functionName} () {
730
859
  local __was_sh_glob=0
731
860
  [[ -o sh_glob ]] && __was_sh_glob=1
732
861
  unsetopt sh_glob
862
+ local __was_extended_glob=0
863
+ [[ -o extended_glob ]] && __was_extended_glob=1
733
864
 
734
865
  # Enable glob_dots when hidden files are requested so that
735
866
  # _files and _directories include dot-prefixed entries
@@ -742,7 +873,34 @@ function ${functionName} () {
742
873
  # current word. If the user has already typed beyond the pattern,
743
874
  # keep PREFIX unchanged for incremental narrowing.
744
875
  local __saved_prefix="\$PREFIX"
745
- if [[ -n "\$pattern" ]]; then
876
+ local __initial_file_pattern="" __initial_directory="" __initial_leaf=""
877
+ if [[ ( "\$type" == "file" || "\$type" == "any" || "\$type" == "directory" ) &&
878
+ -z "\$current\$PREFIX\$SUFFIX" &&
879
+ -z "\${compstate[pattern_match]}" &&
880
+ "\$pattern" != /* && "\$pattern" != "~"* ]]; then
881
+ if [[ "\$pattern" == */* ]]; then
882
+ __initial_directory="\${pattern%/*}/"
883
+ fi
884
+ __initial_leaf="\${pattern##*/}"
885
+ # Native -g filters are split as shell words and use extended glob
886
+ # syntax. Keep unsupported filename forms on the existing path.
887
+ if [[ "\$__initial_leaf" == *[\*\?]* &&
888
+ "\$__initial_directory" != *[\*\?]* &&
889
+ "\$__initial_directory" != *["[]()"]* &&
890
+ "\$__initial_leaf" != *["[]"]* &&
891
+ "\$__initial_leaf" != *'\\'* &&
892
+ "\$__initial_leaf" != *[[:space:]]* &&
893
+ "\$__initial_leaf" != *["#~^()|<>;&"]* &&
894
+ "\$__initial_leaf" != *"'"* && "\$__initial_leaf" != *'"'* ]]; then
895
+ __initial_file_pattern="\$__initial_leaf"
896
+ # Only the literal directory belongs in PREFIX. Quote it so
897
+ # spaces and shell metacharacters remain literal path text.
898
+ if [[ -n "\$__initial_directory" ]]; then
899
+ PREFIX="\${(q)__initial_directory}"
900
+ fi
901
+ fi
902
+ fi
903
+ if [[ -n "\$pattern" && -z "\$__initial_file_pattern" ]]; then
746
904
  # Normalize leading ./ so that ./src/ma matches pattern src/
747
905
  local __norm_prefix="\${PREFIX#./}"
748
906
  local __norm_pattern="\${pattern#./}"
@@ -767,15 +925,84 @@ function ${functionName} () {
767
925
  ext_pattern="*.\$extensions"
768
926
  fi
769
927
  fi
928
+ # _wanted/_next_label write to the named explanation parameter.
929
+ # Keep it local for every file directive, including directory/any.
930
+ local -a expl=()
770
931
  # Use zsh's native file completion
771
932
  case "\$type" in
772
- file)
773
- if [[ -n "\$ext_pattern" ]]; then
774
- # Route filtered files through zsh's standard files/directories
775
- # tags so files-tag styles and tag-order still apply, while
776
- # avoiding _files' all-files fallback for extension-filtered
777
- # matches.
778
- local file_pattern="\${ext_pattern}(#q-.)"
933
+ file|any)
934
+ if [[ "\$type" == "file" || -n "\$__initial_file_pattern" ]]; then
935
+ # Regular-file suggestions retain symlinked files; initial
936
+ # any globs use a non-directory qualifier instead.
937
+ local file_pattern="\${ext_pattern:-*}(#q-.)"
938
+ if [[ -n "\$__initial_file_pattern" ]]; then
939
+ local __file_qualifier="-."
940
+ [[ "\$type" == "any" ]] && __file_qualifier="^-/"
941
+ local -a __initial_file_patterns=()
942
+ if [[ -n "\$extensions" ]]; then
943
+ setopt extended_glob
944
+ local __initial_extension
945
+ for __initial_extension in "\${(@s:,:)extensions}"; do
946
+ __initial_file_patterns+=("(\${__initial_file_pattern}~^*.\${__initial_extension})(#q\${__file_qualifier})")
947
+ done
948
+ else
949
+ __initial_file_patterns=("\${__initial_file_pattern}(#q\${__file_qualifier})")
950
+ fi
951
+ file_pattern="\${(j: :)__initial_file_patterns}"
952
+ fi
953
+ local file_subtree_pattern="**/*(#q/N)"
954
+ local -a file_subtree=() file_recursive_patterns=()
955
+ local -a file_tags=(files directories)
956
+ local file_recursive_pattern file_directory file_prefix file_tag
957
+ if [[ -n "\$__initial_file_pattern" || -z "\$ext_pattern" ]] &&
958
+ zstyle -t ":completion:\${curcontext}:" list-dirs-first; then
959
+ file_tags=(directories files)
960
+ fi
961
+ _tags "\${file_tags[@]}"
962
+ while _tags; do
963
+ local file_matched=0
964
+ for file_tag in "\${file_tags[@]}"; do
965
+ if [[ "\$file_tag" == directories ]]; then
966
+ while _next_label directories expl directory; do
967
+ _path_files -/ "\${expl[@]}" && file_matched=1
968
+ done
969
+ else
970
+ while _next_label files expl file; do
971
+ local -a file_ignore=() file_ignore_args=()
972
+ # Explicit globs bypass automatic fignore. Apply it only
973
+ # when the tag has not supplied its own ignore patterns.
974
+ if [[ -z "\$__initial_file_pattern" && -z "\$ext_pattern" && -z "\$_comp_no_ignore" && -n "\$FIGNORE" ]] && (( \${#_comp_ignore[@]} == 0 )); then
975
+ file_ignore=("?*\${^fignore[@]}")
976
+ file_ignore_args=(-F file_ignore)
977
+ fi
978
+ if _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}"; then
979
+ file_matched=1
980
+ elif [[ ( -n "\$__initial_file_pattern" || -z "\$ext_pattern" ) && "\$PREFIX\$SUFFIX" != */* ]] &&
981
+ zstyle -a ":completion:\${curcontext}:files" recursive-files file_recursive_patterns; then
982
+ # Match _files' recursive fallback, retaining the same
983
+ # file filter and tag-specific ignore options.
984
+ for file_recursive_pattern in "\${file_recursive_patterns[@]}"; do
985
+ if [[ "\$PWD/" = \${~file_recursive_pattern} ]]; then
986
+ if (( \${#file_subtree[@]} == 0 )); then
987
+ file_subtree=(\${~file_subtree_pattern})
988
+ fi
989
+ for file_directory in "\${file_subtree[@]}"; do
990
+ file_prefix="\$PREFIX"
991
+ PREFIX="\$file_directory/\$PREFIX"
992
+ _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}" && file_matched=1
993
+ PREFIX="\$file_prefix"
994
+ done
995
+ break
996
+ fi
997
+ done
998
+ fi
999
+ done
1000
+ fi
1001
+ done
1002
+ (( file_matched )) && break
1003
+ done
1004
+ elif [[ -n "\$ext_pattern" ]]; then
1005
+ local file_pattern="\${ext_pattern}(#q^-/)"
779
1006
  _wanted files expl file _path_files -g "\${file_pattern}"
780
1007
  _wanted directories expl directory _path_files -/
781
1008
  else
@@ -783,21 +1010,17 @@ function ${functionName} () {
783
1010
  fi
784
1011
  ;;
785
1012
  directory)
786
- _directories
787
- ;;
788
- any)
789
- if [[ -n "\$ext_pattern" ]]; then
790
- local file_pattern="\${ext_pattern}(#q^-/)"
791
- _wanted files expl file _path_files -g "\${file_pattern}"
792
- _wanted directories expl directory _path_files -/
1013
+ if [[ -n "\$__initial_file_pattern" ]]; then
1014
+ # -/ would add every directory alongside the filtered glob.
1015
+ _wanted directories expl directory _path_files -g "\${__initial_file_pattern}(#q-/)"
793
1016
  else
794
- _files
1017
+ _directories
795
1018
  fi
796
1019
  ;;
797
1020
  esac
798
-
799
1021
  # Restore PREFIX and glob_dots to their previous state
800
1022
  PREFIX="\$__saved_prefix"
1023
+ if [[ "\$__was_extended_glob" == "1" ]]; then setopt extended_glob; else unsetopt extended_glob; fi
801
1024
  if [[ "\$__was_sh_glob" == "1" ]]; then setopt sh_glob; else unsetopt sh_glob; fi
802
1025
  if [[ "\$__was_glob_dots" == "1" ]]; then setopt glob_dots; else unsetopt glob_dots; fi
803
1026
  else
@@ -1551,8 +1774,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1551
1774
  let all_items = (do $list_entries $ls_pattern)
1552
1775
  let dirs = $all_items | where type == dir
1553
1776
  let files = $all_items | where type == file | where {|f|
1554
- let ext = ($f.name | path parse | get extension)
1555
- $ext in $ext_list
1777
+ $ext_list | any {|ext|
1778
+ $f.name | path basename | str ends-with ('.' + $ext)
1779
+ }
1556
1780
  }
1557
1781
  $dirs | append $files
1558
1782
  }
@@ -1568,8 +1792,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1568
1792
  let all_items = (do $list_entries $ls_pattern)
1569
1793
  let dirs = $all_items | where type == dir
1570
1794
  let files = $all_items | where type == file | where {|f|
1571
- let ext = ($f.name | path parse | get extension)
1572
- $ext in $ext_list
1795
+ $ext_list | any {|ext|
1796
+ $f.name | path basename | str ends-with ('.' + $ext)
1797
+ }
1573
1798
  }
1574
1799
  $dirs | append $files
1575
1800
  }
@@ -1737,6 +1962,15 @@ const pwsh = {
1737
1962
  Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1738
1963
  param(\$wordToComplete, \$commandAst, \$cursorPosition)
1739
1964
 
1965
+ # CompletionText is shell syntax; display labels remain unquoted.
1966
+ \$quoteArgument = {
1967
+ param([string]\$value)
1968
+ if (\$value -cmatch '\\A[a-zA-Z0-9_./:\\\\-]+\\z' -and \$value -cnotmatch '\\A-(?!-).*[.:]' -and \$value -cnotmatch '\\A-?(?:[0-9]|\\.[0-9])') {
1969
+ return \$value
1970
+ }
1971
+ return "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent(\$value) + "'"
1972
+ }
1973
+
1740
1974
  # Extract arguments from AST (handles quoted strings properly)
1741
1975
  \$arguments = @()
1742
1976
  \$commandElements = \$commandAst.CommandElements
@@ -1750,8 +1984,11 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1750
1984
  if (\$element.Extent.EndOffset -ge \$cursorPosition) {
1751
1985
  # wordToComplete has synthesized delimiters and decoded escapes.
1752
1986
  # Only the AST retains the literal value and original quote type.
1987
+ # PSReadLine leaves the cursor before a directory's closing quote.
1753
1988
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
1754
- \$element.Extent.EndOffset -eq \$cursorPosition) {
1989
+ (\$element.Extent.EndOffset -eq \$cursorPosition -or
1990
+ (\$element.Extent.EndOffset -eq (\$cursorPosition + 1) -and
1991
+ \$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')))) {
1755
1992
  if (\$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')) {
1756
1993
  \$valuePrefix = \$element.Value
1757
1994
  \$quoteType = \$element.StringConstantType
@@ -1784,7 +2021,7 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1784
2021
  } elseif (\$quoteType -eq 'DoubleQuoted') {
1785
2022
  '"' + (\$text -replace '[\\x60\$"\\u201c-\\u201e]', '\`\$0') + '"'
1786
2023
  } else {
1787
- \$text
2024
+ & \$quoteArgument \$text
1788
2025
  }
1789
2026
  }
1790
2027
 
@@ -1889,7 +2126,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1889
2126
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1890
2127
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1891
2128
  Where-Object {
1892
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -cin \$extList)
2129
+ \$fileName = \$_.Name
2130
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2131
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2132
+ }).Count -gt 0)
1893
2133
  }
1894
2134
  } else {
1895
2135
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1904,7 +2144,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1904
2144
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1905
2145
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1906
2146
  Where-Object {
1907
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -cin \$extList)
2147
+ \$fileName = \$_.Name
2148
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2149
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2150
+ }).Count -gt 0)
1908
2151
  }
1909
2152
  } else {
1910
2153
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1936,15 +2179,32 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1936
2179
  \$items | ForEach-Object {
1937
2180
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1938
2181
  \$itemType = if (\$_.PSIsContainer) { 'Directory' } else { 'File' }
2182
+ \$resultType = if (\$_.PSIsContainer) { 'ProviderContainer' } else { 'ParameterValue' }
2183
+ # PSReadLine recognizes the platform separator for containers.
2184
+ # Keep the display label's existing slash convention.
2185
+ \$directorySuffix = [IO.Path]::DirectorySeparatorChar
2186
+ \$pathText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)\$directorySuffix" } else { \$completionText }
1939
2187
  # Quoting disables native tilde expansion. A home pattern
1940
2188
  # supplied by the parser must insert the discovered path.
1941
2189
  \$replacementText = if (\$null -ne \$quoteType -and -not \$prefixFromInput -and \$homePattern) {
1942
- if (\$_.PSIsContainer) { \$_.FullName + '/' } else { \$_.FullName }
1943
- } else { \$completionText }
2190
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2191
+ } else { \$pathText }
2192
+ # Quoting suppresses native tilde expansion. Keep safe
2193
+ # ~/ paths bare; otherwise use the enumerated full path.
2194
+ \$insertionText = if (\$null -ne \$quoteType) {
2195
+ & \$quoteCompletion \$replacementText
2196
+ } elseif (\$completionText -cmatch '\\A~/[a-zA-Z0-9_./:\\\\-]*\\z') {
2197
+ \$pathText
2198
+ } else {
2199
+ \$argumentText = if (\$completionText.StartsWith('~/') -or \$completionText.StartsWith('~\\')) {
2200
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2201
+ } else { \$pathText }
2202
+ & \$quoteArgument \$argumentText
2203
+ }
1944
2204
  [System.Management.Automation.CompletionResult]::new(
1945
- (& \$quoteCompletion \$replacementText),
2205
+ \$insertionText,
1946
2206
  \$completionText,
1947
- 'ParameterValue',
2207
+ \$resultType,
1948
2208
  \$itemType
1949
2209
  )
1950
2210
  }
@@ -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 {
@@ -683,6 +808,10 @@ const zsh = {
683
808
  return `#compdef ${programName}
684
809
  function ${functionName} () {
685
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
686
815
  local -a prev
687
816
  # Extract previous arguments, skipping empty ones
688
817
  prev=()
@@ -730,6 +859,8 @@ function ${functionName} () {
730
859
  local __was_sh_glob=0
731
860
  [[ -o sh_glob ]] && __was_sh_glob=1
732
861
  unsetopt sh_glob
862
+ local __was_extended_glob=0
863
+ [[ -o extended_glob ]] && __was_extended_glob=1
733
864
 
734
865
  # Enable glob_dots when hidden files are requested so that
735
866
  # _files and _directories include dot-prefixed entries
@@ -742,7 +873,34 @@ function ${functionName} () {
742
873
  # current word. If the user has already typed beyond the pattern,
743
874
  # keep PREFIX unchanged for incremental narrowing.
744
875
  local __saved_prefix="\$PREFIX"
745
- if [[ -n "\$pattern" ]]; then
876
+ local __initial_file_pattern="" __initial_directory="" __initial_leaf=""
877
+ if [[ ( "\$type" == "file" || "\$type" == "any" || "\$type" == "directory" ) &&
878
+ -z "\$current\$PREFIX\$SUFFIX" &&
879
+ -z "\${compstate[pattern_match]}" &&
880
+ "\$pattern" != /* && "\$pattern" != "~"* ]]; then
881
+ if [[ "\$pattern" == */* ]]; then
882
+ __initial_directory="\${pattern%/*}/"
883
+ fi
884
+ __initial_leaf="\${pattern##*/}"
885
+ # Native -g filters are split as shell words and use extended glob
886
+ # syntax. Keep unsupported filename forms on the existing path.
887
+ if [[ "\$__initial_leaf" == *[\*\?]* &&
888
+ "\$__initial_directory" != *[\*\?]* &&
889
+ "\$__initial_directory" != *["[]()"]* &&
890
+ "\$__initial_leaf" != *["[]"]* &&
891
+ "\$__initial_leaf" != *'\\'* &&
892
+ "\$__initial_leaf" != *[[:space:]]* &&
893
+ "\$__initial_leaf" != *["#~^()|<>;&"]* &&
894
+ "\$__initial_leaf" != *"'"* && "\$__initial_leaf" != *'"'* ]]; then
895
+ __initial_file_pattern="\$__initial_leaf"
896
+ # Only the literal directory belongs in PREFIX. Quote it so
897
+ # spaces and shell metacharacters remain literal path text.
898
+ if [[ -n "\$__initial_directory" ]]; then
899
+ PREFIX="\${(q)__initial_directory}"
900
+ fi
901
+ fi
902
+ fi
903
+ if [[ -n "\$pattern" && -z "\$__initial_file_pattern" ]]; then
746
904
  # Normalize leading ./ so that ./src/ma matches pattern src/
747
905
  local __norm_prefix="\${PREFIX#./}"
748
906
  local __norm_pattern="\${pattern#./}"
@@ -767,15 +925,84 @@ function ${functionName} () {
767
925
  ext_pattern="*.\$extensions"
768
926
  fi
769
927
  fi
928
+ # _wanted/_next_label write to the named explanation parameter.
929
+ # Keep it local for every file directive, including directory/any.
930
+ local -a expl=()
770
931
  # Use zsh's native file completion
771
932
  case "\$type" in
772
- file)
773
- if [[ -n "\$ext_pattern" ]]; then
774
- # Route filtered files through zsh's standard files/directories
775
- # tags so files-tag styles and tag-order still apply, while
776
- # avoiding _files' all-files fallback for extension-filtered
777
- # matches.
778
- local file_pattern="\${ext_pattern}(#q-.)"
933
+ file|any)
934
+ if [[ "\$type" == "file" || -n "\$__initial_file_pattern" ]]; then
935
+ # Regular-file suggestions retain symlinked files; initial
936
+ # any globs use a non-directory qualifier instead.
937
+ local file_pattern="\${ext_pattern:-*}(#q-.)"
938
+ if [[ -n "\$__initial_file_pattern" ]]; then
939
+ local __file_qualifier="-."
940
+ [[ "\$type" == "any" ]] && __file_qualifier="^-/"
941
+ local -a __initial_file_patterns=()
942
+ if [[ -n "\$extensions" ]]; then
943
+ setopt extended_glob
944
+ local __initial_extension
945
+ for __initial_extension in "\${(@s:,:)extensions}"; do
946
+ __initial_file_patterns+=("(\${__initial_file_pattern}~^*.\${__initial_extension})(#q\${__file_qualifier})")
947
+ done
948
+ else
949
+ __initial_file_patterns=("\${__initial_file_pattern}(#q\${__file_qualifier})")
950
+ fi
951
+ file_pattern="\${(j: :)__initial_file_patterns}"
952
+ fi
953
+ local file_subtree_pattern="**/*(#q/N)"
954
+ local -a file_subtree=() file_recursive_patterns=()
955
+ local -a file_tags=(files directories)
956
+ local file_recursive_pattern file_directory file_prefix file_tag
957
+ if [[ -n "\$__initial_file_pattern" || -z "\$ext_pattern" ]] &&
958
+ zstyle -t ":completion:\${curcontext}:" list-dirs-first; then
959
+ file_tags=(directories files)
960
+ fi
961
+ _tags "\${file_tags[@]}"
962
+ while _tags; do
963
+ local file_matched=0
964
+ for file_tag in "\${file_tags[@]}"; do
965
+ if [[ "\$file_tag" == directories ]]; then
966
+ while _next_label directories expl directory; do
967
+ _path_files -/ "\${expl[@]}" && file_matched=1
968
+ done
969
+ else
970
+ while _next_label files expl file; do
971
+ local -a file_ignore=() file_ignore_args=()
972
+ # Explicit globs bypass automatic fignore. Apply it only
973
+ # when the tag has not supplied its own ignore patterns.
974
+ if [[ -z "\$__initial_file_pattern" && -z "\$ext_pattern" && -z "\$_comp_no_ignore" && -n "\$FIGNORE" ]] && (( \${#_comp_ignore[@]} == 0 )); then
975
+ file_ignore=("?*\${^fignore[@]}")
976
+ file_ignore_args=(-F file_ignore)
977
+ fi
978
+ if _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}"; then
979
+ file_matched=1
980
+ elif [[ ( -n "\$__initial_file_pattern" || -z "\$ext_pattern" ) && "\$PREFIX\$SUFFIX" != */* ]] &&
981
+ zstyle -a ":completion:\${curcontext}:files" recursive-files file_recursive_patterns; then
982
+ # Match _files' recursive fallback, retaining the same
983
+ # file filter and tag-specific ignore options.
984
+ for file_recursive_pattern in "\${file_recursive_patterns[@]}"; do
985
+ if [[ "\$PWD/" = \${~file_recursive_pattern} ]]; then
986
+ if (( \${#file_subtree[@]} == 0 )); then
987
+ file_subtree=(\${~file_subtree_pattern})
988
+ fi
989
+ for file_directory in "\${file_subtree[@]}"; do
990
+ file_prefix="\$PREFIX"
991
+ PREFIX="\$file_directory/\$PREFIX"
992
+ _path_files -g "\${file_pattern}" "\${expl[@]}" "\${file_ignore_args[@]}" && file_matched=1
993
+ PREFIX="\$file_prefix"
994
+ done
995
+ break
996
+ fi
997
+ done
998
+ fi
999
+ done
1000
+ fi
1001
+ done
1002
+ (( file_matched )) && break
1003
+ done
1004
+ elif [[ -n "\$ext_pattern" ]]; then
1005
+ local file_pattern="\${ext_pattern}(#q^-/)"
779
1006
  _wanted files expl file _path_files -g "\${file_pattern}"
780
1007
  _wanted directories expl directory _path_files -/
781
1008
  else
@@ -783,21 +1010,17 @@ function ${functionName} () {
783
1010
  fi
784
1011
  ;;
785
1012
  directory)
786
- _directories
787
- ;;
788
- any)
789
- if [[ -n "\$ext_pattern" ]]; then
790
- local file_pattern="\${ext_pattern}(#q^-/)"
791
- _wanted files expl file _path_files -g "\${file_pattern}"
792
- _wanted directories expl directory _path_files -/
1013
+ if [[ -n "\$__initial_file_pattern" ]]; then
1014
+ # -/ would add every directory alongside the filtered glob.
1015
+ _wanted directories expl directory _path_files -g "\${__initial_file_pattern}(#q-/)"
793
1016
  else
794
- _files
1017
+ _directories
795
1018
  fi
796
1019
  ;;
797
1020
  esac
798
-
799
1021
  # Restore PREFIX and glob_dots to their previous state
800
1022
  PREFIX="\$__saved_prefix"
1023
+ if [[ "\$__was_extended_glob" == "1" ]]; then setopt extended_glob; else unsetopt extended_glob; fi
801
1024
  if [[ "\$__was_sh_glob" == "1" ]]; then setopt sh_glob; else unsetopt sh_glob; fi
802
1025
  if [[ "\$__was_glob_dots" == "1" ]]; then setopt glob_dots; else unsetopt glob_dots; fi
803
1026
  else
@@ -1551,8 +1774,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1551
1774
  let all_items = (do $list_entries $ls_pattern)
1552
1775
  let dirs = $all_items | where type == dir
1553
1776
  let files = $all_items | where type == file | where {|f|
1554
- let ext = ($f.name | path parse | get extension)
1555
- $ext in $ext_list
1777
+ $ext_list | any {|ext|
1778
+ $f.name | path basename | str ends-with ('.' + $ext)
1779
+ }
1556
1780
  }
1557
1781
  $dirs | append $files
1558
1782
  }
@@ -1568,8 +1792,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1568
1792
  let all_items = (do $list_entries $ls_pattern)
1569
1793
  let dirs = $all_items | where type == dir
1570
1794
  let files = $all_items | where type == file | where {|f|
1571
- let ext = ($f.name | path parse | get extension)
1572
- $ext in $ext_list
1795
+ $ext_list | any {|ext|
1796
+ $f.name | path basename | str ends-with ('.' + $ext)
1797
+ }
1573
1798
  }
1574
1799
  $dirs | append $files
1575
1800
  }
@@ -1737,6 +1962,15 @@ const pwsh = {
1737
1962
  Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1738
1963
  param(\$wordToComplete, \$commandAst, \$cursorPosition)
1739
1964
 
1965
+ # CompletionText is shell syntax; display labels remain unquoted.
1966
+ \$quoteArgument = {
1967
+ param([string]\$value)
1968
+ if (\$value -cmatch '\\A[a-zA-Z0-9_./:\\\\-]+\\z' -and \$value -cnotmatch '\\A-(?!-).*[.:]' -and \$value -cnotmatch '\\A-?(?:[0-9]|\\.[0-9])') {
1969
+ return \$value
1970
+ }
1971
+ return "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent(\$value) + "'"
1972
+ }
1973
+
1740
1974
  # Extract arguments from AST (handles quoted strings properly)
1741
1975
  \$arguments = @()
1742
1976
  \$commandElements = \$commandAst.CommandElements
@@ -1750,8 +1984,11 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1750
1984
  if (\$element.Extent.EndOffset -ge \$cursorPosition) {
1751
1985
  # wordToComplete has synthesized delimiters and decoded escapes.
1752
1986
  # Only the AST retains the literal value and original quote type.
1987
+ # PSReadLine leaves the cursor before a directory's closing quote.
1753
1988
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
1754
- \$element.Extent.EndOffset -eq \$cursorPosition) {
1989
+ (\$element.Extent.EndOffset -eq \$cursorPosition -or
1990
+ (\$element.Extent.EndOffset -eq (\$cursorPosition + 1) -and
1991
+ \$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')))) {
1755
1992
  if (\$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')) {
1756
1993
  \$valuePrefix = \$element.Value
1757
1994
  \$quoteType = \$element.StringConstantType
@@ -1784,7 +2021,7 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1784
2021
  } elseif (\$quoteType -eq 'DoubleQuoted') {
1785
2022
  '"' + (\$text -replace '[\\x60\$"\\u201c-\\u201e]', '\`\$0') + '"'
1786
2023
  } else {
1787
- \$text
2024
+ & \$quoteArgument \$text
1788
2025
  }
1789
2026
  }
1790
2027
 
@@ -1889,7 +2126,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1889
2126
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1890
2127
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1891
2128
  Where-Object {
1892
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -cin \$extList)
2129
+ \$fileName = \$_.Name
2130
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2131
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2132
+ }).Count -gt 0)
1893
2133
  }
1894
2134
  } else {
1895
2135
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1904,7 +2144,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1904
2144
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1905
2145
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1906
2146
  Where-Object {
1907
- \$_.PSIsContainer -or ((\$_.Extension -replace '^\\.','' ) -cin \$extList)
2147
+ \$fileName = \$_.Name
2148
+ \$_.PSIsContainer -or (@(\$extList | Where-Object {
2149
+ \$fileName.EndsWith('.' + \$_, [System.StringComparison]::Ordinal)
2150
+ }).Count -gt 0)
1908
2151
  }
1909
2152
  } else {
1910
2153
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1936,15 +2179,32 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1936
2179
  \$items | ForEach-Object {
1937
2180
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1938
2181
  \$itemType = if (\$_.PSIsContainer) { 'Directory' } else { 'File' }
2182
+ \$resultType = if (\$_.PSIsContainer) { 'ProviderContainer' } else { 'ParameterValue' }
2183
+ # PSReadLine recognizes the platform separator for containers.
2184
+ # Keep the display label's existing slash convention.
2185
+ \$directorySuffix = [IO.Path]::DirectorySeparatorChar
2186
+ \$pathText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)\$directorySuffix" } else { \$completionText }
1939
2187
  # Quoting disables native tilde expansion. A home pattern
1940
2188
  # supplied by the parser must insert the discovered path.
1941
2189
  \$replacementText = if (\$null -ne \$quoteType -and -not \$prefixFromInput -and \$homePattern) {
1942
- if (\$_.PSIsContainer) { \$_.FullName + '/' } else { \$_.FullName }
1943
- } else { \$completionText }
2190
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2191
+ } else { \$pathText }
2192
+ # Quoting suppresses native tilde expansion. Keep safe
2193
+ # ~/ paths bare; otherwise use the enumerated full path.
2194
+ \$insertionText = if (\$null -ne \$quoteType) {
2195
+ & \$quoteCompletion \$replacementText
2196
+ } elseif (\$completionText -cmatch '\\A~/[a-zA-Z0-9_./:\\\\-]*\\z') {
2197
+ \$pathText
2198
+ } else {
2199
+ \$argumentText = if (\$completionText.StartsWith('~/') -or \$completionText.StartsWith('~\\')) {
2200
+ if (\$_.PSIsContainer) { \$_.FullName + \$directorySuffix } else { \$_.FullName }
2201
+ } else { \$pathText }
2202
+ & \$quoteArgument \$argumentText
2203
+ }
1944
2204
  [System.Management.Automation.CompletionResult]::new(
1945
- (& \$quoteCompletion \$replacementText),
2205
+ \$insertionText,
1946
2206
  \$completionText,
1947
- 'ParameterValue',
2207
+ \$resultType,
1948
2208
  \$itemType
1949
2209
  )
1950
2210
  }
@@ -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.2854",
3
+ "version": "1.0.15-dev.2880",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",