Skip to content

Commit a526db1

Browse files
committed
Add explicit Bash redirect analysis
1 parent 5f14838 commit a526db1

32 files changed

Lines changed: 1448 additions & 44 deletions

IMPLEMENTATION_PLAN.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,21 @@ priorities.
305305
transfers, and occurrence-specific redirect values remain fail closed.
306306
Next add the Netclaw approval matrix before calling the Bash consumer
307307
integration complete.
308+
- [x] Deliver occurrence-level Bash explicit redirect facts for ordinary file
309+
input/output/append, static descriptor duplicate/close/move, computed
310+
descriptor targets, and combined `&>` / `&>>` output. Static descriptor
311+
operations are complete non-path facts even though the v0.2 compatibility
312+
redirect remains `IsDynamicSkip`; computed targets remain incomplete and
313+
cannot be exempted by raw prefix. Arbitrary numeric source descriptors are
314+
retained only when authored at a token boundary; overflow sources remain
315+
incomplete rather than being truncated, and word suffixes such as
316+
`command3>file` keep `3` in the command name. Unquoted LF/CRLF line
317+
continuations are removed before descriptor recognition, including when
318+
they join multi-digit sources. Exact file targets now complete their
319+
containing occurrence, while cwd or value uncertainty still downgrades
320+
the redirect and occurrence after abstract-state joins. Direct lexer and
321+
parser tests plus executable corpus cases pin the boundary. Next map the
322+
PowerShell stream model, then prove the paired Netclaw redirect matrix.
308323
- [ ] Complete PowerShell `foreach` integration and add the
309324
Netclaw approval-matrix cases. The structural slice now preserves literal
310325
scalar/array and executable iterator forms, recursively parses bodies,

SPEC.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -967,8 +967,9 @@ public sealed record Clause
967967
public IReadOnlyList<Arg> Args { get; init; } = [];
968968

969969
/// <summary>
970-
/// Redirect operators on this clause (>, >>, <, 2>, 2>>). Each entry
971-
/// includes direction and target path.
970+
/// Compatibility redirects on this clause (>, >>, <, 2>, 2>>, &>,
971+
/// &>>). Each entry retains the v0.2 direction and target projection;
972+
/// v0.3 consumers use CommandOccurrence.Redirects for exact semantics.
972973
/// </summary>
973974
public IReadOnlyList<Redirect> Redirects { get; init; } = [];
974975

@@ -1270,7 +1271,8 @@ verb_like_word := static word satisfying §6.1; the initial command-name
12701271
arg := word | flag | quoted_string | supported_substitution
12711272
flag := "-" letter+ | "--" word
12721273
redirect := redirect_op target
1273-
redirect_op := ">" | ">>" | "<" | "2>" | "2>>"
1274+
redirect_op := descriptor? (">" | ">>" | "<") | "&>" | "&>>"
1275+
descriptor := digit+
12741276
target := word | quoted_string | supported_substitution
12751277
supported_substitution := "$(" command ")"
12761278
word := non-whitespace, non-operator fragments; may contain
@@ -1396,7 +1398,8 @@ The lexer produces tokens consumed by the parser. Token kinds:
13961398
- **QUOTED_STRING** — single- or double-quoted string. The lexer strips
13971399
the quote delimiters from the token value. Example: `"hello world"`
13981400
becomes the token value `hello world`.
1399-
- **OPERATOR**`&&`, `||`, `;`, `|`, `>`, `>>`, `<`, `2>`, `2>>`,
1401+
- **OPERATOR**`&&`, `||`, `;`, `|`, `>`, `>>`, `<`, numeric-descriptor
1402+
forms such as `2>`, `3>>`, and `10<`, `&>`, `&>>`,
14001403
`(`, `)`, `<<`, `<<-`.
14011404
- **WHITESPACE** — one or more spaces, tabs, or newlines (newlines inside
14021405
a heredoc body are not emitted as ordinary tokens; the delimiter token
@@ -1456,15 +1459,22 @@ The lexer produces tokens consumed by the parser. Token kinds:
14561459

14571460
Operators terminate the current token. `cd /tmp&&ls` lexes as
14581461
`[cd, /tmp, &&, ls]` — no whitespace required around operators. The lexer
1459-
must handle this.
1462+
must handle this. A numeric descriptor is an operator prefix only when its
1463+
digits begin at a shell-token boundary and become adjacent to `<`, `>`, or
1464+
`>>` after Bash removes unquoted line continuations. Continuations may join
1465+
digit fragments or the descriptor and operator; LF and CRLF spellings retain
1466+
their authored span while producing the same descriptor. Digits joined to an
1467+
ordinary, quoted, or escaped word remain part of that word; `command3>file`
1468+
therefore uses command name `command3` and a default-source `>` redirect.
14601469

14611470
### Comment handling
14621471

14631472
- An unquoted `#` that appears at a **word boundary** starts a comment
14641473
that runs to (but does not include) the next newline. A word boundary
14651474
is: start of input, or the position immediately after a whitespace
14661475
run, a newline, an operator (`&&`, `||`, `;`, `|`, `>`, `>>`, `<`,
1467-
`2>`, `2>>`, `(`, `)`, `<<`, `<<-`), a quoted string, or an opaque
1476+
a numeric descriptor adjacent to `>`, `>>`, or `<`, `&>`, `&>>`, `(`,
1477+
`)`, `<<`, `<<-`), a quoted string, or an opaque
14681478
substitution. Equivalently: `#` is comment-start everywhere the
14691479
outer lexer dispatch loop sits, because every other lexer rule has
14701480
already consumed its territory before `#` is considered.

