Skip to content

Commit 873de51

Browse files
Implement Bash here-string analysis (#125)
1 parent d7d8838 commit 873de51

15 files changed

Lines changed: 464 additions & 28 deletions

File tree

IMPLEMENTATION_PLAN.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,8 +551,13 @@ priorities.
551551
flattening nested execution into apparent ordinary verb chains.
552552
- [ ] Promote the Bash command-resolution mutation cases into Netclaw's strict
553553
allow/prompt/deny matrix before the downstream approval-fatigue gate.
554-
- [ ] Add the separately tested Bash `<<<` here-string redirect slice with
555-
bounded operand analysis and trailing-newline semantics.
554+
- [x] Add the separately tested Bash `<<<` here-string redirect slice with
555+
bounded operand analysis and trailing-newline semantics. Default and
556+
numeric sources publish complete non-path facts; exact and finite data
557+
include Bash's appended newline, unknown data remains structurally
558+
complete, and every supported `$()` command stays independently visible.
559+
Malformed operators fail atomically, while native Bash oracles pin
560+
newline and no-field-splitting behavior.
556561

557562
---
558563

SPEC.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,8 +1439,8 @@ The lexer produces tokens consumed by the parser. Token kinds:
14391439
the quote delimiters from the token value. Example: `"hello world"`
14401440
becomes the token value `hello world`.
14411441
- **OPERATOR**`&&`, `||`, `;`, `|`, `>`, `>>`, `<`, numeric-descriptor
1442-
forms such as `2>`, `3>>`, `10<`, `3<<`, and `4<<-`, `&>`, `&>>`,
1443-
`(`, `)`, `<<`, `<<-`.
1442+
forms such as `2>`, `3>>`, `10<`, `3<<`, `4<<-`, and `5<<<`, `&>`,
1443+
`&>>`, `(`, `)`, `<<`, `<<-`, `<<<`.
14441444
- **WHITESPACE** — one or more spaces, tabs, or newlines (newlines inside
14451445
a heredoc body are not emitted as ordinary tokens; the delimiter token
14461446
retains the body's resolver fragments and authored extent). A whitespace run that
@@ -1500,21 +1500,22 @@ The lexer produces tokens consumed by the parser. Token kinds:
15001500
Operators terminate the current token. `cd /tmp&&ls` lexes as
15011501
`[cd, /tmp, &&, ls]` — no whitespace required around operators. The lexer
15021502
must handle this. A numeric descriptor is an operator prefix only when its
1503-
digits begin at a shell-token boundary and become adjacent to `<`, `>`, or
1504-
`>>` after Bash removes unquoted line continuations. Continuations may join
1505-
digit fragments or the descriptor and operator; LF and CRLF spellings retain
1506-
their authored span while producing the same descriptor. Digits joined to an
1507-
ordinary, quoted, or escaped word remain part of that word; `command3>file`
1508-
therefore uses command name `command3` and a default-source `>` redirect.
1503+
digits begin at a shell-token boundary and become adjacent to `<`, `>`, `>>`,
1504+
`<<`, `<<-`, or `<<<` after Bash removes unquoted line continuations.
1505+
Continuations may join digit fragments or the descriptor and operator; LF and
1506+
CRLF spellings retain their authored span while producing the same descriptor.
1507+
Digits joined to an ordinary, quoted, or escaped word remain part of that word;
1508+
`command3>file` therefore uses command name `command3` and a default-source `>`
1509+
redirect.
15091510

15101511
### Comment handling
15111512

15121513
- An unquoted `#` that appears at a **word boundary** starts a comment
15131514
that runs to (but does not include) the next newline. A word boundary
15141515
is: start of input, or the position immediately after a whitespace
15151516
run, a newline, an operator (`&&`, `||`, `;`, `|`, `>`, `>>`, `<`,
1516-
a numeric descriptor adjacent to `>`, `>>`, or `<`, `&>`, `&>>`, `(`,
1517-
`)`, `<<`, `<<-`), a quoted string, or an opaque
1517+
a numeric descriptor adjacent to `>`, `>>`, `<`, `<<`, `<<-`, or `<<<`,
1518+
`&>`, `&>>`, `(`, `)`, `<<`, `<<-`, `<<<`), a quoted string, or an opaque
15181519
substitution. Equivalently: `#` is comment-start everywhere the
15191520
outer lexer dispatch loop sits, because every other lexer rule has
15201521
already consumed its territory before `#` is considered.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,12 @@
249249
- [x] 10.1 Specify heredoc delimiter adjacency and quoting, expansion mode, body provenance, substitutions, tab stripping, completeness, and Bash here-string semantics.
250250
- [x] 10.2 Preserve existing `<<` / `<<-` behavior and fix quoted-delimiter adjacency without regressing the v0.2 compatibility redirect.
251251
- [x] 10.3 Add explicit heredoc delimiter/body/expansion/completeness facts and surface every supported substitution command.
252-
- [ ] 10.4 Add Bash `<<<` here-string tokenization, explicit redirect facts, bounded operand analysis, and trailing-newline semantics.
252+
- [x] 10.4 Add Bash `<<<` here-string tokenization, explicit redirect facts,
253+
bounded operand analysis, and trailing-newline semantics.
254+
- Longest-match lexer and occurrence-level tests cover default and numeric
255+
sources, exact empty and literal data, unknown values, visible command
256+
substitutions, malformed forms, and finite loop-bound operands. Native
257+
Bash oracles pin the appended newline and suppression of field splitting.
253258
- [x] 10.5 Add direct, malformed, quoted/unquoted, tab-stripped, dynamic, and substitution-bearing corpus cases plus real-Bash parse-only validation.
254259
- [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.
255260

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ internal static IReadOnlyList<BashToken> Tokenize(string input)
116116
// ---- operators (longer-match first) ----
117117
// Order matters: a token-boundary numeric descriptor precedes its
118118
// redirect, `&&` precedes `&`, `||` precedes `|`, `>>` precedes
119-
// `>`, and `<<-` precedes `<<` and `<`. Bare `&` background jobs
120-
// remain unsupported.
119+
// `>`, and `<<<` / `<<-` precede `<<` and `<`. Bare `&`
120+
// background jobs remain unsupported.
121121
if (TryReadOperator(
122122
src,
123123
i,
@@ -401,14 +401,16 @@ private static bool TryReadOperator(
401401
if (descriptorEnd + 1 < src.Length && src[descriptorEnd + 1] == '<')
402402
{
403403
redirectLength = descriptorEnd + 2 < src.Length &&
404-
src[descriptorEnd + 2] == '-'
404+
src[descriptorEnd + 2] is '<' or '-'
405405
? 3
406406
: 2;
407407
}
408408

409409
length = descriptorEnd - i + redirectLength;
410410
text = descriptor.ToString() +
411-
(redirectLength == 3 ? "<<-" : redirectLength == 2 ? "<<" : "<");
411+
(redirectLength == 3
412+
? src[descriptorEnd + 2] == '<' ? "<<<" : "<<-"
413+
: redirectLength == 2 ? "<<" : "<");
412414
return true;
413415
}
414416
}
@@ -433,9 +435,11 @@ private static bool TryReadOperator(
433435
if (c0 == '>' && c1 == '>') { length = 2; text = ">>"; return true; }
434436
if (c0 == '<' && c1 == '<')
435437
{
436-
if (i + 2 < src.Length && src[i + 2] == '-')
438+
if (i + 2 < src.Length && src[i + 2] is '<' or '-')
437439
{
438-
length = 3; text = "<<-"; return true;
440+
length = 3;
441+
text = src[i + 2] == '<' ? "<<<" : "<<-";
442+
return true;
439443
}
440444

441445
length = 2; text = "<<"; return true;
@@ -497,6 +501,23 @@ internal static bool IsHeredocOperator(string? operatorText)
497501
(remaining == 2 || remaining == 3 && operatorText[operatorStart + 2] == '-');
498502
}
499503

504+
internal static bool IsHereStringOperator(string? operatorText)
505+
{
506+
if (string.IsNullOrEmpty(operatorText))
507+
{
508+
return false;
509+
}
510+
511+
var operatorStart = 0;
512+
while (operatorStart < operatorText!.Length &&
513+
operatorText[operatorStart] is >= '0' and <= '9')
514+
{
515+
operatorStart++;
516+
}
517+
518+
return operatorText.AsSpan(operatorStart).SequenceEqual("<<<".AsSpan());
519+
}
520+
500521
private static bool CanStartNumericDescriptor(IReadOnlyList<BashToken> tokens)
501522
{
502523
if (tokens.Count == 0)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ internal enum BashTokenKind
2626
/// <summary>One of the bash operators recognized in v0.1: <c>&amp;&amp;</c>,
2727
/// <c>||</c>, <c>;</c>, <c>|</c>, <c>&gt;</c>, <c>&gt;&gt;</c>,
2828
/// <c>&lt;</c>, <c>2&gt;</c>, <c>2&gt;&gt;</c>, <c>(</c>, <c>)</c>,
29-
/// <c>&lt;&lt;</c>, <c>&lt;&lt;-</c>. The literal text is in
29+
/// <c>&lt;&lt;</c>, <c>&lt;&lt;-</c>, <c>&lt;&lt;&lt;</c>. The literal text is in
3030
/// <see cref="BashToken.OperatorText"/>.</summary>
3131
Operator,
3232

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1207,12 +1207,17 @@ private SimpleCommandSyntax RewriteSimple(
12071207
}
12081208

12091209
var clause = RewriteClause(simple.Clause, input, sourceFacts.CwdPathDependencies);
1210-
var redirects = RewriteRedirectFacts(sourceFacts.Redirects, clause);
1210+
var redirects = RewriteRedirectFacts(
1211+
sourceFacts.Redirects,
1212+
sourceFacts.RedirectTargetProvenance,
1213+
input.Bindings,
1214+
clause);
12111215
facts.Add(clause, new CommandOccurrenceFacts
12121216
{
12131217
EffectiveArguments = CreateEffectiveArguments(simple.Clause),
12141218
WorkingDirectory = input.ToDomain(),
12151219
Redirects = redirects,
1220+
RedirectTargetProvenance = sourceFacts.RedirectTargetProvenance,
12161221
CwdPathDependencies = sourceFacts.CwdPathDependencies,
12171222
ValueProvenance = sourceFacts.ValueProvenance,
12181223
IsComplete = sourceFacts.IsComplete && AreRedirectsComplete(redirects),
@@ -1435,6 +1440,8 @@ private IReadOnlyList<Redirect> RewriteCompatibilityRedirects(
14351440

14361441
private static IReadOnlyList<RedirectAnalysis> RewriteRedirectFacts(
14371442
IReadOnlyList<RedirectAnalysis> source,
1443+
IReadOnlyList<RedirectTargetProvenance> provenance,
1444+
BashLoopBindingContext bindings,
14381445
Clause clause)
14391446
{
14401447
if (source.Count == 0)
@@ -1446,6 +1453,18 @@ private static IReadOnlyList<RedirectAnalysis> RewriteRedirectFacts(
14461453
for (var index = 0; index < rewritten.Length; index++)
14471454
{
14481455
var fact = source[index];
1456+
if (fact.Operation == RedirectOperation.HereString)
1457+
{
1458+
rewritten[index] = fact with
1459+
{
1460+
Target = RewriteHereStringTarget(
1461+
fact,
1462+
provenance,
1463+
bindings),
1464+
};
1465+
continue;
1466+
}
1467+
14491468
if (!fact.IsPathRelevant ||
14501469
fact.RedirectIndex < 0 ||
14511470
fact.RedirectIndex >= clause.Redirects.Count)
@@ -1471,6 +1490,45 @@ private static IReadOnlyList<RedirectAnalysis> RewriteRedirectFacts(
14711490
return rewritten;
14721491
}
14731492

1493+
private static ShellValueDomain RewriteHereStringTarget(
1494+
RedirectAnalysis fact,
1495+
IReadOnlyList<RedirectTargetProvenance> provenance,
1496+
BashLoopBindingContext bindings)
1497+
{
1498+
foreach (var candidate in provenance)
1499+
{
1500+
if (candidate.RedirectIndex != fact.RedirectIndex)
1501+
{
1502+
continue;
1503+
}
1504+
1505+
if (!bindings.TryAnalyzeEffectiveValue(candidate.Value, out var domain))
1506+
{
1507+
return fact.Target;
1508+
}
1509+
1510+
if (domain.Kind is not (
1511+
ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet))
1512+
{
1513+
return ShellValueDomain.Unknown;
1514+
}
1515+
1516+
var values = new string[domain.Values.Count];
1517+
for (var index = 0; index < values.Length; index++)
1518+
{
1519+
values[index] = domain.Values[index] + "\n";
1520+
}
1521+
1522+
return new ShellValueDomain
1523+
{
1524+
Kind = domain.Kind,
1525+
Values = values,
1526+
};
1527+
}
1528+
1529+
return fact.Target;
1530+
}
1531+
14741532
private static bool AreRedirectsComplete(IReadOnlyList<RedirectAnalysis> redirects)
14751533
{
14761534
foreach (var redirect in redirects)

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,10 +497,15 @@ private readonly record struct ShellValueProvenanceSet(
497497
Clause Clause,
498498
IReadOnlyList<ShellValueElementProvenance> Provenance);
499499

500+
private readonly record struct RedirectTargetProvenanceSet(
501+
Clause Clause,
502+
IReadOnlyList<RedirectTargetProvenance> Provenance);
503+
500504
private readonly record struct BashParseResult(
501505
ParsedCommand Command,
502506
IReadOnlyList<CwdPathDependencySet> CwdPathDependencySets,
503507
IReadOnlyList<ShellValueProvenanceSet> ValueProvenanceSets,
508+
IReadOnlyList<RedirectTargetProvenanceSet> RedirectTargetProvenanceSets,
504509
IReadOnlyList<BashForInAnalysisPlanReference> ForInPlans);
505510

506511
private static ClauseResult ParseClauseSegment(
@@ -1338,6 +1343,38 @@ private static void BuildRedirect(
13381343
out ShellValue? pathResolverValue)
13391344
{
13401345
pathResolverValue = null;
1346+
if (BashLexer.IsHereStringOperator(redirectOperator.OperatorText))
1347+
{
1348+
var hereStringRaw = SourceSlice(source, target);
1349+
var hereStringValue = BashRedirectAnalysis.NormalizeHereStringOperand(
1350+
GetResolverValue(target, target.Value));
1351+
var (hereStringKind, hereStringResolved, _) = BashResolver.Resolve(
1352+
hereStringValue,
1353+
treatAsPath: false,
1354+
options,
1355+
workingDirectoryUnknown,
1356+
ShellResolutionConsumer.BashRedirect);
1357+
var hereStringIsDynamic = hereStringKind != ArgKind.Literal &&
1358+
hereStringResolved is null;
1359+
redirectList.Add(new Redirect
1360+
{
1361+
Direction = direction,
1362+
Target = hereStringIsDynamic
1363+
? hereStringRaw
1364+
: hereStringResolved ?? target.Value,
1365+
IsDynamicSkip = hereStringIsDynamic,
1366+
});
1367+
element = CreateRedirectElement(
1368+
source,
1369+
redirectOperator,
1370+
target,
1371+
precedingVerbTokenCount,
1372+
hereStringKind,
1373+
isPath: false,
1374+
resolved: hereStringIsDynamic ? null : hereStringResolved);
1375+
return;
1376+
}
1377+
13411378
if (target.Kind == BashTokenKind.OpaqueSubstitution)
13421379
{
13431380
// Opaque region as redirect target → always DynamicSkip.
@@ -1620,7 +1657,7 @@ private static bool TryMapRedirect(string? op, out RedirectDirection direction)
16201657
if (operatorStart > 0)
16211658
{
16221659
var redirect = op.Substring(operatorStart);
1623-
if (redirect == "<")
1660+
if (redirect is "<" or "<<<")
16241661
{
16251662
direction = RedirectDirection.In;
16261663
return true;
@@ -1653,6 +1690,7 @@ private static bool TryMapRedirect(string? op, out RedirectDirection direction)
16531690
direction = RedirectDirection.Append;
16541691
return true;
16551692
case "<":
1693+
case "<<<":
16561694
direction = RedirectDirection.In;
16571695
return true;
16581696
case "2>":

0 commit comments

Comments
 (0)