diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index f9ce134..bfc4fc0 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -305,6 +305,21 @@ priorities. transfers, and occurrence-specific redirect values remain fail closed. Next add the Netclaw approval matrix before calling the Bash consumer integration complete. +- [x] Deliver occurrence-level Bash explicit redirect facts for ordinary file + input/output/append, static descriptor duplicate/close/move, computed + descriptor targets, and combined `&>` / `&>>` output. Static descriptor + operations are complete non-path facts even though the v0.2 compatibility + redirect remains `IsDynamicSkip`; computed targets remain incomplete and + cannot be exempted by raw prefix. Arbitrary numeric source descriptors are + retained only when authored at a token boundary; overflow sources remain + incomplete rather than being truncated, and word suffixes such as + `command3>file` keep `3` in the command name. Unquoted LF/CRLF line + continuations are removed before descriptor recognition, including when + they join multi-digit sources. Exact file targets now complete their + containing occurrence, while cwd or value uncertainty still downgrades + the redirect and occurrence after abstract-state joins. Direct lexer and + parser tests plus executable corpus cases pin the boundary. Next map the + PowerShell stream model, then prove the paired Netclaw redirect matrix. - [ ] Complete PowerShell `foreach` integration and add the Netclaw approval-matrix cases. The structural slice now preserves literal scalar/array and executable iterator forms, recursively parses bodies, diff --git a/SPEC.md b/SPEC.md index 2eac353..b8494e5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -967,8 +967,9 @@ public sealed record Clause public IReadOnlyList Args { get; init; } = []; /// - /// Redirect operators on this clause (>, >>, <, 2>, 2>>). Each entry - /// includes direction and target path. + /// Compatibility redirects on this clause (>, >>, <, 2>, 2>>, &>, + /// &>>). Each entry retains the v0.2 direction and target projection; + /// v0.3 consumers use CommandOccurrence.Redirects for exact semantics. /// public IReadOnlyList Redirects { get; init; } = []; @@ -1270,7 +1271,8 @@ verb_like_word := static word satisfying §6.1; the initial command-name arg := word | flag | quoted_string | supported_substitution flag := "-" letter+ | "--" word redirect := redirect_op target -redirect_op := ">" | ">>" | "<" | "2>" | "2>>" +redirect_op := descriptor? (">" | ">>" | "<") | "&>" | "&>>" +descriptor := digit+ target := word | quoted_string | supported_substitution supported_substitution := "$(" command ")" word := non-whitespace, non-operator fragments; may contain @@ -1396,7 +1398,8 @@ The lexer produces tokens consumed by the parser. Token kinds: - **QUOTED_STRING** — single- or double-quoted string. The lexer strips the quote delimiters from the token value. Example: `"hello world"` becomes the token value `hello world`. -- **OPERATOR** — `&&`, `||`, `;`, `|`, `>`, `>>`, `<`, `2>`, `2>>`, +- **OPERATOR** — `&&`, `||`, `;`, `|`, `>`, `>>`, `<`, numeric-descriptor + forms such as `2>`, `3>>`, and `10<`, `&>`, `&>>`, `(`, `)`, `<<`, `<<-`. - **WHITESPACE** — one or more spaces, tabs, or newlines (newlines inside a heredoc body are not emitted as ordinary tokens; the delimiter token @@ -1456,7 +1459,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. +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. ### Comment handling @@ -1464,7 +1473,8 @@ must handle this. 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 (`&&`, `||`, `;`, `|`, `>`, `>>`, `<`, - `2>`, `2>>`, `(`, `)`, `<<`, `<<-`), 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 02be1e0..966631c 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -65,11 +65,20 @@ ## 4. Explicit Redirect Semantics - [x] 4.1 Add the locked redirect operation and target-analysis types while retaining compatibility redirect members. -- [ ] 4.2 Classify Bash descriptor duplication, close, and move as static only for the complete literal descriptor grammar. -- [ ] 4.3 Keep variable-driven and otherwise computed Bash descriptor targets unknown or incomplete. -- [ ] 4.4 Lex and classify Bash `&>` and `&>>` independently from background-list operators. +- [x] 4.2 Classify Bash descriptor duplication, close, and move as static only for the complete literal descriptor grammar. +- [x] 4.3 Keep variable-driven and otherwise computed Bash descriptor targets unknown or incomplete. +- [x] 4.4 Lex and classify Bash `&>` and `&>>` independently from background-list operators. - [ ] 4.5 Map existing PowerShell stream redirects into the shared explicit model without losing shell-specific stream identity. - [ ] 4.6 Add paired direct tests and corpus cases for static, dynamic, malformed, multiple, combined, and file redirects. + - [x] 4.6a Add Bash direct and executable-corpus cases for static duplicate, + close, and move; computed descriptor targets; combined output overwrite and + append; ordinary file redirects; arbitrary numeric source descriptors; and + overflow/malformed fail-closed boundaries. LF/CRLF continuations at the + descriptor boundary and inside multi-digit sources follow Bash's pre-token + removal semantics. Executable-corpus cases pin malformed atomic failure and + independent occurrence facts for multiple redirects. + - [ ] 4.6b Add the paired PowerShell direct and executable-corpus cases when + task 4.5 maps its stream model. - [ ] 4.7 Verify the explicit model removes the need for raw-prefix inference in a Netclaw integration test. ## 5. Consumer Migration Baseline diff --git a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs index 0b9da58..e4da570 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs @@ -114,10 +114,16 @@ internal static IReadOnlyList Tokenize(string input) } // ---- operators (longer-match first) ---- - // Order matters: `&&` before `&`, `||` before `|`, `>>` before `>`, - // `2>>` before `2>`, `<<-` before `<<`, `<<` before `<`. We don't - // recognize a bare `&` in v0.1 (no background-job support; SPEC §1). - if (TryReadOperator(src, i, out var opLen, out var opText)) + // Order matters: a token-boundary numeric descriptor precedes its + // redirect, `&&` precedes `&`, `||` precedes `|`, `>>` precedes + // `>`, and `<<-` precedes `<<` and `<`. Bare `&` background jobs + // remain unsupported. + if (TryReadOperator( + src, + i, + CanStartNumericDescriptor(tokens), + out var opLen, + out var opText)) { var operatorTok = new BashToken( BashTokenKind.Operator, "", opText, i, opLen, null); @@ -268,7 +274,7 @@ private static bool TryConsumeFileDescriptorTarget( } if (previousIndex < 0 || tokens[previousIndex].Kind != BashTokenKind.Operator || - tokens[previousIndex].OperatorText is not (">" or ">>" or "<" or "2>" or "2>>")) + !CanTakeDescriptorTarget(tokens[previousIndex].OperatorText)) { return false; } @@ -323,16 +329,89 @@ tokens[previousIndex].OperatorText is not (">" or ">>" or "<" or "2>" or "2>>")) return true; } + private static bool CanTakeDescriptorTarget(string? operatorText) + { + if (string.IsNullOrEmpty(operatorText)) + { + return false; + } + + var operatorStart = 0; + while (operatorStart < operatorText!.Length && + operatorText[operatorStart] is >= '0' and <= '9') + { + operatorStart++; + } + + var redirect = operatorText.Substring(operatorStart); + return redirect is ">" or ">>" or "<"; + } + // ---------------------------------------------------------------- operators private static bool TryReadOperator( - ReadOnlySpan src, int i, out int length, out string? text) + ReadOnlySpan src, + int i, + bool canStartNumericDescriptor, + out int length, + out string? text) { + var descriptor = new StringBuilder(); + var descriptorEnd = i; + while (descriptorEnd < src.Length) + { + if (src[descriptorEnd] is >= '0' and <= '9') + { + descriptor.Append(src[descriptorEnd]); + descriptorEnd++; + continue; + } + + if (TrySkipLineContinuation(src, descriptorEnd, out var afterContinuation)) + { + descriptorEnd = afterContinuation; + continue; + } + + break; + } + + if (canStartNumericDescriptor && + descriptor.Length > 0 && + descriptorEnd < src.Length) + { + if (src[descriptorEnd] == '>') + { + var append = descriptorEnd + 1 < src.Length && + src[descriptorEnd + 1] == '>'; + length = descriptorEnd - i + (append ? 2 : 1); + text = descriptor.ToString() + (append ? ">>" : ">"); + return true; + } + + if (src[descriptorEnd] == '<') + { + length = descriptorEnd - i + 1; + text = descriptor.ToString() + "<"; + return true; + } + } + // Multi-char operators first. if (i + 1 < src.Length) { var c0 = src[i]; var c1 = src[i + 1]; + if (c0 == '&' && c1 == '>') + { + if (i + 2 < src.Length && src[i + 2] == '>') + { + length = 3; text = "&>>"; return true; + } + + length = 2; text = "&>"; return true; + } + if (c0 == '&' && c1 == '&') { length = 2; text = "&&"; return true; } if (c0 == '|' && c1 == '|') { length = 2; text = "||"; return true; } if (c0 == '>' && c1 == '>') { length = 2; text = ">>"; return true; } @@ -346,15 +425,6 @@ private static bool TryReadOperator( length = 2; text = "<<"; return true; } - if (c0 == '2' && c1 == '>') - { - if (i + 2 < src.Length && src[i + 2] == '>') - { - length = 3; text = "2>>"; return true; - } - - length = 2; text = "2>"; return true; - } } // Single-char operators. @@ -371,6 +441,49 @@ private static bool TryReadOperator( } } + private static bool TrySkipLineContinuation( + ReadOnlySpan source, + int index, + out int afterContinuation) + { + afterContinuation = index; + if (index + 1 >= source.Length || source[index] != '\\' || + source[index + 1] is not ('\n' or '\r')) + { + return false; + } + + afterContinuation = source[index + 1] == '\r' && + index + 2 < source.Length && source[index + 2] == '\n' + ? index + 3 + : index + 2; + return true; + } + + private static bool CanStartNumericDescriptor(IReadOnlyList tokens) + { + if (tokens.Count == 0) + { + return true; + } + + var index = tokens.Count - 1; + while (index >= 0 && tokens[index].Kind == BashTokenKind.Continuation) + { + index--; + } + + if (index < 0) + { + return true; + } + + return tokens[index].Kind is + BashTokenKind.Whitespace or + BashTokenKind.Operator or + BashTokenKind.Comment; + } + // ---------------------------------------------------------------- quoted private static int ReadSingleQuoted( @@ -1219,10 +1332,6 @@ private static bool IsOperatorStart(ReadOnlySpan src, int i) // Both `&&` and unsupported bare `&` terminate a word. The // tokenizer emits a sentinel for the latter on its next pass. return true; - case '2': - // `2>` and `2>>` start with '2' — only treat them as operator - // starts when the immediate next char is '>'. - return i + 1 < src.Length && src[i + 1] == '>'; default: return false; } diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs index 60ac0f0..77ab3eb 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs @@ -1207,14 +1207,15 @@ private SimpleCommandSyntax RewriteSimple( } var clause = RewriteClause(simple.Clause, input, sourceFacts.CwdPathDependencies); + var redirects = RewriteRedirectFacts(sourceFacts.Redirects, clause); facts.Add(clause, new CommandOccurrenceFacts { EffectiveArguments = CreateEffectiveArguments(simple.Clause), WorkingDirectory = input.ToDomain(), - Redirects = RewriteRedirectFacts(sourceFacts.Redirects, clause), + Redirects = redirects, CwdPathDependencies = sourceFacts.CwdPathDependencies, ValueProvenance = sourceFacts.ValueProvenance, - IsComplete = sourceFacts.IsComplete, + IsComplete = sourceFacts.IsComplete && AreRedirectsComplete(redirects), }); return simple with { @@ -1463,12 +1464,26 @@ private static IReadOnlyList RewriteRedirectFacts( Kind = ShellValueDomainKind.Exact, Values = new[] { redirect.Target }, }, + IsComplete = fact.IsComplete && !redirect.IsDynamicSkip, }; } return rewritten; } + private static bool AreRedirectsComplete(IReadOnlyList redirects) + { + foreach (var redirect in redirects) + { + if (!redirect.IsComplete) + { + return false; + } + } + + return true; + } + private string? RebaseResolution( string? resolved, bool isPath, diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs index 3e39eff..47180a5 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs @@ -1609,6 +1609,41 @@ private static bool IsFdDupTarget(string value) private static bool TryMapRedirect(string? op, out RedirectDirection direction) { + if (!string.IsNullOrEmpty(op)) + { + var operatorStart = 0; + while (operatorStart < op!.Length && op[operatorStart] is >= '0' and <= '9') + { + operatorStart++; + } + + if (operatorStart > 0) + { + var redirect = op.Substring(operatorStart); + if (redirect == "<") + { + direction = RedirectDirection.In; + return true; + } + + if (redirect is ">" or ">>") + { + var isStandardError = string.Equals( + op.Substring(0, operatorStart), + "2", + StringComparison.Ordinal); + direction = redirect == ">>" + ? isStandardError + ? RedirectDirection.ErrAppend + : RedirectDirection.Append + : isStandardError + ? RedirectDirection.ErrOut + : RedirectDirection.Out; + return true; + } + } + } + switch (op) { case ">": @@ -1626,6 +1661,12 @@ private static bool TryMapRedirect(string? op, out RedirectDirection direction) case "2>>": direction = RedirectDirection.ErrAppend; return true; + case "&>": + direction = RedirectDirection.Out; + return true; + case "&>>": + direction = RedirectDirection.Append; + return true; default: direction = default; return false; diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashRedirectAnalysis.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashRedirectAnalysis.cs new file mode 100644 index 0000000..9fef458 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashRedirectAnalysis.cs @@ -0,0 +1,274 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace ShellSyntaxTree.Internal.Bash.Parsing; + +/// +/// Builds occurrence-level redirect facts from parser-owned compatibility +/// leaves and their authored element provenance. +/// +internal static class BashRedirectAnalysis +{ + internal static IReadOnlyList Analyze(Clause clause) + { + if (clause.Redirects.Count == 0) + { + return Array.Empty(); + } + + var elements = new List(clause.Redirects.Count); + foreach (var element in clause.Elements) + { + if (element.Role == ClauseElementRole.Redirect) + { + elements.Add(element); + } + } + + var result = new RedirectAnalysis[clause.Redirects.Count]; + for (var index = 0; index < result.Length; index++) + { + result[index] = index < elements.Count + ? Analyze(index, clause.Redirects[index], elements[index]) + : Incomplete(index); + } + + return result; + } + + private static RedirectAnalysis Analyze( + int redirectIndex, + Redirect compatibility, + ClauseElement element) + { + if (!TryReadOperator(element.Raw, out var source, out var operation, out var length)) + { + return Incomplete(redirectIndex); + } + + var authoredTarget = element.Raw.Substring(length).TrimStart(); + if (operation is RedirectOperation.FileInput or + RedirectOperation.FileOutput or + RedirectOperation.FileAppend && + authoredTarget.Length > 0 && authoredTarget[0] == '&') + { + return AnalyzeDescriptorTarget( + redirectIndex, + source, + authoredTarget, + element.Value); + } + + if (operation == RedirectOperation.Unknown) + { + return new RedirectAnalysis + { + RedirectIndex = redirectIndex, + Source = source, + }; + } + + var isComplete = !compatibility.IsDynamicSkip; + return new RedirectAnalysis + { + RedirectIndex = redirectIndex, + Source = source, + Operation = operation, + Target = isComplete + ? new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { compatibility.Target }, + } + : ShellValueDomain.Unknown, + IsPathRelevant = true, + IsComplete = isComplete, + }; + } + + private static RedirectAnalysis AnalyzeDescriptorTarget( + int redirectIndex, + RedirectSource source, + string authoredTarget, + string decodedTarget) + { + if (string.Equals(authoredTarget, decodedTarget, StringComparison.Ordinal)) + { + if (string.Equals(authoredTarget, "&-", StringComparison.Ordinal)) + { + return new RedirectAnalysis + { + RedirectIndex = redirectIndex, + Source = source, + Operation = RedirectOperation.DescriptorClose, + IsComplete = true, + }; + } + + var descriptorText = authoredTarget.Substring(1); + var isMove = descriptorText.EndsWith("-", StringComparison.Ordinal); + if (isMove) + { + descriptorText = descriptorText.Substring(0, descriptorText.Length - 1); + } + + if (int.TryParse( + descriptorText, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var descriptor) && + descriptor >= 0) + { + return new RedirectAnalysis + { + RedirectIndex = redirectIndex, + Source = source, + Operation = isMove + ? RedirectOperation.DescriptorMove + : RedirectOperation.DescriptorDuplicate, + TargetDescriptor = descriptor, + IsComplete = true, + }; + } + } + + return new RedirectAnalysis + { + RedirectIndex = redirectIndex, + Source = source, + Operation = RedirectOperation.DescriptorDuplicate, + }; + } + + private static bool TryReadOperator( + string raw, + out RedirectSource source, + out RedirectOperation operation, + out int length) + { + source = new RedirectSource { Kind = RedirectSourceKind.Default }; + operation = RedirectOperation.Unknown; + length = 0; + + if (raw.StartsWith("&>>", StringComparison.Ordinal)) + { + operation = RedirectOperation.CombinedOutputAppend; + length = 3; + return true; + } + + if (raw.StartsWith("&>", StringComparison.Ordinal)) + { + operation = RedirectOperation.CombinedOutput; + length = 2; + return true; + } + + var descriptorText = new StringBuilder(); + var operatorStart = 0; + while (operatorStart < raw.Length) + { + if (raw[operatorStart] is >= '0' and <= '9') + { + descriptorText.Append(raw[operatorStart]); + operatorStart++; + continue; + } + + if (TrySkipLineContinuation(raw, operatorStart, out var afterContinuation)) + { + operatorStart = afterContinuation; + continue; + } + + break; + } + + if (descriptorText.Length > 0) + { + if (!int.TryParse( + descriptorText.ToString(), + NumberStyles.None, + CultureInfo.InvariantCulture, + out var descriptor) || + descriptor < 0) + { + source = new RedirectSource(); + return true; + } + + source = new RedirectSource + { + Kind = RedirectSourceKind.Descriptor, + Descriptor = descriptor, + }; + } + + if (raw.AsSpan(operatorStart).StartsWith(">>", StringComparison.Ordinal)) + { + operation = RedirectOperation.FileAppend; + length = operatorStart + 2; + return true; + } + + if (operatorStart < raw.Length && raw[operatorStart] == '>') + { + operation = RedirectOperation.FileOutput; + length = operatorStart + 1; + return true; + } + + if (raw.AsSpan(operatorStart).StartsWith("<<-", StringComparison.Ordinal)) + { + length = operatorStart + 3; + return true; + } + + if (raw.AsSpan(operatorStart).StartsWith("<<", StringComparison.Ordinal)) + { + length = operatorStart + 2; + return true; + } + + if (operatorStart < raw.Length && raw[operatorStart] == '<') + { + operation = RedirectOperation.FileInput; + length = operatorStart + 1; + return true; + } + + source = new RedirectSource(); + return false; + } + + private static bool TrySkipLineContinuation( + string source, + int index, + out int afterContinuation) + { + afterContinuation = index; + if (index + 1 >= source.Length || source[index] != '\\' || + source[index + 1] is not ('\n' or '\r')) + { + return false; + } + + afterContinuation = source[index + 1] == '\r' && + index + 2 < source.Length && source[index + 2] == '\n' + ? index + 3 + : index + 2; + return true; + } + + private static RedirectAnalysis Incomplete(int redirectIndex) => new() + { + RedirectIndex = redirectIndex, + }; +} diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs index 299e25d..a0cf5d1 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs @@ -180,10 +180,31 @@ internal CommandOccurrenceFacts GetFacts(SimpleCommandSyntax simple) => private CommandOccurrenceFacts CreateDefaultFacts(Clause clause) => new() { - IsComplete = clause.Redirects.Count == 0 && + Redirects = BashRedirectAnalysis.Analyze(clause), + IsComplete = clause.Verb.Tokens.Count > 0 && + AreRedirectsComplete(clause) && !HasUnexpandedCommandString(clause), }; + private static bool AreRedirectsComplete(Clause clause) + { + var redirects = BashRedirectAnalysis.Analyze(clause); + if (redirects.Count != clause.Redirects.Count) + { + return false; + } + + foreach (var redirect in redirects) + { + if (!redirect.IsComplete) + { + return false; + } + } + + return true; + } + internal bool TryParse(out ShellBlockSyntax syntax, out string? error) { SkipNewlines(); @@ -1267,11 +1288,14 @@ private void RegisterFacts( parseOptions.WorkingDirectory ?? Environment.CurrentDirectory)); } + var redirectAnalysis = BashRedirectAnalysis.Analyze(simple.Clause); _facts.Add(simple.Clause, new CommandOccurrenceFacts { + Redirects = redirectAnalysis, ValueProvenance = valueProvenance.ToArray(), CwdPathDependencies = cwdPathDependencies.ToArray(), - IsComplete = simple.Clause.Redirects.Count == 0 && + IsComplete = simple.Clause.Verb.Tokens.Count > 0 && + AreRedirectsComplete(simple.Clause) && !HasUnexpandedCommandString(simple.Clause), }); } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs b/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs index dc5a4ed..8e7bfff 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs @@ -479,6 +479,55 @@ private static void AssertCommandsEqual( observed.WorkingDirectory, prefix + $"commands[{index}].workingDirectory"); } + + if (wanted.Redirects is not null) + { + AssertRedirectAnalysesEqual( + wanted.Redirects, + observed.Redirects, + prefix + $"commands[{index}].redirects"); + } + } + } + + private static void AssertRedirectAnalysesEqual( + IReadOnlyList expected, + IReadOnlyList actual, + string path) + { + if (expected.Count != actual.Count) + { + throw new XunitException( + $"{path}.count: expected={expected.Count}, actual={actual.Count}"); + } + + for (var index = 0; index < expected.Count; index++) + { + var wanted = expected[index]; + var observed = actual[index]; + if (wanted.RedirectIndex != observed.RedirectIndex || + wanted.SourceKind != observed.Source.Kind || + wanted.SourceDescriptor != observed.Source.Descriptor || + wanted.Operation != observed.Operation || + wanted.TargetDescriptor != observed.TargetDescriptor || + wanted.IsPathRelevant != observed.IsPathRelevant || + wanted.IsComplete != observed.IsComplete) + { + throw new XunitException( + $"{path}[{index}] differs: expected index={wanted.RedirectIndex}, " + + $"source={wanted.SourceKind}/{wanted.SourceDescriptor}, " + + $"operation={wanted.Operation}, targetDescriptor={wanted.TargetDescriptor}, " + + $"pathRelevant={wanted.IsPathRelevant}, complete={wanted.IsComplete}; " + + $"actual index={observed.RedirectIndex}, " + + $"source={observed.Source.Kind}/{observed.Source.Descriptor}, " + + $"operation={observed.Operation}, targetDescriptor={observed.TargetDescriptor}, " + + $"pathRelevant={observed.IsPathRelevant}, complete={observed.IsComplete}"); + } + + AssertValueDomainEqual( + wanted.Target, + observed.Target, + $"{path}[{index}].target"); } } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index aab8b92..a05dd2b 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -1037,6 +1037,27 @@ public sealed record ExpectedCommandOccurrence public List? EffectiveArguments { get; init; } public ExpectedValueDomain? WorkingDirectory { get; init; } + + public List? Redirects { get; init; } +} + +public sealed record ExpectedRedirectAnalysis +{ + public int RedirectIndex { get; init; } = -1; + + public RedirectSourceKind SourceKind { get; init; } + + public int? SourceDescriptor { get; init; } + + public RedirectOperation Operation { get; init; } + + public int? TargetDescriptor { get; init; } + + public ExpectedValueDomain Target { get; init; } = new(); + + public bool IsPathRelevant { get; init; } + + public bool IsComplete { get; init; } } public sealed record ExpectedEffectiveArgument diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/116_redirect_fd_dup_stderr_to_stdout.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/116_redirect_fd_dup_stderr_to_stdout.json index d734c2e..a2c6f25 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/116_redirect_fd_dup_stderr_to_stdout.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/116_redirect_fd_dup_stderr_to_stdout.json @@ -14,7 +14,29 @@ "isSubshell": false, "isCommandStringWrapped": false } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 8 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "DescriptorDuplicate", + "targetDescriptor": 1, + "target": { "kind": "Unknown" }, + "isPathRelevant": false, + "isComplete": true + } + ] + } ] }, - "notes": "v0.1.1 / B1: POSIX fd-dup targets (&N) are NOT path-resolved. Target carries the verbatim '&1' and IsDynamicSkip=true tells consumers iterating redirects-as-paths to skip." + "notes": "The v0.2 redirect remains fail-closed for path iterators, while v0.3 identifies the complete static descriptor duplication without raw-prefix inference." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/118_redirect_fd_close.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/118_redirect_fd_close.json index fe1c458..e03bbce 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/118_redirect_fd_close.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/118_redirect_fd_close.json @@ -14,7 +14,28 @@ "isSubshell": false, "isCommandStringWrapped": false } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 8 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "DescriptorClose", + "target": { "kind": "Unknown" }, + "isPathRelevant": false, + "isComplete": true + } + ] + } ] }, - "notes": "fd-close form: '>&-' closes the source fd. Same parser treatment as fd-dup — target is the verbatim '&-' and IsDynamicSkip=true." + "notes": "The compatibility target stays verbatim and dynamic, while v0.3 explicitly proves the descriptor-close operation is complete and non-path." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/251_v03_for_multiline_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/251_v03_for_multiline_redirect.json index 7cc4d63..26976f8 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/251_v03_for_multiline_redirect.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/251_v03_for_multiline_redirect.json @@ -37,7 +37,7 @@ { "clauseIndex": 0, "immediateRole": "LoopBody", - "isComplete": false, + "isComplete": true, "ancestry": [ { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 59 }, { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 59 }, @@ -47,7 +47,19 @@ "effectiveArguments": [ { "clauseElementIndex": 2, "value": { "kind": "FiniteSet", "values": ["a", "b"], "pattern": null, "coveringDirectory": null } } ], - "workingDirectory": { "kind": "Exact", "values": ["/work"], "pattern": null, "coveringDirectory": null } + "workingDirectory": { "kind": "Exact", "values": ["/work"], "pattern": null, "coveringDirectory": null }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { "kind": "Exact", "values": ["/work/out.txt"], "pattern": null, "coveringDirectory": null }, + "isPathRelevant": true, + "isComplete": true + } + ] }, { "clauseIndex": 1, @@ -64,5 +76,5 @@ } ] }, - "notes": "Pins newline list terminators and separators, a multiline loop body, finite loop binding analysis, and the compatibility redirect boundary. Redirect-bearing occurrences remain incomplete until explicit redirect analysis lands." + "notes": "Pins newline list terminators, a multiline loop body, finite loop binding analysis, and complete occurrence-level file redirect facts." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/257_v03_dynamic_descriptor_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/257_v03_dynamic_descriptor_redirect.json new file mode 100644 index 0000000..e55e43e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/257_v03_dynamic_descriptor_redirect.json @@ -0,0 +1,39 @@ +{ + "name": "v0.3 Bash computed descriptor target remains incomplete", + "input": "command 2>&$FD", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "ErrOut", "target": "&$FD", "isDynamicSkip": true } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 14 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "DescriptorDuplicate", + "target": { "kind": "Unknown" }, + "isPathRelevant": false, + "isComplete": false + } + ] + } + ] + }, + "notes": "A runtime descriptor value cannot be exempted using the authored ampersand prefix." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/258_v03_descriptor_move.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/258_v03_descriptor_move.json new file mode 100644 index 0000000..abc0a22 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/258_v03_descriptor_move.json @@ -0,0 +1,40 @@ +{ + "name": "v0.3 Bash static descriptor move", + "input": "command 2>&1-", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "ErrOut", "target": "&1-", "isDynamicSkip": true } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 13 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "DescriptorMove", + "targetDescriptor": 1, + "target": { "kind": "Unknown" }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ] + }, + "notes": "The move target is a static descriptor and never a path." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/259_v03_combined_output_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/259_v03_combined_output_redirect.json new file mode 100644 index 0000000..671cd38 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/259_v03_combined_output_redirect.json @@ -0,0 +1,38 @@ +{ + "name": "v0.3 Bash combined output overwrite", + "input": "command &> out.log", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "Out", "target": "/work/out.log", "isDynamicSkip": false } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 18 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "operation": "CombinedOutput", + "target": { "kind": "Exact", "values": ["/work/out.log"] }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "The lexer and occurrence model distinguish combined output from unsupported background lists." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/260_v03_combined_output_append.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/260_v03_combined_output_append.json new file mode 100644 index 0000000..e2fc17c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/260_v03_combined_output_append.json @@ -0,0 +1,38 @@ +{ + "name": "v0.3 Bash combined output append", + "input": "command &>> out.log", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "Append", "target": "/work/out.log", "isDynamicSkip": false } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 19 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "operation": "CombinedOutputAppend", + "target": { "kind": "Exact", "values": ["/work/out.log"] }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "Combined append retains its distinct operation and exact file target." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/261_v03_numeric_source_descriptor.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/261_v03_numeric_source_descriptor.json new file mode 100644 index 0000000..a562d4a --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/261_v03_numeric_source_descriptor.json @@ -0,0 +1,40 @@ +{ + "name": "v0.3 Bash non-standard numeric source descriptor", + "input": "command 3>&1", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "Out", "target": "&1", "isDynamicSkip": true } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 12 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 3, + "operation": "DescriptorDuplicate", + "targetDescriptor": 1, + "target": { "kind": "Unknown" }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ] + }, + "notes": "The numeric source is part of the redirect token rather than an argv element." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/262_v03_numeric_source_move.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/262_v03_numeric_source_move.json new file mode 100644 index 0000000..372de64 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/262_v03_numeric_source_move.json @@ -0,0 +1,40 @@ +{ + "name": "v0.3 Bash multi-digit source descriptor move", + "input": "command 10>&2-", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "Out", "target": "&2-", "isDynamicSkip": true } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 14 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 10, + "operation": "DescriptorMove", + "targetDescriptor": 2, + "target": { "kind": "Unknown" }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ] + }, + "notes": "Both source and target descriptors are preserved for move semantics." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/263_v03_numeric_source_file_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/263_v03_numeric_source_file_redirect.json new file mode 100644 index 0000000..c0f10ed --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/263_v03_numeric_source_file_redirect.json @@ -0,0 +1,39 @@ +{ + "name": "v0.3 Bash numeric source file redirect", + "input": "command 3> out.log", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "Out", "target": "/work/out.log", "isDynamicSkip": false } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 18 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 3, + "operation": "FileOutput", + "target": { "kind": "Exact", "values": ["/work/out.log"] }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "A non-standard source descriptor remains distinct from the exact path target." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/264_v03_overflow_source_descriptor.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/264_v03_overflow_source_descriptor.json new file mode 100644 index 0000000..db455a1 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/264_v03_overflow_source_descriptor.json @@ -0,0 +1,38 @@ +{ + "name": "v0.3 Bash overflow source descriptor fails closed", + "input": "command 999999999999999999999> out.log", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "Out", "target": "/work/out.log", "isDynamicSkip": false } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 38 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Unknown", + "operation": "Unknown", + "target": { "kind": "Unknown" }, + "isPathRelevant": false, + "isComplete": false + } + ] + } + ] + }, + "notes": "The compatibility leaf remains diagnostic-only; an unrepresentable descriptor cannot become complete authorization evidence." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/265_v03_continued_source_file_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/265_v03_continued_source_file_redirect.json new file mode 100644 index 0000000..67e4b96 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/265_v03_continued_source_file_redirect.json @@ -0,0 +1,39 @@ +{ + "name": "v0.3 Bash continued numeric source file redirect", + "input": "command 3\\\n> out.log", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "Out", "target": "/work/out.log", "isDynamicSkip": false } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 20 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 3, + "operation": "FileOutput", + "target": { "kind": "Exact", "values": ["/work/out.log"] }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "Bash removes the unquoted continuation before recognizing descriptor 3." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/266_v03_continued_source_duplicate.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/266_v03_continued_source_duplicate.json new file mode 100644 index 0000000..1a0adaa --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/266_v03_continued_source_duplicate.json @@ -0,0 +1,40 @@ +{ + "name": "v0.3 Bash continued numeric source duplicate", + "input": "command 3\\\n>&1", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "Out", "target": "&1", "isDynamicSkip": true } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 14 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 3, + "operation": "DescriptorDuplicate", + "targetDescriptor": 1, + "target": { "kind": "Unknown" }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ] + }, + "notes": "Continuation removal must not turn the explicit source into an argv value." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/267_v03_continued_multidigit_source_move.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/267_v03_continued_multidigit_source_move.json new file mode 100644 index 0000000..f0f845a --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/267_v03_continued_multidigit_source_move.json @@ -0,0 +1,40 @@ +{ + "name": "v0.3 Bash continued multi-digit source move", + "input": "command 1\\\n0>&2-", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [], + "redirects": [ + { "direction": "Out", "target": "&2-", "isDynamicSkip": true } + ] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 16 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 10, + "operation": "DescriptorMove", + "targetDescriptor": 2, + "target": { "kind": "Unknown" }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ] + }, + "notes": "Continuation removal composes the authored digit fragments into descriptor 10." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/268_v03_malformed_descriptor_target.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/268_v03_malformed_descriptor_target.json new file mode 100644 index 0000000..a57ab7d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/268_v03_malformed_descriptor_target.json @@ -0,0 +1,8 @@ +{ + "name": "v0.3 Bash malformed descriptor target fails atomically", + "input": "command 3>&1bad", + "expected": { + "isUnparseable": true + }, + "notes": "A literal descriptor followed by a malformed suffix cannot produce a partial authorization projection." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/41_redirect_out_and_err.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/41_redirect_out_and_err.json index aa21422..45131dc 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/41_redirect_out_and_err.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/41_redirect_out_and_err.json @@ -15,7 +15,36 @@ "isSubshell": false, "isCommandStringWrapped": false } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 16 } + ], + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "operation": "FileOutput", + "target": { "kind": "Exact", "values": ["/work/out"] }, + "isPathRelevant": true, + "isComplete": true + }, + { + "redirectIndex": 1, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "FileOutput", + "target": { "kind": "Exact", "values": ["/work/err"] }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, - "notes": "PR 4: redirect targets resolve against WorkingDirectory (/work in tests)." + "notes": "Pins independent authored coordinates and exact targets for multiple v0.3 file redirects." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/42_pipe_then_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/42_pipe_then_redirect.json index b909149..a10e932 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/42_pipe_then_redirect.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/42_pipe_then_redirect.json @@ -94,7 +94,7 @@ { "clauseIndex": 1, "immediateRole": "PipelineStage", - "isComplete": false, + "isComplete": true, "ancestry": [ { "ancestorKind": "Block", diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json index 20191e0..d719184 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json @@ -778,7 +778,7 @@ "authoredVerb": "command", "immediateRole": "Ordinary", "ancestry": ["root"], - "isComplete": true, + "isComplete": false, "redirects": [ { "operation": "DescriptorDuplicate", diff --git a/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs b/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs index 231962d..7084ff3 100644 --- a/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Lexing/BashLexerTests.cs @@ -77,6 +77,11 @@ public void Whitespace_token_is_emitted_between_words() [InlineData("<")] [InlineData("2>")] [InlineData("2>>")] + [InlineData("3>")] + [InlineData("10>>")] + [InlineData("0<")] + [InlineData("&>")] + [InlineData("&>>")] [InlineData("(")] [InlineData(")")] public void Each_operator_lexes_in_isolation(string op) @@ -147,6 +152,60 @@ public void Stderr_redirect_2gtgt_prefers_long_form() Assert.Equal("2>>", tokens[1].OperatorText); } + [Theory] + [InlineData("cmd &> out", "&>")] + [InlineData("cmd &>> out", "&>>")] + public void Combined_output_redirect_is_not_a_background_list( + string input, + string expectedOperator) + { + var tokens = LexNonWs(input); + + Assert.Equal(3, tokens.Length); + Assert.Equal(expectedOperator, tokens[1].OperatorText); + Assert.Equal(BashTokenKind.Word, tokens[2].Kind); + Assert.Equal("out", tokens[2].Value); + } + + [Fact] + public void Numeric_descriptor_is_recognized_only_at_a_token_boundary() + { + var descriptor = LexNonWs("command 3>out"); + Assert.Equal("3>", descriptor[1].OperatorText); + + var commandName = LexNonWs("command3>out"); + Assert.Equal("command3", commandName[0].Value); + Assert.Equal(">", commandName[1].OperatorText); + + var separated = LexNonWs("command 3 >out"); + Assert.Equal("3", separated[1].Value); + Assert.Equal(">", separated[2].OperatorText); + + var quotedPrefix = LexNonWs("command \"\"3>out"); + Assert.Equal(BashTokenKind.QuotedString, quotedPrefix[1].Kind); + Assert.Equal("3", quotedPrefix[2].Value); + Assert.Equal(">", quotedPrefix[3].OperatorText); + + var continuedQuotedPrefix = LexNonWs("command \"\"\\\n3>out"); + Assert.Equal(BashTokenKind.QuotedString, continuedQuotedPrefix[1].Kind); + Assert.Equal("3", continuedQuotedPrefix[2].Value); + Assert.Equal(">", continuedQuotedPrefix[3].OperatorText); + } + + [Theory] + [InlineData("command 3\\\n>out", "3>")] + [InlineData("command 3\\\r\n>out", "3>")] + [InlineData("command 1\\\n0>&2-", "10>")] + public void Unquoted_line_continuation_is_removed_before_descriptor_recognition( + string input, + string expectedOperator) + { + var tokens = LexNonWs(input); + + Assert.Equal(expectedOperator, tokens[1].OperatorText); + Assert.DoesNotContain(tokens, token => token.Value is "3" or "10"); + } + [Fact] public void Heredoc_dash_is_recognized() { @@ -593,7 +652,7 @@ public void Word_source_length_includes_escape_sequence() public void Operator_text_is_set_exactly_for_each_kind() { // Sweep all operators in one input to lock the OperatorText shape. - var tokens = LexNonWs("a&&b||c;d|e>f>>g