openspec/changes/v0-3-structured-shell-analysis/tasks.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,20 @@
6565
## 4. Explicit Redirect Semantics
6666

6767
- [x] 4.1 Add the locked redirect operation and target-analysis types while retaining compatibility redirect members.
68-
- [ ] 4.2 Classify Bash descriptor duplication, close, and move as static only for the complete literal descriptor grammar.
69-
- [ ] 4.3 Keep variable-driven and otherwise computed Bash descriptor targets unknown or incomplete.
70-
- [ ] 4.4 Lex and classify Bash `&>` and `&>>` independently from background-list operators.
68+
- [x] 4.2 Classify Bash descriptor duplication, close, and move as static only for the complete literal descriptor grammar.
69+
- [x] 4.3 Keep variable-driven and otherwise computed Bash descriptor targets unknown or incomplete.
70+
- [x] 4.4 Lex and classify Bash `&>` and `&>>` independently from background-list operators.
7171
- [ ] 4.5 Map existing PowerShell stream redirects into the shared explicit model without losing shell-specific stream identity.
7272
- [ ] 4.6 Add paired direct tests and corpus cases for static, dynamic, malformed, multiple, combined, and file redirects.
73+
- [x] 4.6a Add Bash direct and executable-corpus cases for static duplicate,
74+
close, and move; computed descriptor targets; combined output overwrite and
75+
append; ordinary file redirects; arbitrary numeric source descriptors; and
76+
overflow/malformed fail-closed boundaries. LF/CRLF continuations at the
77+
descriptor boundary and inside multi-digit sources follow Bash's pre-token
78+
removal semantics. Executable-corpus cases pin malformed atomic failure and
79+
independent occurrence facts for multiple redirects.
80+
- [ ] 4.6b Add the paired PowerShell direct and executable-corpus cases when
81+
task 4.5 maps its stream model.
7382
- [ ] 4.7 Verify the explicit model removes the need for raw-prefix inference in a Netclaw integration test.
7483

7584
## 5. Consumer Migration Baseline

src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs

Lines changed: 128 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,16 @@ internal static IReadOnlyList<BashToken> Tokenize(string input)
114114
}
115115

