@optique/core 1.0.15-dev.2854 → 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 {
@@ -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=()
@@ -770,17 +899,60 @@ function ${functionName} () {
770
899
  # Use zsh's native file completion
771
900
  case "\$type" in
772
901
  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-.)"
779
- _wanted files expl file _path_files -g "\${file_pattern}"
780
- _wanted directories expl directory _path_files -/
781
- else
782
- _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)
783
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
784
956
  ;;
785
957
  directory)
786
958
  _directories
@@ -1551,8 +1723,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1551
1723
  let all_items = (do $list_entries $ls_pattern)
1552
1724
  let dirs = $all_items | where type == dir
1553
1725
  let files = $all_items | where type == file | where {|f|
1554
- let ext = ($f.name | path parse | get extension)
1555
- $ext in $ext_list
1726
+ $ext_list | any {|ext|
1727
+ $f.name | path basename | str ends-with ('.' + $ext)
1728
+ }
1556
1729
  }
1557
1730
  $dirs | append $files
1558
1731
  }
@@ -1568,8 +1741,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1568
1741
  let all_items = (do $list_entries $ls_pattern)
1569
1742
  let dirs = $all_items | where type == dir
1570
1743
  let files = $all_items | where type == file | where {|f|
1571
- let ext = ($f.name | path parse | get extension)
1572
- $ext in $ext_list
1744
+ $ext_list | any {|ext|
1745
+ $f.name | path basename | str ends-with ('.' + $ext)
1746
+ }
1573
1747
  }
1574
1748
  $dirs | append $files
1575
1749
  }
@@ -1737,6 +1911,15 @@ const pwsh = {
1737
1911
  Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1738
1912
  param(\$wordToComplete, \$commandAst, \$cursorPosition)
1739
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
+
1740
1923
  # Extract arguments from AST (handles quoted strings properly)
1741
1924
  \$arguments = @()
1742
1925
  \$commandElements = \$commandAst.CommandElements
@@ -1750,8 +1933,11 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1750
1933
  if (\$element.Extent.EndOffset -ge \$cursorPosition) {
1751
1934
  # wordToComplete has synthesized delimiters and decoded escapes.
1752
1935
  # Only the AST retains the literal value and original quote type.
1936
+ # PSReadLine leaves the cursor before a directory's closing quote.
1753
1937
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
1754
- \$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')))) {
1755
1941
  if (\$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')) {
1756
1942
  \$valuePrefix = \$element.Value
1757
1943
  \$quoteType = \$element.StringConstantType
@@ -1784,7 +1970,7 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1784
1970
  } elseif (\$quoteType -eq 'DoubleQuoted') {
1785
1971
  '"' + (\$text -replace '[\\x60\$"\\u201c-\\u201e]', '\`\$0') + '"'
1786
1972
  } else {
1787
- \$text
1973
+ & \$quoteArgument \$text
1788
1974
  }
1789
1975
  }
1790
1976
 
@@ -1889,7 +2075,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1889
2075
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1890
2076
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1891
2077
  Where-Object {
1892
- \$_.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)
1893
2082
  }
1894
2083
  } else {
1895
2084
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1904,7 +2093,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1904
2093
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1905
2094
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1906
2095
  Where-Object {
1907
- \$_.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)
1908
2100
  }
1909
2101
  } else {
1910
2102
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1936,15 +2128,32 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1936
2128
  \$items | ForEach-Object {
1937
2129
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1938
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 }
1939
2136
  # Quoting disables native tilde expansion. A home pattern
1940
2137
  # supplied by the parser must insert the discovered path.
1941
2138
  \$replacementText = if (\$null -ne \$quoteType -and -not \$prefixFromInput -and \$homePattern) {
1942
- if (\$_.PSIsContainer) { \$_.FullName + '/' } else { \$_.FullName }
1943
- } 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
+ }
1944
2153
  [System.Management.Automation.CompletionResult]::new(
1945
- (& \$quoteCompletion \$replacementText),
2154
+ \$insertionText,
1946
2155
  \$completionText,
1947
- 'ParameterValue',
2156
+ \$resultType,
1948
2157
  \$itemType
1949
2158
  )
