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

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.
@@ -62,6 +62,17 @@ function sanitizeForTransport(text) {
62
62
  return text.replace(/[\t\n\r\0]/g, " ");
63
63
  }
64
64
  /**
65
+ * Escapes literal values that would otherwise be interpreted as transport
66
+ * directives. Escape the escape prefix itself so decoding is unambiguous.
67
+ * @param text The literal suggestion text.
68
+ * @returns The sanitized and, if necessary, escaped literal text.
69
+ * @internal
70
+ */
71
+ function encodeLiteral(text) {
72
+ const sanitized = sanitizeForTransport(text);
73
+ return sanitized.startsWith("__FILE__:") || sanitized.startsWith("__LITERAL__:") ? `__LITERAL__:${sanitized}` : sanitized;
74
+ }
75
+ /**
65
76
  * The Bash shell completion generator.
66
77
  * @since 0.6.0
67
78
  */
@@ -308,10 +319,11 @@ function _${programName} () {
308
319
  __reply_prefix=""
309
320
  __file_current="$current"
310
321
  __literal=0
311
- if [[ "$line" == __LITERAL__:* ]]; then
322
+ # Protocol tags are case-sensitive even when nocasematch is enabled.
323
+ if [ "\${line:0:12}" = '__LITERAL__:' ]; then
312
324
  line="\${line#__LITERAL__:}"
313
325
  __literal=1
314
- elif [[ "$line" == __JOINED_FILE__:* ]]; then
326
+ elif [ "\${line:0:16}" = '__JOINED_FILE__:' ]; then
315
327
  line="\${line#__JOINED_FILE__:}"
316
328
  __reply_prefix="\${line%%:*}"
317
329
  __reply_prefix="\${__reply_prefix//%3A/:}"
@@ -332,7 +344,7 @@ function _${programName} () {
332
344
  __file_quote_offset=$((__file_quote_offset - \${#__reply_prefix}))
333
345
  fi
334
346
  fi
335
- if [[ $__literal == 0 && "$line" == __FILE__:* ]]; then
347
+ if [ "$__literal" = 0 ] && [ "\${line:0:9}" = '__FILE__:' ]; then
336
348
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
337
349
  IFS=':' read -r _ type extensions pattern hidden <<< "$line"
338
350
  extensions="\${extensions//,\\./,}"; extensions="\${extensions#.}"
@@ -700,7 +712,12 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
700
712
  desc=\${line#*\$'\\0'}
701
713
  desc=\${desc%%\$'\\0'*}
702
714
 
703
- if [[ "\$value" == __FILE__:* ]]; then
715
+ local __literal=0
716
+ if [[ "\$value" == __LITERAL__:* ]]; then
717
+ value="\${value#__LITERAL__:}"
718
+ __literal=1
719
+ fi
720
+ if [[ \$__literal == 0 && "\$value" == __FILE__:* ]]; then
704
721
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
705
722
  local type extensions pattern hidden
706
723
  IFS=':' read -r _ type extensions pattern hidden <<< "\$value"
@@ -798,10 +815,13 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
798
815
  local -a matches
799
816
  local -i i
800
817
  for (( i=1; i <= \${#completions[@]}; i++ )); do
818
+ # _describe treats colons as separators and backslashes as escapes.
819
+ value="\${completions[i]//\\\\/\\\\\\\\}"
820
+ value="\${value//:/\\\\:}"
801
821
  if [[ -n "\${descriptions[i]}" ]]; then
802
- matches+=("\${completions[i]}:\${descriptions[i]}")
822
+ matches+=("\${value}:\${descriptions[i]}")
803
823
  else
804
- matches+=("\${completions[i]}")
824
+ matches+=("\${value}")
805
825
  fi
806
826
  done
807
827
  _describe 'commands' matches
@@ -816,7 +836,7 @@ fi
816
836
  *encodeSuggestions(suggestions) {
817
837
  for (const suggestion of suggestions) if (suggestion.kind === "literal") {
818
838
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
819
- yield `${sanitizeForTransport(suggestion.text)}\0${description}\0`;
839
+ yield `${encodeLiteral(suggestion.text)}\0${description}\0`;
820
840
  } else {
821
841
  const extensions = encodeExtensions(suggestion.extensions);
822
842
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -875,7 +895,12 @@ function ${functionName}
875
895
  ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current 2>/dev/null)\n` : ` set -l output (${programName} $prev $current 2>/dev/null)\n`}
876
896
  # Process each line of output
877
897
  for line in $output
878
- if string match -q '__FILE__:*' -- $line
898
+ set -l literal 0
899
+ if string match -q '__LITERAL__:*' -- $line
900
+ set line (string sub -s 13 -- $line)
901
+ set literal 1
902
+ end
903
+ if test $literal -eq 0; and string match -q '__FILE__:*' -- $line
879
904
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
880
905
  set -l directive (string split \\t -- $line)[1]
881
906
  set -l parts (string split ':' -- $directive)
@@ -1166,7 +1191,7 @@ complete -c ${programName} -f -a '(${functionName})'
1166
1191
  if (i > 0) yield "\n";
1167
1192
  if (suggestion.kind === "literal") {
1168
1193
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1169
- yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
1194
+ yield `${encodeLiteral(suggestion.text)}\t${description}`;
1170
1195
  } else {
1171
1196
  const extensions = encodeExtensions(suggestion.extensions);
1172
1197
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -1405,8 +1430,10 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1405
1430
  }
1406
1431
 
1407
1432
  # Process each line of output
1408
- $output | lines | each {|line|
1409
- if ($line | str starts-with '__FILE__:') {
1433
+ $output | lines | each {|raw_line|
1434
+ let literal = ($raw_line | str starts-with '__LITERAL__:')
1435
+ let line = if $literal { $raw_line | str substring 12.. } else { $raw_line }
1436
+ if (not $literal) and ($line | str starts-with '__FILE__:') {
1410
1437
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
1411
1438
  let directive = ($line | split row "\t" | first)
1412
1439
  let parts = ($directive | split row ':')
@@ -1466,22 +1493,26 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1466
1493
  # Generate file completions based on type.
1467
1494
  # When the glob base contains wildcard characters, use it as-is;
1468
1495
  # otherwise append * to treat it as a prefix.
1469
- # Note: into glob is required so that ls expands wildcards from a variable
1496
+ # Note: into glob is required so that ls expands wildcards from a variable.
1497
+ # A literal prefix has its brackets wrapped as [[] and []] so that path
1498
+ # text like "report[1]" is not parsed as a glob character class; a
1499
+ # wildcard pattern is a glob as a whole, so its brackets stay classes.
1470
1500
  let has_glob = ($glob_base =~ '[*?]')
1471
1501
  let ls_pattern = if ($glob_base | is-empty) {
1472
1502
  "."
1473
1503
  } else if $has_glob {
1474
1504
  ($glob_base | into glob)
1475
1505
  } else {
1476
- ($glob_base + "*" | into glob)
1506
+ (($glob_base | str replace -a -r '([\\[\\]])' '[$1]') + "*" | into glob)
1477
1507
  }
1478
1508
 
1479
1509
  # When using a glob pattern, also compute a directory listing pattern
1480
- # so that file/any modes can include directories for navigation
1510
+ # so that file/any modes can include directories for navigation; its
1511
+ # directory part is a literal path
1481
1512
  let glob_dir_pattern = if $has_glob {
1482
1513
  let dir_part = ($glob_base | path dirname)
1483
1514
  let dir = if ($dir_part | is-empty) or ($dir_part == $glob_base) { "." } else { $dir_part }
1484
- ($dir + "/*" | into glob)
1515
+ (($dir | str replace -a -r '([\\[\\]])' '[$1]') + "/*" | into glob)
1485
1516
  } else {
1486
1517
  null
1487
1518
  }
@@ -1674,7 +1705,7 @@ ${functionName}-external
1674
1705
  if (i > 0) yield "\n";
1675
1706
  if (suggestion.kind === "literal") {
1676
1707
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1677
- yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
1708
+ yield `${encodeLiteral(suggestion.text)}\t${description}`;
1678
1709
  } else {
1679
1710
  const extensions = encodeExtensions(suggestion.extensions);
1680
1711
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -1702,12 +1733,33 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1702
1733
  # Extract arguments from AST (handles quoted strings properly)
1703
1734
  \$arguments = @()
1704
1735
  \$commandElements = \$commandAst.CommandElements
1736
+ \$valuePrefix = \$wordToComplete
1737
+ \$quoteType = \$null
1705
1738
 
1706
1739
  # Keep only complete arguments before the cursor. The current word
1707
1740
  # (including a token ending exactly at the cursor) is appended below.
1708
1741
  for (\$i = 1; \$i -lt \$commandElements.Count; \$i++) {
1709
1742
  \$element = \$commandElements[\$i]
1710
- if (\$element.Extent.EndOffset -ge \$cursorPosition) { break }
1743
+ if (\$element.Extent.EndOffset -ge \$cursorPosition) {
1744
+ # wordToComplete has synthesized delimiters and decoded escapes.
1745
+ # Only the AST retains the literal value and original quote type.
1746
+ if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
1747
+ \$element.Extent.EndOffset -eq \$cursorPosition) {
1748
+ if (\$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')) {
1749
+ \$valuePrefix = \$element.Value
1750
+ \$quoteType = \$element.StringConstantType
1751
+ } elseif (\$element.Extent.Text -match "^[^=]+=['\\u2018-\\u201b]") {
1752
+ # Joined values are BareWord constants despite their quotes.
1753
+ # Quote the entire replacement to keep it one argument.
1754
+ \$valuePrefix = \$element.Value
1755
+ \$quoteType = 'SingleQuoted'
1756
+ } elseif (\$element.Extent.Text -match '^[^=]+=["\\u201c-\\u201e]') {
1757
+ \$valuePrefix = \$element.Value
1758
+ \$quoteType = 'DoubleQuoted'
1759
+ }
1760
+ }
1761
+ break
1762
+ }
1711
1763
 
1712
1764
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
1713
1765
  \$arguments += \$element.Value
@@ -1716,23 +1768,41 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1716
1768
  }
1717
1769
  }
1718
1770
 
1771
+ # CompletionText replaces the whole token, including its opening quote.
1772
+ # Escape literals without evaluating variables or expressions.
1773
+ \$quoteCompletion = {
1774
+ param(\$text)
1775
+ if (\$quoteType -eq 'SingleQuoted') {
1776
+ "'" + (\$text -replace "['\\u2018-\\u201b]", '\$0\$0') + "'"
1777
+ } elseif (\$quoteType -eq 'DoubleQuoted') {
1778
+ '"' + (\$text -replace '[\\x60\$"\\u201c-\\u201e]', '\`\$0') + '"'
1779
+ } else {
1780
+ \$text
1781
+ }
1782
+ }
1783
+
1719
1784
  # Build arguments array for completion command
1720
1785
  \$completionArgs = @()
1721
1786
  ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1722
1787
  ` : ""} \$completionArgs += \$arguments
1723
- \$completionArgs += \$wordToComplete
1724
1788
 
1725
1789
  # Call completion command and capture output
1726
1790
  try {
1727
- \$output = & ${programName} \$completionArgs 2>\$null
1791
+ # Quote the prefix separately: array arguments expand a literal tilde
1792
+ # when passed to native programs on Unix.
1793
+ \$output = & ${programName} \$completionArgs "\$valuePrefix" 2>\$null
1728
1794
  if (-not \$output) { return }
1729
1795
 
1730
1796
  # Parse tab-separated output and create CompletionResult objects
1731
1797
  \$output -split "\`n" | ForEach-Object {
1732
- \$line = \$_.Trim()
1798
+ \$literal = \$_.StartsWith('__LITERAL__:', [System.StringComparison]::Ordinal)
1799
+ # Quoted values preserve whitespace; only remove the CR of CRLF.
1800
+ # Keep the existing trimming for unquoted completions.
1801
+ \$line = if (\$null -ne \$quoteType) { \$_.TrimEnd([char]13) } else { \$_.Trim() }
1733
1802
  if (-not \$line) { return }
1803
+ if (\$literal) { \$line = \$line.Substring(12) }
1734
1804
 
1735
- if (\$line -match '^__FILE__:') {
1805
+ if (-not \$literal -and \$_.StartsWith('__FILE__:', [System.StringComparison]::Ordinal)) {
1736
1806
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
1737
1807
  \$directive = (\$line -split "\`t")[0]
1738
1808
  \$parts = \$directive -split ':', 5
@@ -1748,7 +1818,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1748
1818
  # comparing so that Windows backslashes match forward slashes
1749
1819
  # in the transported pattern.
1750
1820
  \$normalizedPattern = if (\$pattern) { \$pattern.Replace('\\', '/') -replace '^\\./','' } else { '' }
1751
- \$normalizedWord = if (\$wordToComplete) { \$wordToComplete.Replace('\\', '/') -replace '^\\./','' } else { '' }
1821
+ \$normalizedWord = if (\$valuePrefix) { \$valuePrefix.Replace('\\', '/') -replace '^\\./','' } else { '' }
1752
1822
  \$comparison = if (\$IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
1753
1823
  # For wildcard patterns, compare only the directory prefix
1754
1824
  \$comparePattern = \$normalizedPattern
@@ -1760,12 +1830,25 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1760
1830
  \$comparePattern = ''
1761
1831
  }
1762
1832
  }
1763
- \$prefix = if ((\$comparePattern -or \$normalizedWord) -and \$normalizedWord -and \$normalizedWord.StartsWith(\$comparePattern, \$comparison) -and \$normalizedWord.Length -ge \$comparePattern.Length -and \$wordToComplete -ne \$pattern) {
1764
- \$wordToComplete
1833
+ # A quoted home-pattern directory inserts an absolute path.
1834
+ # Compare subsequent input against the same expanded prefix.
1835
+ # Detect home syntax before removing ./, which can target a
1836
+ # literal local tilde directory rather than the home directory.
1837
+ \$homePattern = \$pattern -and (\$pattern -eq '~' -or \$pattern.Replace('\\', '/').StartsWith('~/'))
1838
+ if (\$homePattern) {
1839
+ # Preserve the home root when the pattern is exactly ~.
1840
+ \$expandedPattern = if (\$comparePattern -eq '~') { \$HOME } else { \$HOME.Replace('\\', '/').TrimEnd('/') + \$comparePattern.Substring(1) }
1841
+ \$expandedPattern = [IO.Path]::GetFullPath(\$expandedPattern).Replace('\\', '/')
1842
+ if (\$normalizedWord.StartsWith(\$expandedPattern, \$comparison)) {
1843
+ \$comparePattern = \$expandedPattern
1844
+ }
1845
+ }
1846
+ \$prefix = if ((\$comparePattern -or \$normalizedWord) -and \$normalizedWord -and \$normalizedWord.StartsWith(\$comparePattern, \$comparison) -and \$normalizedWord.Length -ge \$comparePattern.Length -and \$valuePrefix -ne \$pattern) {
1847
+ \$valuePrefix
1765
1848
  } elseif (\$pattern) {
1766
1849
  \$pattern
1767
- } elseif (\$wordToComplete) {
1768
- \$wordToComplete
1850
+ } elseif (\$valuePrefix) {
1851
+ \$valuePrefix
1769
1852
  } else { '' }
1770
1853
 
1771
1854
  # Use -Force to include hidden files when requested, or when
@@ -1773,15 +1856,22 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1773
1856
  \$prefixBasename = if (\$prefix) { Split-Path -Leaf \$prefix } else { '' }
1774
1857
  \$forceParam = if (\$hidden -or (\$prefixBasename -and \$prefixBasename.StartsWith('.'))) { @{Force = \$true} } else { @{} }
1775
1858
 
1859
+ # A quoted tilde is literal when passed to a native command.
1860
+ # Prevent PowerShell's file provider from expanding it to home.
1861
+ # Parser-provided home patterns still need home expansion.
1862
+ \$prefixFromInput = \$valuePrefix -and \$prefix -ceq \$valuePrefix
1863
+ \$lookupPrefix = if (\$prefixFromInput -and \$null -ne \$quoteType -and \$prefix.StartsWith('~')) { './' + \$prefix } else { \$prefix }
1864
+
1776
1865
  # If prefix is a directory without trailing slash, append
1777
1866
  # one so Get-ChildItem lists its contents
1778
- if (\$prefix -and (Test-Path -Path \$prefix -PathType Container) -and -not \$prefix.EndsWith('/') -and -not \$prefix.EndsWith('\\')) {
1867
+ if (\$prefix -and (Test-Path -Path \$lookupPrefix -PathType Container) -and -not \$prefix.EndsWith('/') -and -not \$prefix.EndsWith('\\')) {
1779
1868
  \$prefix = \$prefix + '/'
1869
+ \$lookupPrefix = \$lookupPrefix + '/'
1780
1870
  }
1781
1871
 
1782
1872
  # Build the glob path — when the prefix contains wildcard
1783
1873
  # characters, use it as-is; otherwise append *
1784
- \$globPath = if (\$prefix -match '[\*\?]') { \$prefix } else { "\${prefix}*" }
1874
+ \$globPath = if (\$lookupPrefix -match '[\*\?]') { \$lookupPrefix } else { "\${lookupPrefix}*" }
1785
1875
 
1786
1876
  # Get file system items based on type
1787
1877
  \$items = @()
@@ -1818,7 +1908,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1818
1908
  # For file/any modes with glob patterns, also add directories
1819
1909
  # from the base directory for navigation
1820
1910
  if (\$prefix -match '[\*\?]' -and \$type -ne 'directory') {
1821
- \$globDir = Split-Path -Parent \$prefix
1911
+ \$globDir = Split-Path -Parent \$lookupPrefix
1822
1912
  if (-not \$globDir) { \$globDir = '.' }
1823
1913
  \$extraDirs = Get-ChildItem @forceParam -Directory -Path "\$globDir/*" -ErrorAction SilentlyContinue
1824
1914
  if (\$extraDirs) { \$items = @(\$items) + @(\$extraDirs) | Select-Object -Unique }
@@ -1839,8 +1929,13 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1839
1929
  \$items | ForEach-Object {
1840
1930
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1841
1931
  \$itemType = if (\$_.PSIsContainer) { 'Directory' } else { 'File' }
1932
+ # Quoting disables native tilde expansion. A home pattern
1933
+ # supplied by the parser must insert the discovered path.
1934
+ \$replacementText = if (\$null -ne \$quoteType -and -not \$prefixFromInput -and \$homePattern) {
1935
+ if (\$_.PSIsContainer) { \$_.FullName + '/' } else { \$_.FullName }
1936
+ } else { \$completionText }
1842
1937
  [System.Management.Automation.CompletionResult]::new(
1843
- \$completionText,
1938
+ (& \$quoteCompletion \$replacementText),
1844
1939
  \$completionText,
1845
1940
  'ParameterValue',
1846
1941
  \$itemType
@@ -1850,11 +1945,14 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1850
1945
  # Parse literal completion: text\\tlistItemText\\tdescription
1851
1946
  \$parts = \$line -split "\`t", 3
1852
1947
  \$completionText = \$parts[0]
1948
+ # Empty display text is invalid for CompletionResult. Keep
1949
+ # ignoring empty suggestions without dropping later records.
1950
+ if (-not \$completionText) { return }
1853
1951
  \$listItemText = if (\$parts.Length -gt 1 -and \$parts[1]) { \$parts[1] } else { \$completionText }
1854
1952
  \$toolTip = if (\$parts.Length -gt 2 -and \$parts[2]) { \$parts[2] } else { \$completionText }
1855
1953
 
1856
1954
  [System.Management.Automation.CompletionResult]::new(
1857
- \$completionText,
1955
+ (& \$quoteCompletion \$completionText),
1858
1956
  \$listItemText,
1859
1957
  'ParameterValue',
1860
1958
  \$toolTip
@@ -1874,7 +1972,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1874
1972
  if (suggestion.kind === "literal") {
1875
1973
  const description = suggestion.description == null ? "" : sanitizeForTransport(require_message.formatMessage(suggestion.description, { colors: false }));
1876
1974
  const text = sanitizeForTransport(suggestion.text);
1877
- yield `${text}\t${text}\t${description}`;
1975
+ yield `${encodeLiteral(suggestion.text)}\t${text}\t${description}`;
1878
1976
  } else {
1879
1977
  const extensions = encodeExtensions(suggestion.extensions);
1880
1978
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -62,6 +62,17 @@ function sanitizeForTransport(text) {
62
62
  return text.replace(/[\t\n\r\0]/g, " ");
63
63
  }
64
64
  /**
65
+ * Escapes literal values that would otherwise be interpreted as transport
66
+ * directives. Escape the escape prefix itself so decoding is unambiguous.
67
+ * @param text The literal suggestion text.
68
+ * @returns The sanitized and, if necessary, escaped literal text.
69
+ * @internal
70
+ */
71
+ function encodeLiteral(text) {
72
+ const sanitized = sanitizeForTransport(text);
73
+ return sanitized.startsWith("__FILE__:") || sanitized.startsWith("__LITERAL__:") ? `__LITERAL__:${sanitized}` : sanitized;
74
+ }
75
+ /**
65
76
  * The Bash shell completion generator.
66
77
  * @since 0.6.0
67
78
  */
@@ -308,10 +319,11 @@ function _${programName} () {
308
319
  __reply_prefix=""
309
320
  __file_current="$current"
310
321
  __literal=0
311
- if [[ "$line" == __LITERAL__:* ]]; then
322
+ # Protocol tags are case-sensitive even when nocasematch is enabled.
323
+ if [ "\${line:0:12}" = '__LITERAL__:' ]; then
312
324
  line="\${line#__LITERAL__:}"
313
325
  __literal=1
314
- elif [[ "$line" == __JOINED_FILE__:* ]]; then
326
+ elif [ "\${line:0:16}" = '__JOINED_FILE__:' ]; then
315
327
  line="\${line#__JOINED_FILE__:}"
316
328
  __reply_prefix="\${line%%:*}"
317
329
  __reply_prefix="\${__reply_prefix//%3A/:}"
@@ -332,7 +344,7 @@ function _${programName} () {
332
344
  __file_quote_offset=$((__file_quote_offset - \${#__reply_prefix}))
333
345
  fi
334
346
  fi
335
- if [[ $__literal == 0 && "$line" == __FILE__:* ]]; then
347
+ if [ "$__literal" = 0 ] && [ "\${line:0:9}" = '__FILE__:' ]; then
336
348
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
337
349
  IFS=':' read -r _ type extensions pattern hidden <<< "$line"
338
350
  extensions="\${extensions//,\\./,}"; extensions="\${extensions#.}"
@@ -700,7 +712,12 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
700
712
  desc=\${line#*\$'\\0'}
701
713
  desc=\${desc%%\$'\\0'*}
702
714
 
703
- if [[ "\$value" == __FILE__:* ]]; then
715
+ local __literal=0
716
+ if [[ "\$value" == __LITERAL__:* ]]; then
717
+ value="\${value#__LITERAL__:}"
718
+ __literal=1
719
+ fi
720
+ if [[ \$__literal == 0 && "\$value" == __FILE__:* ]]; then
704
721
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
705
722
  local type extensions pattern hidden
706
723
  IFS=':' read -r _ type extensions pattern hidden <<< "\$value"
@@ -798,10 +815,13 @@ function _${programName.replace(/[^a-zA-Z0-9]/g, "_")} () {
798
815
  local -a matches
799
816
  local -i i
800
817
  for (( i=1; i <= \${#completions[@]}; i++ )); do
818
+ # _describe treats colons as separators and backslashes as escapes.
819
+ value="\${completions[i]//\\\\/\\\\\\\\}"
820
+ value="\${value//:/\\\\:}"
801
821
  if [[ -n "\${descriptions[i]}" ]]; then
802
- matches+=("\${completions[i]}:\${descriptions[i]}")
822
+ matches+=("\${value}:\${descriptions[i]}")
803
823
  else
804
- matches+=("\${completions[i]}")
824
+ matches+=("\${value}")
805
825
  fi
806
826
  done
807
827
  _describe 'commands' matches
@@ -816,7 +836,7 @@ fi
816
836
  *encodeSuggestions(suggestions) {
817
837
  for (const suggestion of suggestions) if (suggestion.kind === "literal") {
818
838
  const description = suggestion.description == null ? "" : sanitizeForTransport(formatMessage(suggestion.description, { colors: false }));
819
- yield `${sanitizeForTransport(suggestion.text)}\0${description}\0`;
839
+ yield `${encodeLiteral(suggestion.text)}\0${description}\0`;
820
840
  } else {
821
841
  const extensions = encodeExtensions(suggestion.extensions);
822
842
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -875,7 +895,12 @@ function ${functionName}
875
895
  ${escapedArgs ? ` set -l output (${programName} ${escapedArgs} $prev $current 2>/dev/null)\n` : ` set -l output (${programName} $prev $current 2>/dev/null)\n`}
876
896
  # Process each line of output
877
897
  for line in $output
878
- if string match -q '__FILE__:*' -- $line
898
+ set -l literal 0
899
+ if string match -q '__LITERAL__:*' -- $line
900
+ set line (string sub -s 13 -- $line)
901
+ set literal 1
902
+ end
903
+ if test $literal -eq 0; and string match -q '__FILE__:*' -- $line
879
904
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
880
905
  set -l directive (string split \\t -- $line)[1]
881
906
  set -l parts (string split ':' -- $directive)
@@ -1166,7 +1191,7 @@ complete -c ${programName} -f -a '(${functionName})'
1166
1191
  if (i > 0) yield "\n";
1167
1192
  if (suggestion.kind === "literal") {
1168
1193
  const description = suggestion.description == null ? "" : sanitizeForTransport(formatMessage(suggestion.description, { colors: false }));
1169
- yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
1194
+ yield `${encodeLiteral(suggestion.text)}\t${description}`;
1170
1195
  } else {
1171
1196
  const extensions = encodeExtensions(suggestion.extensions);
1172
1197
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -1405,8 +1430,10 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1405
1430
  }
1406
1431
 
1407
1432
  # Process each line of output
1408
- $output | lines | each {|line|
1409
- if ($line | str starts-with '__FILE__:') {
1433
+ $output | lines | each {|raw_line|
1434
+ let literal = ($raw_line | str starts-with '__LITERAL__:')
1435
+ let line = if $literal { $raw_line | str substring 12.. } else { $raw_line }
1436
+ if (not $literal) and ($line | str starts-with '__FILE__:') {
1410
1437
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
1411
1438
  let directive = ($line | split row "\t" | first)
1412
1439
  let parts = ($directive | split row ':')
@@ -1466,22 +1493,26 @@ ${escapedArgs ? ` ^${programName} ${escapedArgs} ...$final_args | complete |
1466
1493
  # Generate file completions based on type.
1467
1494
  # When the glob base contains wildcard characters, use it as-is;
1468
1495
  # otherwise append * to treat it as a prefix.
1469
- # Note: into glob is required so that ls expands wildcards from a variable
1496
+ # Note: into glob is required so that ls expands wildcards from a variable.
1497
+ # A literal prefix has its brackets wrapped as [[] and []] so that path
1498
+ # text like "report[1]" is not parsed as a glob character class; a
1499
+ # wildcard pattern is a glob as a whole, so its brackets stay classes.
1470
1500
  let has_glob = ($glob_base =~ '[*?]')
1471
1501
  let ls_pattern = if ($glob_base | is-empty) {
1472
1502
  "."
1473
1503
  } else if $has_glob {
1474
1504
  ($glob_base | into glob)
1475
1505
  } else {
1476
- ($glob_base + "*" | into glob)
1506
+ (($glob_base | str replace -a -r '([\\[\\]])' '[$1]') + "*" | into glob)
1477
1507
  }
1478
1508
 
1479
1509
  # When using a glob pattern, also compute a directory listing pattern
1480
- # so that file/any modes can include directories for navigation
1510
+ # so that file/any modes can include directories for navigation; its
1511
+ # directory part is a literal path
1481
1512
  let glob_dir_pattern = if $has_glob {
1482
1513
  let dir_part = ($glob_base | path dirname)
1483
1514
  let dir = if ($dir_part | is-empty) or ($dir_part == $glob_base) { "." } else { $dir_part }
1484
- ($dir + "/*" | into glob)
1515
+ (($dir | str replace -a -r '([\\[\\]])' '[$1]') + "/*" | into glob)
1485
1516
  } else {
1486
1517
  null
1487
1518
  }
@@ -1674,7 +1705,7 @@ ${functionName}-external
1674
1705
  if (i > 0) yield "\n";
1675
1706
  if (suggestion.kind === "literal") {
1676
1707
  const description = suggestion.description == null ? "" : sanitizeForTransport(formatMessage(suggestion.description, { colors: false }));
1677
- yield `${sanitizeForTransport(suggestion.text)}\t${description}`;
1708
+ yield `${encodeLiteral(suggestion.text)}\t${description}`;
1678
1709
  } else {
1679
1710
  const extensions = encodeExtensions(suggestion.extensions);
1680
1711
  const hidden = suggestion.includeHidden ? "1" : "0";
@@ -1702,12 +1733,33 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1702
1733
  # Extract arguments from AST (handles quoted strings properly)
1703
1734
  \$arguments = @()
1704
1735
  \$commandElements = \$commandAst.CommandElements
1736
+ \$valuePrefix = \$wordToComplete
1737
+ \$quoteType = \$null
1705
1738
 
1706
1739
  # Keep only complete arguments before the cursor. The current word
1707
1740
  # (including a token ending exactly at the cursor) is appended below.
1708
1741
  for (\$i = 1; \$i -lt \$commandElements.Count; \$i++) {
1709
1742
  \$element = \$commandElements[\$i]
1710
- if (\$element.Extent.EndOffset -ge \$cursorPosition) { break }
1743
+ if (\$element.Extent.EndOffset -ge \$cursorPosition) {
1744
+ # wordToComplete has synthesized delimiters and decoded escapes.
1745
+ # Only the AST retains the literal value and original quote type.
1746
+ if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
1747
+ \$element.Extent.EndOffset -eq \$cursorPosition) {
1748
+ if (\$element.StringConstantType -in @('SingleQuoted', 'DoubleQuoted')) {
1749
+ \$valuePrefix = \$element.Value
1750
+ \$quoteType = \$element.StringConstantType
1751
+ } elseif (\$element.Extent.Text -match "^[^=]+=['\\u2018-\\u201b]") {
1752
+ # Joined values are BareWord constants despite their quotes.
1753
+ # Quote the entire replacement to keep it one argument.
1754
+ \$valuePrefix = \$element.Value
1755
+ \$quoteType = 'SingleQuoted'
1756
+ } elseif (\$element.Extent.Text -match '^[^=]+=["\\u201c-\\u201e]') {
1757
+ \$valuePrefix = \$element.Value
1758
+ \$quoteType = 'DoubleQuoted'
1759
+ }
1760
+ }
1761
+ break
1762
+ }
1711
1763
 
1712
1764
  if (\$element -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
1713
1765
  \$arguments += \$element.Value
@@ -1716,23 +1768,41 @@ Register-ArgumentCompleter -Native -CommandName ${programName} -ScriptBlock {
1716
1768
  }
1717
1769
  }
1718
1770
 
1771
+ # CompletionText replaces the whole token, including its opening quote.
1772
+ # Escape literals without evaluating variables or expressions.
1773
+ \$quoteCompletion = {
1774
+ param(\$text)
1775
+ if (\$quoteType -eq 'SingleQuoted') {
1776
+ "'" + (\$text -replace "['\\u2018-\\u201b]", '\$0\$0') + "'"
1777
+ } elseif (\$quoteType -eq 'DoubleQuoted') {
1778
+ '"' + (\$text -replace '[\\x60\$"\\u201c-\\u201e]', '\`\$0') + '"'
1779
+ } else {
1780
+ \$text
1781
+ }
1782
+ }
1783
+
1719
1784
  # Build arguments array for completion command
1720
1785
  \$completionArgs = @()
1721
1786
  ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1722
1787
  ` : ""} \$completionArgs += \$arguments
1723
- \$completionArgs += \$wordToComplete
1724
1788
 
1725
1789
  # Call completion command and capture output
1726
1790
  try {
1727
- \$output = & ${programName} \$completionArgs 2>\$null
1791
+ # Quote the prefix separately: array arguments expand a literal tilde
1792
+ # when passed to native programs on Unix.
1793
+ \$output = & ${programName} \$completionArgs "\$valuePrefix" 2>\$null
1728
1794
  if (-not \$output) { return }
1729
1795
 
1730
1796
  # Parse tab-separated output and create CompletionResult objects
1731
1797
  \$output -split "\`n" | ForEach-Object {
1732
- \$line = \$_.Trim()
1798
+ \$literal = \$_.StartsWith('__LITERAL__:', [System.StringComparison]::Ordinal)
1799
+ # Quoted values preserve whitespace; only remove the CR of CRLF.
1800
+ # Keep the existing trimming for unquoted completions.
1801
+ \$line = if (\$null -ne \$quoteType) { \$_.TrimEnd([char]13) } else { \$_.Trim() }
1733
1802
  if (-not \$line) { return }
1803
+ if (\$literal) { \$line = \$line.Substring(12) }
1734
1804
 
1735
- if (\$line -match '^__FILE__:') {
1805
+ if (-not \$literal -and \$_.StartsWith('__FILE__:', [System.StringComparison]::Ordinal)) {
1736
1806
  # Parse file completion directive: __FILE__:type:extensions:pattern:hidden
1737
1807
  \$directive = (\$line -split "\`t")[0]
1738
1808
  \$parts = \$directive -split ':', 5
@@ -1748,7 +1818,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1748
1818
  # comparing so that Windows backslashes match forward slashes
1749
1819
  # in the transported pattern.
1750
1820
  \$normalizedPattern = if (\$pattern) { \$pattern.Replace('\\', '/') -replace '^\\./','' } else { '' }
1751
- \$normalizedWord = if (\$wordToComplete) { \$wordToComplete.Replace('\\', '/') -replace '^\\./','' } else { '' }
1821
+ \$normalizedWord = if (\$valuePrefix) { \$valuePrefix.Replace('\\', '/') -replace '^\\./','' } else { '' }
1752
1822
  \$comparison = if (\$IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
1753
1823
  # For wildcard patterns, compare only the directory prefix
1754
1824
  \$comparePattern = \$normalizedPattern
@@ -1760,12 +1830,25 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1760
1830
  \$comparePattern = ''
1761
1831
  }
1762
1832
  }
1763
- \$prefix = if ((\$comparePattern -or \$normalizedWord) -and \$normalizedWord -and \$normalizedWord.StartsWith(\$comparePattern, \$comparison) -and \$normalizedWord.Length -ge \$comparePattern.Length -and \$wordToComplete -ne \$pattern) {
1764
- \$wordToComplete
1833
+ # A quoted home-pattern directory inserts an absolute path.
1834
+ # Compare subsequent input against the same expanded prefix.
1835
+ # Detect home syntax before removing ./, which can target a
1836
+ # literal local tilde directory rather than the home directory.
1837
+ \$homePattern = \$pattern -and (\$pattern -eq '~' -or \$pattern.Replace('\\', '/').StartsWith('~/'))
1838
+ if (\$homePattern) {
1839
+ # Preserve the home root when the pattern is exactly ~.
1840
+ \$expandedPattern = if (\$comparePattern -eq '~') { \$HOME } else { \$HOME.Replace('\\', '/').TrimEnd('/') + \$comparePattern.Substring(1) }
1841
+ \$expandedPattern = [IO.Path]::GetFullPath(\$expandedPattern).Replace('\\', '/')
1842
+ if (\$normalizedWord.StartsWith(\$expandedPattern, \$comparison)) {
1843
+ \$comparePattern = \$expandedPattern
1844
+ }
1845
+ }
1846
+ \$prefix = if ((\$comparePattern -or \$normalizedWord) -and \$normalizedWord -and \$normalizedWord.StartsWith(\$comparePattern, \$comparison) -and \$normalizedWord.Length -ge \$comparePattern.Length -and \$valuePrefix -ne \$pattern) {
1847
+ \$valuePrefix
1765
1848
  } elseif (\$pattern) {
1766
1849
  \$pattern
1767
- } elseif (\$wordToComplete) {
1768
- \$wordToComplete
1850
+ } elseif (\$valuePrefix) {
1851
+ \$valuePrefix
1769
1852
  } else { '' }
1770
1853
 
1771
1854
  # Use -Force to include hidden files when requested, or when
@@ -1773,15 +1856,22 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1773
1856
  \$prefixBasename = if (\$prefix) { Split-Path -Leaf \$prefix } else { '' }
1774
1857
  \$forceParam = if (\$hidden -or (\$prefixBasename -and \$prefixBasename.StartsWith('.'))) { @{Force = \$true} } else { @{} }
1775
1858
 
1859
+ # A quoted tilde is literal when passed to a native command.
1860
+ # Prevent PowerShell's file provider from expanding it to home.
1861
+ # Parser-provided home patterns still need home expansion.
1862
+ \$prefixFromInput = \$valuePrefix -and \$prefix -ceq \$valuePrefix
1863
+ \$lookupPrefix = if (\$prefixFromInput -and \$null -ne \$quoteType -and \$prefix.StartsWith('~')) { './' + \$prefix } else { \$prefix }
1864
+
1776
1865
  # If prefix is a directory without trailing slash, append
1777
1866
  # one so Get-ChildItem lists its contents
1778
- if (\$prefix -and (Test-Path -Path \$prefix -PathType Container) -and -not \$prefix.EndsWith('/') -and -not \$prefix.EndsWith('\\')) {
1867
+ if (\$prefix -and (Test-Path -Path \$lookupPrefix -PathType Container) -and -not \$prefix.EndsWith('/') -and -not \$prefix.EndsWith('\\')) {
1779
1868
  \$prefix = \$prefix + '/'
1869
+ \$lookupPrefix = \$lookupPrefix + '/'
1780
1870
  }
1781
1871
 
1782
1872
  # Build the glob path — when the prefix contains wildcard
1783
1873
  # characters, use it as-is; otherwise append *
1784
- \$globPath = if (\$prefix -match '[\*\?]') { \$prefix } else { "\${prefix}*" }
1874
+ \$globPath = if (\$lookupPrefix -match '[\*\?]') { \$lookupPrefix } else { "\${lookupPrefix}*" }
1785
1875
 
1786
1876
  # Get file system items based on type
1787
1877
  \$items = @()
@@ -1818,7 +1908,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1818
1908
  # For file/any modes with glob patterns, also add directories
1819
1909
  # from the base directory for navigation
1820
1910
  if (\$prefix -match '[\*\?]' -and \$type -ne 'directory') {
1821
- \$globDir = Split-Path -Parent \$prefix
1911
+ \$globDir = Split-Path -Parent \$lookupPrefix
1822
1912
  if (-not \$globDir) { \$globDir = '.' }
1823
1913
  \$extraDirs = Get-ChildItem @forceParam -Directory -Path "\$globDir/*" -ErrorAction SilentlyContinue
1824
1914
  if (\$extraDirs) { \$items = @(\$items) + @(\$extraDirs) | Select-Object -Unique }
@@ -1839,8 +1929,13 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1839
1929
  \$items | ForEach-Object {
1840
1930
  \$completionText = if (\$_.PSIsContainer) { "\$dirPrefix\$(\$_.Name)/" } else { "\$dirPrefix\$(\$_.Name)" }
1841
1931
  \$itemType = if (\$_.PSIsContainer) { 'Directory' } else { 'File' }
1932
+ # Quoting disables native tilde expansion. A home pattern
1933
+ # supplied by the parser must insert the discovered path.
1934
+ \$replacementText = if (\$null -ne \$quoteType -and -not \$prefixFromInput -and \$homePattern) {
1935
+ if (\$_.PSIsContainer) { \$_.FullName + '/' } else { \$_.FullName }
1936
+ } else { \$completionText }
1842
1937
  [System.Management.Automation.CompletionResult]::new(
1843
- \$completionText,
1938
+ (& \$quoteCompletion \$replacementText),
1844
1939
  \$completionText,
1845
1940
  'ParameterValue',
1846
1941
  \$itemType
@@ -1850,11 +1945,14 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1850
1945
  # Parse literal completion: text\\tlistItemText\\tdescription
1851
1946
  \$parts = \$line -split "\`t", 3
1852
1947
  \$completionText = \$parts[0]
1948
+ # Empty display text is invalid for CompletionResult. Keep
1949
+ # ignoring empty suggestions without dropping later records.
1950
+ if (-not \$completionText) { return }
1853
1951
  \$listItemText = if (\$parts.Length -gt 1 -and \$parts[1]) { \$parts[1] } else { \$completionText }
1854
1952
  \$toolTip = if (\$parts.Length -gt 2 -and \$parts[2]) { \$parts[2] } else { \$completionText }
1855
1953
 
1856
1954
  [System.Management.Automation.CompletionResult]::new(
1857
- \$completionText,
1955
+ (& \$quoteCompletion \$completionText),
1858
1956
  \$listItemText,
1859
1957
  'ParameterValue',
1860
1958
  \$toolTip
@@ -1874,7 +1972,7 @@ ${escapedArgs ? ` \$completionArgs += @(${escapedArgs})
1874
1972
  if (suggestion.kind === "literal") {
1875
1973
  const description = suggestion.description == null ? "" : sanitizeForTransport(formatMessage(suggestion.description, { colors: false }));
1876
1974
  const text = sanitizeForTransport(suggestion.text);
1877
- yield `${text}\t${text}\t${description}`;
1975
+ yield `${encodeLiteral(suggestion.text)}\t${text}\t${description}`;
1878
1976
  } else {
1879
1977
  const extensions = encodeExtensions(suggestion.extensions);
1880
1978
  const hidden = suggestion.includeHidden ? "1" : "0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/core",
3
- "version": "1.0.15-dev.2824",
3
+ "version": "1.0.15-dev.2849",
4
4
  "description": "Type-safe combinatorial command-line interface parser",
5
5
  "keywords": [
6
6
  "CLI",