From 9620dc8692a313cedd1fe93032596a67b6cdce6e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 6 Aug 2026 17:43:56 +0000 Subject: [PATCH 1/3] Correct shell resolver provenance --- IMPLEMENTATION_PLAN.md | 16 +- .../v0-3-structured-shell-analysis/tasks.md | 4 +- .../Internal/Bash/Lexing/BashLexer.cs | 296 ++++++- .../Internal/Bash/Lexing/BashToken.cs | 8 + .../Bash/Parsing/BashCommandParser.cs | 734 ++++++++++-------- .../Internal/Pwsh/Lexing/PwshLexer.cs | 541 +++++++++++-- .../Internal/Pwsh/Lexing/PwshToken.cs | 8 + .../Pwsh/Parsing/PwshCommandParser.cs | 389 ++++++++-- .../Internal/Pwsh/Verbs/PwshAliases.cs | 13 + .../Internal/Pwsh/Verbs/PwshBindingTables.cs | 55 +- .../Internal/Resolving/BashResolver.cs | 132 ++++ .../Internal/Resolving/PwshResolver.cs | 231 +++++- .../Internal/Resolving/ShellValue.cs | 447 +++++++++++ ...1_netclaw_repro_compound_with_comment.json | 4 +- .../166_curl_data_mixed_literal_dynamic.json | 9 +- ...curl_data_transformed_literal_dynamic.json | 9 +- .../bash/168_escaped_home_literal_path.json | 14 + .../bash/169_empty_quote_blocks_tilde.json | 14 + .../170_adjacent_escaped_redirect_target.json | 14 + .../171_runtime_special_parameter_path.json | 14 + .../bash/172_quoted_wildcard_redirect.json | 14 + .../bash/173_unquoted_wildcard_redirect.json | 14 + ...4_double_quoted_backtick_substitution.json | 14 + .../bash/175_ansi_c_quote_unparseable.json | 10 + .../bash/176_quoted_fd_shaped_redirect.json | 14 + .../bash/177_escaped_fd_shaped_redirect.json | 14 + .../Corpus/bash/178_empty_path_value.json | 14 + .../bash/179_empty_inline_native_value.json | 18 + .../bash/180_dynamic_command_identity.json | 10 + ...ntime_multidigit_positional_parameter.json | 14 + .../182_quoted_dollar_star_cardinality.json | 14 + .../183_escaped_open_brace_literal_path.json | 14 + ...184_unterminated_braced_interpolation.json | 10 + .../185_provider_looking_unquoted_path.json | 14 + .../186_provider_looking_quoted_path.json | 14 + .../bash/63_find_root_with_predicate.json | 4 +- .../280_curl_data_mixed_literal_dynamic.json | 12 +- ...curl_data_transformed_literal_dynamic.json | 12 +- .../282_escaped_home_literal_path.json | 14 + ...283_literalpath_abbreviation_wildcard.json | 17 + .../284_unknown_cmdlet_path_semantics.json | 17 + .../powershell/285_null_sink_redirect.json | 14 + .../powershell/286_escaped_null_redirect.json | 14 + .../287_native_quoted_wildcard.json | 14 + .../powershell/288_cmdlet_path_wildcard.json | 17 + .../289_adjacent_escaped_redirect_target.json | 14 + .../290_cmdlet_index_expression.json | 17 + .../291_cmdlet_colon_member_expression.json | 17 + .../292_native_member_spelling.json | 18 + .../293_native_inline_member_spelling.json | 18 + .../294_ambiguous_colon_parameter.json | 17 + .../295_ambiguous_separated_parameter.json | 17 + .../296_unproved_single_letter_psdrive.json | 14 + .../297_unproved_psdrive_redirect.json | 14 + .../powershell/298_drive_relative_path.json | 14 + .../299_drive_relative_redirect.json | 14 + .../powershell/300_quoted_member_suffix.json | 17 + .../301_dynamic_command_identity.json | 15 + .../powershell/302_empty_path_value.json | 14 + .../303_runtime_question_parameter_path.json | 14 + .../304_runtime_numeric_variable_path.json | 14 + .../305_runtime_unicode_variable_path.json | 14 + ...306_unterminated_braced_interpolation.json | 10 + .../307_escaped_open_brace_literal_path.json | 14 + .../308_runtime_scoped_variable_path.json | 14 + .../309_runtime_braced_variable_path.json | 14 + .../DesignCorpus/V03DesignCorpusTests.cs | 30 +- .../DesignCorpus/v0.3/bash.json | 15 + .../DesignCorpus/v0.3/powershell.json | 28 + .../Lexing/BashLexerTests.cs | 22 + .../Lexing/PwshLexerTests.cs | 26 + .../Parsing/ClauseElementTests.cs | 18 +- .../Parsing/ResolverProvenanceTests.cs | 292 +++++++ .../Parsing/ShellValueOracleTests.cs | 639 +++++++++++++++ 74 files changed, 4175 insertions(+), 498 deletions(-) create mode 100644 src/ShellSyntaxTree/Internal/Resolving/ShellValue.cs create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/168_escaped_home_literal_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/169_empty_quote_blocks_tilde.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/170_adjacent_escaped_redirect_target.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/171_runtime_special_parameter_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/172_quoted_wildcard_redirect.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/173_unquoted_wildcard_redirect.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/174_double_quoted_backtick_substitution.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/175_ansi_c_quote_unparseable.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/176_quoted_fd_shaped_redirect.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/177_escaped_fd_shaped_redirect.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/178_empty_path_value.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/179_empty_inline_native_value.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/180_dynamic_command_identity.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/181_runtime_multidigit_positional_parameter.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/182_quoted_dollar_star_cardinality.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/183_escaped_open_brace_literal_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/184_unterminated_braced_interpolation.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/185_provider_looking_unquoted_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/186_provider_looking_quoted_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/282_escaped_home_literal_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/283_literalpath_abbreviation_wildcard.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/284_unknown_cmdlet_path_semantics.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/285_null_sink_redirect.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/286_escaped_null_redirect.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/287_native_quoted_wildcard.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/288_cmdlet_path_wildcard.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/289_adjacent_escaped_redirect_target.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/290_cmdlet_index_expression.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/291_cmdlet_colon_member_expression.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/292_native_member_spelling.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/293_native_inline_member_spelling.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/294_ambiguous_colon_parameter.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/295_ambiguous_separated_parameter.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/296_unproved_single_letter_psdrive.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/297_unproved_psdrive_redirect.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/298_drive_relative_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/299_drive_relative_redirect.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/300_quoted_member_suffix.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/302_empty_path_value.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/303_runtime_question_parameter_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/304_runtime_numeric_variable_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/305_runtime_unicode_variable_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/306_unterminated_braced_interpolation.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/307_escaped_open_brace_literal_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/308_runtime_scoped_variable_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/309_runtime_braced_variable_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Parsing/ResolverProvenanceTests.cs create mode 100644 tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1b9c800..5c2c55c 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -151,7 +151,7 @@ priorities. synchronize the accepted shared and PowerShell contracts into `SPEC.md` / `SPEC.POWERSHELL.md` together with source and snapshot tests so the repository authority never intentionally drifts from the assembly. -- [ ] Correct the lexer-to-resolver provenance boundary before issue #69. +- [x] Correct the lexer-to-resolver provenance boundary before issue #69. Paired Bash and PowerShell shell-oracle cases must distinguish escaped literal resolver syntax from expandable syntax even when both decode to the same string, including standalone, adjacent-token, all-static @@ -180,6 +180,20 @@ priorities. tilde, wildcard, provider, and PSDrive semantics after quote removal; unknown wildcard cardinality or drive mappings fail closed without enumeration. Bash redirects instead require exactly one proved target. + The completed correction uses an internal ordered `ShellValue` fragment + model in both front ends and passes an explicit consumer context into + each resolver without changing the public API. Direct lexer tests pin + typed Bash special and multidigit positional parameters, quote-sensitive + `$*` / `$@` cardinality, and PowerShell special, numeric, scoped, braced, + and Unicode variable identity. Paired live-shell oracles cover standalone + and adjacent escapes, static mixed quoting, literal-plus-expandable + composition, runtime parameter forms, incomplete versus escaped braced + interpolation, Bash provider-looking literals, native versus cmdlet + provider and wildcard behavior, `Path` versus `LiteralPath`, adjacent and + wildcard redirects, and PowerShell tilde/provider/PSDrive redirects. + Executable corpus cases preserve the corrected v0.2 compatibility + projection; unknown facts remain `DynamicSkip` or unparseable, while + completely proved mixed fragments resolve exactly. - [ ] Implement [issue #69](https://github.com/Aaronontheweb/ShellSyntaxTree/issues/69) against the corrected fragment contract. Preserve raw, decoded, and span facts plus unaffected classifications; explicitly document only diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 8167e25..be741cd 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -13,8 +13,8 @@ ## 2. Resolver Provenance Correction and Shared Preparation -- [ ] 2.1 Implement shell-specific lexical fragment provenance that distinguishes literal, typed recognized-expansion, and opaque resolver input; retains transform eligibility, expansion identity, cardinality, and opaque cause; aggregates complete argument and redirect-target fragment runs; and passes explicit Bash-argument, Bash-redirect, PowerShell-native, cmdlet-Path, cmdlet-LiteralPath, and PowerShell-redirect resolver context without changing the public API. -- [ ] 2.2 Add paired Bash and PowerShell shell-oracle regressions for standalone escapes, adjacent escaped values, all-static mixed quoting, within-token escapes, genuine literal-plus-expandable values, adjacent and wildcard redirect targets, runtime special/positional/numeric/Unicode variables, incomplete and escaped-literal braced interpolation, Bash provider-looking literals, and PowerShell native-versus-cmdlet, Path-versus-LiteralPath, and redirect-context divergence; require exact compatibility path results when every fragment, binding fact, and required resolver fact is exact, otherwise fail closed. +- [x] 2.1 Implement shell-specific lexical fragment provenance that distinguishes literal, typed recognized-expansion, and opaque resolver input; retains transform eligibility, expansion identity, cardinality, and opaque cause; aggregates complete argument and redirect-target fragment runs; and passes explicit Bash-argument, Bash-redirect, PowerShell-native, cmdlet-Path, cmdlet-LiteralPath, and PowerShell-redirect resolver context without changing the public API. +- [x] 2.2 Add paired Bash and PowerShell shell-oracle regressions for standalone escapes, adjacent escaped values, all-static mixed quoting, within-token escapes, genuine literal-plus-expandable values, adjacent and wildcard redirect targets, runtime special/positional/numeric/Unicode variables, incomplete and escaped-literal braced interpolation, Bash provider-looking literals, and PowerShell native-versus-cmdlet, Path-versus-LiteralPath, and redirect-context divergence; require exact compatibility path results when every fragment, binding fact, and required resolver fact is exact, otherwise fail closed. - [ ] 2.3 Implement issue #69's shell-neutral native argument-fragment classifier with explicit Bash and PowerShell adapters that preserve the new provenance. - [ ] 2.4 Prove raw spelling, decoded logical values, source spans, and unaffected classifications remain unchanged; document each oracle-proved false exact, `Glob`, `Tilde`, provider, path, or avoidable `DynamicSkip` compatibility correction. - [ ] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics. diff --git a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs index 320771f..2540f97 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs @@ -5,8 +5,8 @@ // ----------------------------------------------------------------------- using System; using System.Collections.Generic; -using System.Text; using ShellSyntaxTree.Internal.Lexing; +using ShellSyntaxTree.Internal.Resolving; namespace ShellSyntaxTree.Internal.Bash.Lexing; @@ -91,7 +91,7 @@ internal static IReadOnlyList Tokenize(string input) tokens.Add(new BashToken( BashTokenKind.Whitespace, "", null, start, i - start, null) - { IsStatementSeparator = true }); + { IsStatementSeparator = true }); continue; } @@ -151,6 +151,20 @@ internal static IReadOnlyList Tokenize(string input) if (c == '$' && i + 1 < src.Length) { var next = src[i + 1]; + if (next is '\'' or '"') + { + tokens.Add(new BashToken( + BashTokenKind.UnparseableSentinel, + src.Slice(i).ToString(), + null, + i, + src.Length - i, + next == '\'' + ? "ANSI-C quoted strings are not supported" + : "localized quoted strings are not supported")); + return tokens; + } + if (next == '(') { // $(( -> arithmetic, unparseable. Detect before $(. @@ -284,7 +298,10 @@ private static int ReadSingleQuoted( var inner = src.Slice(start + 1, i - start - 1).ToString(); tokens.Add(new BashToken( BashTokenKind.QuotedString, inner, null, start, (i - start) + 1, null) - { IsSingleQuoted = true }); + { + IsSingleQuoted = true, + ResolverValue = ShellValue.Literal(inner, start + 1, i - start - 1), + }); return i + 1; } @@ -293,17 +310,21 @@ private static int ReadDoubleQuoted( { // Double quotes preserve whitespace but recognize \", \\, \$, and // \\+newline as escape sequences (SPEC §5). Other backslashes are - // preserved literally. $VAR / ${VAR} are *not* expanded — kept literal. - var sb = new StringBuilder(); + // preserved literally. Expansion spelling stays decoded while its + // typed resolver provenance remains attached to the token. + var value = new ShellValueBuilder(); + value.AppendBoundary(start + 1); var i = start + 1; while (i < src.Length) { var c = src[i]; if (c == '"') { + var resolverValue = value.Build(); tokens.Add(new BashToken( - BashTokenKind.QuotedString, sb.ToString(), null, - start, (i - start) + 1, null)); + BashTokenKind.QuotedString, resolverValue.Decoded, null, + start, (i - start) + 1, null) + { ResolverValue = resolverValue }); return i + 1; } @@ -312,7 +333,7 @@ private static int ReadDoubleQuoted( var n = src[i + 1]; if (n == '"' || n == '\\' || n == '$' || n == '`') { - sb.Append(n); + value.AppendLiteral(n, i, 2); i += 2; continue; } @@ -325,12 +346,56 @@ private static int ReadDoubleQuoted( } // Other backslashes preserved literally per SPEC §5. - sb.Append(c); + value.AppendLiteral(c, i, 1); i++; continue; } - sb.Append(c); + if (c == '$' + && TryAppendBashExpansion( + src, ref i, value, allowFieldSplit: false, out var error)) + { + if (error is not null) + { + tokens.Add(new BashToken( + BashTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), + null, + start, + src.Length - start, + error)); + return src.Length; + } + + continue; + } + + if (c == '`') + { + var scan = OpaqueRegionScanner.ScanSymmetric(src, i, '`'); + if (!scan.Closed) + { + tokens.Add(new BashToken( + BashTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), + null, + start, + src.Length - start, + "unbalanced backtick command substitution")); + return src.Length; + } + + var length = scan.EndIndex - i + 1; + value.AppendOpaque( + src.Slice(i, length).ToString(), + ShellOpaqueCause.CommandSubstitution, + i, + length); + i += length; + continue; + } + + value.AppendLiteral(c, i, 1); i++; } @@ -374,7 +439,14 @@ private static int ConsumeCommandSubstitution( null, start, length, - null)); + null) + { + ResolverValue = ShellValue.Opaque( + src.Slice(start, length).ToString(), + ShellOpaqueCause.CommandSubstitution, + start, + length), + }); return start + length; } @@ -401,7 +473,14 @@ private static int ConsumeBacktickSubstitution( null, start, length, - null)); + null) + { + ResolverValue = ShellValue.Opaque( + src.Slice(start, length).ToString(), + ShellOpaqueCause.CommandSubstitution, + start, + length), + }); return start + length; } @@ -543,7 +622,7 @@ private static int ReadWord( // resolver classifies based on the original source positions if // necessary. SPEC §5 explicitly says `echo \$HOME` produces a // Literal token.) - var sb = new StringBuilder(); + var value = new ShellValueBuilder(); var i = start; while (i < src.Length) { @@ -560,7 +639,7 @@ private static int ReadWord( if (i + 1 >= src.Length) { // Trailing lone backslash — preserve it as literal. - sb.Append('\\'); + value.AppendLiteral('\\', i, 1); i++; break; } @@ -573,7 +652,7 @@ private static int ReadWord( break; } - sb.Append(n); + value.AppendLiteral(n, i, 2); i += 2; continue; } @@ -599,25 +678,55 @@ private static int ReadWord( if (bodyHasSlash) break; - // Simple form — absorb whole ${...} verbatim. - // StringBuilder.Append(ReadOnlySpan) is net6+ - // only; spell out the loop for netstandard2.0 parity. - var braceLen = scan.EndIndex - i + 1; - for (var k = 0; k < braceLen; k++) + if (TryAppendBashExpansion( + src, ref i, value, allowFieldSplit: true, out var error)) { - sb.Append(src[i + k]); + if (error is not null) + { + break; + } + + continue; } + } - i += braceLen; + if (TryAppendBashExpansion( + src, ref i, value, allowFieldSplit: true, out _)) + { continue; } } - sb.Append(c); + if (c == '~' && i == start) + { + value.AppendExpansion( + "~", + ShellLexicalTransform.Tilde, + new ShellExpansionReference(ShellExpansionKind.Tilde, null), + ShellValueCardinality.ExactlyOne, + i, + 1); + } + else if (c is '*' or '?' or '[') + { + value.AppendExpansion( + c.ToString(), + ShellLexicalTransform.Glob, + new ShellExpansionReference(ShellExpansionKind.Glob, null), + ShellValueCardinality.ZeroOrMore, + i, + 1); + } + else + { + value.AppendLiteral(c, i, 1); + } + i++; } - if (sb.Length == 0) + var resolverValue = value.Build(); + if (resolverValue.Decoded.Length == 0) { // Defensive: caller should not invoke ReadWord on a position // that produces no chars (would loop forever). Advance one @@ -628,10 +737,145 @@ private static int ReadWord( } tokens.Add(new BashToken( - BashTokenKind.Word, sb.ToString(), null, start, i - start, null)); + BashTokenKind.Word, resolverValue.Decoded, null, start, i - start, null) + { ResolverValue = resolverValue }); return i; } + private static bool TryAppendBashExpansion( + ReadOnlySpan src, + ref int index, + ShellValueBuilder value, + bool allowFieldSplit, + out string? error) + { + error = null; + var start = index; + if (start + 1 >= src.Length || src[start] != '$') + { + return false; + } + + var next = src[start + 1]; + if (next == '(') + { + if (start + 2 < src.Length && src[start + 2] == '(') + { + error = "arithmetic expansion '$((…))' not supported in v0.1"; + index = src.Length; + return true; + } + + var scan = OpaqueRegionScanner.Scan(src, start + 1, '(', ')'); + if (!scan.Closed) + { + error = "unbalanced '$(' command substitution"; + index = src.Length; + return true; + } + + var length = scan.EndIndex - start + 1; + value.AppendOpaque( + src.Slice(start, length).ToString(), + ShellOpaqueCause.CommandSubstitution, + start, + length); + index += length; + return true; + } + + string name; + int expansionLength; + if (next == '{') + { + var scan = OpaqueRegionScanner.Scan(src, start + 1, '{', '}'); + if (!scan.Closed) + { + error = "unbalanced '${' parameter expansion"; + index = src.Length; + return true; + } + + expansionLength = scan.EndIndex - start + 1; + name = src.Slice(start + 2, expansionLength - 3).ToString(); + if (name.Length == 0 || name.IndexOf('/') >= 0) + { + error = "complex parameter expansion '${var//pat/repl}' not supported in v0.1"; + index += expansionLength; + return true; + } + } + else if (IsBashIdentifierStart(next)) + { + var end = start + 2; + while (end < src.Length && IsBashIdentifierContinuation(src[end])) + { + end++; + } + + expansionLength = end - start; + name = src.Slice(start + 1, expansionLength - 1).ToString(); + } + else if (next is '?' or '$' or '#' or '-' or '!' or '@' or '*' + || next is >= '0' and <= '9') + { + expansionLength = 2; + name = next.ToString(); + } + else + { + return false; + } + + var kind = IsAllAsciiDigits(name) + ? ShellExpansionKind.PositionalParameter + : name.Length == 1 && name[0] is '?' or '$' or '#' or '-' or '!' or '@' or '*' + ? ShellExpansionKind.SpecialParameter + : ShellExpansionKind.Variable; + var cardinality = name == "@" || (name == "*" && allowFieldSplit) + ? ShellValueCardinality.ZeroOrMore + : ShellValueCardinality.ExactlyOne; + var transforms = ShellLexicalTransform.Variable; + if (allowFieldSplit) + { + transforms |= ShellLexicalTransform.FieldSplit; + } + + value.AppendExpansion( + src.Slice(start, expansionLength).ToString(), + transforms, + new ShellExpansionReference(kind, name), + cardinality, + start, + expansionLength); + index += expansionLength; + return true; + } + + private static bool IsAllAsciiDigits(string value) + { + if (value.Length == 0) + { + return false; + } + + foreach (var character in value) + { + if (character is < '0' or > '9') + { + return false; + } + } + + return true; + } + + private static bool IsBashIdentifierStart(char value) => + value == '_' || value is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + + private static bool IsBashIdentifierContinuation(char value) => + IsBashIdentifierStart(value) || value is >= '0' and <= '9'; + private static bool IsOperatorStart(ReadOnlySpan src, int i) { var c = src[i]; @@ -772,7 +1016,7 @@ private static int ConsumeHeredoc( { tokens.Add(new BashToken( BashTokenKind.Whitespace, "", null, j, 1, null) - { IsStatementSeparator = true }); + { IsStatementSeparator = true }); return j + 1; } diff --git a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashToken.cs b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashToken.cs index e517e99..a740066 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashToken.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashToken.cs @@ -3,6 +3,8 @@ // Copyright (C) 2026 - 2026 Aaron Stannard // // ----------------------------------------------------------------------- +using ShellSyntaxTree.Internal.Resolving; + namespace ShellSyntaxTree.Internal.Bash.Lexing; /// @@ -49,6 +51,12 @@ internal readonly record struct BashToken( /// public bool IsSingleQuoted { get; init; } + /// + /// Resolver-relevant decoded fragments. Null only for token kinds that + /// never carry an argument value. + /// + public ShellValue? ResolverValue { get; init; } + /// /// True when this token contains /// a newline and therefore acts as a statement separator equivalent to diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs index 2b6a347..83137a4 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs @@ -511,6 +511,29 @@ private static List FilterSignificant(IReadOnlyList tokens continue; } + if (filtered.Count > 0 + && IsNativeArgumentFragment(filtered[filtered.Count - 1]) + && IsNativeArgumentFragment(t) + && IsAdjacent(filtered[filtered.Count - 1], t)) + { + var previous = filtered[filtered.Count - 1]; + var previousValue = previous.ResolverValue + ?? ShellValue.Literal(previous.Value, previous.SourceStart, previous.SourceLength); + var currentValue = t.ResolverValue + ?? ShellValue.Literal(t.Value, t.SourceStart, t.SourceLength); + filtered[filtered.Count - 1] = new BashToken( + BashTokenKind.Word, + previous.Value + t.Value, + null, + previous.SourceStart, + t.SourceStart + t.SourceLength - previous.SourceStart, + null) + { + ResolverValue = ShellValue.Concat(new[] { previousValue, currentValue }), + }; + continue; + } + filtered.Add(t); } @@ -802,6 +825,14 @@ private static ClauseResult ParseClauseSegment( var verbPositions = new HashSet(); var firstToken = segment.Tokens[0]; + if ((firstToken.Kind == BashTokenKind.Word + || firstToken.Kind == BashTokenKind.QuotedString) + && !HasStaticCommandIdentity(firstToken)) + { + return ClauseResult.Fail( + "dynamic Bash command identity is not supported in v0.2"); + } + string? firstVerb = null; if (firstToken.Kind == BashTokenKind.Word && !IsFlagWord(firstToken)) { @@ -955,6 +986,25 @@ private static ClauseResult ParseClauseSegment( return ClauseResult.Ok(clause); } + private static bool HasStaticCommandIdentity(BashToken token) + { + if (token.ResolverValue is null) + { + return true; + } + + foreach (var fragment in token.ResolverValue.Fragments) + { + if (fragment.Kind != ShellValueFragmentKind.Literal + || fragment.Cardinality != ShellValueCardinality.ExactlyOne) + { + return false; + } + } + + return true; + } + private static bool IsFlagWord(BashToken token) { if (token.Kind != BashTokenKind.Word) @@ -1141,186 +1191,357 @@ private static void ExtractRedirectsAndArgs( switch (t.Kind) { case BashTokenKind.Word: - { - var sourceRaw = SourceSlice(source, t); - - // Bash concatenates adjacent word fragments into one - // argv entry. Preserve that behavior for an inline option - // whose value is quoted or computed: - // `--data="@request file"` / `--data=$(generate)`. - if (NativeFlagSyntax.TrySplitEqualsPrefix( - t.Value, out var adjacentFlagPart, out var adjacentValuePrefix) - && i + 1 < segmentTokens.Count - && IsAdjacent(t, segmentTokens[i + 1]) - && segmentTokens[i + 1].Kind is BashTokenKind.QuotedString - or BashTokenKind.OpaqueSubstitution) { - var valueStart = i + 1; - var valueEnd = valueStart; - var valueBuilder = new StringBuilder(adjacentValuePrefix); - var hasOpaqueFragment = false; - var allFragmentsSingleQuoted = adjacentValuePrefix.Length == 0; - var hasSingleQuotedFragment = false; - var hasNonSingleQuotedFragment = adjacentValuePrefix.Length > 0; - var hasSensitiveLiteralFragment = false; - var previousFragment = t; - while (valueEnd < segmentTokens.Count - && IsAdjacent(previousFragment, segmentTokens[valueEnd]) - && IsNativeArgumentFragment(segmentTokens[valueEnd])) + var sourceRaw = SourceSlice(source, t); + + // Bash concatenates adjacent word fragments into one + // argv entry. Preserve that behavior for an inline option + // whose value is quoted or computed: + // `--data="@request file"` / `--data=$(generate)`. + if (NativeFlagSyntax.TrySplitEqualsPrefix( + t.Value, out var adjacentFlagPart, out var adjacentValuePrefix) + && i + 1 < segmentTokens.Count + && IsAdjacent(t, segmentTokens[i + 1]) + && segmentTokens[i + 1].Kind is BashTokenKind.QuotedString + or BashTokenKind.OpaqueSubstitution) { - var fragment = segmentTokens[valueEnd]; - valueBuilder.Append(fragment.Value); - hasOpaqueFragment |= fragment.Kind == BashTokenKind.OpaqueSubstitution; - hasSingleQuotedFragment |= fragment.Kind == BashTokenKind.QuotedString - && fragment.IsSingleQuoted; - hasNonSingleQuotedFragment |= fragment.Kind != BashTokenKind.QuotedString - || !fragment.IsSingleQuoted; - hasSensitiveLiteralFragment |= fragment.Kind == BashTokenKind.QuotedString - && fragment.IsSingleQuoted - && NativeFlagSyntax.ContainsResolverSensitiveLiteralSyntax(fragment.Value); - allFragmentsSingleQuoted &= fragment.Kind == BashTokenKind.QuotedString - && fragment.IsSingleQuoted; - previousFragment = fragment; - valueEnd++; + var valueStart = i + 1; + var valueEnd = valueStart; + var valueBuilder = new StringBuilder(adjacentValuePrefix); + var hasOpaqueFragment = false; + var allFragmentsSingleQuoted = adjacentValuePrefix.Length == 0; + var hasSingleQuotedFragment = false; + var hasNonSingleQuotedFragment = adjacentValuePrefix.Length > 0; + var hasSensitiveLiteralFragment = false; + var previousFragment = t; + while (valueEnd < segmentTokens.Count + && IsAdjacent(previousFragment, segmentTokens[valueEnd]) + && IsNativeArgumentFragment(segmentTokens[valueEnd])) + { + var fragment = segmentTokens[valueEnd]; + valueBuilder.Append(fragment.Value); + hasOpaqueFragment |= fragment.Kind == BashTokenKind.OpaqueSubstitution; + hasSingleQuotedFragment |= fragment.Kind == BashTokenKind.QuotedString + && fragment.IsSingleQuoted; + hasNonSingleQuotedFragment |= fragment.Kind != BashTokenKind.QuotedString + || !fragment.IsSingleQuoted; + hasSensitiveLiteralFragment |= fragment.Kind == BashTokenKind.QuotedString + && fragment.IsSingleQuoted + && NativeFlagSyntax.ContainsResolverSensitiveLiteralSyntax(fragment.Value); + allFragmentsSingleQuoted &= fragment.Kind == BashTokenKind.QuotedString + && fragment.IsSingleQuoted; + previousFragment = fragment; + valueEnd++; + } + + var lastValueToken = segmentTokens[valueEnd - 1]; + var adjacentValue = valueBuilder.ToString(); + var equalsOffset = SourceSlice(source, t).IndexOf('='); + var adjacentRawStart = t.SourceStart + equalsOffset + 1; + var adjacentRaw = source.Substring( + adjacentRawStart, + lastValueToken.SourceStart + lastValueToken.SourceLength + - adjacentRawStart); + argList.Add(new Arg + { + Raw = adjacentFlagPart, + Resolved = null, + Kind = ArgKind.Literal, + IsPath = false, + }); + + Arg valueArg; + if (hasOpaqueFragment + || (hasSingleQuotedFragment + && hasNonSingleQuotedFragment + && hasSensitiveLiteralFragment) + || (verbKeyForFlagValuePaths is not null + && BashPerVerbRules.ValueOfFlagIsOpaqueCommand( + verbKeyForFlagValuePaths, adjacentFlagPart))) + { + valueArg = new Arg + { + Raw = adjacentRaw, + Kind = ArgKind.DynamicSkip, + IsPath = false, + }; + } + else + { + var adjacentValueForResolution = adjacentValue; + var adjacentValueIsPath = verbKeyForFlagValuePaths is not null + && BashPerVerbRules.TryGetFlagValuePath( + verbKeyForFlagValuePaths, + adjacentFlagPart, + adjacentValue, + out adjacentValueForResolution); + var adjacentResolverValue = GetResolverValue( + t, + adjacentValueForResolution); + var (adjacentKind, adjacentResolved, adjacentIsPath) = BashResolver.Resolve( + adjacentResolverValue, + adjacentValueIsPath, + options, + workingDirectoryUnknown, + ShellResolutionConsumer.BashArgument); + valueArg = new Arg + { + Raw = adjacentRaw, + Resolved = adjacentResolved, + Kind = adjacentKind, + IsPath = adjacentIsPath, + }; + } + + argList.Add(valueArg); + elementList.Add(CreateCombinedElement( + source, + t, + lastValueToken, + adjacentFlagPart + "=" + adjacentValue, + precedingVerbTokenCount, + valueArg.Kind, + isFlag: true, + valueArg.IsPath, + valueArg.Resolved)); + i = valueEnd; + continue; } - var lastValueToken = segmentTokens[valueEnd - 1]; - var adjacentValue = valueBuilder.ToString(); - var equalsOffset = SourceSlice(source, t).IndexOf('='); - var adjacentRawStart = t.SourceStart + equalsOffset + 1; - var adjacentRaw = source.Substring( - adjacentRawStart, - lastValueToken.SourceStart + lastValueToken.SourceLength - - adjacentRawStart); - argList.Add(new Arg + // Equals-form flag-with-value: `--output=file.txt`. The + // flag half is a Literal arg with IsFlag=true (Raw + // starts with '-'); the value half is classified per + // the flag-value path rule. + if (TrySplitInlineFlag( + t, out var flagPart, out var valuePart)) { - Raw = adjacentFlagPart, - Resolved = null, - Kind = ArgKind.Literal, - IsPath = false, - }); - - Arg valueArg; - if (hasOpaqueFragment - || (hasSingleQuotedFragment - && hasNonSingleQuotedFragment - && hasSensitiveLiteralFragment) - || (verbKeyForFlagValuePaths is not null + var rawEquals = sourceRaw.IndexOf('='); + var rawValuePart = rawEquals >= 0 + ? sourceRaw.Substring(rawEquals + 1) + : valuePart; + // Flag arg. + argList.Add(new Arg + { + Raw = flagPart, + Resolved = null, + Kind = ArgKind.Literal, + IsPath = false, + }); + + // Value arg — classify via FlagValueIsPath if the + // verb owns the flag, otherwise fall back to plain + // literal (the equals-form is its own visible split, + // so we don't apply LooksLikePath here). + var inlineValueForResolution = valuePart; + var inlineValueIsOpaqueCommand = verbKeyForFlagValuePaths is not null && BashPerVerbRules.ValueOfFlagIsOpaqueCommand( - verbKeyForFlagValuePaths, adjacentFlagPart))) + verbKeyForFlagValuePaths, flagPart); + var valueIsPath = !inlineValueIsOpaqueCommand + && verbKeyForFlagValuePaths is not null + && BashPerVerbRules.TryGetFlagValuePath( + verbKeyForFlagValuePaths, + flagPart, + valuePart, + out inlineValueForResolution); + var inlineResolverValue = GetResolverValue(t, inlineValueForResolution); + var (vKind, vResolved, vIsPath) = inlineValueIsOpaqueCommand + ? (ArgKind.DynamicSkip, null, false) + : BashResolver.Resolve( + inlineResolverValue, + valueIsPath, + options, + workingDirectoryUnknown, + ShellResolutionConsumer.BashArgument); + argList.Add(new Arg + { + Raw = rawValuePart, + Resolved = vResolved, + Kind = vKind, + IsPath = vIsPath, + }); + elementList.Add(CreateElement( + source, + t, + ClauseElementRole.Argument, + precedingVerbTokenCount, + vKind, + isFlag: true, + isPath: vIsPath, + resolved: vResolved)); + + // The split form doesn't propagate to a "next-arg is + // the value" pending-state — the value already + // landed in argList. + break; + } + + if (IsFlag(sourceRaw)) { - valueArg = new Arg + // Plain flag arg. Don't bump positionalIndex. + argList.Add(new Arg { - Raw = adjacentRaw, - Kind = ArgKind.DynamicSkip, + Raw = sourceRaw, + Resolved = null, + Kind = ArgKind.Literal, IsPath = false, - }; + }); + elementList.Add(CreateElement( + source, + t, + ClauseElementRole.Argument, + precedingVerbTokenCount, + ArgKind.Literal, + isFlag: true, + isPath: false, + resolved: null)); + + // If this flag takes a value (per the verb's table), + // mark the *next* non-flag arg as that value. We + // do this whether or not the verb-chain probe + // pre-consumed it; pre-consumed pairs are also + // routed through this branch, so the pending state + // attributes correctly. + if (verbKeyForFlagValuePaths is not null + && BashVerbs.FlagsWithValue.TryGetValue(verbKeyForFlagValuePaths, out var flagsTable) + && flagsTable.Contains(sourceRaw)) + { + pendingFlagForValue = sourceRaw; + } + else + { + pendingFlagForValue = null; + } + + break; } - else + + // Non-flag positional. Classify path / resolve. + var valueForResolution = t.Value; + var valueIsOpaqueCommand = false; + bool treatAsPath; + if (pendingFlagForValue is not null && verbKeyForFlagValuePaths is not null) { - var adjacentValueForResolution = adjacentValue; - var adjacentValueIsPath = verbKeyForFlagValuePaths is not null + // This is the value of a preceding flag — use the + // flag-value rule, NOT the positional-index rule. + valueIsOpaqueCommand = BashPerVerbRules.ValueOfFlagIsOpaqueCommand( + verbKeyForFlagValuePaths, pendingFlagForValue); + treatAsPath = !valueIsOpaqueCommand && BashPerVerbRules.TryGetFlagValuePath( verbKeyForFlagValuePaths, - adjacentFlagPart, - adjacentValue, - out adjacentValueForResolution); - var (adjacentKind, adjacentResolved, adjacentIsPath) = BashResolver.Resolve( - adjacentValueForResolution, - adjacentValueIsPath, - options, - workingDirectoryUnknown, - allFragmentsSingleQuoted); - valueArg = new Arg - { - Raw = adjacentRaw, - Resolved = adjacentResolved, - Kind = adjacentKind, - IsPath = adjacentIsPath, - }; + pendingFlagForValue, + t.Value, + out valueForResolution); + pendingFlagForValue = null; + } + else + { + treatAsPath = BashPerVerbRules.IsPositionalPathArg(verb, positionalIndex, t.Value); + positionalIndex++; } - argList.Add(valueArg); - elementList.Add(CreateCombinedElement( + var resolverValue = GetResolverValue(t, valueForResolution); + var (kind, resolved, isPath) = valueIsOpaqueCommand + ? (ArgKind.DynamicSkip, null, false) + : BashResolver.Resolve( + resolverValue, + treatAsPath, + options, + workingDirectoryUnknown, + ShellResolutionConsumer.BashArgument); + argList.Add(new Arg + { + Raw = sourceRaw, + Resolved = resolved, + Kind = kind, + IsPath = isPath, + }); + elementList.Add(CreateElement( source, t, - lastValueToken, - adjacentFlagPart + "=" + adjacentValue, + ClauseElementRole.Argument, precedingVerbTokenCount, - valueArg.Kind, - isFlag: true, - valueArg.IsPath, - valueArg.Resolved)); - i = valueEnd; - continue; + kind, + isFlag: false, + isPath: isPath, + resolved: resolved)); + + break; } - // Equals-form flag-with-value: `--output=file.txt`. The - // flag half is a Literal arg with IsFlag=true (Raw - // starts with '-'); the value half is classified per - // the flag-value path rule. - if (NativeFlagSyntax.TrySplitEqualsFlag( - t.Value, out var flagPart, out var valuePart)) + case BashTokenKind.QuotedString: { - // Flag arg. - argList.Add(new Arg + var sourceRaw = SourceSlice(source, t); + + // Quoted strings never act as flags (a leading dash in + // a quoted string is the user's signal "literal"). They + // still classify as positional path / non-path through + // the per-verb rule + resolver. + var valueForResolution = t.Value; + var valueIsOpaqueCommand = false; + bool treatAsPath; + if (pendingFlagForValue is not null && verbKeyForFlagValuePaths is not null) { - Raw = flagPart, - Resolved = null, - Kind = ArgKind.Literal, - IsPath = false, - }); + valueIsOpaqueCommand = BashPerVerbRules.ValueOfFlagIsOpaqueCommand( + verbKeyForFlagValuePaths, pendingFlagForValue); + treatAsPath = !valueIsOpaqueCommand + && BashPerVerbRules.TryGetFlagValuePath( + verbKeyForFlagValuePaths, + pendingFlagForValue, + t.Value, + out valueForResolution); + pendingFlagForValue = null; + } + else + { + treatAsPath = BashPerVerbRules.IsPositionalPathArg(verb, positionalIndex, t.Value); + positionalIndex++; + } - // Value arg — classify via FlagValueIsPath if the - // verb owns the flag, otherwise fall back to plain - // literal (the equals-form is its own visible split, - // so we don't apply LooksLikePath here). - var inlineValueForResolution = valuePart; - var inlineValueIsOpaqueCommand = verbKeyForFlagValuePaths is not null - && BashPerVerbRules.ValueOfFlagIsOpaqueCommand( - verbKeyForFlagValuePaths, flagPart); - var valueIsPath = !inlineValueIsOpaqueCommand - && verbKeyForFlagValuePaths is not null - && BashPerVerbRules.TryGetFlagValuePath( - verbKeyForFlagValuePaths, - flagPart, - valuePart, - out inlineValueForResolution); - var (vKind, vResolved, vIsPath) = inlineValueIsOpaqueCommand + // Single-quoted tokens carry literal bytes per SPEC §5 + // — bypass tilde / $HOME / $VAR / glob handling so + // `'$HOME'` doesn't expand. + var resolverValue = GetResolverValue(t, valueForResolution); + var (kind, resolved, isPath) = valueIsOpaqueCommand ? (ArgKind.DynamicSkip, null, false) : BashResolver.Resolve( - inlineValueForResolution, valueIsPath, options, workingDirectoryUnknown); + resolverValue, + treatAsPath, + options, + workingDirectoryUnknown, + ShellResolutionConsumer.BashArgument); argList.Add(new Arg { - Raw = valuePart, - Resolved = vResolved, - Kind = vKind, - IsPath = vIsPath, + Raw = sourceRaw, + Resolved = resolved, + Kind = kind, + IsPath = isPath, }); elementList.Add(CreateElement( source, t, ClauseElementRole.Argument, precedingVerbTokenCount, - vKind, - isFlag: true, - isPath: vIsPath, - resolved: vResolved)); - - // The split form doesn't propagate to a "next-arg is - // the value" pending-state — the value already - // landed in argList. + kind, + isFlag: false, + isPath: isPath, + resolved: resolved)); break; } - if (IsFlag(sourceRaw)) + case BashTokenKind.OpaqueSubstitution: { - // Plain flag arg. Don't bump positionalIndex. + // Locked interpretation #2 — opaque region collapses to + // a single DynamicSkip arg. Don't bump positionalIndex + // — the opaque region replaces what would otherwise be + // one positional and the IsPath signal doesn't apply. + // Bump the positional counter for the SPEC §12 rm + // example so a *subsequent* positional gets the right + // index, though. argList.Add(new Arg { - Raw = sourceRaw, + Raw = t.Value, Resolved = null, - Kind = ArgKind.Literal, + Kind = ArgKind.DynamicSkip, IsPath = false, }); elementList.Add(CreateElement( @@ -1328,167 +1549,14 @@ private static void ExtractRedirectsAndArgs( t, ClauseElementRole.Argument, precedingVerbTokenCount, - ArgKind.Literal, - isFlag: true, + ArgKind.DynamicSkip, + isFlag: false, isPath: false, resolved: null)); - - // If this flag takes a value (per the verb's table), - // mark the *next* non-flag arg as that value. We - // do this whether or not the verb-chain probe - // pre-consumed it; pre-consumed pairs are also - // routed through this branch, so the pending state - // attributes correctly. - if (verbKeyForFlagValuePaths is not null - && BashVerbs.FlagsWithValue.TryGetValue(verbKeyForFlagValuePaths, out var flagsTable) - && flagsTable.Contains(sourceRaw)) - { - pendingFlagForValue = sourceRaw; - } - else - { - pendingFlagForValue = null; - } - - break; - } - - // Non-flag positional. Classify path / resolve. - var valueForResolution = t.Value; - var valueIsOpaqueCommand = false; - bool treatAsPath; - if (pendingFlagForValue is not null && verbKeyForFlagValuePaths is not null) - { - // This is the value of a preceding flag — use the - // flag-value rule, NOT the positional-index rule. - valueIsOpaqueCommand = BashPerVerbRules.ValueOfFlagIsOpaqueCommand( - verbKeyForFlagValuePaths, pendingFlagForValue); - treatAsPath = !valueIsOpaqueCommand - && BashPerVerbRules.TryGetFlagValuePath( - verbKeyForFlagValuePaths, - pendingFlagForValue, - t.Value, - out valueForResolution); - pendingFlagForValue = null; - } - else - { - treatAsPath = BashPerVerbRules.IsPositionalPathArg(verb, positionalIndex, t.Value); positionalIndex++; - } - - var (kind, resolved, isPath) = valueIsOpaqueCommand - ? (ArgKind.DynamicSkip, null, false) - : BashResolver.Resolve( - valueForResolution, treatAsPath, options, workingDirectoryUnknown); - argList.Add(new Arg - { - Raw = sourceRaw, - Resolved = resolved, - Kind = kind, - IsPath = isPath, - }); - elementList.Add(CreateElement( - source, - t, - ClauseElementRole.Argument, - precedingVerbTokenCount, - kind, - isFlag: false, - isPath: isPath, - resolved: resolved)); - - break; - } - - case BashTokenKind.QuotedString: - { - var sourceRaw = SourceSlice(source, t); - - // Quoted strings never act as flags (a leading dash in - // a quoted string is the user's signal "literal"). They - // still classify as positional path / non-path through - // the per-verb rule + resolver. - var valueForResolution = t.Value; - var valueIsOpaqueCommand = false; - bool treatAsPath; - if (pendingFlagForValue is not null && verbKeyForFlagValuePaths is not null) - { - valueIsOpaqueCommand = BashPerVerbRules.ValueOfFlagIsOpaqueCommand( - verbKeyForFlagValuePaths, pendingFlagForValue); - treatAsPath = !valueIsOpaqueCommand - && BashPerVerbRules.TryGetFlagValuePath( - verbKeyForFlagValuePaths, - pendingFlagForValue, - t.Value, - out valueForResolution); pendingFlagForValue = null; + break; } - else - { - treatAsPath = BashPerVerbRules.IsPositionalPathArg(verb, positionalIndex, t.Value); - positionalIndex++; - } - - // Single-quoted tokens carry literal bytes per SPEC §5 - // — bypass tilde / $HOME / $VAR / glob handling so - // `'$HOME'` doesn't expand. - var (kind, resolved, isPath) = valueIsOpaqueCommand - ? (ArgKind.DynamicSkip, null, false) - : BashResolver.Resolve( - valueForResolution, - treatAsPath, - options, - workingDirectoryUnknown, - t.IsSingleQuoted); - argList.Add(new Arg - { - Raw = sourceRaw, - Resolved = resolved, - Kind = kind, - IsPath = isPath, - }); - elementList.Add(CreateElement( - source, - t, - ClauseElementRole.Argument, - precedingVerbTokenCount, - kind, - isFlag: false, - isPath: isPath, - resolved: resolved)); - break; - } - - case BashTokenKind.OpaqueSubstitution: - { - // Locked interpretation #2 — opaque region collapses to - // a single DynamicSkip arg. Don't bump positionalIndex - // — the opaque region replaces what would otherwise be - // one positional and the IsPath signal doesn't apply. - // Bump the positional counter for the SPEC §12 rm - // example so a *subsequent* positional gets the right - // index, though. - argList.Add(new Arg - { - Raw = t.Value, - Resolved = null, - Kind = ArgKind.DynamicSkip, - IsPath = false, - }); - elementList.Add(CreateElement( - source, - t, - ClauseElementRole.Argument, - precedingVerbTokenCount, - ArgKind.DynamicSkip, - isFlag: false, - isPath: false, - resolved: null)); - positionalIndex++; - pendingFlagForValue = null; - break; - } default: break; @@ -1535,7 +1603,9 @@ private static void BuildRedirect( return; } - if (IsFdDupTarget(target.Value)) + if (target.Kind == BashTokenKind.Word + && string.Equals(SourceSlice(source, target), target.Value, StringComparison.Ordinal) + && IsFdDupTarget(target.Value)) { // POSIX fd-dup / fd-close shorthand: `&N`, `&N-`, `&-`. These // duplicate or close a file descriptor; they are NOT file paths @@ -1566,8 +1636,13 @@ private static void BuildRedirect( // locked interpretation #3: a glob target stays IsPath=true with // Kind=Glob; an env-var target becomes DynamicSkip; a literal // resolves against WorkingDirectory. + var resolverValue = GetResolverValue(target, target.Value); var (kind, resolved, isPath) = BashResolver.Resolve( - target.Value, treatAsPath: true, options, workingDirectoryUnknown); + resolverValue, + treatAsPath: true, + options, + workingDirectoryUnknown, + ShellResolutionConsumer.BashRedirect); bool isDynamic; string redirectTarget; @@ -1607,18 +1682,18 @@ private static ClauseElement CreateElement( bool isFlag, bool isPath, string? resolved) => new() - { - Raw = SourceSlice(source, token), - Value = token.Value, - Role = role, - SourceStart = token.SourceStart, - SourceLength = token.SourceLength, - PrecedingVerbElementCount = precedingVerbTokenCount, - Kind = kind, - IsFlag = isFlag, - IsPath = isPath, - Resolved = resolved, - }; + { + Raw = SourceSlice(source, token), + Value = token.Value, + Role = role, + SourceStart = token.SourceStart, + SourceLength = token.SourceLength, + PrecedingVerbElementCount = precedingVerbTokenCount, + Kind = kind, + IsFlag = isFlag, + IsPath = isPath, + Resolved = resolved, + }; private static ClauseElement CreateCombinedElement( string source, @@ -1674,6 +1749,45 @@ private static ClauseElement CreateRedirectElement( }; } + private static ShellValue GetResolverValue(BashToken token, string logicalValue) + { + var value = token.ResolverValue + ?? ShellValue.Literal(token.Value, token.SourceStart, token.SourceLength); + if (string.Equals(value.Decoded, logicalValue, StringComparison.Ordinal)) + { + return value; + } + + var prefixLength = value.Decoded.Length - logicalValue.Length; + if (prefixLength >= 0 + && value.Decoded.EndsWith(logicalValue, StringComparison.Ordinal)) + { + return value.Slice(prefixLength); + } + + return ShellValue.Opaque(logicalValue, ShellOpaqueCause.Unsupported); + } + + private static bool TrySplitInlineFlag( + BashToken token, out string flagPart, out string valuePart) + { + if (NativeFlagSyntax.TrySplitEqualsFlag( + token.Value, out flagPart, out valuePart)) + { + return true; + } + + if (!NativeFlagSyntax.TrySplitEqualsPrefix( + token.Value, out flagPart, out valuePart)) + { + return false; + } + + var equals = token.Value.IndexOf('='); + return token.ResolverValue is not null + && token.ResolverValue.Slice(equals + 1).Fragments.Count > 0; + } + private static bool IsFlag(string raw) => raw.Length > 0 && raw[0] == '-'; diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs index 878e889..f7da416 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Text; using ShellSyntaxTree.Internal.Lexing; +using ShellSyntaxTree.Internal.Resolving; namespace ShellSyntaxTree.Internal.Pwsh.Lexing; @@ -85,7 +86,7 @@ internal static IReadOnlyList Tokenize(string input) tokens.Add(new PwshToken( PwshTokenKind.Whitespace, "", null, start, i - start, null) - { IsStatementSeparator = true }); + { IsStatementSeparator = true }); continue; } @@ -321,7 +322,8 @@ private static int ReadSingleQuoted( { // Single quotes preserve bytes literally (SPEC §5). A doubled '' is // an escaped single quote. - var sb = new StringBuilder(); + var value = new ShellValueBuilder(); + value.AppendBoundary(start + 1); var i = start + 1; while (i < src.Length) { @@ -329,18 +331,23 @@ private static int ReadSingleQuoted( { if (i + 1 < src.Length && src[i + 1] == '\'') { - sb.Append('\''); + value.AppendLiteral('\'', i, 2); i += 2; continue; } + var resolverValue = value.Build(); tokens.Add(new PwshToken( - PwshTokenKind.QuotedString, sb.ToString(), null, - start, (i - start) + 1, null) { IsSingleQuoted = true }); + PwshTokenKind.QuotedString, resolverValue.Decoded, null, + start, (i - start) + 1, null) + { + IsSingleQuoted = true, + ResolverValue = resolverValue, + }); return i + 1; } - sb.Append(src[i]); + value.AppendLiteral(src[i], i, 1); i++; } @@ -355,9 +362,10 @@ private static int ReadDoubleQuoted( ReadOnlySpan src, int start, List tokens) { // Double quotes allow backtick escapes and recognize $var / $(...) - // interpolation, but the parser does NOT expand — $var stays literal - // in the value (SPEC §5). - var sb = new StringBuilder(); + // interpolation. The lexer does not evaluate it; the typed region is + // retained for the resolver. + var value = new ShellValueBuilder(); + value.AppendBoundary(start + 1); var hasInterpolation = false; var i = start + 1; while (i < src.Length) @@ -368,15 +376,19 @@ private static int ReadDoubleQuoted( // A doubled "" is an escaped double quote. if (i + 1 < src.Length && src[i + 1] == '"') { - sb.Append('"'); + value.AppendLiteral('"', i, 2); i += 2; continue; } + var resolverValue = value.Build(); tokens.Add(new PwshToken( - PwshTokenKind.QuotedString, sb.ToString(), null, + PwshTokenKind.QuotedString, resolverValue.Decoded, null, start, (i - start) + 1, null) - { HasInterpolation = hasInterpolation }); + { + HasInterpolation = hasInterpolation, + ResolverValue = resolverValue, + }); return i + 1; } @@ -388,16 +400,32 @@ private static int ReadDoubleQuoted( return src.Length; } - i = AppendBacktickEscape(src, i, sb); + i = AppendBacktickEscapeFragment(src, i, value, 0); continue; } if (c == '$' && StartsInterpolation(src, i)) { hasInterpolation = true; + if (TryAppendPwshExpansion(src, ref i, value, out var error)) + { + if (error is not null) + { + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), + null, + start, + src.Length - start, + error)); + return src.Length; + } + + continue; + } } - sb.Append(c); + value.AppendLiteral(c, i, 1); i++; } @@ -459,7 +487,14 @@ private static int TryReadAtConstruct( tokens.Add(new PwshToken( PwshTokenKind.Splat, src.Slice(start, i - start).ToString(), - null, start, i - start, null)); + null, start, i - start, null) + { + ResolverValue = ShellValue.Opaque( + src.Slice(start, i - start).ToString(), + ShellOpaqueCause.Splat, + start, + i - start), + }); return i; } @@ -519,10 +554,16 @@ private static int TryReadHereString( : ReadOnlySpan.Empty; var hasInterpolation = false; var invalidUnicodeAt = -1; - var body = quote == '"' - ? DecodeExpandableString( - bodySpan, out hasInterpolation, out invalidUnicodeAt) - : bodySpan.ToString(); + string? interpolationError = null; + var resolverValue = quote == '"' + ? DecodeExpandableValue( + bodySpan, + bodyStart, + out hasInterpolation, + out invalidUnicodeAt, + out interpolationError) + : ShellValue.Literal( + bodySpan.ToString(), bodyStart, bodySpan.Length); if (quote == '"' && invalidUnicodeAt >= 0) { var sourcePosition = bodyStart + invalidUnicodeAt; @@ -530,15 +571,28 @@ private static int TryReadHereString( return src.Length; } + if (interpolationError is not null) + { + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), + null, + start, + src.Length - start, + interpolationError)); + return src.Length; + } + var end = k + 2; // past quote + '@' tokens.Add(new PwshToken( - PwshTokenKind.QuotedString, body, null, + PwshTokenKind.QuotedString, resolverValue.Decoded, null, start, end - start, null) - { - IsHereString = true, - IsSingleQuoted = quote == '\'', - HasInterpolation = hasInterpolation, - }); + { + IsHereString = true, + IsSingleQuoted = quote == '\'', + HasInterpolation = hasInterpolation, + ResolverValue = resolverValue, + }); return end; } @@ -552,12 +606,18 @@ private static int TryReadHereString( return src.Length; } - private static string DecodeExpandableString( - ReadOnlySpan value, out bool hasInterpolation, out int invalidUnicodeAt) + private static ShellValue DecodeExpandableValue( + ReadOnlySpan value, + int? sourceStart, + out bool hasInterpolation, + out int invalidUnicodeAt, + out string? interpolationError) { - var decoded = new StringBuilder(value.Length); + var decoded = new ShellValueBuilder(); + decoded.AppendBoundary(sourceStart); hasInterpolation = false; invalidUnicodeAt = -1; + interpolationError = null; for (var i = 0; i < value.Length; i++) { if (value[i] == '`' && i + 1 < value.Length) @@ -565,30 +625,51 @@ private static string DecodeExpandableString( if (IsMalformedUnicodeEscape(value, i)) { invalidUnicodeAt = i; - return decoded.ToString(); + return decoded.Build(); } - i = AppendBacktickEscape(value, i, decoded) - 1; + i = AppendBacktickEscapeFragment(value, i, decoded, sourceStart) - 1; continue; } if (value[i] == '$' && StartsInterpolation(value, i)) { hasInterpolation = true; + var expansionIndex = i; + if (TryAppendPwshExpansion( + value, + ref expansionIndex, + decoded, + out interpolationError, + sourceStart)) + { + if (interpolationError is not null) + { + return decoded.Build(); + } + + i = expansionIndex - 1; + continue; + } } - decoded.Append(value[i]); + decoded.AppendLiteral(value[i], sourceStart + i, 1); } - return decoded.ToString(); + return decoded.Build(); } internal static bool TryDecodeExpandableValue( string value, out string decoded, out bool hasInterpolation) { - decoded = DecodeExpandableString( - value.AsSpan(), out hasInterpolation, out var invalidUnicodeAt); - return invalidUnicodeAt < 0; + var resolverValue = DecodeExpandableValue( + value.AsSpan(), + null, + out hasInterpolation, + out var invalidUnicodeAt, + out var interpolationError); + decoded = resolverValue.Decoded; + return invalidUnicodeAt < 0 && interpolationError is null; } private static bool StartsInterpolation(ReadOnlySpan value, int dollarIndex) @@ -603,6 +684,133 @@ private static bool StartsInterpolation(ReadOnlySpan value, int dollarInde || char.IsLetterOrDigit(next); } + private static bool TryAppendPwshExpansion( + ReadOnlySpan value, + ref int index, + ShellValueBuilder target, + out string? error, + int? sourceOffset = 0) + { + error = null; + var start = index; + if (start + 1 >= value.Length || value[start] != '$') + { + return false; + } + + var next = value[start + 1]; + if (next == '(') + { + var scan = OpaqueRegionScanner.Scan( + value, + start + 1, + '(', + ')', + OpaqueRegionScanner.PwshEscape); + if (!scan.Closed) + { + error = "unbalanced '$(' subexpression"; + index = value.Length; + return true; + } + + var subexpressionLength = scan.EndIndex - start + 1; + target.AppendOpaque( + value.Slice(start, subexpressionLength).ToString(), + ShellOpaqueCause.PowerShellSubexpression, + sourceOffset + start, + subexpressionLength); + index += subexpressionLength; + return true; + } + + string name; + int length; + if (next == '{') + { + var scan = OpaqueRegionScanner.Scan( + value, + start + 1, + '{', + '}', + OpaqueRegionScanner.PwshEscape); + if (!scan.Closed) + { + error = "unbalanced '${' variable interpolation"; + index = value.Length; + return true; + } + + length = scan.EndIndex - start + 1; + name = value.Slice(start + 2, length - 3).ToString(); + if (name.Length == 0) + { + error = "empty '${}' variable interpolation"; + index += length; + return true; + } + } + else if (next is '?' or '^' or '$') + { + length = 2; + name = next.ToString(); + } + else if (next == '_' || char.IsLetterOrDigit(next)) + { + var end = start + 2; + while (end < value.Length + && (value[end] == '_' || char.IsLetterOrDigit(value[end]))) + { + end++; + } + + if (end < value.Length && value[end] == ':') + { + end++; + while (end < value.Length + && (value[end] == '_' || char.IsLetterOrDigit(value[end]))) + { + end++; + } + } + + length = end - start; + name = value.Slice(start + 1, length - 1).ToString(); + } + else + { + return false; + } + + var kind = name is "?" or "^" or "$" + ? ShellExpansionKind.SpecialParameter + : ShellExpansionKind.Variable; + target.AppendExpansion( + value.Slice(start, length).ToString(), + ShellLexicalTransform.Variable, + new ShellExpansionReference(kind, name), + ShellValueCardinality.ExactlyOne, + sourceOffset + start, + length); + index += length; + return true; + } + + private static int AppendBacktickEscapeFragment( + ReadOnlySpan value, + int backtickIndex, + ShellValueBuilder target, + int? sourceStart) + { + var decoded = new StringBuilder(); + var end = AppendBacktickEscape(value, backtickIndex, decoded); + target.AppendLiteral( + decoded.ToString(), + sourceStart + backtickIndex, + end - backtickIndex); + return end; + } + private static int AppendBacktickEscape( ReadOnlySpan value, int backtickIndex, StringBuilder target) { @@ -736,7 +944,16 @@ private static int ConsumeBalancedRegion( var length = scan.EndIndex - start + 1; tokens.Add(new PwshToken( - kind, src.Slice(start, length).ToString(), null, start, length, null)); + kind, src.Slice(start, length).ToString(), null, start, length, null) + { + ResolverValue = ShellValue.Opaque( + src.Slice(start, length).ToString(), + kind == PwshTokenKind.Subexpression + ? ShellOpaqueCause.PowerShellSubexpression + : ShellOpaqueCause.Unsupported, + start, + length), + }); return start + length; } @@ -865,9 +1082,11 @@ private static int ReadParameter( // Keep an unquoted inline value attached to its source token. The // parser interprets ':' only for cmdlet-style parameters and '=' // only for native options. + var valueStart = -1; if (i < src.Length && (src[i] == ':' || src[i] == '=')) { i++; + valueStart = i; i = ScanWordRun(src, i, out var invalidUnicodeAt); if (invalidUnicodeAt >= 0) { @@ -876,9 +1095,117 @@ private static int ReadParameter( } } + var resolverValue = ShellValue.Literal( + src.Slice(start, i - start).ToString(), + start, + i - start); + var hasInterpolation = false; + if (valueStart >= 0) + { + var valueBuilder = new ShellValueBuilder(); + valueBuilder.AppendLiteral( + src.Slice(start, valueStart - start).ToString(), + start, + valueStart - start); + var valueIndex = valueStart; + while (valueIndex < i) + { + var character = src[valueIndex]; + if (character == '`' && valueIndex + 1 < i) + { + valueIndex = AppendBacktickEscapeFragment( + src, + valueIndex, + valueBuilder, + 0); + continue; + } + + if (character == '$' && StartsInterpolation(src, valueIndex)) + { + hasInterpolation = true; + if (TryAppendPwshExpansion( + src, + ref valueIndex, + valueBuilder, + out var interpolationError)) + { + if (interpolationError is not null) + { + resolverValue = ShellValue.Opaque( + src.Slice(start, i - start).ToString(), + ShellOpaqueCause.Unsupported, + start, + i - start); + break; + } + + if (IsPowerShellExpressionSuffix(src, valueIndex, i)) + { + valueBuilder.AppendOpaque( + src.Slice(valueIndex, i - valueIndex).ToString(), + ShellOpaqueCause.PowerShellExpressionSuffix, + valueIndex, + i - valueIndex); + valueIndex = i; + } + + continue; + } + } + + if (character == '~' && valueIndex == valueStart) + { + valueBuilder.AppendExpansion( + "~", + ShellLexicalTransform.Tilde, + new ShellExpansionReference(ShellExpansionKind.Tilde, null), + ShellValueCardinality.ExactlyOne, + valueIndex, + 1); + } + else if (character is '*' or '?' or '[') + { + valueBuilder.AppendExpansion( + character.ToString(), + ShellLexicalTransform.Glob, + new ShellExpansionReference(ShellExpansionKind.Glob, null), + ShellValueCardinality.ZeroOrMore, + valueIndex, + 1); + } + else if (character == ',') + { + valueBuilder.AppendExpansion( + ",", + ShellLexicalTransform.FieldSplit, + new ShellExpansionReference(ShellExpansionKind.ArraySeparator, null), + ShellValueCardinality.ZeroOrMore, + valueIndex, + 1); + } + else + { + valueBuilder.AppendLiteral(character, valueIndex, 1); + } + + valueIndex++; + } + + if (resolverValue.Fragments.Count == 1 + && resolverValue.Fragments[0].Kind == ShellValueFragmentKind.Literal) + { + resolverValue = valueBuilder.Build(); + } + } + tokens.Add(new PwshToken( PwshTokenKind.Parameter, src.Slice(start, i - start).ToString(), - null, start, i - start, null)); + null, start, i - start, null) + { + HasInterpolation = hasInterpolation, + ResolverValue = resolverValue, + }); return i; } @@ -887,7 +1214,7 @@ private static int ReadParameter( private static int ReadWord( ReadOnlySpan src, int start, List tokens) { - var sb = new StringBuilder(); + var value = new ShellValueBuilder(); var hasInterpolation = false; var i = start; while (i < src.Length) @@ -904,7 +1231,7 @@ private static int ReadWord( { if (i + 1 >= src.Length) { - sb.Append('`'); + value.AppendLiteral('`', i, 1); i++; break; } @@ -921,7 +1248,7 @@ private static int ReadWord( return src.Length; } - i = AppendBacktickEscape(src, i, sb); + i = AppendBacktickEscapeFragment(src, i, value, 0); continue; } @@ -939,41 +1266,138 @@ private static int ReadWord( src, i + 1, '{', '}', OpaqueRegionScanner.PwshEscape); if (!scan.Closed) { - // Unbalanced — emit the $ literally and let the outer - // loop reach the '{' and produce a sentinel. - sb.Append('$'); - i++; - continue; + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(i).ToString(), + null, + i, + src.Length - i, + "unbalanced '${' variable interpolation")); + return src.Length; } - var braceLen = scan.EndIndex - i + 1; - for (var k = 0; k < braceLen; k++) + if (TryAppendPwshExpansion(src, ref i, value, out var error)) { - sb.Append(src[i + k]); - } + if (error is not null) + { + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), + null, + start, + src.Length - start, + error)); + return src.Length; + } + + if (IsPowerShellExpressionSuffix(src, i, src.Length)) + { + var suffixEnd = i; + while (suffixEnd < src.Length && !IsWordBoundary(src[suffixEnd])) + { + suffixEnd++; + } + + value.AppendOpaque( + src.Slice(i, suffixEnd - i).ToString(), + ShellOpaqueCause.PowerShellExpressionSuffix, + i, + suffixEnd - i); + i = suffixEnd; + } - i += braceLen; - continue; + continue; + } } if (c == '$' && StartsInterpolation(src, i)) { hasInterpolation = true; + if (TryAppendPwshExpansion(src, ref i, value, out var error)) + { + if (error is not null) + { + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), + null, + start, + src.Length - start, + error)); + return src.Length; + } + + if (IsPowerShellExpressionSuffix(src, i, src.Length)) + { + var suffixEnd = i; + while (suffixEnd < src.Length && !IsWordBoundary(src[suffixEnd])) + { + suffixEnd++; + } + + value.AppendOpaque( + src.Slice(i, suffixEnd - i).ToString(), + ShellOpaqueCause.PowerShellExpressionSuffix, + i, + suffixEnd - i); + i = suffixEnd; + } + + continue; + } + } + + if (c == '~' && i == start) + { + value.AppendExpansion( + "~", + ShellLexicalTransform.Tilde, + new ShellExpansionReference(ShellExpansionKind.Tilde, null), + ShellValueCardinality.ExactlyOne, + i, + 1); + } + else if (c is '*' or '?' or '[') + { + value.AppendExpansion( + c.ToString(), + ShellLexicalTransform.Glob, + new ShellExpansionReference(ShellExpansionKind.Glob, null), + ShellValueCardinality.ZeroOrMore, + i, + 1); + } + else if (c == ',') + { + value.AppendExpansion( + ",", + ShellLexicalTransform.FieldSplit, + new ShellExpansionReference(ShellExpansionKind.ArraySeparator, null), + ShellValueCardinality.ZeroOrMore, + i, + 1); + } + else + { + value.AppendLiteral(c, i, 1); } - sb.Append(c); i++; } - if (sb.Length == 0) + var resolverValue = value.Build(); + if (resolverValue.Decoded.Length == 0) { // Defensive: make progress on a char the dispatcher missed. return start + 1; } tokens.Add(new PwshToken( - PwshTokenKind.Word, sb.ToString(), null, start, i - start, null) - { HasInterpolation = hasInterpolation }); + PwshTokenKind.Word, resolverValue.Decoded, null, start, i - start, null) + { + HasInterpolation = hasInterpolation, + ResolverValue = resolverValue, + }); return i; } @@ -1047,6 +1471,13 @@ private static int ScanWordRun( return i; } + private static bool IsPowerShellExpressionSuffix( + ReadOnlySpan source, int index, int end) => + index < end && (source[index] == '[' + || (source[index] == '.' + && index + 1 < end + && (source[index + 1] == '_' || char.IsLetter(source[index + 1])))); + private static bool IsWordBoundary(char c) { switch (c) diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs index 623e525..ed8e188 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs @@ -3,6 +3,8 @@ // Copyright (C) 2026 - 2026 Aaron Stannard // // ----------------------------------------------------------------------- +using ShellSyntaxTree.Internal.Resolving; + namespace ShellSyntaxTree.Internal.Pwsh.Lexing; /// @@ -59,6 +61,12 @@ internal readonly record struct PwshToken( /// public bool HasInterpolation { get; init; } + /// + /// Resolver-relevant decoded fragments. Null only for token kinds that + /// never carry an argument value. + /// + public ShellValue? ResolverValue { get; init; } + /// /// True when this token contains /// a newline and therefore acts as a statement separator equivalent to diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index 0c37dc5..111db11 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -175,6 +175,34 @@ private static List FilterSignificant(IReadOnlyList tokens continue; } + if (filtered.Count > 0 + && IsNativeArgumentFragment(filtered[filtered.Count - 1]) + && IsNativeArgumentFragment(t) + && IsAdjacent(filtered[filtered.Count - 1], t)) + { + var previous = filtered[filtered.Count - 1]; + var previousValue = previous.ResolverValue + ?? ShellValue.Literal(previous.Value, previous.SourceStart, previous.SourceLength); + var currentValue = t.ResolverValue + ?? ShellValue.Opaque( + t.Value, + ShellOpaqueCause.Unsupported, + t.SourceStart, + t.SourceLength); + filtered[filtered.Count - 1] = new PwshToken( + PwshTokenKind.Word, + previous.Value + t.Value, + null, + previous.SourceStart, + t.SourceStart + t.SourceLength - previous.SourceStart, + null) + { + HasInterpolation = previous.HasInterpolation || t.HasInterpolation, + ResolverValue = ShellValue.Concat(new[] { previousValue, currentValue }), + }; + continue; + } + filtered.Add(t); } @@ -727,6 +755,8 @@ private readonly struct ClassifiedVerb public bool IsDynamic { get; init; } + public bool BindingSemanticsProven { get; init; } + /// Body indices that are verb-chain tokens (skipped as args). public HashSet VerbPositions { get; init; } } @@ -754,6 +784,7 @@ private static ClassifiedVerb ClassifyVerb(List body, int start) var isVariableWord = head.Kind == PwshTokenKind.Word && head.Value.Length > 0 && head.Value[0] == '$'; if (isVariableWord || head.HasInterpolation + || head.ResolverValue?.HasOpaqueFragment == true || head.Kind is PwshTokenKind.ScriptBlock or PwshTokenKind.Subexpression or PwshTokenKind.Splat) { @@ -785,6 +816,8 @@ private static ClassifiedVerb ClassifyVerb(List body, int start) { Kind = PwshCommandKind.Cmdlet, VerbTokens = new List { word }, + BindingSemanticsProven = PwshVerbs.FileVerbs.Contains(word) + || PwshAliases.IsKnownCanonical(word), VerbPositions = verbPositions, }; } @@ -797,6 +830,7 @@ private static ClassifiedVerb ClassifyVerb(List body, int start) Kind = PwshCommandKind.Alias, VerbTokens = new List { word }, CanonicalVerb = alias, + BindingSemanticsProven = true, VerbPositions = verbPositions, }; } @@ -807,6 +841,7 @@ private static ClassifiedVerb ClassifyVerb(List body, int start) { Kind = PwshCommandKind.PwshInvocation, VerbTokens = new List { word }, + BindingSemanticsProven = true, VerbPositions = verbPositions, }; } @@ -907,6 +942,7 @@ private static ArgResult ExtractArgsAndRedirects( var precedingVerbTokenCount = 0; string? pendingValueParam = null; // cmdlet/alias §6.5 value-binding string? pendingNativeFlag = null; // native flag-with-value + var pendingAmbiguousBinding = false; var cmdletStyle = verb.Kind is PwshCommandKind.Cmdlet or PwshCommandKind.Alias or PwshCommandKind.PwshInvocation; var canonical = verb.CanonicalVerb ?? (verb.VerbTokens.Count > 0 ? verb.VerbTokens[0] : null); @@ -946,6 +982,7 @@ private static ArgResult ExtractArgsAndRedirects( pendingValueParam = null; pendingNativeFlag = null; + pendingAmbiguousBinding = false; var consumed = BuildRedirect( body, i, @@ -972,6 +1009,7 @@ private static ArgResult ExtractArgsAndRedirects( { pendingValueParam = null; pendingNativeFlag = null; + pendingAmbiguousBinding = false; var raw = t.Value; var bindingSeparator = FirstBindingSeparator(raw); @@ -996,8 +1034,108 @@ private static ArgResult ExtractArgsAndRedirects( var colon = raw.IndexOf(':'); var paramName = colon > 0 ? raw.Substring(0, colon) : raw; var colonValue = colon > 0 ? raw.Substring(colon + 1) : null; + var binding = PwshBindingTables.Resolve(canonical, paramName); + + if (colonValue is not null + && colonValue.Length == 0 + && i + 1 < body.Count + && IsAdjacent(t, body[i + 1]) + && IsNativeArgumentFragment(body[i + 1])) + { + var valueEnd = i + 1; + var valueBuilder = new StringBuilder(); + var resolverValues = new List(); + var previous = t; + while (valueEnd < body.Count + && IsAdjacent(previous, body[valueEnd]) + && IsNativeArgumentFragment(body[valueEnd])) + { + var fragment = body[valueEnd]; + valueBuilder.Append(fragment.Value); + resolverValues.Add(fragment.ResolverValue + ?? ShellValue.Opaque( + fragment.Value, + ShellOpaqueCause.Unsupported, + fragment.SourceStart, + fragment.SourceLength)); + previous = fragment; + valueEnd++; + } + + var lastValueToken = body[valueEnd - 1]; + var logicalValue = valueBuilder.ToString(); + var resolverValue = ShellValue.Concat(resolverValues); + var canonicalParameter = binding.CanonicalName ?? paramName; + var valueIsPath = PwshPerVerbRules.ParameterValueIsPath( + canonical, + canonicalParameter); + var inlineConsumer = string.Equals( + canonicalParameter, + "-LiteralPath", + StringComparison.OrdinalIgnoreCase) + ? ShellResolutionConsumer.PowerShellCmdletLiteralPath + : ShellResolutionConsumer.PowerShellCmdletPath; + var rawInlineValue = source.Substring( + body[i + 1].SourceStart, + lastValueToken.SourceStart + lastValueToken.SourceLength + - body[i + 1].SourceStart); + args.Add(new Arg + { + Raw = paramName, + Kind = ArgKind.Literal, + IsPath = false, + }); + + Arg valueArgument; + if (binding.IsAmbiguous + || resolverValue.HasOpaqueFragment + || (valueIsPath && !verb.BindingSemanticsProven)) + { + valueArgument = new Arg + { + Raw = rawInlineValue, + Kind = ArgKind.DynamicSkip, + IsPath = false, + }; + } + else + { + var (kind, resolved, isPath) = PwshResolver.Resolve( + resolverValue, + valueIsPath, + options, + workingDirectoryUnknown, + inlineConsumer); + valueArgument = new Arg + { + Raw = rawInlineValue, + Kind = kind, + Resolved = resolved, + IsPath = isPath, + }; + } - args.Add(new Arg { Raw = paramName, Kind = ArgKind.Literal, IsPath = false }); + args.Add(valueArgument); + elements.Add(CreateCombinedElement( + source, + t, + lastValueToken, + paramName + ":" + logicalValue, + precedingVerbTokenCount, + valueArgument.Kind, + isFlag: true, + valueArgument.IsPath, + valueArgument.Resolved)); + i = valueEnd - 1; + continue; + } + + args.Add(new Arg + { + Raw = paramName, + Kind = binding.IsAmbiguous ? ArgKind.DynamicSkip : ArgKind.Literal, + IsPath = false, + }); if (colonValue is not null && paramName.IndexOf('=') >= 0) { @@ -1016,12 +1154,44 @@ private static ArgResult ExtractArgsAndRedirects( else if (colonValue is not null) { // Colon form always binds (§6.5.3 rule 1). - var valueIsPath = PwshPerVerbRules.ParameterValueIsPath(canonical, paramName); - args.Add(ResolveValue(colonValue, valueIsPath, options, workingDirectoryUnknown, false)); + var canonicalParameter = binding.CanonicalName ?? paramName; + var valueIsPath = PwshPerVerbRules.ParameterValueIsPath( + canonical, + canonicalParameter); + var inlineConsumer = string.Equals( + canonicalParameter, + "-LiteralPath", + StringComparison.OrdinalIgnoreCase) + ? ShellResolutionConsumer.PowerShellCmdletLiteralPath + : ShellResolutionConsumer.PowerShellCmdletPath; + args.Add(binding.IsAmbiguous + ? new Arg + { + Raw = colonValue, + Kind = ArgKind.DynamicSkip, + IsPath = false, + } + : ResolveValueToken( + colonValue, + colonValue, + valueIsPath, + options, + workingDirectoryUnknown, + false, + t, + inlineConsumer, + verb.BindingSemanticsProven)); } - else if (PwshBindingTables.ResolveBinding(canonical, paramName) == PwshBinding.Value) + else { - pendingValueParam = paramName; + if (binding.IsAmbiguous) + { + pendingAmbiguousBinding = true; + } + else if (binding.Binding == PwshBinding.Value) + { + pendingValueParam = binding.CanonicalName ?? paramName; + } } } else @@ -1035,19 +1205,12 @@ private static ArgResult ExtractArgsAndRedirects( raw, out var adjacentFlagPart, out var adjacentValuePrefix) && i + 1 < body.Count && IsAdjacent(t, body[i + 1]) - && body[i + 1].Kind is PwshTokenKind.QuotedString - or PwshTokenKind.ScriptBlock - or PwshTokenKind.Subexpression - or PwshTokenKind.Splat) + && IsNativeArgumentFragment(body[i + 1])) { var valueStart = i + 1; var valueEnd = valueStart; var valueBuilder = new StringBuilder(adjacentValuePrefix); var hasOpaqueFragment = false; - var allFragmentsSingleQuoted = adjacentValuePrefix.Length == 0; - var hasSingleQuotedFragment = false; - var hasNonSingleQuotedFragment = adjacentValuePrefix.Length > 0; - var hasSensitiveLiteralFragment = false; var previousFragment = t; while (valueEnd < body.Count && IsAdjacent(previousFragment, body[valueEnd]) @@ -1057,21 +1220,26 @@ or PwshTokenKind.Subexpression valueBuilder.Append(fragment.Value); hasOpaqueFragment |= fragment.Kind != PwshTokenKind.Word && fragment.Kind != PwshTokenKind.QuotedString; - hasSingleQuotedFragment |= fragment.Kind == PwshTokenKind.QuotedString - && fragment.IsSingleQuoted; - hasNonSingleQuotedFragment |= fragment.Kind != PwshTokenKind.QuotedString - || !fragment.IsSingleQuoted; - hasSensitiveLiteralFragment |= fragment.Kind == PwshTokenKind.QuotedString - && fragment.IsSingleQuoted - && NativeFlagSyntax.ContainsResolverSensitiveLiteralSyntax(fragment.Value); - allFragmentsSingleQuoted &= fragment.Kind == PwshTokenKind.QuotedString - && fragment.IsSingleQuoted; previousFragment = fragment; valueEnd++; } var lastValueToken = body[valueEnd - 1]; var adjacentValue = valueBuilder.ToString(); + var prefixResolverValue = GetResolverValue(t, adjacentValuePrefix); + var resolverFragments = new List { prefixResolverValue }; + for (var fragmentIndex = valueStart; fragmentIndex < valueEnd; fragmentIndex++) + { + var fragmentToken = body[fragmentIndex]; + resolverFragments.Add(fragmentToken.ResolverValue + ?? ShellValue.Opaque( + fragmentToken.Value, + ShellOpaqueCause.Unsupported, + fragmentToken.SourceStart, + fragmentToken.SourceLength)); + } + + var adjacentResolverValue = ShellValue.Concat(resolverFragments); var equalsOffset = SourceSlice(source, t).IndexOf('='); var adjacentRawStart = t.SourceStart + equalsOffset + 1; var adjacentRaw = source.Substring( @@ -1087,9 +1255,8 @@ or PwshTokenKind.Subexpression Arg valueArg; if (hasOpaqueFragment - || (hasSingleQuotedFragment - && hasNonSingleQuotedFragment - && hasSensitiveLiteralFragment) + || adjacentResolverValue.HasOpaqueFragmentOtherThan( + ShellOpaqueCause.PowerShellExpressionSuffix) || BashPerVerbRules.ValueOfFlagIsOpaqueCommand( verbKey, adjacentFlagPart)) { @@ -1108,13 +1275,23 @@ or PwshTokenKind.Subexpression adjacentFlagPart, adjacentValue, out adjacentValueForResolution); - valueArg = ResolveValueToken( - adjacentRaw, + var effectiveResolverValue = GetResolverValue( + adjacentResolverValue, adjacentValueForResolution, + preserveLiteralPrefixBoundary: true); + var (kind, resolved, isPath) = PwshResolver.Resolve( + effectiveResolverValue, adjacentValueIsPath, options, workingDirectoryUnknown, - allFragmentsSingleQuoted); + ShellResolutionConsumer.PowerShellNativeArgument); + valueArg = new Arg + { + Raw = adjacentRaw, + Kind = kind, + Resolved = resolved, + IsPath = isPath, + }; } args.Add(valueArg); @@ -1157,7 +1334,8 @@ or PwshTokenKind.Subexpression valueIsPath, options, workingDirectoryUnknown, - false)); + false, + t)); } } else @@ -1211,10 +1389,13 @@ or PwshTokenKind.Subexpression isPath: false, resolved: null)); - if (pendingValueParam is not null || pendingNativeFlag is not null) + if (pendingValueParam is not null + || pendingNativeFlag is not null + || pendingAmbiguousBinding) { pendingValueParam = null; pendingNativeFlag = null; + pendingAmbiguousBinding = false; } else { @@ -1230,10 +1411,26 @@ or PwshTokenKind.Subexpression var valueForResolution = t.Value; var valueIsOpaqueCommand = false; + var consumer = ShellResolutionConsumer.PowerShellNativeArgument; + var consumerSemanticsProven = true; + var bindingIsAmbiguous = false; bool treatAsPath; - if (pendingValueParam is not null) + if (pendingAmbiguousBinding) + { + treatAsPath = false; + bindingIsAmbiguous = true; + pendingAmbiguousBinding = false; + } + else if (pendingValueParam is not null) { treatAsPath = PwshPerVerbRules.ParameterValueIsPath(canonical, pendingValueParam); + consumer = string.Equals( + pendingValueParam, + "-LiteralPath", + StringComparison.OrdinalIgnoreCase) + ? ShellResolutionConsumer.PowerShellCmdletLiteralPath + : ShellResolutionConsumer.PowerShellCmdletPath; + consumerSemanticsProven = verb.BindingSemanticsProven; pendingValueParam = null; } else if (pendingNativeFlag is not null) @@ -1254,10 +1451,16 @@ or PwshTokenKind.Subexpression treatAsPath = cmdletStyle ? PwshPerVerbRules.IsPositionalPathArg(canonical, isFileVerb, positionalIndex, t.Value) : BashPerVerbRules.IsPositionalPathArg(nativeVerbChain, positionalIndex, t.Value); + if (cmdletStyle && treatAsPath) + { + consumer = ShellResolutionConsumer.PowerShellCmdletPath; + consumerSemanticsProven = verb.BindingSemanticsProven; + } + positionalIndex++; } - var resolvedArg = valueIsOpaqueCommand + var resolvedArg = valueIsOpaqueCommand || bindingIsAmbiguous ? new Arg { Raw = rawValue, Kind = ArgKind.DynamicSkip, IsPath = false } : ResolveValueToken( rawValue, @@ -1265,7 +1468,10 @@ or PwshTokenKind.Subexpression treatAsPath, options, workingDirectoryUnknown, - isLiteralBytes); + isLiteralBytes, + t, + consumer, + consumerSemanticsProven); args.Add(resolvedArg); elements.Add(CreateElement( source, @@ -1283,25 +1489,75 @@ or PwshTokenKind.Subexpression private static Arg ResolveValueToken( string raw, string logicalValue, bool treatAsPath, - PwshParserOptions options, bool workingDirectoryUnknown, bool isLiteralBytes) + PwshParserOptions options, bool workingDirectoryUnknown, bool isLiteralBytes, + PwshToken? token = null, + ShellResolutionConsumer consumer = ShellResolutionConsumer.PowerShellNativeArgument, + bool consumerSemanticsProven = true) { - // §8 comma-array: an unquoted top-level comma in a path slot marks - // the whole token DynamicSkip — the v0.2.0 parser neither splits nor - // resolves a comma-joined array path. - if (treatAsPath && !isLiteralBytes && PwshResolver.LooksLikeCommaArray(logicalValue)) + if (treatAsPath && !consumerSemanticsProven) { return new Arg { Raw = raw, Kind = ArgKind.DynamicSkip, IsPath = false }; } + var resolverValue = token is null + ? ShellValue.Literal(logicalValue) + : GetResolverValue( + token.Value, + logicalValue, + preserveLiteralPrefixBoundary: + consumer == ShellResolutionConsumer.PowerShellNativeArgument); var (kind, resolved, isPath) = PwshResolver.Resolve( - logicalValue, treatAsPath, options, workingDirectoryUnknown, isLiteralBytes); + resolverValue, + treatAsPath, + options, + workingDirectoryUnknown, + consumer); return new Arg { Raw = raw, Resolved = resolved, Kind = kind, IsPath = isPath }; } private static Arg ResolveValue( string logicalValue, bool treatAsPath, PwshParserOptions options, bool workingDirectoryUnknown, bool isLiteralBytes) - => ResolveValueToken(logicalValue, logicalValue, treatAsPath, options, workingDirectoryUnknown, isLiteralBytes); + => ResolveValueToken( + logicalValue, + logicalValue, + treatAsPath, + options, + workingDirectoryUnknown, + isLiteralBytes); + + private static ShellValue GetResolverValue( + PwshToken token, + string logicalValue, + bool preserveLiteralPrefixBoundary = false) + { + var value = token.ResolverValue + ?? ShellValue.Literal(token.Value, token.SourceStart, token.SourceLength); + return GetResolverValue(value, logicalValue, preserveLiteralPrefixBoundary); + } + + private static ShellValue GetResolverValue( + ShellValue value, + string logicalValue, + bool preserveLiteralPrefixBoundary = false) + { + if (string.Equals(value.Decoded, logicalValue, StringComparison.Ordinal)) + { + return value; + } + + var prefixLength = value.Decoded.Length - logicalValue.Length; + if (prefixLength >= 0 + && value.Decoded.EndsWith(logicalValue, StringComparison.Ordinal)) + { + return value.Slice( + prefixLength, + logicalValue.Length, + preserveLiteralPrefixBoundary); + } + + return ShellValue.Opaque(logicalValue, ShellOpaqueCause.Unsupported); + } // ---------------------------------------------------------------- redirects @@ -1371,8 +1627,7 @@ private static int BuildRedirect( } // $null is the discard sink — not a file (§8). - if (target.Kind == PwshTokenKind.Word - && string.Equals(target.Value, "$null", StringComparison.OrdinalIgnoreCase)) + if (IsPowerShellNullSink(target)) { redirects.Add(new Redirect { @@ -1394,8 +1649,13 @@ private static int BuildRedirect( var isLiteralBytes = target.Kind == PwshTokenKind.QuotedString && target.IsSingleQuoted; var raw = SourceSlice(source, target); + var resolverValue = GetResolverValue(target, target.Value); var (kind, resolved, isPath) = PwshResolver.Resolve( - target.Value, treatAsPath: true, options, workingDirectoryUnknown, isLiteralBytes); + resolverValue, + treatAsPath: true, + options, + workingDirectoryUnknown, + ShellResolutionConsumer.PowerShellRedirect); redirects.Add(new Redirect { @@ -1415,6 +1675,25 @@ private static int BuildRedirect( return 2; } + private static bool IsPowerShellNullSink(PwshToken token) + { + if (token.Kind != PwshTokenKind.Word + || token.ResolverValue is null + || token.ResolverValue.Fragments.Count != 1) + { + return false; + } + + var fragment = token.ResolverValue.Fragments[0]; + return fragment.Kind == ShellValueFragmentKind.Expansion + && fragment.Expansion is not null + && fragment.Expansion.Value.Kind == ShellExpansionKind.Variable + && string.Equals( + fragment.Expansion.Value.Name, + "null", + StringComparison.OrdinalIgnoreCase); + } + private static ClauseElement CreateElement( string source, PwshToken token, @@ -1425,18 +1704,18 @@ private static ClauseElement CreateElement( bool isPath, string? resolved, string? value = null) => new() - { - Raw = SourceSlice(source, token), - Value = value ?? token.Value, - Role = role, - SourceStart = token.SourceStart, - SourceLength = token.SourceLength, - PrecedingVerbElementCount = precedingVerbTokenCount, - Kind = kind, - IsFlag = isFlag, - IsPath = isPath, - Resolved = resolved, - }; + { + Raw = SourceSlice(source, token), + Value = value ?? token.Value, + Role = role, + SourceStart = token.SourceStart, + SourceLength = token.SourceLength, + PrecedingVerbElementCount = precedingVerbTokenCount, + Kind = kind, + IsFlag = isFlag, + IsPath = isPath, + Resolved = resolved, + }; private static ClauseElement CreateCombinedElement( string source, diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs index 9213297..517495b 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs @@ -223,4 +223,17 @@ internal static class PwshAliases return Map.TryGetValue(token, out var canonical) ? canonical : null; } + + internal static bool IsKnownCanonical(string token) + { + foreach (var canonical in Map.Values) + { + if (string.Equals(canonical, token, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } } diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshBindingTables.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshBindingTables.cs index 7e5ec08..b1aaabf 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshBindingTables.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshBindingTables.cs @@ -18,6 +18,12 @@ internal enum PwshBinding Value, } +internal readonly record struct PwshBindingResult( + PwshBinding Binding, + string? CanonicalName, + bool IsKnown, + bool IsAmbiguous); + /// /// The static parameter-binding tables from SPEC.POWERSHELL.md §6.5.2. The /// parser has no compiled cmdlet metadata, so it decides whether a @@ -87,45 +93,62 @@ internal static class PwshBindingTables /// table match → unambiguous prefix match → unknown defaults to switch. /// internal static PwshBinding ResolveBinding(string? canonicalVerb, string paramName) + => Resolve(canonicalVerb, paramName).Binding; + + internal static PwshBindingResult Resolve(string? canonicalVerb, string paramName) { if (string.IsNullOrEmpty(paramName)) { - return PwshBinding.Switch; + return new PwshBindingResult(PwshBinding.Switch, null, false, false); } // 1. (verb, name) override row. if (!string.IsNullOrEmpty(canonicalVerb) && Overrides.TryGetValue((canonicalVerb!, paramName), out var overridden)) { - return overridden; + return new PwshBindingResult(overridden, paramName, true, false); } // 2. Exact table match. if (ValueParameters.Contains(paramName)) { - return PwshBinding.Value; + return new PwshBindingResult(PwshBinding.Value, paramName, true, false); } if (SwitchParameters.Contains(paramName)) { - return PwshBinding.Switch; + return new PwshBindingResult(PwshBinding.Switch, paramName, true, false); } // 3. Unambiguous prefix match. PowerShell prefix matching: the token // must prefix exactly one entry across both tables; two or more is // ambiguous and treated as unknown. - var valueHits = CountPrefixMatches(ValueParameters, paramName); - var switchHits = CountPrefixMatches(SwitchParameters, paramName); + var valueHits = FindPrefixMatches(ValueParameters, paramName); + var switchHits = FindPrefixMatches(SwitchParameters, paramName); if (valueHits + switchHits == 1) { - return valueHits == 1 ? PwshBinding.Value : PwshBinding.Switch; + return valueHits == 1 + ? new PwshBindingResult( + PwshBinding.Value, + FindPrefixMatch(ValueParameters, paramName), + true, + false) + : new PwshBindingResult( + PwshBinding.Switch, + FindPrefixMatch(SwitchParameters, paramName), + true, + false); } // 4. Unknown (or ambiguous) → switch. §6.5.3 rule 4. - return PwshBinding.Switch; + return new PwshBindingResult( + PwshBinding.Switch, + null, + false, + valueHits + switchHits > 1); } - private static int CountPrefixMatches(HashSet table, string prefix) + private static int FindPrefixMatches(HashSet table, string prefix) { var count = 0; foreach (var entry in table) @@ -140,6 +163,20 @@ private static int CountPrefixMatches(HashSet table, string prefix) return count; } + private static string? FindPrefixMatch(HashSet table, string prefix) + { + foreach (var entry in table) + { + if (entry.Length > prefix.Length + && entry.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return entry; + } + } + + return null; + } + private sealed class VerbNameComparer : IEqualityComparer<(string Verb, string Name)> { internal static readonly VerbNameComparer Instance = new(); diff --git a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs index e38dfba..ab0e8f1 100644 --- a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs +++ b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs @@ -228,6 +228,138 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( return (hadTilde ? ArgKind.Tilde : ArgKind.Literal, resolved, true); } + /// + /// Resolve a lexer-proved shell value without reconstructing expansion + /// eligibility from its decoded text. + /// + internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( + ShellValue value, + bool treatAsPath, + BashParserOptions options, + bool workingDirectoryUnknown, + ShellResolutionConsumer consumer) + { + if (consumer is not ShellResolutionConsumer.BashArgument + and not ShellResolutionConsumer.BashRedirect) + { + throw new ArgumentOutOfRangeException(nameof(consumer)); + } + + if (treatAsPath && value.Decoded.Length == 0) + { + return (ArgKind.DynamicSkip, null, false); + } + + var composed = new StringBuilder(value.Decoded.Length); + var hadHomeExpansion = false; + var hasGlobExpansion = false; + for (var fragmentIndex = 0; fragmentIndex < value.Fragments.Count; fragmentIndex++) + { + var fragment = value.Fragments[fragmentIndex]; + if (fragment.Kind == ShellValueFragmentKind.Literal) + { + composed.Append(fragment.Value); + continue; + } + + if (fragment.Kind == ShellValueFragmentKind.Opaque + || fragment.Expansion is null) + { + return (ArgKind.DynamicSkip, null, false); + } + + var expansion = fragment.Expansion.Value; + switch (expansion.Kind) + { + case ShellExpansionKind.Variable: + case ShellExpansionKind.SpecialParameter: + case ShellExpansionKind.PositionalParameter: + if (fragment.Cardinality != ShellValueCardinality.ExactlyOne + || !string.Equals(expansion.Name, "HOME", StringComparison.Ordinal) + || (fragment.AllowedTransforms & ShellLexicalTransform.Variable) == 0) + { + return treatAsPath + ? (ArgKind.DynamicSkip, null, false) + : (ArgKind.EnvVar, null, false); + } + + var home = GetHomeDirectory(options); + if ((fragment.AllowedTransforms & ShellLexicalTransform.FieldSplit) != 0 + && ContainsFieldSplitOrGlobCharacter(home)) + { + return (ArgKind.DynamicSkip, null, false); + } + + composed.Append(home); + hadHomeExpansion = true; + break; + + case ShellExpansionKind.Tilde: + // An empty quoted fragment before '~' is still an authored + // word prefix and suppresses Bash tilde expansion. + if (fragmentIndex != 0 + || (fragment.AllowedTransforms & ShellLexicalTransform.Tilde) == 0) + { + composed.Append(fragment.Value); + break; + } + + if (value.Decoded.Length > 1 + && value.Decoded[1] != '/' + && value.Decoded[1] != '\\') + { + return treatAsPath + ? (ArgKind.DynamicSkip, null, false) + : (ArgKind.Tilde, null, false); + } + + composed.Append(GetHomeDirectory(options).TrimEnd('/', '\\')); + hadHomeExpansion = true; + break; + + case ShellExpansionKind.Glob: + composed.Append(fragment.Value); + hasGlobExpansion = true; + break; + + default: + return (ArgKind.DynamicSkip, null, false); + } + } + + if (hasGlobExpansion) + { + return consumer == ShellResolutionConsumer.BashRedirect + ? (ArgKind.DynamicSkip, null, false) + : (ArgKind.Glob, null, treatAsPath); + } + + if (!treatAsPath) + { + return (hadHomeExpansion ? ArgKind.Tilde : ArgKind.Literal, null, false); + } + + var resolved = TryResolveAbsolutePath( + composed.ToString(), options, workingDirectoryUnknown); + return resolved is null + ? (ArgKind.DynamicSkip, null, false) + : (hadHomeExpansion ? ArgKind.Tilde : ArgKind.Literal, resolved, true); + } + + private static bool ContainsFieldSplitOrGlobCharacter(string value) + { + foreach (var character in value) + { + if (char.IsWhiteSpace(character) + || character is '*' or '?' or '[') + { + return true; + } + } + + return false; + } + /// /// SPEC §8 LooksLikePath heuristic. Used to fall back when no per-verb /// rule applies. Conservative — when a token "looks like a path" we run diff --git a/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs index caef250..093bd7a 100644 --- a/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs +++ b/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs @@ -141,6 +141,230 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( return (hadHomeish ? ArgKind.Tilde : ArgKind.Literal, resolved, true); } + /// + /// Resolve a lexer-proved value under an explicit PowerShell consumer. + /// Provider and PSDrive semantics are deliberately absent from native + /// arguments and applied only by cmdlet path and redirect consumers. + /// + internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( + ShellValue value, + bool treatAsPath, + ShellParserOptions options, + bool workingDirectoryUnknown, + ShellResolutionConsumer consumer) + { + if (consumer is not ShellResolutionConsumer.PowerShellNativeArgument + and not ShellResolutionConsumer.PowerShellCmdletPath + and not ShellResolutionConsumer.PowerShellCmdletLiteralPath + and not ShellResolutionConsumer.PowerShellRedirect) + { + throw new ArgumentOutOfRangeException(nameof(consumer)); + } + + if (treatAsPath && value.Decoded.Length == 0) + { + return (ArgKind.DynamicSkip, null, false); + } + + var composed = new StringBuilder(value.Decoded.Length); + var hadHomeish = false; + var hasLexicalGlob = false; + var expandableStringContextProven = false; + for (var fragmentIndex = 0; fragmentIndex < value.Fragments.Count; fragmentIndex++) + { + var fragment = value.Fragments[fragmentIndex]; + if (fragment.Kind == ShellValueFragmentKind.Literal) + { + composed.Append(fragment.Value); + expandableStringContextProven = true; + continue; + } + + if (fragment.Kind == ShellValueFragmentKind.Opaque + || fragment.Expansion is null) + { + if (fragment.Kind == ShellValueFragmentKind.Opaque + && fragment.OpaqueCause == ShellOpaqueCause.PowerShellExpressionSuffix + && expandableStringContextProven) + { + composed.Append(fragment.Value); + continue; + } + + return (ArgKind.DynamicSkip, null, false); + } + + var expansion = fragment.Expansion.Value; + switch (expansion.Kind) + { + case ShellExpansionKind.Variable: + case ShellExpansionKind.SpecialParameter: + case ShellExpansionKind.PositionalParameter: + if (!IsHomeVariable(expansion.Name) + || (fragment.AllowedTransforms & ShellLexicalTransform.Variable) == 0) + { + return treatAsPath + ? (ArgKind.DynamicSkip, null, false) + : (ArgKind.EnvVar, null, false); + } + + composed.Append(GetHomeDirectory(options)); + hadHomeish = true; + break; + + case ShellExpansionKind.Tilde: + if (consumer == ShellResolutionConsumer.PowerShellNativeArgument + && fragmentIndex == 0 + && (fragment.AllowedTransforms & ShellLexicalTransform.Tilde) != 0) + { + if (value.Decoded.Length > 1 + && value.Decoded[1] != '/' + && value.Decoded[1] != '\\') + { + return treatAsPath + ? (ArgKind.DynamicSkip, null, false) + : (ArgKind.Tilde, null, false); + } + + composed.Append(GetHomeDirectory(options).TrimEnd('/', '\\')); + hadHomeish = true; + } + else + { + composed.Append(fragment.Value); + } + + break; + + case ShellExpansionKind.Glob: + composed.Append(fragment.Value); + hasLexicalGlob = true; + break; + + case ShellExpansionKind.ArraySeparator: + return (ArgKind.DynamicSkip, null, false); + + default: + return (ArgKind.DynamicSkip, null, false); + } + } + + var working = composed.ToString(); + var pathLikeConsumer = consumer is ShellResolutionConsumer.PowerShellCmdletPath + or ShellResolutionConsumer.PowerShellCmdletLiteralPath + or ShellResolutionConsumer.PowerShellRedirect; + if (pathLikeConsumer && working.Length > 0 && working[0] == '~') + { + if (working.Length > 1 && working[1] != '/' && working[1] != '\\') + { + return (ArgKind.DynamicSkip, null, false); + } + + var home = GetHomeDirectory(options).TrimEnd('/', '\\'); + working = home + working.Substring(1); + hadHomeish = true; + } + + if (pathLikeConsumer) + { + var fileSystemProviderProven = false; + foreach (var qualifier in ProviderQualifiers) + { + if (working.StartsWith(qualifier, StringComparison.OrdinalIgnoreCase)) + { + working = working.Substring(qualifier.Length); + fileSystemProviderProven = true; + break; + } + } + + var colon = working.IndexOf(':'); + if (colon >= 1 && IsDriveQualifier(working, colon)) + { + var driveName = working.Substring(0, colon); + if (driveName.Length == 1) + { + if (!fileSystemProviderProven + && !IsProvedFileSystemDrive(driveName, options)) + { + return (ArgKind.DynamicSkip, null, false); + } + } + else if (IsKnownNonFileSystemDrive(driveName) + && consumer != ShellResolutionConsumer.PowerShellRedirect) + { + return (ArgKind.Literal, null, false); + } + else if (driveName.Length > 1) + { + return (ArgKind.DynamicSkip, null, false); + } + } + } + + var hasPostFormationGlob = pathLikeConsumer + && working.IndexOfAny(new[] { '*', '?', '[' }) >= 0; + if (consumer == ShellResolutionConsumer.PowerShellRedirect + && (hasLexicalGlob || hasPostFormationGlob)) + { + return (ArgKind.DynamicSkip, null, false); + } + + if (consumer == ShellResolutionConsumer.PowerShellCmdletPath + && (hasLexicalGlob || hasPostFormationGlob)) + { + return (ArgKind.Glob, null, treatAsPath); + } + + if (consumer == ShellResolutionConsumer.PowerShellNativeArgument + && hasLexicalGlob) + { + return (ArgKind.Glob, null, treatAsPath); + } + + if (!treatAsPath) + { + return (hadHomeish ? ArgKind.Tilde : ArgKind.Literal, null, false); + } + + var resolved = TryResolveAbsolutePath( + working, options, workingDirectoryUnknown); + return resolved is null + ? (ArgKind.DynamicSkip, null, false) + : (hadHomeish ? ArgKind.Tilde : ArgKind.Literal, resolved, true); + } + + private static bool IsHomeVariable(string? name) => + string.Equals(name, "HOME", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "env:USERPROFILE", StringComparison.OrdinalIgnoreCase); + + private static bool IsKnownNonFileSystemDrive(string name) => + string.Equals(name, "Alias", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Cert", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Env", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Function", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "HKCU", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "HKLM", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Variable", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "WSMan", StringComparison.OrdinalIgnoreCase); + + private static bool IsProvedFileSystemDrive( + string driveName, ShellParserOptions options) => + HasDrive(options.WorkingDirectory, driveName) + || HasDrive(options.HomeDirectory, driveName); + + private static bool HasDrive(string? path, string driveName) => + !string.IsNullOrEmpty(path) + && path!.Length >= 2 + && path[1] == ':' + && string.Equals(path.Substring(0, 1), driveName, StringComparison.OrdinalIgnoreCase); + + private static bool IsDriveRelativePath(string path) => + path.Length >= 2 + && IsAsciiLetter(path[0]) + && path[1] == ':' + && (path.Length == 2 || (path[2] != '/' && path[2] != '\\')); + /// /// True when contains an unquoted top-level /// comma — PowerShell's array operator (SPEC.POWERSHELL.md §8). A @@ -349,7 +573,7 @@ private static string JoinPath(string baseDir, string sub) private static string? TryResolveAbsolutePath( string token, ShellParserOptions options, bool workingDirectoryUnknown) { - if (string.IsNullOrEmpty(token)) + if (string.IsNullOrEmpty(token) || IsDriveRelativePath(token)) { return null; } @@ -491,7 +715,10 @@ private static bool IsRootedPath(string token) return true; } - if (token.Length >= 2 && IsAsciiLetter(token[0]) && token[1] == ':') + if (token.Length >= 3 + && IsAsciiLetter(token[0]) + && token[1] == ':' + && (token[2] == '/' || token[2] == '\\')) { return true; } diff --git a/src/ShellSyntaxTree/Internal/Resolving/ShellValue.cs b/src/ShellSyntaxTree/Internal/Resolving/ShellValue.cs new file mode 100644 index 0000000..5ed197f --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Resolving/ShellValue.cs @@ -0,0 +1,447 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.Text; + +namespace ShellSyntaxTree.Internal.Resolving; + +internal enum ShellValueFragmentKind +{ + Literal, + Expansion, + Opaque, +} + +[Flags] +internal enum ShellLexicalTransform +{ + None = 0, + Variable = 1, + Tilde = 2, + Glob = 4, + FieldSplit = 8, +} + +internal enum ShellExpansionKind +{ + Variable, + SpecialParameter, + PositionalParameter, + Tilde, + Glob, + ArraySeparator, +} + +internal enum ShellValueCardinality +{ + ExactlyOne, + ZeroOrOne, + ZeroOrMore, + Unknown, +} + +internal enum ShellOpaqueCause +{ + None, + CommandSubstitution, + PowerShellSubexpression, + PowerShellExpressionSuffix, + Splat, + Unsupported, +} + +internal enum ShellResolutionConsumer +{ + BashArgument, + BashRedirect, + PowerShellNativeArgument, + PowerShellCmdletPath, + PowerShellCmdletLiteralPath, + PowerShellRedirect, +} + +internal readonly record struct ShellExpansionReference( + ShellExpansionKind Kind, + string? Name); + +internal readonly record struct ShellValueFragment( + string Value, + ShellValueFragmentKind Kind, + ShellLexicalTransform AllowedTransforms, + ShellExpansionReference? Expansion, + ShellValueCardinality Cardinality, + ShellOpaqueCause OpaqueCause, + int? SourceStart, + int? SourceLength); + +/// +/// One decoded shell value together with the shell-owned facts needed to +/// decide which authored regions may still transform. +/// +internal sealed class ShellValue +{ + private readonly ShellValueFragment[] _fragments; + + internal ShellValue(string decoded, ShellValueFragment[] fragments) + { + Decoded = decoded; + _fragments = fragments; + } + + internal string Decoded { get; } + + internal IReadOnlyList Fragments => _fragments; + + internal bool HasOpaqueFragment + { + get + { + foreach (var fragment in _fragments) + { + if (fragment.Kind == ShellValueFragmentKind.Opaque) + { + return true; + } + } + + return false; + } + } + + internal bool HasOpaqueFragmentOtherThan(ShellOpaqueCause allowedCause) + { + foreach (var fragment in _fragments) + { + if (fragment.Kind == ShellValueFragmentKind.Opaque + && fragment.OpaqueCause != allowedCause) + { + return true; + } + } + + return false; + } + + internal static ShellValue Literal( + string value, + int? sourceStart = null, + int? sourceLength = null) => + Create( + value, + ShellValueFragmentKind.Literal, + ShellLexicalTransform.None, + null, + ShellValueCardinality.ExactlyOne, + ShellOpaqueCause.None, + sourceStart, + sourceLength); + + internal static ShellValue Opaque( + string value, + ShellOpaqueCause cause, + int? sourceStart = null, + int? sourceLength = null) => + Create( + value, + ShellValueFragmentKind.Opaque, + ShellLexicalTransform.None, + null, + ShellValueCardinality.Unknown, + cause, + sourceStart, + sourceLength); + + internal static ShellValue Expansion( + string value, + ShellLexicalTransform allowedTransforms, + ShellExpansionReference expansion, + ShellValueCardinality cardinality, + int? sourceStart = null, + int? sourceLength = null) => + Create( + value, + ShellValueFragmentKind.Expansion, + allowedTransforms, + expansion, + cardinality, + ShellOpaqueCause.None, + sourceStart, + sourceLength); + + internal static ShellValue Concat(IEnumerable values) + { + var builder = new ShellValueBuilder(); + foreach (var value in values) + { + builder.Append(value); + } + + return builder.Build(); + } + + internal ShellValue Slice(int start) => Slice(start, Decoded.Length - start, false); + + internal ShellValue Slice(int start, int length) => Slice(start, length, false); + + internal ShellValue Slice( + int start, + int length, + bool preserveLiteralPrefixBoundary) + { + if (start < 0 || length < 0 || start + length > Decoded.Length) + { + throw new ArgumentOutOfRangeException(nameof(start)); + } + + var builder = new ShellValueBuilder(); + if (preserveLiteralPrefixBoundary + && start > 0 + && TryGetLiteralBoundary(start, out var boundarySourceStart)) + { + builder.AppendBoundary(boundarySourceStart); + } + + var sliceEnd = start + length; + var decodedOffset = 0; + foreach (var fragment in _fragments) + { + if (decodedOffset > sliceEnd + || (decodedOffset == sliceEnd && fragment.Value.Length > 0)) + { + break; + } + + var fragmentEnd = decodedOffset + fragment.Value.Length; + if (fragment.Value.Length == 0 + && decodedOffset >= start + && decodedOffset <= sliceEnd) + { + builder.Append(fragment); + } + + var overlapStart = Math.Max(start, decodedOffset); + var overlapEnd = Math.Min(sliceEnd, fragmentEnd); + if (overlapStart < overlapEnd) + { + var localStart = overlapStart - decodedOffset; + var localLength = overlapEnd - overlapStart; + var fragmentStart = fragment.SourceStart; + var fragmentLength = fragment.SourceLength; + if (localStart != 0 || localLength != fragment.Value.Length) + { + if (fragmentStart is not null + && fragmentLength == fragment.Value.Length) + { + fragmentStart += localStart; + fragmentLength = localLength; + } + else + { + fragmentStart = null; + fragmentLength = null; + } + } + + builder.Append(fragment with + { + Value = fragment.Value.Substring(localStart, localLength), + SourceStart = fragmentStart, + SourceLength = fragmentLength, + }); + } + + decodedOffset = fragmentEnd; + } + + return builder.Build(); + } + + private bool TryGetLiteralBoundary(int decodedBoundary, out int? sourceStart) + { + var decodedOffset = 0; + foreach (var fragment in _fragments) + { + var fragmentEnd = decodedOffset + fragment.Value.Length; + if (decodedBoundary > decodedOffset + && decodedBoundary <= fragmentEnd + && fragment.Kind == ShellValueFragmentKind.Literal) + { + sourceStart = fragment.SourceStart is not null + && fragment.SourceLength == fragment.Value.Length + ? fragment.SourceStart + decodedBoundary - decodedOffset + : null; + return true; + } + + if (fragmentEnd >= decodedBoundary) + { + break; + } + + decodedOffset = fragmentEnd; + } + + sourceStart = null; + return false; + } + + private static ShellValue Create( + string value, + ShellValueFragmentKind kind, + ShellLexicalTransform allowedTransforms, + ShellExpansionReference? expansion, + ShellValueCardinality cardinality, + ShellOpaqueCause opaqueCause, + int? sourceStart, + int? sourceLength) + { + if (value.Length == 0 && sourceStart is null && sourceLength is null) + { + return new ShellValue(string.Empty, Array.Empty()); + } + + return new ShellValue( + value, + new[] + { + new ShellValueFragment( + value, + kind, + allowedTransforms, + expansion, + cardinality, + opaqueCause, + sourceStart, + sourceLength), + }); + } +} + +internal sealed class ShellValueBuilder +{ + private readonly List _fragments = new(); + + internal void AppendBoundary(int? sourceStart) => + Append(new ShellValueFragment( + string.Empty, + ShellValueFragmentKind.Literal, + ShellLexicalTransform.None, + null, + ShellValueCardinality.ExactlyOne, + ShellOpaqueCause.None, + sourceStart, + 0)); + + internal void AppendLiteral( + char value, + int? sourceStart, + int? sourceLength) => + AppendLiteral(value.ToString(), sourceStart, sourceLength); + + internal void AppendLiteral( + string value, + int? sourceStart, + int? sourceLength) => + Append(new ShellValueFragment( + value, + ShellValueFragmentKind.Literal, + ShellLexicalTransform.None, + null, + ShellValueCardinality.ExactlyOne, + ShellOpaqueCause.None, + sourceStart, + sourceLength)); + + internal void AppendExpansion( + string value, + ShellLexicalTransform allowedTransforms, + ShellExpansionReference expansion, + ShellValueCardinality cardinality, + int? sourceStart, + int? sourceLength) => + Append(new ShellValueFragment( + value, + ShellValueFragmentKind.Expansion, + allowedTransforms, + expansion, + cardinality, + ShellOpaqueCause.None, + sourceStart, + sourceLength)); + + internal void AppendOpaque( + string value, + ShellOpaqueCause cause, + int? sourceStart, + int? sourceLength) => + Append(new ShellValueFragment( + value, + ShellValueFragmentKind.Opaque, + ShellLexicalTransform.None, + null, + ShellValueCardinality.Unknown, + cause, + sourceStart, + sourceLength)); + + internal void Append(ShellValue value) + { + foreach (var fragment in value.Fragments) + { + Append(fragment); + } + } + + internal void Append(ShellValueFragment fragment) + { + if (_fragments.Count > 0) + { + var previous = _fragments[_fragments.Count - 1]; + var sourceIsContiguous = previous.SourceStart is not null + && previous.SourceLength is not null + && fragment.SourceStart == previous.SourceStart + previous.SourceLength; + var bothUnmapped = previous.SourceStart is null && fragment.SourceStart is null; + if (previous.Value.Length > 0 + && fragment.Value.Length > 0 + && previous.Kind == ShellValueFragmentKind.Literal + && fragment.Kind == ShellValueFragmentKind.Literal + && previous.AllowedTransforms == fragment.AllowedTransforms + && previous.Expansion == fragment.Expansion + && previous.Cardinality == fragment.Cardinality + && previous.OpaqueCause == fragment.OpaqueCause + && (sourceIsContiguous || bothUnmapped)) + { + _fragments[_fragments.Count - 1] = previous with + { + Value = previous.Value + fragment.Value, + SourceLength = sourceIsContiguous + ? previous.SourceLength + fragment.SourceLength + : null, + }; + return; + } + } + + _fragments.Add(fragment); + } + + internal ShellValue Build() + { + if (_fragments.Count == 0) + { + return ShellValue.Literal(string.Empty); + } + + var decoded = new StringBuilder(); + foreach (var fragment in _fragments) + { + decoded.Append(fragment.Value); + } + + return new ShellValue(decoded.ToString(), _fragments.ToArray()); + } +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/131_netclaw_repro_compound_with_comment.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/131_netclaw_repro_compound_with_comment.json index f1795ca..3acb312 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/131_netclaw_repro_compound_with_comment.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/131_netclaw_repro_compound_with_comment.json @@ -9,7 +9,7 @@ "verb": ["curl"], "args": [ { "raw": "-s", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "\"https://api.github.com/repos/sample-org/sample-repo/pulls?state=open\"", "kind": "Glob", "isPath": false, "resolved": "__NULL__" } + { "raw": "\"https://api.github.com/repos/sample-org/sample-repo/pulls?state=open\"", "kind": "Literal", "isPath": false, "resolved": "__NULL__" } ], "redirects": [], "isSubshell": false, @@ -40,5 +40,5 @@ } ] }, - "notes": "Issue #25 follow-up: leading comment + `||`-fallback that surfaced the approval-state desync cascade (verb-chain extracted as `# Get` at persistence time → cache miss at retry-authorization → tool fails after user clicked Approve). Paths and org names sanitized per SPEC §14." + "notes": "Issue #25 follow-up: leading comment + `||`-fallback that surfaced the approval-state desync cascade (verb-chain extracted as `# Get` at persistence time → cache miss at retry-authorization → tool fails after user clicked Approve). The quoted URL wildcard marker is literal. Paths and org names sanitized per SPEC §14." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/166_curl_data_mixed_literal_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/166_curl_data_mixed_literal_dynamic.json index c220181..1e75479 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/166_curl_data_mixed_literal_dynamic.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/166_curl_data_mixed_literal_dynamic.json @@ -9,7 +9,7 @@ "verb": ["curl"], "args": [ { "raw": "--data", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "'@$HOME'\".json\"", "kind": "DynamicSkip", "isPath": false, "isFlag": false, "resolved": "__NULL__" }, + { "raw": "'@$HOME'\".json\"", "kind": "Literal", "isPath": true, "isFlag": false, "resolved": "/work/$HOME.json" }, { "raw": "https://example.invalid/api", "kind": "Literal", "isPath": false, "isFlag": false } ], "redirects": [], @@ -32,9 +32,10 @@ "sourceStart": 5, "sourceLength": 22, "precedingVerbElementCount": 1, - "kind": "DynamicSkip", + "kind": "Literal", "isFlag": true, - "isPath": false + "isPath": true, + "resolved": "/work/$HOME.json" }, { "raw": "https://example.invalid/api", @@ -53,5 +54,5 @@ } ] }, - "notes": "Resolver-sensitive mixed quoting safe-fails instead of expanding literal bytes." + "notes": "All fragments are literal, so the curl @ prefix is removed without reinterpreting the quoted $HOME text." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/167_curl_data_transformed_literal_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/167_curl_data_transformed_literal_dynamic.json index 3d6eaa6..4ed332e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/167_curl_data_transformed_literal_dynamic.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/167_curl_data_transformed_literal_dynamic.json @@ -9,7 +9,7 @@ "verb": ["curl"], "args": [ { "raw": "--data", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "'@~'\"/secret.json\"", "kind": "DynamicSkip", "isPath": false, "isFlag": false, "resolved": "__NULL__" }, + { "raw": "'@~'\"/secret.json\"", "kind": "Literal", "isPath": true, "isFlag": false, "resolved": "/work/~/secret.json" }, { "raw": "https://example.invalid/api", "kind": "Literal", "isPath": false, "isFlag": false } ], "redirects": [], @@ -32,9 +32,10 @@ "sourceStart": 5, "sourceLength": 25, "precedingVerbElementCount": 1, - "kind": "DynamicSkip", + "kind": "Literal", "isFlag": true, - "isPath": false + "isPath": true, + "resolved": "/work/~/secret.json" }, { "raw": "https://example.invalid/api", @@ -53,5 +54,5 @@ } ] }, - "notes": "Resolver-sensitive syntax exposed after curl's @ marker is removed still safe-fails." + "notes": "The quoted tilde remains literal after curl's @ marker is removed." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/168_escaped_home_literal_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/168_escaped_home_literal_path.json new file mode 100644 index 0000000..11d7865 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/168_escaped_home_literal_path.json @@ -0,0 +1,14 @@ +{ + "name": "Escaped HOME remains a literal path", + "input": "cat \\$HOME", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "\\$HOME", "kind": "Literal", "isPath": true, "resolved": "/work/$HOME" }], + "redirects": [] + }] + }, + "notes": "The escape removes variable-transform eligibility without erasing the authored path value." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/169_empty_quote_blocks_tilde.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/169_empty_quote_blocks_tilde.json new file mode 100644 index 0000000..1248670 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/169_empty_quote_blocks_tilde.json @@ -0,0 +1,14 @@ +{ + "name": "Empty quoted prefix blocks tilde expansion", + "input": "cat \"\"~", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "\"\"~", "kind": "Literal", "isPath": true, "resolved": "/work/~" }], + "redirects": [] + }] + }, + "notes": "A zero-length quoted fragment is still a semantic word boundary and suppresses leading-tilde expansion." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/170_adjacent_escaped_redirect_target.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/170_adjacent_escaped_redirect_target.json new file mode 100644 index 0000000..f973043 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/170_adjacent_escaped_redirect_target.json @@ -0,0 +1,14 @@ +{ + "name": "Adjacent escaped redirect fragments form one literal target", + "input": "echo ok > \\$HOME\".txt\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["echo", "ok"], + "args": [], + "redirects": [{ "direction": "Out", "target": "/work/$HOME.txt", "isDynamicSkip": false }] + }] + }, + "notes": "The complete adjacent target run is aggregated before redirect resolution." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/171_runtime_special_parameter_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/171_runtime_special_parameter_path.json new file mode 100644 index 0000000..8615d92 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/171_runtime_special_parameter_path.json @@ -0,0 +1,14 @@ +{ + "name": "Runtime special parameter path fails closed", + "input": "cat \"$?\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "\"$?\"", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__" }], + "redirects": [] + }] + }, + "notes": "The parser retains the special-parameter identity but cannot prove its runtime value." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/172_quoted_wildcard_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/172_quoted_wildcard_redirect.json new file mode 100644 index 0000000..13a32b6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/172_quoted_wildcard_redirect.json @@ -0,0 +1,14 @@ +{ + "name": "Quoted wildcard redirect is one literal target", + "input": "echo ok > \"*.txt\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["echo", "ok"], + "args": [], + "redirects": [{ "direction": "Out", "target": "/work/*.txt", "isDynamicSkip": false }] + }] + }, + "notes": "Quote provenance suppresses Bash pathname expansion for the redirect word." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/173_unquoted_wildcard_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/173_unquoted_wildcard_redirect.json new file mode 100644 index 0000000..4162ed8 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/173_unquoted_wildcard_redirect.json @@ -0,0 +1,14 @@ +{ + "name": "Unquoted wildcard redirect fails closed", + "input": "echo ok > *.txt", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["echo", "ok"], + "args": [], + "redirects": [{ "direction": "Out", "target": "*.txt", "isDynamicSkip": true }] + }] + }, + "notes": "Without filesystem enumeration the parser cannot prove Bash produces exactly one redirect target." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/174_double_quoted_backtick_substitution.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/174_double_quoted_backtick_substitution.json new file mode 100644 index 0000000..cf8b56f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/174_double_quoted_backtick_substitution.json @@ -0,0 +1,14 @@ +{ + "name": "Backtick substitution inside double quotes remains opaque", + "input": "cat \"`printf /etc/passwd`\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "\"`printf /etc/passwd`\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "Bash executes the backtick region before forming the quoted argument, so the parser cannot claim an exact path." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/175_ansi_c_quote_unparseable.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/175_ansi_c_quote_unparseable.json new file mode 100644 index 0000000..d412b39 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/175_ansi_c_quote_unparseable.json @@ -0,0 +1,10 @@ +{ + "name": "Unsupported ANSI-C quoting fails closed", + "input": "cat $'/etc/passwd'", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "ANSI-C", + "clauses": [] + }, + "notes": "ANSI-C quoting transforms authored bytes and remains outside the supported grammar." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/176_quoted_fd_shaped_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/176_quoted_fd_shaped_redirect.json new file mode 100644 index 0000000..58ddc25 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/176_quoted_fd_shaped_redirect.json @@ -0,0 +1,14 @@ +{ + "name": "Quoted fd-shaped redirect target is a file", + "input": "echo ok > \"&1\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["echo", "ok"], + "args": [], + "redirects": [{ "direction": "Out", "target": "/work/&1", "isDynamicSkip": false }] + }] + }, + "notes": "Only the complete unquoted raw descriptor grammar denotes duplication; quoted &1 is a literal filename." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/177_escaped_fd_shaped_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/177_escaped_fd_shaped_redirect.json new file mode 100644 index 0000000..94bad9a --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/177_escaped_fd_shaped_redirect.json @@ -0,0 +1,14 @@ +{ + "name": "Escaped fd-shaped redirect target is a file", + "input": "echo ok > \\&1", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["echo", "ok"], + "args": [], + "redirects": [{ "direction": "Out", "target": "/work/&1", "isDynamicSkip": false }] + }] + }, + "notes": "Escape provenance prevents decoded-only descriptor recognition." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/178_empty_path_value.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/178_empty_path_value.json new file mode 100644 index 0000000..85a53f4 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/178_empty_path_value.json @@ -0,0 +1,14 @@ +{ + "name": "Empty Bash path value fails closed", + "input": "cat \"\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "\"\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "An empty filename cannot be normalized into a truthful static path." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/179_empty_inline_native_value.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/179_empty_inline_native_value.json new file mode 100644 index 0000000..953dd8d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/179_empty_inline_native_value.json @@ -0,0 +1,18 @@ +{ + "name": "Empty inline native value preserves argv cardinality", + "input": "curl --data=\"\" https://example.invalid/api", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["curl"], + "args": [ + { "raw": "--data", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "\"\"", "kind": "Literal", "isPath": false }, + { "raw": "https://example.invalid/api", "kind": "Literal", "isPath": false } + ], + "redirects": [] + }] + }, + "notes": "A zero-length quote boundary still contributes one native option value." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/180_dynamic_command_identity.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/180_dynamic_command_identity.json new file mode 100644 index 0000000..7acafa1 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/180_dynamic_command_identity.json @@ -0,0 +1,10 @@ +{ + "name": "Opaque Bash command identity fails closed", + "input": "r$(printf m) -rf /tmp/x", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "dynamic Bash command identity", + "clauses": [] + }, + "notes": "Adjacent aggregation must not erase a command substitution from the executable identity." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/181_runtime_multidigit_positional_parameter.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/181_runtime_multidigit_positional_parameter.json new file mode 100644 index 0000000..ecffe85 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/181_runtime_multidigit_positional_parameter.json @@ -0,0 +1,14 @@ +{ + "name": "Runtime multidigit positional parameter fails closed", + "input": "cat \"${10}\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "\"${10}\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "The lexer retains ${10} as one positional-parameter expansion whose runtime value is unknown." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/182_quoted_dollar_star_cardinality.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/182_quoted_dollar_star_cardinality.json new file mode 100644 index 0000000..abf97c5 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/182_quoted_dollar_star_cardinality.json @@ -0,0 +1,14 @@ +{ + "name": "Quoted dollar-star has one unknown runtime value", + "input": "cat \"$*\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "\"$*\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "Inside double quotes, $* produces exactly one argv value, but its runtime text remains unknown." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/183_escaped_open_brace_literal_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/183_escaped_open_brace_literal_path.json new file mode 100644 index 0000000..3b33209 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/183_escaped_open_brace_literal_path.json @@ -0,0 +1,14 @@ +{ + "name": "Escaped interpolation start remains a literal path", + "input": "cat \"\\${HOME\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "\"\\${HOME\"", "kind": "Literal", "isPath": true, "resolved": "/work/${HOME" }], + "redirects": [] + }] + }, + "notes": "Escaping the dollar prevents interpolation; the unmatched-looking brace is literal shell data." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/184_unterminated_braced_interpolation.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/184_unterminated_braced_interpolation.json new file mode 100644 index 0000000..389429e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/184_unterminated_braced_interpolation.json @@ -0,0 +1,10 @@ +{ + "name": "Unterminated braced interpolation is unparseable", + "input": "cat \"${HOME\"", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unbalanced '${'", + "clauses": [] + }, + "notes": "The outer quote does not complete the open braced parameter expansion." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/185_provider_looking_unquoted_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/185_provider_looking_unquoted_path.json new file mode 100644 index 0000000..72792ef --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/185_provider_looking_unquoted_path.json @@ -0,0 +1,14 @@ +{ + "name": "Bash provider-looking unquoted path remains literal", + "input": "cat filesystem::/safe", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "filesystem::/safe", "kind": "Literal", "isPath": true, "resolved": "/work/filesystem::/safe" }], + "redirects": [] + }] + }, + "notes": "Bash does not apply PowerShell provider semantics to authored argument text." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/186_provider_looking_quoted_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/186_provider_looking_quoted_path.json new file mode 100644 index 0000000..d2f6336 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/186_provider_looking_quoted_path.json @@ -0,0 +1,14 @@ +{ + "name": "Bash provider-looking quoted path remains literal", + "input": "cat \"filesystem::/safe\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["cat"], + "args": [{ "raw": "\"filesystem::/safe\"", "kind": "Literal", "isPath": true, "resolved": "/work/filesystem::/safe" }], + "redirects": [] + }] + }, + "notes": "Quote removal does not introduce foreign provider semantics into Bash." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/63_find_root_with_predicate.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/63_find_root_with_predicate.json index 79411e9..0aaca51 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/63_find_root_with_predicate.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/63_find_root_with_predicate.json @@ -10,7 +10,7 @@ "args": [ { "raw": "/var/log", "kind": "Literal", "isPath": true, "resolved": "/var/log" }, { "raw": "-name", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "\"*.log\"", "kind": "Glob", "isPath": false } + { "raw": "\"*.log\"", "kind": "Literal", "isPath": false } ], "redirects": [], "isSubshell": false, @@ -18,5 +18,5 @@ } ] }, - "notes": "SPEC §7: find i=0 is the path root (IsPath=true); subsequent args are predicate args (IsPath=false). The quoted glob inside `-name` still gets Kind=Glob via the resolver but with IsPath=false because find's predicate slot is not a path." + "notes": "SPEC §7: find i=0 is the path root (IsPath=true); subsequent args are predicate args (IsPath=false). The quoted wildcard inside `-name` is one literal predicate value." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/280_curl_data_mixed_literal_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/280_curl_data_mixed_literal_dynamic.json index cf4efc9..22450f1 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/280_curl_data_mixed_literal_dynamic.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/280_curl_data_mixed_literal_dynamic.json @@ -17,8 +17,9 @@ }, { "raw": "\u0027@$HOME\u0027\u0022.json\u0022", - "kind": "DynamicSkip", - "isPath": false + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/$HOME.json" }, { "raw": "https://example.invalid/api", @@ -46,9 +47,10 @@ "sourceStart": 5, "sourceLength": 22, "precedingVerbElementCount": 1, - "kind": "DynamicSkip", + "kind": "Literal", "isFlag": true, - "isPath": false + "isPath": true, + "resolved": "C:/work/$HOME.json" }, { "raw": "https://example.invalid/api", @@ -65,5 +67,5 @@ } ] }, - "notes": "Resolver-sensitive mixed quoting safe-fails instead of expanding literal bytes." + "notes": "All fragments are literal, so the curl @ prefix is removed without reinterpreting the quoted $HOME text." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/281_curl_data_transformed_literal_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/281_curl_data_transformed_literal_dynamic.json index 596f155..3079d33 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/281_curl_data_transformed_literal_dynamic.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/281_curl_data_transformed_literal_dynamic.json @@ -17,8 +17,9 @@ }, { "raw": "\u0027@~\u0027\u0022/secret.json\u0022", - "kind": "DynamicSkip", - "isPath": false + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/~/secret.json" }, { "raw": "https://example.invalid/api", @@ -46,9 +47,10 @@ "sourceStart": 5, "sourceLength": 25, "precedingVerbElementCount": 1, - "kind": "DynamicSkip", + "kind": "Literal", "isFlag": true, - "isPath": false + "isPath": true, + "resolved": "C:/work/~/secret.json" }, { "raw": "https://example.invalid/api", @@ -65,5 +67,5 @@ } ] }, - "notes": "Resolver-sensitive syntax exposed after curl\u0027s @ marker is removed still safe-fails." + "notes": "The quoted tilde remains literal after curl\u0027s @ marker is removed." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/282_escaped_home_literal_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/282_escaped_home_literal_path.json new file mode 100644 index 0000000..6d64f2f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/282_escaped_home_literal_path.json @@ -0,0 +1,14 @@ +{ + "name": "Backtick escaped HOME remains a literal path", + "input": "Get-Content `$HOME", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "`$HOME", "kind": "Literal", "isPath": true, "resolved": "C:/work/$HOME" }], + "redirects": [] + }] + }, + "notes": "The backtick escape removes interpolation eligibility without erasing the authored path value." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/283_literalpath_abbreviation_wildcard.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/283_literalpath_abbreviation_wildcard.json new file mode 100644 index 0000000..0ad39be --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/283_literalpath_abbreviation_wildcard.json @@ -0,0 +1,17 @@ +{ + "name": "LiteralPath abbreviation suppresses wildcard semantics", + "input": "Get-Content -LiteralP \"*.txt\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [ + { "raw": "-LiteralP", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "\"*.txt\"", "kind": "Literal", "isPath": true, "resolved": "C:/work/*.txt" } + ], + "redirects": [] + }] + }, + "notes": "Unambiguous parameter-prefix binding retains canonical LiteralPath identity." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/284_unknown_cmdlet_path_semantics.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/284_unknown_cmdlet_path_semantics.json new file mode 100644 index 0000000..8411fe2 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/284_unknown_cmdlet_path_semantics.json @@ -0,0 +1,17 @@ +{ + "name": "Cmdlet shape alone does not prove Path semantics", + "input": "Get-Foo -Path FileSystem::C:/safe", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Foo"], + "args": [ + { "raw": "-Path", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "FileSystem::C:/safe", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__" } + ], + "redirects": [] + }] + }, + "notes": "Only a closed-table known cmdlet or alias may activate provider and parameter semantics." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/285_null_sink_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/285_null_sink_redirect.json new file mode 100644 index 0000000..d7d15be --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/285_null_sink_redirect.json @@ -0,0 +1,14 @@ +{ + "name": "PowerShell null sink redirect remains non-file", + "input": "Write-Output ok > $null", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Write-Output"], + "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], + "redirects": [{ "direction": "Out", "target": "$null", "isDynamicSkip": true }] + }] + }, + "notes": "The unescaped variable token is the PowerShell discard sink, not a filesystem target." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/286_escaped_null_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/286_escaped_null_redirect.json new file mode 100644 index 0000000..ef22657 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/286_escaped_null_redirect.json @@ -0,0 +1,14 @@ +{ + "name": "Escaped PowerShell null text is a literal file target", + "input": "Write-Output ok > `$null", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Write-Output"], + "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], + "redirects": [{ "direction": "Out", "target": "C:/work/$null", "isDynamicSkip": false }] + }] + }, + "notes": "Decoded text alone is insufficient: the backtick proves this is not the null sink." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/287_native_quoted_wildcard.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/287_native_quoted_wildcard.json new file mode 100644 index 0000000..b03b3af --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/287_native_quoted_wildcard.json @@ -0,0 +1,14 @@ +{ + "name": "Quoted native wildcard is a literal path", + "input": "git add \"*.txt\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["git", "add"], + "args": [{ "raw": "\"*.txt\"", "kind": "Literal", "isPath": true, "resolved": "C:/work/*.txt" }], + "redirects": [] + }] + }, + "notes": "PowerShell native argument wildcard eligibility is quote-sensitive." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/288_cmdlet_path_wildcard.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/288_cmdlet_path_wildcard.json new file mode 100644 index 0000000..b0033b8 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/288_cmdlet_path_wildcard.json @@ -0,0 +1,17 @@ +{ + "name": "Cmdlet Path wildcard remains a pattern", + "input": "Get-Content -Path \"*.txt\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [ + { "raw": "-Path", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "\"*.txt\"", "kind": "Glob", "isPath": true, "resolved": "__NULL__" } + ], + "redirects": [] + }] + }, + "notes": "Cmdlet Path applies wildcard semantics after quote removal, unlike native arguments." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/289_adjacent_escaped_redirect_target.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/289_adjacent_escaped_redirect_target.json new file mode 100644 index 0000000..dafd65e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/289_adjacent_escaped_redirect_target.json @@ -0,0 +1,14 @@ +{ + "name": "Adjacent escaped PowerShell redirect fragments form one target", + "input": "Write-Output ok > `$HOME\".txt\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Write-Output"], + "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], + "redirects": [{ "direction": "Out", "target": "C:/work/$HOME.txt", "isDynamicSkip": false }] + }] + }, + "notes": "The redirect target is aggregated before provider-aware resolution." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/290_cmdlet_index_expression.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/290_cmdlet_index_expression.json new file mode 100644 index 0000000..37766a1 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/290_cmdlet_index_expression.json @@ -0,0 +1,17 @@ +{ + "name": "Cmdlet index expression fails closed", + "input": "Get-Content -LiteralPath $HOME[0]", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [ + { "raw": "-LiteralPath", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "$HOME[0]", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + }] + }, + "notes": "PowerShell binds this as an IndexExpressionAst, not HOME plus literal suffix text." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/291_cmdlet_colon_member_expression.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/291_cmdlet_colon_member_expression.json new file mode 100644 index 0000000..42fd6a6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/291_cmdlet_colon_member_expression.json @@ -0,0 +1,17 @@ +{ + "name": "Cmdlet colon member expression fails closed", + "input": "Get-Content -Path:$HOME.Length", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [ + { "raw": "-Path", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "$HOME.Length", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + }] + }, + "notes": "PowerShell binds the colon tail as a MemberExpressionAst and the parser does not evaluate it." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/292_native_member_spelling.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/292_native_member_spelling.json new file mode 100644 index 0000000..23dc573 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/292_native_member_spelling.json @@ -0,0 +1,18 @@ +{ + "name": "Spaced native member expression fails closed", + "input": "curl --output $HOME.Length https://example.invalid/api", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["curl"], + "args": [ + { "raw": "--output", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "$HOME.Length", "kind": "DynamicSkip", "isPath": false }, + { "raw": "https://example.invalid/api", "kind": "Literal", "isPath": false } + ], + "redirects": [] + }] + }, + "notes": "A bare variable at the start of a spaced native argument remains a PowerShell member expression; native command kind does not turn the suffix into literal text." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/293_native_inline_member_spelling.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/293_native_inline_member_spelling.json new file mode 100644 index 0000000..c4aaf0f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/293_native_inline_member_spelling.json @@ -0,0 +1,18 @@ +{ + "name": "Native inline member-looking spelling is literal suffix text", + "input": "curl --output=$HOME.Length https://example.invalid/api", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["curl"], + "args": [ + { "raw": "--output", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "$HOME.Length", "kind": "Tilde", "isPath": true, "resolved": "C:/Users/user.Length" }, + { "raw": "https://example.invalid/api", "kind": "Literal", "isPath": false } + ], + "redirects": [] + }] + }, + "notes": "The same authored fragment has native rather than cmdlet binding semantics." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/294_ambiguous_colon_parameter.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/294_ambiguous_colon_parameter.json new file mode 100644 index 0000000..66fc60c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/294_ambiguous_colon_parameter.json @@ -0,0 +1,17 @@ +{ + "name": "Ambiguous colon parameter fails closed", + "input": "Get-Content -P:\"safe.txt\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [ + { "raw": "-P", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "\"safe.txt\"", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + }] + }, + "notes": "-P matches multiple known parameters, so neither binding nor path mode is proved." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/295_ambiguous_separated_parameter.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/295_ambiguous_separated_parameter.json new file mode 100644 index 0000000..9452adb --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/295_ambiguous_separated_parameter.json @@ -0,0 +1,17 @@ +{ + "name": "Ambiguous separated parameter fails closed", + "input": "Get-Content -P safe.txt", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [ + { "raw": "-P", "kind": "DynamicSkip", "isPath": false, "isFlag": true }, + { "raw": "safe.txt", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + }] + }, + "notes": "The following token cannot safely be treated as either a bound value or a positional path." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/296_unproved_single_letter_psdrive.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/296_unproved_single_letter_psdrive.json new file mode 100644 index 0000000..9a2c3bc --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/296_unproved_single_letter_psdrive.json @@ -0,0 +1,14 @@ +{ + "name": "Unproved single-letter PSDrive fails closed", + "input": "Get-Content Z:\\x", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "Z:\\x", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "A single-letter drive is not proved FileSystem merely from its spelling." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/297_unproved_psdrive_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/297_unproved_psdrive_redirect.json new file mode 100644 index 0000000..930c24b --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/297_unproved_psdrive_redirect.json @@ -0,0 +1,14 @@ +{ + "name": "Unproved PSDrive redirect fails closed", + "input": "Write-Output ok > Z:\\x", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Write-Output"], + "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], + "redirects": [{ "direction": "Out", "target": "Z:\\x", "isDynamicSkip": true }] + }] + }, + "notes": "Redirect path semantics require a proved PSDrive provider mapping." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/298_drive_relative_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/298_drive_relative_path.json new file mode 100644 index 0000000..05969b0 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/298_drive_relative_path.json @@ -0,0 +1,14 @@ +{ + "name": "Drive-relative path fails closed", + "input": "Get-Content C:relative.txt", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "C:relative.txt", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "C:relative.txt depends on PowerShell's per-drive current location, which parser options do not model." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/299_drive_relative_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/299_drive_relative_redirect.json new file mode 100644 index 0000000..0e012bc --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/299_drive_relative_redirect.json @@ -0,0 +1,14 @@ +{ + "name": "Drive-relative redirect fails closed", + "input": "Write-Output ok > C:relative.txt", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Write-Output"], + "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], + "redirects": [{ "direction": "Out", "target": "C:relative.txt", "isDynamicSkip": true }] + }] + }, + "notes": "A configured C drive does not prove its drive-relative current location." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/300_quoted_member_suffix.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/300_quoted_member_suffix.json new file mode 100644 index 0000000..ee45f41 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/300_quoted_member_suffix.json @@ -0,0 +1,17 @@ +{ + "name": "Quoted member-looking suffix remains exact", + "input": "Get-Content -Path $HOME\".Length\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [ + { "raw": "-Path", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "$HOME\".Length\"", "kind": "Tilde", "isPath": true, "resolved": "C:/Users/user.Length" } + ], + "redirects": [] + }] + }, + "notes": "The quote boundary makes .Length literal text rather than a MemberExpressionAst." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json new file mode 100644 index 0000000..f0f7c21 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json @@ -0,0 +1,15 @@ +{ + "name": "Opaque PowerShell command identity remains dynamic", + "input": "Get-$(Write-Output Content) /etc/passwd", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-$(Write-Output Content)"], + "isDynamic": true, + "args": [{ "raw": "/etc/passwd", "kind": "Literal", "isPath": true, "resolved": "/etc/passwd" }], + "redirects": [] + }] + }, + "notes": "Adjacent aggregation preserves the opaque executable identity through VerbChain.IsDynamic." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/302_empty_path_value.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/302_empty_path_value.json new file mode 100644 index 0000000..1787b24 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/302_empty_path_value.json @@ -0,0 +1,14 @@ +{ + "name": "Empty PowerShell path value fails closed", + "input": "Get-Content \"\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "\"\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "An empty filesystem argument is not a resolvable static path." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/303_runtime_question_parameter_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/303_runtime_question_parameter_path.json new file mode 100644 index 0000000..62c1262 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/303_runtime_question_parameter_path.json @@ -0,0 +1,14 @@ +{ + "name": "Runtime PowerShell status variable fails closed", + "input": "Get-Content \"$?\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "\"$?\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "The special variable has a runtime value and cannot be reclassified as literal punctuation." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/304_runtime_numeric_variable_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/304_runtime_numeric_variable_path.json new file mode 100644 index 0000000..3f74952 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/304_runtime_numeric_variable_path.json @@ -0,0 +1,14 @@ +{ + "name": "Runtime PowerShell numeric variable fails closed", + "input": "Get-Content \"$1\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "\"$1\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "Numeric variable identity is retained while its runtime path value remains unknown." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/305_runtime_unicode_variable_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/305_runtime_unicode_variable_path.json new file mode 100644 index 0000000..26f7aca --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/305_runtime_unicode_variable_path.json @@ -0,0 +1,14 @@ +{ + "name": "Runtime PowerShell Unicode variable fails closed", + "input": "Get-Content \"$é\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "\"$é\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "Unicode variable names are recognized expansions rather than literal filenames." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/306_unterminated_braced_interpolation.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/306_unterminated_braced_interpolation.json new file mode 100644 index 0000000..9cdd890 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/306_unterminated_braced_interpolation.json @@ -0,0 +1,10 @@ +{ + "name": "Unterminated PowerShell braced interpolation is unparseable", + "input": "Get-Content \"${HOME\"", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unbalanced '${'", + "clauses": [] + }, + "notes": "PowerShell reports parser errors; no compatibility path is exposed." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/307_escaped_open_brace_literal_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/307_escaped_open_brace_literal_path.json new file mode 100644 index 0000000..3a421e7 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/307_escaped_open_brace_literal_path.json @@ -0,0 +1,14 @@ +{ + "name": "Escaped PowerShell interpolation start remains literal", + "input": "Get-Content \"`${HOME\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "\"`${HOME\"", "kind": "Literal", "isPath": true, "resolved": "C:/work/${HOME" }], + "redirects": [] + }] + }, + "notes": "The backtick escapes the dollar, so the open brace remains exact literal data." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/308_runtime_scoped_variable_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/308_runtime_scoped_variable_path.json new file mode 100644 index 0000000..6dc93f3 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/308_runtime_scoped_variable_path.json @@ -0,0 +1,14 @@ +{ + "name": "Runtime PowerShell scoped variable fails closed", + "input": "Get-Content \"$global:scoped\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "\"$global:scoped\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "Scoped variable identity is retained while its runtime filesystem value remains unknown." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/309_runtime_braced_variable_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/309_runtime_braced_variable_path.json new file mode 100644 index 0000000..423ccf6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/309_runtime_braced_variable_path.json @@ -0,0 +1,14 @@ +{ + "name": "Runtime PowerShell braced variable fails closed", + "input": "Get-Content \"${braced-name}\"", + "expected": { + "isUnparseable": false, + "clauses": [{ + "operator": "None", + "verb": ["Get-Content"], + "args": [{ "raw": "\"${braced-name}\"", "kind": "DynamicSkip", "isPath": false }], + "redirects": [] + }] + }, + "notes": "A braced variable name is a recognized expansion rather than an exact literal filename." +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs index 2774c88..231cfa2 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs @@ -58,7 +58,7 @@ public void Design_corpus_is_well_formed_and_balanced_across_shells() } [Fact] - public void Current_expectations_match_the_v0_2_parsers() + public void Compatibility_projection_matches_current_or_promoted_expectations() { foreach (var file in LoadFiles()) { @@ -66,13 +66,17 @@ public void Current_expectations_match_the_v0_2_parsers() foreach (var designCase in file.Cases) { var actual = parser.Parse(designCase.Input); + var expectedIsUnparseable = designCase.CompatibilityProjectionLanded + ? designCase.Desired.IsUnparseable + : designCase.Current.IsUnparseable; Assert.True( - actual.IsUnparseable == designCase.Current.IsUnparseable, - $"{designCase.Id}: current IsUnparseable expected " - + $"{designCase.Current.IsUnparseable}, actual {actual.IsUnparseable}. " + actual.IsUnparseable == expectedIsUnparseable, + $"{designCase.Id}: IsUnparseable expected " + + $"{expectedIsUnparseable}, actual {actual.IsUnparseable}. " + $"Reason: {actual.UnparseableReason}"); - if (designCase.Current.ReasonContains is not null) + if (!designCase.CompatibilityProjectionLanded + && designCase.Current.ReasonContains is not null) { Assert.Contains( designCase.Current.ReasonContains, @@ -80,9 +84,12 @@ public void Current_expectations_match_the_v0_2_parsers() StringComparison.OrdinalIgnoreCase); } - if (designCase.Current.Argument is not null) + var expectedArgument = designCase.CompatibilityProjectionLanded + ? designCase.Desired.Argument + : designCase.Current.Argument; + if (expectedArgument is not null) { - var expected = designCase.Current.Argument; + var expected = expectedArgument; ValidateArgumentExpectation(designCase.Id, expected, actual.Clauses.Count); var clause = Assert.IsType(actual.Clauses[expected.ClauseIndex]); var argument = Assert.IsType(clause.Args[expected.ArgumentIndex]); @@ -92,11 +99,14 @@ public void Current_expectations_match_the_v0_2_parsers() Assert.Equal(expected.Resolved, argument.Resolved); } - if (designCase.Current.CompatibilityClause is not null) + var expectedClause = designCase.CompatibilityProjectionLanded + ? designCase.Desired.CompatibilityClause + : designCase.Current.CompatibilityClause; + if (expectedClause is not null) { AssertCompatibilityClause( designCase.Id, - designCase.Current.CompatibilityClause, + expectedClause, actual); } } @@ -391,6 +401,8 @@ public sealed record V03DesignCase public string Input { get; init; } = ""; + public bool CompatibilityProjectionLanded { get; init; } + public CurrentBehaviorExpectation Current { get; init; } = new(); public DesiredDesignExpectation Desired { get; init; } = new(); diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json index c6699c5..79b68de 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json @@ -517,6 +517,7 @@ }, { "id": "bash-escaped-home-literal-path", + "compatibilityProjectionLanded": true, "concern": "Escaped variable provenance survives word decoding", "input": "cat \\$HOME", "current": { @@ -561,6 +562,7 @@ }, { "id": "bash-escaped-home-adjacent-native-fragment", + "compatibilityProjectionLanded": true, "concern": "Escaped inline prefix composes with a quoted suffix", "input": "curl --data=@\\$HOME\".json\" https://example.invalid/api", "current": { @@ -605,6 +607,7 @@ }, { "id": "bash-static-mixed-quote-native-fragment", + "compatibilityProjectionLanded": true, "concern": "All-static mixed quoting resolves without DynamicSkip", "input": "curl --data='@$HOME'\".json\" https://example.invalid/api", "current": { @@ -648,6 +651,7 @@ }, { "id": "bash-double-quoted-escaped-home-path", + "compatibilityProjectionLanded": true, "concern": "Escape provenance survives within one double-quoted token", "input": "cat \"\\$HOME.txt\"", "current": { @@ -691,6 +695,7 @@ }, { "id": "bash-double-quoted-literal-expandable-composition", + "compatibilityProjectionLanded": true, "concern": "Literal and expandable regions compose within one token", "input": "echo \"\\${HOME}-$HOME\"", "current": { "isUnparseable": false }, @@ -716,6 +721,7 @@ }, { "id": "bash-runtime-special-parameter-path", + "compatibilityProjectionLanded": true, "concern": "Runtime special parameter punctuation cannot become a glob path", "input": "cat \"$?\"", "current": { @@ -742,6 +748,7 @@ }, { "id": "bash-runtime-positional-parameter-path", + "compatibilityProjectionLanded": true, "concern": "Runtime positional parameters cannot become literal paths", "input": "cat \"$1\"", "current": { @@ -768,6 +775,7 @@ }, { "id": "bash-runtime-argument-vector-boundary", + "compatibilityProjectionLanded": true, "concern": "Quoted dollar-at can produce zero or multiple arguments", "input": "cat \"$@\"", "current": { @@ -794,6 +802,7 @@ }, { "id": "bash-unterminated-braced-interpolation", + "compatibilityProjectionLanded": true, "concern": "An outer closing quote does not complete an open braced interpolation", "input": "cat \"${HOME\"", "current": { "isUnparseable": false }, @@ -806,6 +815,7 @@ }, { "id": "bash-adjacent-redirect-target-fragments", + "compatibilityProjectionLanded": true, "concern": "Adjacent escaped and quoted redirect fragments form one target", "input": "echo ok > \\$HOME\".txt\"", "current": { @@ -876,6 +886,7 @@ }, { "id": "bash-provider-looking-unquoted-path", + "compatibilityProjectionLanded": true, "concern": "Bash does not interpret an unquoted PowerShell provider-looking prefix", "input": "cat filesystem::/safe", "current": { @@ -902,6 +913,7 @@ }, { "id": "bash-provider-looking-quoted-path", + "compatibilityProjectionLanded": true, "concern": "Quoting does not introduce PowerShell provider semantics into Bash", "input": "cat \"filesystem::/safe\"", "current": { @@ -927,6 +939,7 @@ }, { "id": "bash-escaped-open-brace-literal-path", + "compatibilityProjectionLanded": true, "concern": "An escaped interpolation start remains literal rather than incomplete", "input": "cat \"\\${HOME\"", "current": { @@ -952,6 +965,7 @@ }, { "id": "bash-unquoted-wildcard-redirect", + "compatibilityProjectionLanded": true, "concern": "Unquoted redirect wildcard cannot prove exactly one target", "input": "echo ok > *.txt", "current": { @@ -1018,6 +1032,7 @@ }, { "id": "bash-quoted-wildcard-redirect", + "compatibilityProjectionLanded": true, "concern": "Quoted redirect wildcard is one literal target", "input": "echo ok > \"*.txt\"", "current": { diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index a5fdbbb..59e9527 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -488,6 +488,7 @@ }, { "id": "pwsh-escaped-home-literal-path", + "compatibilityProjectionLanded": true, "concern": "Backtick-escaped variable provenance survives word decoding", "input": "Get-Content `$HOME", "current": { @@ -532,6 +533,7 @@ }, { "id": "pwsh-escaped-home-adjacent-native-fragment", + "compatibilityProjectionLanded": true, "concern": "Backtick-escaped inline prefix composes with a quoted suffix", "input": "curl --data=@`$HOME\".json\" https://example.invalid/api", "current": { @@ -576,6 +578,7 @@ }, { "id": "pwsh-static-mixed-quote-native-fragment", + "compatibilityProjectionLanded": true, "concern": "All-static mixed quoting resolves without DynamicSkip", "input": "curl --data='@$HOME'\".json\" https://example.invalid/api", "current": { @@ -619,6 +622,7 @@ }, { "id": "pwsh-double-quoted-escaped-home-path", + "compatibilityProjectionLanded": true, "concern": "Backtick escape provenance survives within one double-quoted token", "input": "Get-Content \"`$HOME.txt\"", "current": { @@ -687,6 +691,7 @@ }, { "id": "pwsh-runtime-question-parameter-path", + "compatibilityProjectionLanded": true, "concern": "Runtime status punctuation cannot become a glob path", "input": "Get-Content \"$?\"", "current": { @@ -713,6 +718,7 @@ }, { "id": "pwsh-runtime-caret-parameter-path", + "compatibilityProjectionLanded": true, "concern": "Runtime first-token state cannot become a literal path", "input": "Get-Content \"$^\"", "current": { @@ -738,6 +744,7 @@ }, { "id": "pwsh-runtime-dollar-parameter-path", + "compatibilityProjectionLanded": true, "concern": "Runtime last-token state cannot become a literal path", "input": "Get-Content \"$$\"", "current": { @@ -763,6 +770,7 @@ }, { "id": "pwsh-runtime-numeric-variable-path", + "compatibilityProjectionLanded": true, "concern": "Numeric variable names remain runtime-produced", "input": "Get-Content \"$1\"", "current": { @@ -788,6 +796,7 @@ }, { "id": "pwsh-runtime-unicode-variable-path", + "compatibilityProjectionLanded": true, "concern": "Unicode variable names remain runtime-produced", "input": "Get-Content \"$é\"", "current": { @@ -813,6 +822,7 @@ }, { "id": "pwsh-unterminated-braced-interpolation", + "compatibilityProjectionLanded": true, "concern": "An outer closing quote does not complete an open braced interpolation", "input": "Get-Content \"${HOME\"", "current": { "isUnparseable": false }, @@ -825,6 +835,7 @@ }, { "id": "pwsh-cmdlet-quoted-tilde-path", + "compatibilityProjectionLanded": true, "concern": "Cmdlet path binding interprets quoted tilde after value formation", "input": "Get-Content \"~\"", "current": { @@ -850,6 +861,7 @@ }, { "id": "pwsh-native-quoted-tilde-path", + "compatibilityProjectionLanded": true, "concern": "Quoted tilde remains literal for a native file operand", "input": "curl --output \"~\" https://example.invalid", "current": { @@ -876,6 +888,7 @@ }, { "id": "pwsh-cmdlet-filesystem-provider-path", + "compatibilityProjectionLanded": true, "concern": "Cmdlet path binding applies the FileSystem provider qualifier", "input": "Get-Content \"FileSystem::C:\\logs\\x.txt\"", "current": { @@ -901,6 +914,7 @@ }, { "id": "pwsh-native-provider-looking-path", + "compatibilityProjectionLanded": true, "concern": "Native arguments do not inherit cmdlet provider semantics", "input": "curl --output \"FileSystem::report.txt\" https://example.invalid", "current": { @@ -927,6 +941,7 @@ }, { "id": "pwsh-cmdlet-nonfilesystem-psdrive", + "compatibilityProjectionLanded": true, "concern": "A non-FileSystem PSDrive is not reported as a filesystem path", "input": "Get-Content \"Env:\\PATH\"", "current": { @@ -952,6 +967,7 @@ }, { "id": "pwsh-cmdlet-path-quoted-wildcard", + "compatibilityProjectionLanded": true, "concern": "Cmdlet Path binding retains wildcard semantics after quote removal", "input": "Get-Content -Path \"*.txt\"", "current": { @@ -977,6 +993,7 @@ }, { "id": "pwsh-cmdlet-literalpath-quoted-wildcard", + "compatibilityProjectionLanded": true, "concern": "Cmdlet LiteralPath binding suppresses wildcard semantics", "input": "Get-Content -LiteralPath \"*.txt\"", "current": { @@ -1003,6 +1020,7 @@ }, { "id": "pwsh-native-quoted-wildcard-path", + "compatibilityProjectionLanded": true, "concern": "Quoted wildcard remains literal for a native file operand", "input": "curl --output \"*.txt\" https://example.invalid", "current": { @@ -1028,6 +1046,7 @@ }, { "id": "pwsh-adjacent-redirect-target-fragments", + "compatibilityProjectionLanded": true, "concern": "Adjacent escaped and quoted redirect fragments form one target", "input": "Write-Output ok > `$HOME\".txt\"", "current": { @@ -1099,6 +1118,7 @@ }, { "id": "pwsh-native-unquoted-tilde-path", + "compatibilityProjectionLanded": true, "concern": "Unquoted tilde remains eligible for native expansion", "input": "curl --output ~ https://example.invalid", "current": { @@ -1124,6 +1144,7 @@ }, { "id": "pwsh-native-unquoted-wildcard-path", + "compatibilityProjectionLanded": true, "concern": "Unquoted native wildcard remains dynamic without enumeration", "input": "curl --output *.txt https://example.invalid", "current": { @@ -1149,6 +1170,7 @@ }, { "id": "pwsh-cmdlet-literalpath-quoted-tilde", + "compatibilityProjectionLanded": true, "concern": "LiteralPath still applies quoted tilde semantics", "input": "Get-Content -LiteralPath \"~\"", "current": { @@ -1174,6 +1196,7 @@ }, { "id": "pwsh-cmdlet-literalpath-provider", + "compatibilityProjectionLanded": true, "concern": "LiteralPath still applies a FileSystem provider qualifier", "input": "Get-Content -LiteralPath \"FileSystem::C:\\logs\\x.txt\"", "current": { @@ -1199,6 +1222,7 @@ }, { "id": "pwsh-escaped-open-brace-literal-path", + "compatibilityProjectionLanded": true, "concern": "An escaped interpolation start remains literal rather than incomplete", "input": "Get-Content \"`${HOME\"", "current": { @@ -1224,6 +1248,7 @@ }, { "id": "pwsh-redirect-quoted-tilde", + "compatibilityProjectionLanded": true, "concern": "PowerShell redirect applies tilde semantics after quote removal", "input": "Write-Output ok > \"~\"", "current": { @@ -1293,6 +1318,7 @@ }, { "id": "pwsh-redirect-quoted-wildcard", + "compatibilityProjectionLanded": true, "concern": "PowerShell redirect wildcard remains unknown even when quoted", "input": "Write-Output ok > \"*.txt\"", "current": { @@ -1360,6 +1386,7 @@ }, { "id": "pwsh-redirect-filesystem-provider", + "compatibilityProjectionLanded": true, "concern": "PowerShell redirect applies a FileSystem provider qualifier", "input": "Write-Output ok > \"FileSystem::C:\\logs\\x.txt\"", "current": { @@ -1429,6 +1456,7 @@ }, { "id": "pwsh-redirect-unknown-psdrive", + "compatibilityProjectionLanded": true, "concern": "PowerShell redirect cannot guess an unproved PSDrive provider mapping", "input": "Write-Output ok > \"ZZ:\\drive.txt\"", "current": { diff --git a/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs b/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs index f87aa47..b34f670 100644 --- a/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using System.Linq; using ShellSyntaxTree.Internal.Bash.Lexing; +using ShellSyntaxTree.Internal.Resolving; using Xunit; namespace ShellSyntaxTree.Tests.Lexing; @@ -311,6 +312,27 @@ public void Bare_dollar_var_stays_as_word() Assert.Equal("$HOME", tokens[1].Value); } + [Theory] + [InlineData("${10}", "PositionalParameter", "ExactlyOne")] + [InlineData("$*", "SpecialParameter", "ZeroOrMore")] + [InlineData("\"$*\"", "SpecialParameter", "ExactlyOne")] + [InlineData("\"$@\"", "SpecialParameter", "ZeroOrMore")] + public void Runtime_parameter_provenance_retains_identity_and_cardinality( + string input, + string expectedKind, + string expectedCardinality) + { + var token = Assert.Single(LexNonWs(input)); + Assert.NotNull(token.ResolverValue); + var expansion = Assert.Single( + token.ResolverValue.Fragments, + fragment => fragment.Kind == ShellValueFragmentKind.Expansion); + + Assert.NotNull(expansion.Expansion); + Assert.Equal(expectedKind, expansion.Expansion.Value.Kind.ToString()); + Assert.Equal(expectedCardinality, expansion.Cardinality.ToString()); + } + // ------------------------------------------------------------ opaque regions [Fact] diff --git a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs index 173f35a..8acca7e 100644 --- a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using System.Linq; using ShellSyntaxTree.Internal.Pwsh.Lexing; +using ShellSyntaxTree.Internal.Resolving; using Xunit; namespace ShellSyntaxTree.Tests.Lexing; @@ -77,6 +78,31 @@ public void Expandable_string_records_interpolation(string input) Assert.True(t.HasInterpolation); } + [Theory] + [InlineData("\"$?\"", "SpecialParameter", "?")] + [InlineData("\"$1\"", "Variable", "1")] + [InlineData("\"$é\"", "Variable", "é")] + [InlineData("\"$global:scoped\"", "Variable", "global:scoped")] + [InlineData("\"${braced-name}\"", "Variable", "braced-name")] + public void Runtime_variable_provenance_retains_typed_identity( + string input, + string expectedKind, + string expectedName) + { + var token = Assert.Single(Significant(input)); + Assert.NotNull(token.ResolverValue); + var expansion = Assert.Single( + token.ResolverValue.Fragments, + fragment => fragment.Kind == ShellValueFragmentKind.Expansion); + + Assert.NotNull(expansion.Expansion); + Assert.Equal(expectedKind, expansion.Expansion.Value.Kind.ToString()); + Assert.Equal(expectedName, expansion.Expansion.Value.Name); + Assert.Equal( + ShellValueCardinality.ExactlyOne.ToString(), + expansion.Cardinality.ToString()); + } + [Theory] [InlineData("\"Get-Date\"")] [InlineData("\"Write-Host `$name\"")] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ClauseElementTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ClauseElementTests.cs index d87768f..90b7a44 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ClauseElementTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ClauseElementTests.cs @@ -305,7 +305,7 @@ public void Unquoted_value_prefix_joins_adjacent_native_fragments() } [Fact] - public void Resolver_sensitive_mixed_quoting_safe_fails() + public void All_static_mixed_quoting_resolves_without_reinterpreting_literal_fragments() { const string source = "curl --data='@$HOME'\".json\" https://example.invalid/api"; foreach (var (shell, parser) in Parsers()) @@ -315,9 +315,11 @@ public void Resolver_sensitive_mixed_quoting_safe_fails() clause.Elements, element => element.Value == "--data=@$HOME.json"); - Assert.Equal(ArgKind.DynamicSkip, option.Kind); - Assert.False(option.IsPath, shell); - Assert.Null(option.Resolved); + Assert.Equal(ArgKind.Literal, option.Kind); + Assert.True(option.IsPath, shell); + Assert.Equal( + shell == "bash" ? "/work/$HOME.json" : "C:/work/$HOME.json", + option.Resolved); } const string transformedSource = @@ -329,9 +331,11 @@ public void Resolver_sensitive_mixed_quoting_safe_fails() clause.Elements, element => element.Value == "--data=@~/secret.json"); - Assert.Equal(ArgKind.DynamicSkip, option.Kind); - Assert.False(option.IsPath, shell); - Assert.Null(option.Resolved); + Assert.Equal(ArgKind.Literal, option.Kind); + Assert.True(option.IsPath, shell); + Assert.Equal( + shell == "bash" ? "/work/~/secret.json" : "C:/work/~/secret.json", + option.Resolved); } } diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ResolverProvenanceTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ResolverProvenanceTests.cs new file mode 100644 index 0000000..1e5e469 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Parsing/ResolverProvenanceTests.cs @@ -0,0 +1,292 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using Xunit; + +namespace ShellSyntaxTree.Tests.Parsing; + +public class ResolverProvenanceTests +{ + private static readonly BashParser Bash = new(new BashParserOptions + { + HomeDirectory = "/home/test", + WorkingDirectory = "/work", + }); + + private static readonly PwshParser Pwsh = new(new PwshParserOptions + { + HomeDirectory = "C:/Users/user", + WorkingDirectory = "C:/work", + }); + + [Theory] + [InlineData("cat \\$HOME", "\\$HOME", "/work/$HOME")] + [InlineData("cat \"\\$HOME.txt\"", "\"\\$HOME.txt\"", "/work/$HOME.txt")] + [InlineData("cat \"\"~", "\"\"~", "/work/~")] + public void Bash_literal_boundaries_block_reconstructed_expansion( + string source, string raw, string resolved) + { + var argument = Assert.Single(Assert.Single(Bash.Parse(source).Clauses).Args); + + Assert.Equal(raw, argument.Raw); + Assert.Equal(ArgKind.Literal, argument.Kind); + Assert.True(argument.IsPath); + Assert.Equal(resolved, argument.Resolved); + } + + [Fact] + public void Bash_unquoted_home_with_field_splitting_risk_fails_closed() + { + var parser = new BashParser(new BashParserOptions + { + HomeDirectory = "/home/test user", + WorkingDirectory = "/work", + }); + + var unquoted = Assert.Single(Assert.Single(parser.Parse("cat $HOME").Clauses).Args); + Assert.Equal(ArgKind.DynamicSkip, unquoted.Kind); + Assert.False(unquoted.IsPath); + + var quoted = Assert.Single(Assert.Single(parser.Parse("cat \"$HOME\"").Clauses).Args); + Assert.Equal(ArgKind.Tilde, quoted.Kind); + Assert.True(quoted.IsPath); + Assert.Equal("/home/test user", quoted.Resolved); + } + + [Fact] + public void Bash_redirect_aggregates_all_adjacent_fragments() + { + var redirect = Assert.Single( + Assert.Single(Bash.Parse("echo ok > \\$HOME\".txt\"").Clauses).Redirects); + + Assert.False(redirect.IsDynamicSkip); + Assert.Equal("/work/$HOME.txt", redirect.Target); + } + + [Fact] + public void Bash_command_substitution_inside_double_quotes_is_opaque() + { + var argument = Assert.Single( + Assert.Single(Bash.Parse("cat \"`printf /etc/passwd`\"").Clauses).Args); + + Assert.Equal(ArgKind.DynamicSkip, argument.Kind); + Assert.False(argument.IsPath); + Assert.Null(argument.Resolved); + } + + [Fact] + public void Bash_unsupported_ansi_c_quote_is_unparseable() + { + var parsed = Bash.Parse("cat $'/etc/passwd'"); + + Assert.True(parsed.IsUnparseable); + Assert.Contains("ANSI-C", parsed.UnparseableReason); + } + + [Theory] + [InlineData("echo ok > \"&1\"")] + [InlineData("echo ok > \\&1")] + public void Bash_literal_fd_shaped_redirect_target_is_a_file(string source) + { + var redirect = Assert.Single(Assert.Single(Bash.Parse(source).Clauses).Redirects); + + Assert.False(redirect.IsDynamicSkip); + Assert.Equal("/work/&1", redirect.Target); + } + + [Fact] + public void Empty_path_value_fails_closed() + { + var bashArgument = Assert.Single(Assert.Single(Bash.Parse("cat \"\"").Clauses).Args); + Assert.Equal(ArgKind.DynamicSkip, bashArgument.Kind); + + var pwshArgument = Assert.Single( + Assert.Single(Pwsh.Parse("Get-Content \"\"").Clauses).Args); + Assert.Equal(ArgKind.DynamicSkip, pwshArgument.Kind); + } + + [Fact] + public void Bash_empty_inline_native_value_preserves_argument_cardinality() + { + var clause = Assert.Single( + Bash.Parse("curl --data=\"\" https://example.invalid/api").Clauses); + + Assert.Equal(3, clause.Args.Count); + Assert.Equal("--data", clause.Args[0].Raw); + Assert.Equal("\"\"", clause.Args[1].Raw); + Assert.Equal(ArgKind.Literal, clause.Args[1].Kind); + } + + [Theory] + [InlineData("Get-Content `$HOME", "`$HOME", "C:/work/$HOME")] + [InlineData("Get-Content \"`$HOME.txt\"", "\"`$HOME.txt\"", "C:/work/$HOME.txt")] + public void PowerShell_backtick_escapes_remain_literal( + string source, string raw, string resolved) + { + var argument = Assert.Single(Assert.Single(Pwsh.Parse(source).Clauses).Args); + + Assert.Equal(raw, argument.Raw); + Assert.Equal(ArgKind.Literal, argument.Kind); + Assert.True(argument.IsPath); + Assert.Equal(resolved, argument.Resolved); + } + + [Fact] + public void PowerShell_literalpath_abbreviation_suppresses_wildcards() + { + var argument = Assert.Single( + Assert.Single(Pwsh.Parse("Get-Content -LiteralP \"*.txt\"").Clauses).Args, + candidate => !candidate.IsFlag); + + Assert.Equal(ArgKind.Literal, argument.Kind); + Assert.True(argument.IsPath); + Assert.Equal("C:/work/*.txt", argument.Resolved); + } + + [Fact] + public void PowerShell_comma_array_semantics_are_quote_sensitive() + { + var unquoted = Assert.Single( + Assert.Single(Pwsh.Parse("Get-Content a,b").Clauses).Args); + Assert.Equal(ArgKind.DynamicSkip, unquoted.Kind); + Assert.False(unquoted.IsPath); + + var quoted = Assert.Single( + Assert.Single(Pwsh.Parse("Get-Content \"a,b\"").Clauses).Args); + Assert.Equal(ArgKind.Literal, quoted.Kind); + Assert.True(quoted.IsPath); + Assert.Equal("C:/work/a,b", quoted.Resolved); + + var nonPath = Assert.Single( + Assert.Single(Pwsh.Parse("Write-Output a,b").Clauses).Args); + Assert.Equal(ArgKind.DynamicSkip, nonPath.Kind); + Assert.False(nonPath.IsPath); + } + + [Fact] + public void Merely_cmdlet_shaped_command_does_not_prove_path_semantics() + { + var argument = Assert.Single( + Assert.Single(Pwsh.Parse("Get-Foo -Path FileSystem::C:/safe").Clauses).Args, + candidate => !candidate.IsFlag); + + Assert.Equal(ArgKind.DynamicSkip, argument.Kind); + Assert.False(argument.IsPath); + Assert.Null(argument.Resolved); + } + + [Theory] + [InlineData("Get-Content -LiteralPath $HOME[0]")] + [InlineData("Get-Content -Path:$HOME.Length")] + public void PowerShell_cmdlet_member_and_index_expressions_fail_closed(string source) + { + var argument = Assert.Single(Pwsh.Parse(source).Clauses).Args[1]; + + Assert.Equal(ArgKind.DynamicSkip, argument.Kind); + Assert.False(argument.IsPath); + Assert.Null(argument.Resolved); + } + + [Fact] + public void PowerShell_spaced_native_member_expression_fails_closed() + { + var argument = Assert.Single( + Pwsh.Parse("curl --output $HOME.Length https://example.invalid").Clauses).Args[1]; + + Assert.Equal(ArgKind.DynamicSkip, argument.Kind); + Assert.False(argument.IsPath); + Assert.Null(argument.Resolved); + } + + [Fact] + public void PowerShell_inline_native_member_spelling_is_literal_suffix_text() + { + var argument = Assert.Single( + Pwsh.Parse("curl --output=$HOME.Length https://example.invalid").Clauses).Args[1]; + + Assert.Equal(ArgKind.Tilde, argument.Kind); + Assert.True(argument.IsPath); + Assert.Equal("C:/Users/user.Length", argument.Resolved); + } + + [Theory] + [InlineData("Get-Content -P:\"safe.txt\"")] + [InlineData("Get-Content -P safe.txt")] + public void PowerShell_ambiguous_parameter_binding_fails_closed(string source) + { + var clause = Assert.Single(Pwsh.Parse(source).Clauses); + + Assert.Contains(clause.Args, argument => argument.Kind == ArgKind.DynamicSkip); + Assert.DoesNotContain(clause.Args, argument => argument.Resolved == "C:/work/safe.txt"); + } + + [Theory] + [InlineData("Get-Content Z:\\x")] + [InlineData("Write-Output ok > Z:\\x")] + [InlineData("Get-Content C:relative.txt")] + [InlineData("Write-Output ok > C:relative.txt")] + public void Unproved_single_letter_psdrive_fails_closed(string source) + { + var clause = Assert.Single(Pwsh.Parse(source).Clauses); + if (clause.Redirects.Count > 0) + { + Assert.True(Assert.Single(clause.Redirects).IsDynamicSkip); + return; + } + + var argument = Assert.Single(clause.Args); + Assert.Equal(ArgKind.DynamicSkip, argument.Kind); + Assert.False(argument.IsPath); + } + + [Fact] + public void Inline_quoted_literalpath_is_one_exact_value() + { + var clause = Assert.Single(Pwsh.Parse("Get-Content -LiteralPath:\"*.txt\"").Clauses); + + Assert.Equal(2, clause.Args.Count); + Assert.Equal("-LiteralPath", clause.Args[0].Raw); + Assert.Equal("\"*.txt\"", clause.Args[1].Raw); + Assert.Equal(ArgKind.Literal, clause.Args[1].Kind); + Assert.Equal("C:/work/*.txt", clause.Args[1].Resolved); + } + + [Fact] + public void Quoted_suffix_after_variable_is_not_a_member_expression() + { + var argument = Assert.Single( + Assert.Single(Pwsh.Parse("Get-Content -Path $HOME\".Length\"").Clauses).Args, + candidate => !candidate.IsFlag); + + Assert.Equal(ArgKind.Tilde, argument.Kind); + Assert.Equal("C:/Users/user.Length", argument.Resolved); + } + + [Fact] + public void Opaque_command_identity_does_not_become_literal() + { + var bash = Bash.Parse("r$(printf m) -rf /tmp/x"); + Assert.True(bash.IsUnparseable); + Assert.Empty(bash.Clauses); + + var pwshClause = Assert.Single(Pwsh.Parse( + "Get-$(Write-Output Content) /etc/passwd").Clauses); + Assert.True(pwshClause.Verb.IsDynamic); + } + + [Fact] + public void PowerShell_redirect_distinguishes_null_sink_from_escaped_literal() + { + var sink = Assert.Single( + Assert.Single(Pwsh.Parse("Write-Output ok > $null").Clauses).Redirects); + Assert.True(sink.IsDynamicSkip); + Assert.Equal("$null", sink.Target); + + var literal = Assert.Single( + Assert.Single(Pwsh.Parse("Write-Output ok > `$null").Clauses).Redirects); + Assert.False(literal.IsDynamicSkip); + Assert.Equal("C:/work/$null", literal.Target); + } +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs new file mode 100644 index 0000000..ecbdf62 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -0,0 +1,639 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Diagnostics; +using System.IO; +using Xunit; + +namespace ShellSyntaxTree.Tests.Parsing; + +public class ShellValueOracleTests +{ + [Fact] + public void Standalone_escapes_are_literal_in_both_shells() + { + if (IsAvailable("bash")) + { + var bash = Run("bash", "-c", "printf '<%s>\\n' \\$HOME"); + Assert.Equal("<$HOME>", bash); + } + + if (IsAvailable("pwsh") && File.Exists("/usr/bin/printf")) + { + var pwsh = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' `$HOME"); + Assert.Equal("<$HOME>", pwsh); + } + } + + [Fact] + public void Bash_escape_and_adjacent_quote_produce_one_literal_argument() + { + if (!IsAvailable("bash")) + { + return; + } + + var output = Run( + "bash", + "-c", + "printf '<%s>\\n' --data=@\\$HOME\".json\""); + + Assert.Equal("<--data=@$HOME.json>", output); + } + + [Fact] + public void Static_mixed_quoting_and_within_token_escapes_are_literal() + { + if (IsAvailable("bash")) + { + var bash = Run( + "bash", + "-c", + "printf '<%s>\\n' pre\"mid\"'post'\\ value pre\\$HOMEpost"); + Assert.Equal( + new[] { "", "" }, + Lines(bash)); + } + + if (IsAvailable("pwsh") && File.Exists("/usr/bin/printf")) + { + var pwsh = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' pre\"mid\"'post' pre`$HOMEpost"); + Assert.Equal( + new[] { "", "" }, + Lines(pwsh)); + } + } + + [Fact] + public void Literal_and_expandable_fragments_compose_in_both_shells() + { + if (IsAvailable("bash")) + { + var bash = Run( + "bash", + "-c", + "HOME=/oracle/home; printf '<%s>\\n' \"prefix-$HOME-suffix\""); + Assert.Equal("", bash); + } + + if (IsAvailable("pwsh") && File.Exists("/usr/bin/printf")) + { + var home = Environment.GetEnvironmentVariable("HOME"); + Assert.False(string.IsNullOrEmpty(home)); + var pwsh = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' \"prefix-$HOME-suffix\""); + Assert.Equal($"", pwsh); + } + } + + [Fact] + public void Runtime_parameter_forms_execute_in_both_shells() + { + if (IsAvailable("bash")) + { + var bash = Run( + "bash", + "-c", + "printf '<%s>\\n' \"$?\" \"$#\" \"$1\" \"${10}\" \"$*\" \"$@\"", + "oracle", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten"); + Assert.Equal( + new[] + { + "<0>", + "<10>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + }, + Lines(bash)); + } + + if (IsAvailable("pwsh") && File.Exists("/usr/bin/printf")) + { + var pwsh = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "$1='one'; $é='unicode'; $global:scoped='scoped'; ${braced-name}='braced'; " + + "& /usr/bin/printf '<%s>\\n' $? $1 $é $global:scoped ${braced-name}"); + Assert.Equal( + new[] { "", "", "", "", "" }, + Lines(pwsh)); + } + } + + [Fact] + public void Incomplete_and_escaped_braced_interpolation_differ_in_both_shells() + { + if (IsAvailable("bash")) + { + var incomplete = RunUnchecked( + "bash", + "-n", + "-c", + "printf '<%s>\\n' \"${HOME\""); + Assert.NotEqual(0, incomplete.ExitCode); + + var escaped = Run( + "bash", + "-c", + "printf '<%s>\\n' \"\\${HOME\""); + Assert.Equal("<${HOME>", escaped); + } + + if (IsAvailable("pwsh") && File.Exists("/usr/bin/printf")) + { + var incomplete = RunUnchecked( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "Write-Output \"${HOME\""); + Assert.NotEqual(0, incomplete.ExitCode); + + var escaped = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' \"`${HOME\""); + Assert.Equal("<${HOME>", escaped); + } + } + + [Fact] + public void Provider_looking_values_are_shell_and_consumer_specific() + { + if (IsAvailable("bash")) + { + var bash = Run( + "bash", + "-c", + "printf '<%s>\\n' filesystem::/safe \"FileSystem::/tmp\""); + Assert.Equal( + new[] { "", "" }, + Lines(bash)); + } + + if (IsAvailable("pwsh") + && File.Exists("/usr/bin/printf") + && Directory.Exists("/tmp")) + { + var native = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' FileSystem::/tmp \"FileSystem::/tmp\""); + var cmdlet = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "(Get-Item 'FileSystem::/tmp').FullName"); + + Assert.Equal( + new[] { "", "" }, + Lines(native)); + Assert.Equal("/tmp", cmdlet); + } + } + + [Fact] + public void Adjacent_redirect_fragments_form_one_literal_target_in_both_shells() + { + var root = Path.Combine( + Path.GetTempPath(), + "shellsyntaxtree-oracle-" + Guid.NewGuid().ToString("N")); + var bashDirectory = Path.Combine(root, "bash"); + var pwshDirectory = Path.Combine(root, "pwsh"); + Directory.CreateDirectory(bashDirectory); + Directory.CreateDirectory(pwshDirectory); + try + { + if (IsAvailable("bash")) + { + RunInWorkingDirectory( + "bash", + bashDirectory, + "-c", + "printf bash > \\$HOME\".txt\""); + Assert.Equal( + "bash", + File.ReadAllText(Path.Combine(bashDirectory, "$HOME.txt"))); + } + + if (IsAvailable("pwsh")) + { + RunInWorkingDirectory( + "pwsh", + pwshDirectory, + "-NoLogo", + "-NoProfile", + "-Command", + "Write-Output pwsh > `$HOME\".txt\""); + Assert.Equal( + "pwsh", + File.ReadAllText(Path.Combine(pwshDirectory, "$HOME.txt")).Trim()); + } + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Wildcard_argument_and_redirect_contexts_diverge() + { + if (Path.DirectorySeparatorChar != '/') + { + return; + } + + var root = Path.Combine( + Path.GetTempPath(), + "shellsyntaxtree-oracle-" + Guid.NewGuid().ToString("N")); + var bashDirectory = Path.Combine(root, "bash"); + var pwshDirectory = Path.Combine(root, "pwsh"); + Directory.CreateDirectory(bashDirectory); + Directory.CreateDirectory(pwshDirectory); + try + { + if (IsAvailable("bash")) + { + File.WriteAllText(Path.Combine(bashDirectory, "a.txt"), "a"); + File.WriteAllText(Path.Combine(bashDirectory, "b.txt"), "b"); + var ambiguous = RunUncheckedInWorkingDirectory( + "bash", + bashDirectory, + "-c", + "printf ambiguous > *.txt"); + Assert.NotEqual(0, ambiguous.ExitCode); + + RunInWorkingDirectory( + "bash", + bashDirectory, + "-c", + "printf literal > \"*.txt\""); + Assert.Equal( + "literal", + File.ReadAllText(Path.Combine(bashDirectory, "*.txt"))); + } + + if (IsAvailable("pwsh") && File.Exists("/usr/bin/printf")) + { + var matchedPath = Path.Combine(pwshDirectory, "a.txt"); + File.WriteAllText(matchedPath, "a"); + var nativeUnquoted = RunInWorkingDirectory( + "pwsh", + pwshDirectory, + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' *.txt"); + var nativeQuoted = RunInWorkingDirectory( + "pwsh", + pwshDirectory, + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' \"*.txt\""); + var cmdlet = RunInWorkingDirectory( + "pwsh", + pwshDirectory, + "-NoLogo", + "-NoProfile", + "-Command", + "(Resolve-Path -Path '*.txt').Path; Test-Path -LiteralPath '*.txt'"); + + Assert.Equal("", nativeUnquoted); + Assert.Equal("<*.txt>", nativeQuoted); + Assert.Equal( + new[] { matchedPath, "False" }, + Lines(cmdlet)); + + RunInWorkingDirectory( + "pwsh", + pwshDirectory, + "-NoLogo", + "-NoProfile", + "-Command", + "Write-Output redirected > \"*.txt\""); + Assert.Equal("redirected", File.ReadAllText(matchedPath).Trim()); + } + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void PowerShell_redirects_apply_tilde_provider_and_psdrive_semantics() + { + if (!IsAvailable("pwsh") || Path.DirectorySeparatorChar != '/') + { + return; + } + + var home = Environment.GetEnvironmentVariable("HOME"); + Assert.False(string.IsNullOrEmpty(home)); + var root = Path.Combine( + Path.GetTempPath(), + "shellsyntaxtree-oracle-" + Guid.NewGuid().ToString("N")); + var tildeFileName = ".shellsyntaxtree-oracle-" + Guid.NewGuid().ToString("N") + ".txt"; + var tildePath = Path.Combine(home!, tildeFileName); + Directory.CreateDirectory(root); + try + { + var escapedRoot = root.Replace("'", "''", StringComparison.Ordinal); + Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + $"Write-Output provider > 'FileSystem::{escapedRoot}/provider.txt'"); + Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + $"New-PSDrive -Name SST -PSProvider FileSystem -Root '{escapedRoot}' | Out-Null; " + + "Write-Output drive > 'SST:/drive.txt'"); + Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + $"Write-Output tilde > '~/{tildeFileName}'"); + + Assert.Equal("provider", File.ReadAllText(Path.Combine(root, "provider.txt")).Trim()); + Assert.Equal("drive", File.ReadAllText(Path.Combine(root, "drive.txt")).Trim()); + Assert.Equal("tilde", File.ReadAllText(tildePath).Trim()); + } + finally + { + if (File.Exists(tildePath)) + { + File.Delete(tildePath); + } + + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void PowerShell_escape_and_adjacent_quote_produce_one_literal_argument() + { + if (!IsAvailable("pwsh") || !File.Exists("/usr/bin/printf")) + { + return; + } + + var output = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' --data=@`$HOME\".json\""); + + Assert.Equal("<--data=@$HOME.json>", output); + } + + [Fact] + public void Bash_ansi_c_quoting_transforms_the_authored_value() + { + if (!IsAvailable("bash")) + { + return; + } + + var output = Run( + "bash", + "-c", + "printf '<%s>\\n' $'/etc/passwd'"); + + Assert.Equal("", output); + } + + [Fact] + public void Bash_quoted_and_escaped_fd_spelling_redirects_to_literal_files() + { + if (!IsAvailable("bash")) + { + return; + } + + var workingDirectory = Path.Combine( + Path.GetTempPath(), + "shellsyntaxtree-oracle-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workingDirectory); + try + { + RunInWorkingDirectory( + "bash", + workingDirectory, + "-c", + "printf quoted > \"&1\"; printf escaped > \\&2"); + + Assert.Equal("quoted", File.ReadAllText(Path.Combine(workingDirectory, "&1"))); + Assert.Equal("escaped", File.ReadAllText(Path.Combine(workingDirectory, "&2"))); + } + finally + { + Directory.Delete(workingDirectory, recursive: true); + } + } + + [Fact] + public void PowerShell_native_and_cmdlet_member_spelling_have_different_meanings() + { + if (!IsAvailable("pwsh") || !File.Exists("/usr/bin/printf")) + { + return; + } + + var home = Environment.GetEnvironmentVariable("HOME"); + Assert.False(string.IsNullOrEmpty(home)); + + var nativeExpression = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' $HOME.Length"); + var nativeInline = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "& /usr/bin/printf '<%s>\\n' --output=$HOME.Length"); + var cmdlet = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "Write-Output $HOME.Length"); + var quotedSuffix = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "Write-Output $HOME\".Length\""); + + Assert.Equal( + $"<{home!.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)}>", + nativeExpression); + Assert.Equal($"<--output={home}.Length>", nativeInline); + Assert.Equal(home!.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), cmdlet); + Assert.Equal(home + ".Length", quotedSuffix); + } + + [Fact] + public void Dynamic_command_fragments_are_executable_identity() + { + if (IsAvailable("bash")) + { + var bash = Run( + "bash", + "-c", + "p$(printf rintf) '<%s>\\n' safe"); + Assert.Equal("", bash); + } + + if (IsAvailable("pwsh")) + { + var pwsh = Run( + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "& \"Write-$(Write-Output Output)\" safe"); + Assert.Equal("safe", pwsh); + } + } + + private static bool IsAvailable(string executable) + { + try + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = executable, + ArgumentList = { "--version" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }); + return process is not null && process.WaitForExit(10_000); + } + catch (Exception error) when (error is System.ComponentModel.Win32Exception + or FileNotFoundException) + { + return false; + } + } + + private static string Run(string executable, params string[] arguments) + => RunCore(executable, workingDirectory: null, arguments); + + private static string RunInWorkingDirectory( + string executable, string workingDirectory, params string[] arguments) + => RunCore(executable, workingDirectory, arguments); + + private static string RunCore( + string executable, string? workingDirectory, params string[] arguments) + { + var result = RunUncheckedCore(executable, workingDirectory, arguments); + Assert.Equal(0, result.ExitCode); + Assert.True( + string.IsNullOrEmpty(result.StandardError), + $"{executable} oracle wrote to stderr: {result.StandardError}"); + return result.StandardOutput.TrimEnd('\r', '\n'); + } + + private static ProcessResult RunUnchecked( + string executable, + params string[] arguments) => + RunUncheckedCore(executable, workingDirectory: null, arguments); + + private static ProcessResult RunUncheckedInWorkingDirectory( + string executable, + string workingDirectory, + params string[] arguments) => + RunUncheckedCore(executable, workingDirectory, arguments); + + private static ProcessResult RunUncheckedCore( + string executable, string? workingDirectory, params string[] arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = workingDirectory ?? string.Empty, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo); + Assert.NotNull(process); + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + Assert.True(process.WaitForExit(10_000), $"{executable} oracle timed out"); + return new ProcessResult(process.ExitCode, standardOutput, standardError); + } + + private static string[] Lines(string output) => + output.Replace("\r\n", "\n", StringComparison.Ordinal) + .Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); + + private readonly record struct ProcessResult( + int ExitCode, + string StandardOutput, + string StandardError); +} From 59b55acf80967d56ce357b26684a07b117d8151a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 6 Aug 2026 18:05:59 +0000 Subject: [PATCH 2/3] test: gate Bash shell oracles to Unix --- .../Parsing/ShellValueOracleTests.cs | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index ecbdf62..9f1f786 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -15,7 +15,7 @@ public class ShellValueOracleTests [Fact] public void Standalone_escapes_are_literal_in_both_shells() { - if (IsAvailable("bash")) + if (IsNativeBashAvailable()) { var bash = Run("bash", "-c", "printf '<%s>\\n' \\$HOME"); Assert.Equal("<$HOME>", bash); @@ -36,7 +36,7 @@ public void Standalone_escapes_are_literal_in_both_shells() [Fact] public void Bash_escape_and_adjacent_quote_produce_one_literal_argument() { - if (!IsAvailable("bash")) + if (!IsNativeBashAvailable()) { return; } @@ -52,7 +52,7 @@ public void Bash_escape_and_adjacent_quote_produce_one_literal_argument() [Fact] public void Static_mixed_quoting_and_within_token_escapes_are_literal() { - if (IsAvailable("bash")) + if (IsNativeBashAvailable()) { var bash = Run( "bash", @@ -80,7 +80,7 @@ public void Static_mixed_quoting_and_within_token_escapes_are_literal() [Fact] public void Literal_and_expandable_fragments_compose_in_both_shells() { - if (IsAvailable("bash")) + if (IsNativeBashAvailable()) { var bash = Run( "bash", @@ -106,7 +106,7 @@ public void Literal_and_expandable_fragments_compose_in_both_shells() [Fact] public void Runtime_parameter_forms_execute_in_both_shells() { - if (IsAvailable("bash")) + if (IsNativeBashAvailable()) { var bash = Run( "bash", @@ -163,7 +163,7 @@ public void Runtime_parameter_forms_execute_in_both_shells() [Fact] public void Incomplete_and_escaped_braced_interpolation_differ_in_both_shells() { - if (IsAvailable("bash")) + if (IsNativeBashAvailable()) { var incomplete = RunUnchecked( "bash", @@ -202,7 +202,7 @@ public void Incomplete_and_escaped_braced_interpolation_differ_in_both_shells() [Fact] public void Provider_looking_values_are_shell_and_consumer_specific() { - if (IsAvailable("bash")) + if (IsNativeBashAvailable()) { var bash = Run( "bash", @@ -249,7 +249,7 @@ public void Adjacent_redirect_fragments_form_one_literal_target_in_both_shells() Directory.CreateDirectory(pwshDirectory); try { - if (IsAvailable("bash")) + if (IsNativeBashAvailable()) { RunInWorkingDirectory( "bash", @@ -298,7 +298,7 @@ public void Wildcard_argument_and_redirect_contexts_diverge() Directory.CreateDirectory(pwshDirectory); try { - if (IsAvailable("bash")) + if (IsNativeBashAvailable()) { File.WriteAllText(Path.Combine(bashDirectory, "a.txt"), "a"); File.WriteAllText(Path.Combine(bashDirectory, "b.txt"), "b"); @@ -442,7 +442,7 @@ public void PowerShell_escape_and_adjacent_quote_produce_one_literal_argument() [Fact] public void Bash_ansi_c_quoting_transforms_the_authored_value() { - if (!IsAvailable("bash")) + if (!IsNativeBashAvailable()) { return; } @@ -458,7 +458,7 @@ public void Bash_ansi_c_quoting_transforms_the_authored_value() [Fact] public void Bash_quoted_and_escaped_fd_spelling_redirects_to_literal_files() { - if (!IsAvailable("bash")) + if (!IsNativeBashAvailable()) { return; } @@ -531,7 +531,7 @@ public void PowerShell_native_and_cmdlet_member_spelling_have_different_meanings [Fact] public void Dynamic_command_fragments_are_executable_identity() { - if (IsAvailable("bash")) + if (IsNativeBashAvailable()) { var bash = Run( "bash", @@ -574,6 +574,9 @@ private static bool IsAvailable(string executable) } } + private static bool IsNativeBashAvailable() => + Path.DirectorySeparatorChar == '/' && IsAvailable("bash"); + private static string Run(string executable, params string[] arguments) => RunCore(executable, workingDirectory: null, arguments); From 6a772066dab4d774ebbe494e2c04b1c676ba6a79 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 6 Aug 2026 18:10:26 +0000 Subject: [PATCH 3/3] ci: retrigger PR validation