116116
// ---- operators (longer-match first) ----
117-
// Order matters: `&&` before `&`, `||` before `|`, `>>` before `>`,
118-
// `2>>` before `2>`, `<<-` before `<<`, `<<` before `<`. We don't
119-
// recognize a bare `&` in v0.1 (no background-job support; SPEC §1).
120-
if (TryReadOperator(src, i, out var opLen, out var opText))
117+
// Order matters: a token-boundary numeric descriptor precedes its
118+
// redirect, `&&` precedes `&`, `||` precedes `|`, `>>` precedes
119+
// `>`, and `<<-` precedes `<<` and `<`. Bare `&` background jobs
120+
// remain unsupported.
121+
if (TryReadOperator(
122+
src,
123+
i,
124+
CanStartNumericDescriptor(tokens),
125+
out var opLen,
126+
out var opText))
121127
{
122128
var operatorTok = new BashToken(
123129
BashTokenKind.Operator, "", opText, i, opLen, null);
@@ -268,7 +274,7 @@ private static bool TryConsumeFileDescriptorTarget(
268274
}
269275

270276
if (previousIndex < 0 || tokens[previousIndex].Kind != BashTokenKind.Operator ||
271-
tokens[previousIndex].OperatorText is not (">" or ">>" or "<" or "2>" or "2>>"))
277+
!CanTakeDescriptorTarget(tokens[previousIndex].OperatorText))
272278
{
273279
return false;
274280
}
@@ -323,16 +329,89 @@ tokens[previousIndex].OperatorText is not (">" or ">>" or "<" or "2>" or "2>>"))
323329
return true;
324330
}
325331

332+
private static bool CanTakeDescriptorTarget(string? operatorText)
333+
{
334+
if (string.IsNullOrEmpty(operatorText))
335+
{
336+
return false;
337+
}
338+
339+
var operatorStart = 0;
340+
while (operatorStart < operatorText!.Length &&
341+
operatorText[operatorStart] is >= '0' and <= '9')
342+
{
343+
operatorStart++;
344+
}
345+
346+
var redirect = operatorText.Substring(operatorStart);
347+
return redirect is ">" or ">>" or "<";
348+
}
349+
326350
// ---------------------------------------------------------------- operators
327351

328352
private static bool TryReadOperator(
329-
ReadOnlySpan<char> src, int i, out int length, out string? text)
353+
ReadOnlySpan<char> src,
354+
int i,
355+
bool canStartNumericDescriptor,
356+
out int length,
357+
out string? text)
330358
{
359+
var descriptor = new StringBuilder();
360+
var descriptorEnd = i;
361+
while (descriptorEnd < src.Length)
362+
{
363+
if (src[descriptorEnd] is >= '0' and <= '9')
364+
{
365+
descriptor.Append(src[descriptorEnd]);
366+
descriptorEnd++;
367+
continue;
368+
}
369+
370+
if (TrySkipLineContinuation(src, descriptorEnd, out var afterContinuation))
371+
{
372+
descriptorEnd = afterContinuation;
373+
continue;
374+
}
375+
376+
break;
377+
}
378+
379+
if (canStartNumericDescriptor &&
380+
descriptor.Length > 0 &&
381+
descriptorEnd < src.Length)
382+
{
383+
if (src[descriptorEnd] == '>')
384+
{
385+
var append = descriptorEnd + 1 < src.Length &&
386+
src[descriptorEnd + 1] == '>';
387+
length = descriptorEnd - i + (append ? 2 : 1);
388+
text = descriptor.ToString() + (append ? ">>" : ">");
389+
return true;
390+
}
391+
392+
if (src[descriptorEnd] == '<')
393+
{
394+
length = descriptorEnd - i + 1;
395+
text = descriptor.ToString() + "<";
396+
return true;
397+
}
398+
}
399+
331400
// Multi-char operators first.
332401
if (i + 1 < src.Length)
333402
{
334403
var c0 = src[i];
335404
var c1 = src[i + 1];
405+
if (c0 == '&' && c1 == '>')
406+
{
407+
if (i + 2 < src.Length && src[i + 2] == '>')
408+
{
409+
length = 3; text = "&>>"; return true;
410+
}
411+
412+
length = 2; text = "&>"; return true;
413+
}
414+
336415
if (c0 == '&' && c1 == '&') { length = 2; text = "&&"; return true; }
337416
if (c0 == '|' && c1 == '|') { length = 2; text = "||"; return true; }
338417
if (c0 == '>' && c1 == '>') { length = 2; text = ">>"; return true; }
@@ -346,15 +425,6 @@ private static bool TryReadOperator(
346425
length = 2; text = "<<"; return true;
347426
}
348427

349-
if (c0 == '2' && c1 == '>')
350-
{
351-
if (i + 2 < src.Length && src[i + 2] == '>')
352-
{
353-
length = 3; text = "2>>"; return true;
354-
}
355-
356-
length = 2; text = "2>"; return true;
357-
}
358428
}
359429

360430
// Single-char operators.
@@ -371,6 +441,49 @@ private static bool TryReadOperator(
371441
}
372442
}
373443