1950
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 {
@@ -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=()
@@ -770,17 +899,60 @@ function ${functionName} () {
770
899
  # Use zsh's native file completion
771
900
  case "\$type" in
772
901
  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-.)"
779
- _wanted files expl file _path_files -g "\${file_pattern}"
780
- _wanted directories expl directory _path_files -/
781
- else
782
- _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)
783
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
784
956
  ;;
785
957
  directory)
786
958
  _directories
@@ -1551,8 +1723,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1551
1723
  let all_items = (do $list_entries $ls_pattern)
1552
1724
  let dirs = $all_items | where type == dir
1553
1725
  let files = $all_items | where type == file | where {|f|
1554
- let ext = ($f.name | path parse | get extension)
1555
- $ext in $ext_list
1726
+ $ext_list | any {|ext|
1727
+ $f.name | path basename | str ends-with ('.' + $ext)
1728
+ }
1556
1729
  }
1557
1730
  $dirs | append $files
1558
1731
  }
@@ -1568,8 +1741,9 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1568
1741
  let all_items = (do $list_entries $ls_pattern)
1569
1742
  let dirs = $all_items | where type == dir
1570
1743
  let files = $all_items | where type == file | where {|f|
1571
- let ext = ($f.name | path parse | get extension)
1572
- $ext in $ext_list
1744
+ $ext_list | any {|ext|
1745
+ $f.name | path basename | str ends-with ('.' + $ext)
1746
+ }
1573
1747
  }
1574
1748
  $dirs | append $files
1575
1749
  }
@@ -1737,6 +1911,15 @@ const pwsh = {
1737
1911
  Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1738
1912
  param(\$wordToComplete, \$commandAst, \$cursorPosition)
1739
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
+
1740
1923
  # Extract arguments from AST (handles quoted strings properly)
1741
1924
  \$arguments = @()
1742
1925
  \$commandElements = \$commandAst.CommandElements
@@ -1750,8 +1933,11 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1750
1933
  if (\$element.Extent.EndOffset -ge \$cursorPosition) {
1751
1934
  # wordToComplete has synthesized delimiters and decoded escapes.
1752
1935
  # Only the AST retains the literal value and original quote type.
1936
+ # PSReadLine leaves the cursor before a directory's closing quote.
1753
1937
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
1754
- \$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')))) {
1755
1941
  if (\$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')) {
1756
1942
  \$valuePrefix = \$element.Value
1757
1943
  \$quoteType = \$element.StringConstantType
@@ -1784,7 +1970,7 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1784
1970
  } elseif (\$quoteType -eq 'DoubleQuoted') {
1785
1971
  '"' + (\$text -replace '[\\x60\$"\\u201c-\\u201e]', '\`\$0') + '"'
1786
1972
  } else {
1787
- \$text
1973
+ & \$quoteArgument \$text
1788
1974
  }
1789
1975
  }
1790
1976
 
@@ -1889,7 +2075,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1889
2075
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1890
2076
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1891
2077
  Where-Object {
1892
- \$_.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)
1893
2082
  }
1894
2083
  } else {
1895
2084
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1904,7 +2093,10 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1904
2093
  \$extList = (\$extensions -split ',') -replace '^\\.',''
1905
2094
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue |
1906
2095
  Where-Object {
1907
- \$_.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)
1908
2100
  }
1909
2101
  } else {
1910
2102
  \$items = Get-ChildItem @forceParam -Path \$globPath -ErrorAction SilentlyContinue
@@ -1936,15 +2128,32 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1936
2128
  \$items | ForEach-Object {
1937
2129
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1938
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 }
1939
2136
  # Quoting disables native tilde expansion. A home pattern
1940
2137
  # supplied by the parser must insert the discovered path.
1941
2138
  \$replacementText = if (\$null -ne \$quoteType -and -not \$prefixFromInput -and \$homePattern) {
1942
- if (\$_.PSIsContainer) { \$_.FullName + '/' } else { \$_.FullName }
1943
- } 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
+ }
1944
2153
  [System.Management.Automation.CompletionResult]::new(
1945
- (& \$quoteCompletion \$replacementText),
2154
+ \$insertionText,
1946
2155
  \$completionText,
1947
- 'ParameterValue',
2156
+ \$resultType,
1948
2157
  \$itemType
1949
2158
  )
1950
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.2854",
3
+ "version": "1.0.15-dev.2873",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",