i2>>j(k)"); + var tokens = LexNonWs("a&&b||c;d|e>f>>gi 2>>j(k)"); var ops = tokens.Where(t => t.Kind == BashTokenKind.Operator) .Select(t => t.OperatorText).ToArray(); Assert.Equal( diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs index f564553..6141467 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs @@ -772,7 +772,7 @@ echo done Assert.Equal(RedirectDirection.Out, redirect.Direction); Assert.Equal("/work/out.txt", redirect.Target); Assert.False(redirect.IsDynamicSkip); - Assert.False(result.Commands[0].IsComplete); + Assert.True(result.Commands[0].IsComplete); Assert.True(result.Commands[1].IsComplete); Assert.All(result.Commands, command => Assert.Equal(CommandOccurrenceRole.LoopBody, command.ImmediateRole)); diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs index 29bef46..635b8d5 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs @@ -235,16 +235,201 @@ public void Unsupported_wrapper_tail_fails_closed(string source) } [Fact] - public void Redirect_leaf_is_structurally_visible_but_incomplete_until_redirect_analysis_lands() + public void Literal_file_redirect_has_complete_explicit_analysis() { var result = Parse("echo ok > out.txt"); Assert.False(result.IsUnparseable); var command = Assert.Single(result.Commands); - Assert.False(command.IsComplete); + Assert.True(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(0, redirect.RedirectIndex); + Assert.Equal(RedirectSourceKind.Default, redirect.Source.Kind); + Assert.Null(redirect.Source.Descriptor); + Assert.Equal(RedirectOperation.FileOutput, redirect.Operation); + Assert.Equal(ShellValueDomainKind.Exact, redirect.Target.Kind); + Assert.Equal("/work/out.txt", Assert.Single(redirect.Target.Values)); + Assert.True(redirect.IsPathRelevant); + Assert.True(redirect.IsComplete); Assert.Single(result.Clauses); } + [Theory] + [InlineData("dotnet test 2>&1", RedirectSourceKind.Descriptor, 2, RedirectOperation.DescriptorDuplicate, 1)] + [InlineData("command 2>&-", RedirectSourceKind.Descriptor, 2, RedirectOperation.DescriptorClose, null)] + [InlineData("command 2>&1-", RedirectSourceKind.Descriptor, 2, RedirectOperation.DescriptorMove, 1)] + [InlineData("command <&0", RedirectSourceKind.Default, null, RedirectOperation.DescriptorDuplicate, 0)] + [InlineData("command <&-", RedirectSourceKind.Default, null, RedirectOperation.DescriptorClose, null)] + [InlineData("command <&0-", RedirectSourceKind.Default, null, RedirectOperation.DescriptorMove, 0)] + [InlineData("command 3>&1", RedirectSourceKind.Descriptor, 3, RedirectOperation.DescriptorDuplicate, 1)] + [InlineData("command 10>&2-", RedirectSourceKind.Descriptor, 10, RedirectOperation.DescriptorMove, 2)] + public void Literal_descriptor_redirects_are_complete_and_not_paths( + string source, + RedirectSourceKind sourceKind, + int? sourceDescriptor, + RedirectOperation operation, + int? targetDescriptor) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(sourceKind, redirect.Source.Kind); + Assert.Equal(sourceDescriptor, redirect.Source.Descriptor); + Assert.Equal(operation, redirect.Operation); + Assert.Equal(targetDescriptor, redirect.TargetDescriptor); + Assert.Equal(ShellValueDomainKind.Unknown, redirect.Target.Kind); + Assert.False(redirect.IsPathRelevant); + Assert.True(redirect.IsComplete); + } + + [Theory] + [InlineData("command 3> out.log", 3, RedirectOperation.FileOutput)] + [InlineData("command 10>> out.log", 10, RedirectOperation.FileAppend)] + [InlineData("command 4< input.txt", 4, RedirectOperation.FileInput)] + public void Numeric_source_file_redirect_preserves_descriptor( + string source, + int sourceDescriptor, + RedirectOperation operation) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(RedirectSourceKind.Descriptor, redirect.Source.Kind); + Assert.Equal(sourceDescriptor, redirect.Source.Descriptor); + Assert.Equal(operation, redirect.Operation); + Assert.True(redirect.IsPathRelevant); + Assert.True(redirect.IsComplete); + } + + [Fact] + public void Quoted_adjacent_digits_are_an_argument_not_a_source_descriptor() + { + var result = Parse("command \"\"3> out.log"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(RedirectSourceKind.Default, redirect.Source.Kind); + Assert.Equal(RedirectOperation.FileOutput, redirect.Operation); + Assert.Contains(command.Clause.Args, argument => argument.Raw == "\"\"3"); + } + + [Theory] + [InlineData("command 3\\\n> out.log", 3, RedirectOperation.FileOutput, null)] + [InlineData("command 3\\\r\n>&1", 3, RedirectOperation.DescriptorDuplicate, 1)] + [InlineData("command 1\\\n0>&2-", 10, RedirectOperation.DescriptorMove, 2)] + public void Continued_numeric_source_preserves_descriptor_semantics( + string source, + int sourceDescriptor, + RedirectOperation operation, + int? targetDescriptor) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + Assert.DoesNotContain(command.Clause.Args, argument => argument.Raw is "3" or "10"); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(RedirectSourceKind.Descriptor, redirect.Source.Kind); + Assert.Equal(sourceDescriptor, redirect.Source.Descriptor); + Assert.Equal(operation, redirect.Operation); + Assert.Equal(targetDescriptor, redirect.TargetDescriptor); + Assert.True(redirect.IsComplete); + } + + [Fact] + public void Overflow_numeric_source_descriptor_remains_incomplete() + { + var result = Parse("command 999999999999999999999> out.log"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.False(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(RedirectSourceKind.Unknown, redirect.Source.Kind); + Assert.Equal(RedirectOperation.Unknown, redirect.Operation); + Assert.False(redirect.IsComplete); + } + + [Fact] + public void Malformed_numeric_descriptor_target_is_unparseable() + { + var result = Parse("command 3>&1bad"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Multiple_redirects_preserve_authored_coordinates_and_independent_operations() + { + var result = Parse("command > out.log 2>&1"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + Assert.Collection( + command.Redirects, + redirect => + { + Assert.Equal(0, redirect.RedirectIndex); + Assert.Equal(RedirectOperation.FileOutput, redirect.Operation); + Assert.Equal("/work/out.log", Assert.Single(redirect.Target.Values)); + }, + redirect => + { + Assert.Equal(1, redirect.RedirectIndex); + Assert.Equal(RedirectOperation.DescriptorDuplicate, redirect.Operation); + Assert.Equal(1, redirect.TargetDescriptor); + }); + } + + [Theory] + [InlineData("command 2>&$FD")] + [InlineData("command >&${FD}")] + public void Computed_descriptor_target_remains_incomplete(string source) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.False(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(RedirectOperation.DescriptorDuplicate, redirect.Operation); + Assert.Null(redirect.TargetDescriptor); + Assert.Equal(ShellValueDomainKind.Unknown, redirect.Target.Kind); + Assert.False(redirect.IsPathRelevant); + Assert.False(redirect.IsComplete); + } + + [Theory] + [InlineData("command &> out.log", RedirectOperation.CombinedOutput)] + [InlineData("command &>> out.log", RedirectOperation.CombinedOutputAppend)] + public void Combined_output_redirects_have_explicit_operations( + string source, + RedirectOperation operation) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(RedirectSourceKind.Default, redirect.Source.Kind); + Assert.Equal(operation, redirect.Operation); + Assert.Equal("/work/out.log", Assert.Single(redirect.Target.Values)); + Assert.True(redirect.IsPathRelevant); + Assert.True(redirect.IsComplete); + } + [Fact] public void Dynamic_command_string_preserves_compatibility_but_is_not_complete() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index c5f0638..3c88349 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -12,6 +12,76 @@ namespace ShellSyntaxTree.Tests.Parsing; public class ShellValueOracleTests { + [Fact] + public void Bash_descriptor_duplicate_routes_stderr_to_stdout() + { + if (!IsNativeBashAvailable()) + { + return; + } + + var result = RunUnchecked( + "bash", + "-c", + "{ printf out; printf err >&2; } 2>&1"); + + Assert.Equal(0, result.ExitCode); + Assert.Equal("outerr", result.StandardOutput); + Assert.Empty(result.StandardError); + } + + [Fact] + public void Bash_removes_line_continuation_before_descriptor_recognition() + { + if (!IsNativeBashAvailable()) + { + return; + } + + var result = RunUnchecked( + "bash", + "-c", + "{ printf via3 >&3; } 3\\\n>&1"); + + Assert.Equal(0, result.ExitCode); + Assert.Equal("via3", result.StandardOutput); + Assert.Empty(result.StandardError); + } + + [Fact] + public void Bash_combined_output_overwrite_and_append_share_one_file() + { + if (!IsNativeBashAvailable()) + { + return; + } + + var root = Path.Combine( + Path.GetTempPath(), + "shellsyntaxtree-redirect-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var result = RunUncheckedInWorkingDirectory( + "bash", + root, + "-c", + "{ printf out; printf err >&2; } &> combined.log; " + + "{ printf plus; printf more >&2; } &>> combined.log"); + + Assert.Equal(0, result.ExitCode); + Assert.Empty(result.StandardOutput); + Assert.Empty(result.StandardError); + Assert.Equal( + "outerrplusmore", + File.ReadAllText(Path.Combine(root, "combined.log"))); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Theory] [InlineData("cat <