444+
private static bool TrySkipLineContinuation(
445+
ReadOnlySpan<char> source,
446+
int index,
447+
out int afterContinuation)
448+
{
449+
afterContinuation = index;
450+
if (index + 1 >= source.Length || source[index] != '\\' ||
451+
source[index + 1] is not ('\n' or '\r'))
452+
{
453+
return false;
454+
}
455+
456+
afterContinuation = source[index + 1] == '\r' &&
457+
index + 2 < source.Length && source[index + 2] == '\n'
458+
? index + 3
459+
: index + 2;
460+
return true;
461+
}
462+
463+
private static bool CanStartNumericDescriptor(IReadOnlyList<BashToken> tokens)
464+
{
465+
if (tokens.Count == 0)
466+
{
467+
return true;
468+
}
469+
470+
var index = tokens.Count - 1;
471+
while (index >= 0 && tokens[index].Kind == BashTokenKind.Continuation)
472+
{
473+
index--;
474+
}
475+
476+
if (index < 0)
477+
{
478+
return true;
479+
}
480+
481+
return tokens[index].Kind is
482+
BashTokenKind.Whitespace or
483+
BashTokenKind.Operator or
484+
BashTokenKind.Comment;
485+
}
486+
374487
// ---------------------------------------------------------------- quoted
375488

376489
private static int ReadSingleQuoted(
@@ -1219,10 +1332,6 @@ private static bool IsOperatorStart(ReadOnlySpan<char> src, int i)
12191332
// Both `&&` and unsupported bare `&` terminate a word. The
12201333
// tokenizer emits a sentinel for the latter on its next pass.
12211334
return true;
1222-
case '2':
1223-
// `2>` and `2>>` start with '2' — only treat them as operator
1224-
// starts when the immediate next char is '>'.
1225-
return i + 1 < src.Length && src[i + 1] == '>';
12261335
default:
12271336
return false;
12281337
}

src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,14 +1207,15 @@ private SimpleCommandSyntax RewriteSimple(
12071207
}
12081208

12091209
var clause = RewriteClause(simple.Clause, input, sourceFacts.CwdPathDependencies);
1210+
var redirects = RewriteRedirectFacts(sourceFacts.Redirects, clause);
12101211
facts.Add(clause, new CommandOccurrenceFacts
12111212
{
12121213
EffectiveArguments = CreateEffectiveArguments(simple.Clause),
12131214
WorkingDirectory = input.ToDomain(),
1214-
Redirects = RewriteRedirectFacts(sourceFacts.Redirects, clause),
1215+
Redirects = redirects,
12151216
CwdPathDependencies = sourceFacts.CwdPathDependencies,
12161217
ValueProvenance = sourceFacts.ValueProvenance,
1217-
IsComplete = sourceFacts.IsComplete,
1218+
IsComplete = sourceFacts.IsComplete && AreRedirectsComplete(redirects),
12181219
});
12191220
return simple with
12201221
{
@@ -1463,12 +1464,26 @@ private static IReadOnlyList<RedirectAnalysis> RewriteRedirectFacts(
14631464
Kind = ShellValueDomainKind.Exact,
14641465
Values = new[] { redirect.Target },
14651466
},
1467+
IsComplete = fact.IsComplete && !redirect.IsDynamicSkip,
14661468
};
14671469
}
14681470

14691471
return rewritten;
14701472
}
14711473

1474+
private static bool AreRedirectsComplete(IReadOnlyList<RedirectAnalysis> redirects)
1475+
{
1476+
foreach (var redirect in redirects)
1477+
{
1478+
if (!redirect.IsComplete)
1479+
{
1480+
return false;
1481+
}
1482+
}
1483+
1484+
return true;
1485+
}
1486+
14721487
private string? RebaseResolution(
14731488
string? resolved,
14741489
bool isPath,

0 commit comments

Comments
 (0)