diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index e13b1c8..21a61da 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -551,8 +551,13 @@ priorities. flattening nested execution into apparent ordinary verb chains. - [ ] Promote the Bash command-resolution mutation cases into Netclaw's strict allow/prompt/deny matrix before the downstream approval-fatigue gate. -- [ ] Add the separately tested Bash `<<<` here-string redirect slice with - bounded operand analysis and trailing-newline semantics. +- [x] Add the separately tested Bash `<<<` here-string redirect slice with + bounded operand analysis and trailing-newline semantics. Default and + numeric sources publish complete non-path facts; exact and finite data + include Bash's appended newline, unknown data remains structurally + complete, and every supported `$()` command stays independently visible. + Malformed operators fail atomically, while native Bash oracles pin + newline and no-field-splitting behavior. --- diff --git a/SPEC.md b/SPEC.md index c112ea1..d0cdddc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1439,8 +1439,8 @@ The lexer produces tokens consumed by the parser. Token kinds: the quote delimiters from the token value. Example: `"hello world"` becomes the token value `hello world`. - **OPERATOR** — `&&`, `||`, `;`, `|`, `>`, `>>`, `<`, numeric-descriptor - forms such as `2>`, `3>>`, `10<`, `3<<`, and `4<<-`, `&>`, `&>>`, - `(`, `)`, `<<`, `<<-`. + forms such as `2>`, `3>>`, `10<`, `3<<`, `4<<-`, and `5<<<`, `&>`, + `&>>`, `(`, `)`, `<<`, `<<-`, `<<<`. - **WHITESPACE** — one or more spaces, tabs, or newlines (newlines inside a heredoc body are not emitted as ordinary tokens; the delimiter token retains the body's resolver fragments and authored extent). A whitespace run that @@ -1500,12 +1500,13 @@ The lexer produces tokens consumed by the parser. Token kinds: Operators terminate the current token. `cd /tmp&&ls` lexes as `[cd, /tmp, &&, ls]` — no whitespace required around operators. The lexer must handle this. A numeric descriptor is an operator prefix only when its -digits begin at a shell-token boundary and become adjacent to `<`, `>`, or -`>>` after Bash removes unquoted line continuations. Continuations may join -digit fragments or the descriptor and operator; LF and CRLF spellings retain -their authored span while producing the same descriptor. Digits joined to an -ordinary, quoted, or escaped word remain part of that word; `command3>file` -therefore uses command name `command3` and a default-source `>` redirect. +digits begin at a shell-token boundary and become adjacent to `<`, `>`, `>>`, +`<<`, `<<-`, or `<<<` after Bash removes unquoted line continuations. +Continuations may join digit fragments or the descriptor and operator; LF and +CRLF spellings retain their authored span while producing the same descriptor. +Digits joined to an ordinary, quoted, or escaped word remain part of that word; +`command3>file` therefore uses command name `command3` and a default-source `>` +redirect. ### Comment handling @@ -1513,8 +1514,8 @@ therefore uses command name `command3` and a default-source `>` redirect. that runs to (but does not include) the next newline. A word boundary is: start of input, or the position immediately after a whitespace run, a newline, an operator (`&&`, `||`, `;`, `|`, `>`, `>>`, `<`, - a numeric descriptor adjacent to `>`, `>>`, or `<`, `&>`, `&>>`, `(`, - `)`, `<<`, `<<-`), a quoted string, or an opaque + a numeric descriptor adjacent to `>`, `>>`, `<`, `<<`, `<<-`, or `<<<`, + `&>`, `&>>`, `(`, `)`, `<<`, `<<-`, `<<<`), a quoted string, or an opaque substitution. Equivalently: `#` is comment-start everywhere the outer lexer dispatch loop sits, because every other lexer rule has already consumed its territory before `#` is considered. diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 159a1d4..d8d77ce 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -249,7 +249,12 @@ - [x] 10.1 Specify heredoc delimiter adjacency and quoting, expansion mode, body provenance, substitutions, tab stripping, completeness, and Bash here-string semantics. - [x] 10.2 Preserve existing `<<` / `<<-` behavior and fix quoted-delimiter adjacency without regressing the v0.2 compatibility redirect. - [x] 10.3 Add explicit heredoc delimiter/body/expansion/completeness facts and surface every supported substitution command. -- [ ] 10.4 Add Bash `<<<` here-string tokenization, explicit redirect facts, bounded operand analysis, and trailing-newline semantics. +- [x] 10.4 Add Bash `<<<` here-string tokenization, explicit redirect facts, + bounded operand analysis, and trailing-newline semantics. + - Longest-match lexer and occurrence-level tests cover default and numeric + sources, exact empty and literal data, unknown values, visible command + substitutions, malformed forms, and finite loop-bound operands. Native + Bash oracles pin the appended newline and suppression of field splitting. - [x] 10.5 Add direct, malformed, quoted/unquoted, tab-stripped, dynamic, and substitution-bearing corpus cases plus real-Bash parse-only validation. - [x] 10.5a Add direct, executable-corpus, real-Bash output, and real-Bash parse-only coverage for the bounded substitution-discovery slice, explicit redirect facts, and the full heredoc matrix. diff --git a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs index 6b7e334..edea34f 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs @@ -116,8 +116,8 @@ internal static IReadOnlyList Tokenize(string input) // ---- operators (longer-match first) ---- // Order matters: a token-boundary numeric descriptor precedes its // redirect, `&&` precedes `&`, `||` precedes `|`, `>>` precedes - // `>`, and `<<-` precedes `<<` and `<`. Bare `&` background jobs - // remain unsupported. + // `>`, and `<<<` / `<<-` precede `<<` and `<`. Bare `&` + // background jobs remain unsupported. if (TryReadOperator( src, i, @@ -401,14 +401,16 @@ private static bool TryReadOperator( if (descriptorEnd + 1 < src.Length && src[descriptorEnd + 1] == '<') { redirectLength = descriptorEnd + 2 < src.Length && - src[descriptorEnd + 2] == '-' + src[descriptorEnd + 2] is '<' or '-' ? 3 : 2; } length = descriptorEnd - i + redirectLength; text = descriptor.ToString() + - (redirectLength == 3 ? "<<-" : redirectLength == 2 ? "<<" : "<"); + (redirectLength == 3 + ? src[descriptorEnd + 2] == '<' ? "<<<" : "<<-" + : redirectLength == 2 ? "<<" : "<"); return true; } } @@ -433,9 +435,11 @@ private static bool TryReadOperator( if (c0 == '>' && c1 == '>') { length = 2; text = ">>"; return true; } if (c0 == '<' && c1 == '<') { - if (i + 2 < src.Length && src[i + 2] == '-') + if (i + 2 < src.Length && src[i + 2] is '<' or '-') { - length = 3; text = "<<-"; return true; + length = 3; + text = src[i + 2] == '<' ? "<<<" : "<<-"; + return true; } length = 2; text = "<<"; return true; @@ -497,6 +501,23 @@ internal static bool IsHeredocOperator(string? operatorText) (remaining == 2 || remaining == 3 && operatorText[operatorStart + 2] == '-'); } + internal static bool IsHereStringOperator(string? operatorText) + { + if (string.IsNullOrEmpty(operatorText)) + { + return false; + } + + var operatorStart = 0; + while (operatorStart < operatorText!.Length && + operatorText[operatorStart] is >= '0' and <= '9') + { + operatorStart++; + } + + return operatorText.AsSpan(operatorStart).SequenceEqual("<<<".AsSpan()); + } + private static bool CanStartNumericDescriptor(IReadOnlyList tokens) { if (tokens.Count == 0) diff --git a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashTokenKind.cs b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashTokenKind.cs index da786ca..9e1a19f 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashTokenKind.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashTokenKind.cs @@ -26,7 +26,7 @@ internal enum BashTokenKind /// One of the bash operators recognized in v0.1: &&, /// ||, ;, |, >, >>, /// <, 2>, 2>>, (, ), - /// <<, <<-. The literal text is in + /// <<, <<-, <<<. The literal text is in /// . Operator, diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs index 77ab3eb..22efc4d 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs @@ -1207,12 +1207,17 @@ private SimpleCommandSyntax RewriteSimple( } var clause = RewriteClause(simple.Clause, input, sourceFacts.CwdPathDependencies); - var redirects = RewriteRedirectFacts(sourceFacts.Redirects, clause); + var redirects = RewriteRedirectFacts( + sourceFacts.Redirects, + sourceFacts.RedirectTargetProvenance, + input.Bindings, + clause); facts.Add(clause, new CommandOccurrenceFacts { EffectiveArguments = CreateEffectiveArguments(simple.Clause), WorkingDirectory = input.ToDomain(), Redirects = redirects, + RedirectTargetProvenance = sourceFacts.RedirectTargetProvenance, CwdPathDependencies = sourceFacts.CwdPathDependencies, ValueProvenance = sourceFacts.ValueProvenance, IsComplete = sourceFacts.IsComplete && AreRedirectsComplete(redirects), @@ -1435,6 +1440,8 @@ private IReadOnlyList RewriteCompatibilityRedirects( private static IReadOnlyList RewriteRedirectFacts( IReadOnlyList source, + IReadOnlyList provenance, + BashLoopBindingContext bindings, Clause clause) { if (source.Count == 0) @@ -1446,6 +1453,18 @@ private static IReadOnlyList RewriteRedirectFacts( for (var index = 0; index < rewritten.Length; index++) { var fact = source[index]; + if (fact.Operation == RedirectOperation.HereString) + { + rewritten[index] = fact with + { + Target = RewriteHereStringTarget( + fact, + provenance, + bindings), + }; + continue; + } + if (!fact.IsPathRelevant || fact.RedirectIndex < 0 || fact.RedirectIndex >= clause.Redirects.Count) @@ -1471,6 +1490,45 @@ private static IReadOnlyList RewriteRedirectFacts( return rewritten; } + private static ShellValueDomain RewriteHereStringTarget( + RedirectAnalysis fact, + IReadOnlyList provenance, + BashLoopBindingContext bindings) + { + foreach (var candidate in provenance) + { + if (candidate.RedirectIndex != fact.RedirectIndex) + { + continue; + } + + if (!bindings.TryAnalyzeEffectiveValue(candidate.Value, out var domain)) + { + return fact.Target; + } + + if (domain.Kind is not ( + ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet)) + { + return ShellValueDomain.Unknown; + } + + var values = new string[domain.Values.Count]; + for (var index = 0; index < values.Length; index++) + { + values[index] = domain.Values[index] + "\n"; + } + + return new ShellValueDomain + { + Kind = domain.Kind, + Values = values, + }; + } + + return fact.Target; + } + private static bool AreRedirectsComplete(IReadOnlyList redirects) { foreach (var redirect in redirects) diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs index 95bcacf..0148f90 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs @@ -497,10 +497,15 @@ private readonly record struct ShellValueProvenanceSet( Clause Clause, IReadOnlyList Provenance); + private readonly record struct RedirectTargetProvenanceSet( + Clause Clause, + IReadOnlyList Provenance); + private readonly record struct BashParseResult( ParsedCommand Command, IReadOnlyList CwdPathDependencySets, IReadOnlyList ValueProvenanceSets, + IReadOnlyList RedirectTargetProvenanceSets, IReadOnlyList ForInPlans); private static ClauseResult ParseClauseSegment( @@ -1338,6 +1343,38 @@ private static void BuildRedirect( out ShellValue? pathResolverValue) { pathResolverValue = null; + if (BashLexer.IsHereStringOperator(redirectOperator.OperatorText)) + { + var hereStringRaw = SourceSlice(source, target); + var hereStringValue = BashRedirectAnalysis.NormalizeHereStringOperand( + GetResolverValue(target, target.Value)); + var (hereStringKind, hereStringResolved, _) = BashResolver.Resolve( + hereStringValue, + treatAsPath: false, + options, + workingDirectoryUnknown, + ShellResolutionConsumer.BashRedirect); + var hereStringIsDynamic = hereStringKind != ArgKind.Literal && + hereStringResolved is null; + redirectList.Add(new Redirect + { + Direction = direction, + Target = hereStringIsDynamic + ? hereStringRaw + : hereStringResolved ?? target.Value, + IsDynamicSkip = hereStringIsDynamic, + }); + element = CreateRedirectElement( + source, + redirectOperator, + target, + precedingVerbTokenCount, + hereStringKind, + isPath: false, + resolved: hereStringIsDynamic ? null : hereStringResolved); + return; + } + if (target.Kind == BashTokenKind.OpaqueSubstitution) { // Opaque region as redirect target → always DynamicSkip. @@ -1620,7 +1657,7 @@ private static bool TryMapRedirect(string? op, out RedirectDirection direction) if (operatorStart > 0) { var redirect = op.Substring(operatorStart); - if (redirect == "<") + if (redirect is "<" or "<<<") { direction = RedirectDirection.In; return true; @@ -1653,6 +1690,7 @@ private static bool TryMapRedirect(string? op, out RedirectDirection direction) direction = RedirectDirection.Append; return true; case "<": + case "<<<": direction = RedirectDirection.In; return true; case "2>": diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashRedirectAnalysis.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashRedirectAnalysis.cs index 34b12ce..f5c6f69 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashRedirectAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashRedirectAnalysis.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Text; using ShellSyntaxTree.Internal.Bash.Lexing; +using ShellSyntaxTree.Internal.Resolving; namespace ShellSyntaxTree.Internal.Bash.Parsing; @@ -90,6 +91,15 @@ private static RedirectAnalysis Analyze( .EndsWith("<<-", StringComparison.Ordinal)); } + if (operation == RedirectOperation.HereString) + { + return AnalyzeHereString( + redirectIndex, + compatibility, + element, + redirectSource); + } + if (operation is RedirectOperation.FileInput or RedirectOperation.FileOutput or RedirectOperation.FileAppend && @@ -129,6 +139,58 @@ RedirectOperation.FileOutput or }; } + private static RedirectAnalysis AnalyzeHereString( + int redirectIndex, + Redirect compatibility, + ClauseElement element, + RedirectSource source) + { + var target = compatibility.IsDynamicSkip + ? ShellValueDomain.Unknown + : new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { (element.Resolved ?? element.Value) + "\n" }, + }; + return new RedirectAnalysis + { + RedirectIndex = redirectIndex, + Source = source, + Operation = RedirectOperation.HereString, + Target = target, + IsPathRelevant = false, + IsComplete = source.Kind != RedirectSourceKind.Unknown, + }; + } + + internal static ShellValue NormalizeHereStringOperand(ShellValue value) + { + var fragments = new ShellValueFragment[value.Fragments.Count]; + var changed = false; + for (var index = 0; index < fragments.Length; index++) + { + var fragment = value.Fragments[index]; + if (fragment.Expansion is { Kind: ShellExpansionKind.Glob }) + { + changed = true; + fragments[index] = fragment with + { + Kind = ShellValueFragmentKind.Literal, + AllowedTransforms = ShellLexicalTransform.None, + Expansion = null, + Cardinality = ShellValueCardinality.ExactlyOne, + }; + continue; + } + + var transforms = fragment.AllowedTransforms & ~ShellLexicalTransform.FieldSplit; + changed |= transforms != fragment.AllowedTransforms; + fragments[index] = fragment with { AllowedTransforms = transforms }; + } + + return changed ? new ShellValue(value.Decoded, fragments) : value; + } + private static RedirectAnalysis AnalyzeHereDocument( int redirectIndex, string? source, @@ -313,7 +375,12 @@ private static bool TryReadOperator( else { source = new RedirectSource(); - if (raw.AsSpan(operatorStart).StartsWith("<<-", StringComparison.Ordinal)) + if (raw.AsSpan(operatorStart).StartsWith("<<<", StringComparison.Ordinal)) + { + operation = RedirectOperation.HereString; + length = operatorStart + 3; + } + else if (raw.AsSpan(operatorStart).StartsWith("<<-", StringComparison.Ordinal)) { operation = RedirectOperation.HereDocument; length = operatorStart + 3; @@ -342,6 +409,13 @@ private static bool TryReadOperator( return true; } + if (raw.AsSpan(operatorStart).StartsWith("<<<", StringComparison.Ordinal)) + { + operation = RedirectOperation.HereString; + length = operatorStart + 3; + return true; + } + if (raw.AsSpan(operatorStart).StartsWith("<<-", StringComparison.Ordinal)) { operation = RedirectOperation.HereDocument; diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs index fc82d51..a15ef6b 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs @@ -66,6 +66,7 @@ private static BashParseResult ParseStructured( command, CreateDependencySets(projection.Commands, analyzedFacts), CreateValueProvenanceSets(projection.Commands, analyzedFacts), + CreateRedirectProvenanceSets(projection.Commands, analyzedFacts), analyzedForInPlans); } @@ -103,6 +104,24 @@ private static IReadOnlyList CreateValueProvenanceSets( return sets; } + private static IReadOnlyList + CreateRedirectProvenanceSets( + IReadOnlyList commands, + Func factsFactory) + { + var sets = new RedirectTargetProvenanceSet[commands.Count]; + for (var index = 0; index < sets.Length; index++) + { + var clause = commands[index].Clause; + var facts = factsFactory(new SimpleCommandSyntax { Clause = clause }); + sets[index] = new RedirectTargetProvenanceSet( + clause, + facts.RedirectTargetProvenance); + } + + return sets; + } + private static BashParseResult StructuralFailure( string source, string? reason, @@ -118,6 +137,7 @@ private static BashParseResult StructuralFailure( }, Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); private sealed class StructuralCoordinator @@ -1286,12 +1306,21 @@ private void RegisterFacts( BashParserOptions parseOptions) { var valueProvenance = new List(); + var redirectProvenance = new List(); var cwdPathDependencies = new List(); + var redirectAnalysis = BashRedirectAnalysis.Analyze( + simple.Clause, + _source, + sourceTokens); + var redirectIndex = 0; for (var elementIndex = 0; elementIndex < simple.Clause.Elements.Count; elementIndex++) { var element = simple.Clause.Elements[elementIndex]; + var currentRedirectIndex = element.Role == ClauseElementRole.Redirect + ? redirectIndex++ + : -1; if (!TryGetElementValue(element, sourceTokens, out var value)) { continue; @@ -1304,6 +1333,17 @@ private void RegisterFacts( value)); } + if (currentRedirectIndex >= 0 && + currentRedirectIndex < redirectAnalysis.Count && + redirectAnalysis[currentRedirectIndex].Operation == + RedirectOperation.HereString) + { + redirectProvenance.Add(new RedirectTargetProvenance( + currentRedirectIndex, + elementIndex, + BashRedirectAnalysis.NormalizeHereStringOperand(value), + UsesOutermostInvocationScope: false)); + } } foreach (var pathResolution in pathResolutions) @@ -1323,13 +1363,10 @@ private void RegisterFacts( parseOptions.WorkingDirectory ?? Environment.CurrentDirectory)); } - var redirectAnalysis = BashRedirectAnalysis.Analyze( - simple.Clause, - _source, - sourceTokens); _facts.Add(simple.Clause, new CommandOccurrenceFacts { Redirects = redirectAnalysis, + RedirectTargetProvenance = redirectProvenance.ToArray(), ValueProvenance = valueProvenance.ToArray(), CwdPathDependencies = cwdPathDependencies.ToArray(), IsComplete = simple.Clause.Verb.Tokens.Count > 0 && @@ -1439,9 +1476,19 @@ private bool TryRegisterDecodedFacts( return false; } + if (!TryFindRedirectProvenance( + innerResult.RedirectTargetProvenanceSets, + source.Clause, + out var redirectProvenance)) + { + error = "decoded bash -c redirect provenance could not be mapped safely"; + return false; + } + _facts.Add(clonedClause, new CommandOccurrenceFacts { Redirects = ClearDecodedHereDocumentSpans(source.Redirects), + RedirectTargetProvenance = redirectProvenance, CwdPathDependencies = cwdPathDependencies, ValueProvenance = valueProvenance, IsComplete = source.IsComplete, @@ -1518,6 +1565,24 @@ private static bool TryFindValueProvenance( return false; } + private static bool TryFindRedirectProvenance( + IReadOnlyList provenanceSets, + Clause clause, + out IReadOnlyList provenance) + { + foreach (var set in provenanceSets) + { + if (object.ReferenceEquals(set.Clause, clause)) + { + provenance = set.Provenance; + return true; + } + } + + provenance = Array.Empty(); + return false; + } + private static bool TryFindDependencies( IReadOnlyList dependencySets, Clause clause, diff --git a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs index de8c0cc..44be178 100644 --- a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs +++ b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs @@ -336,7 +336,11 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( if (!treatAsPath) { - return (hadHomeExpansion ? ArgKind.Tilde : ArgKind.Literal, null, false); + return (hadHomeExpansion ? ArgKind.Tilde : ArgKind.Literal, + hadHomeExpansion && consumer == ShellResolutionConsumer.BashRedirect + ? composed.ToString() + : null, + false); } var resolved = TryResolveAbsolutePath( diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json index d719184..8a2356a 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json @@ -895,6 +895,7 @@ }, { "id": "bash-here-string-literal-data", + "compatibilityProjectionLanded": true, "concern": "Static here-string data does not force a raw-command approval prompt", "input": "cat <<< \"hello\"", "current": { "isUnparseable": true, "reasonContains": "missing delimiter" }, @@ -926,6 +927,7 @@ }, { "id": "bash-here-string-dynamic-data", + "compatibilityProjectionLanded": true, "concern": "Unknown stdin data does not make an otherwise complete redirect structurally incomplete", "input": "cat <<< \"$value\"", "current": { "isUnparseable": true, "reasonContains": "missing delimiter" }, diff --git a/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs b/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs index f563bcb..1a62c9c 100644 --- a/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs @@ -236,6 +236,22 @@ public void Numeric_source_heredoc_prefers_the_longest_redirect_operator( Assert.NotNull(tokens[2].HeredocBodyValue); } + [Theory] + [InlineData("cmd << + item.Role == ClauseElementRole.Redirect); + Assert.False(element.IsPath); + } + + [Fact] + public void Dynamic_here_string_data_is_unknown_but_structurally_complete() + { + var result = Parse("cat <<< \"$value\""); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var occurrence = Assert.Single(result.Commands); + Assert.True(occurrence.IsComplete); + var redirect = Assert.Single(occurrence.Redirects); + Assert.Equal(RedirectOperation.HereString, redirect.Operation); + Assert.Equal(ShellValueDomainKind.Unknown, redirect.Target.Kind); + Assert.False(redirect.IsPathRelevant); + Assert.True(redirect.IsComplete); + Assert.True(Assert.Single(occurrence.Clause.Redirects).IsDynamicSkip); + } + + [Fact] + public void Here_string_substitution_is_visible_and_leaves_data_unknown() + { + var result = Parse("cat <<< \"$(printf payload)\""); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(new[] { "printf payload", "cat" }, result.Commands.Select(CommandVerb)); + Assert.Equal(CommandOccurrenceRole.Substitution, result.Commands[0].ImmediateRole); + var consumer = result.Commands[1]; + Assert.True(consumer.IsComplete); + var redirect = Assert.Single(consumer.Redirects); + Assert.Equal(RedirectOperation.HereString, redirect.Operation); + Assert.Equal(ShellValueDomainKind.Unknown, redirect.Target.Kind); + Assert.True(redirect.IsComplete); + } + + [Theory] + [InlineData("cat <<<")] + [InlineData("cat <<<< payload")] + public void Malformed_here_string_fails_the_whole_parse(string source) + { + var result = Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + [Fact] public void Expanding_heredoc_publishes_complete_body_and_substitution_facts() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index 8335a3a..d4a55a2 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -102,6 +102,27 @@ public void Bash_heredoc_expansion_matches_delimiter_and_body_rules( Assert.Equal(expected, Run("bash", "-c", source)); } + [Theory] + [InlineData("cat <<< \"hello\"", "hello\n")] + [InlineData("value='two words'; cat <<< $value", "two words\n")] + [InlineData("cat <<< *.txt", "*.txt\n")] + [InlineData("cat <<< \"$(printf payload)\"", "payload\n")] + public void Bash_here_strings_append_one_newline_without_field_splitting( + string source, + string expected) + { + if (!IsNativeBashAvailable()) + { + return; + } + + var result = RunUnchecked("bash", "-c", source); + + Assert.Equal(0, result.ExitCode); + Assert.Equal(expected, result.StandardOutput); + Assert.Empty(result.StandardError); + } + [Fact] public void Bash_prompt_parameter_transform_can_execute_command_text_in_heredoc() {