Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
21 changes: 11 additions & 10 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1500,21 +1500,22 @@ 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

- An unquoted `#` that appears at a **word boundary** starts a comment
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.
Expand Down
7 changes: 6 additions & 1 deletion openspec/changes/v0-3-structured-shell-analysis/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 27 additions & 6 deletions src/ShellSyntaxTree/Internal/Bash/Lexing/BashLexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ internal static IReadOnlyList<BashToken> 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,
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;
Expand Down Expand Up @@ -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<BashToken> tokens)
{
if (tokens.Count == 0)
Expand Down
2 changes: 1 addition & 1 deletion src/ShellSyntaxTree/Internal/Bash/Lexing/BashTokenKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ internal enum BashTokenKind
/// <summary>One of the bash operators recognized in v0.1: <c>&amp;&amp;</c>,
/// <c>||</c>, <c>;</c>, <c>|</c>, <c>&gt;</c>, <c>&gt;&gt;</c>,
/// <c>&lt;</c>, <c>2&gt;</c>, <c>2&gt;&gt;</c>, <c>(</c>, <c>)</c>,
/// <c>&lt;&lt;</c>, <c>&lt;&lt;-</c>. The literal text is in
/// <c>&lt;&lt;</c>, <c>&lt;&lt;-</c>, <c>&lt;&lt;&lt;</c>. The literal text is in
/// <see cref="BashToken.OperatorText"/>.</summary>
Operator,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -1435,6 +1440,8 @@ private IReadOnlyList<Redirect> RewriteCompatibilityRedirects(

private static IReadOnlyList<RedirectAnalysis> RewriteRedirectFacts(
IReadOnlyList<RedirectAnalysis> source,
IReadOnlyList<RedirectTargetProvenance> provenance,
BashLoopBindingContext bindings,
Clause clause)
{
if (source.Count == 0)
Expand All @@ -1446,6 +1453,18 @@ private static IReadOnlyList<RedirectAnalysis> 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)
Expand All @@ -1471,6 +1490,45 @@ private static IReadOnlyList<RedirectAnalysis> RewriteRedirectFacts(
return rewritten;
}

private static ShellValueDomain RewriteHereStringTarget(
RedirectAnalysis fact,
IReadOnlyList<RedirectTargetProvenance> 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<RedirectAnalysis> redirects)
{
foreach (var redirect in redirects)
Expand Down
40 changes: 39 additions & 1 deletion src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -497,10 +497,15 @@ private readonly record struct ShellValueProvenanceSet(
Clause Clause,
IReadOnlyList<ShellValueElementProvenance> Provenance);

private readonly record struct RedirectTargetProvenanceSet(
Clause Clause,
IReadOnlyList<RedirectTargetProvenance> Provenance);

private readonly record struct BashParseResult(
ParsedCommand Command,
IReadOnlyList<CwdPathDependencySet> CwdPathDependencySets,
IReadOnlyList<ShellValueProvenanceSet> ValueProvenanceSets,
IReadOnlyList<RedirectTargetProvenanceSet> RedirectTargetProvenanceSets,
IReadOnlyList<BashForInAnalysisPlanReference> ForInPlans);

private static ClauseResult ParseClauseSegment(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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>":
Expand Down
Loading