diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 2cd25eb..294e4a8 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -91,22 +91,28 @@ bulldoze priorities.
- [x] CorpusRunnerTests skeleton + 50 corpus entries
- [x] `BashParser.Parse` delegates to `BashCommandParser.Parse`
-### 6. Resolver (SPEC §8)
-
-- [ ] Tilde + `$HOME` expansion against `BashParserOptions.HomeDirectory`
-- [ ] All other `$VAR` / `${VAR}` → `DynamicSkip`
-- [ ] `filesystem::/path` prefix stripping
-- [ ] Glob detection (don't expand)
-- [ ] Relative path joining against `BashParserOptions.WorkingDirectory`
-- [ ] `LooksLikePath` heuristic per §8
-
-### 7. Per-verb path-arg rules (SPEC §7)
-
-- [ ] Default: every non-flag positional after the verb chain is a path
-- [ ] Per-verb overrides: `chmod`, `chown`, `chgrp`, `ln`, `find`,
- `grep`, `rg`, `sed`, `awk`, `tar`, `curl`/`wget`, `scp`/`rsync`,
- `cd`-family
-- [ ] Flag-with-value handling (`-o file`, `git -C /repo`, `--output=file`)
+### 6. Resolver (SPEC §8) — PR 4, complete
+
+- [x] Tilde + `$HOME` expansion against `BashParserOptions.HomeDirectory`
+- [x] All other `$VAR` / `${VAR}` → `DynamicSkip` (in path slots) /
+ `EnvVar` (in non-path slots)
+- [x] `filesystem::/path` prefix stripping
+- [x] Glob detection (don't expand); locked interp #3 distinguishes
+ Glob (IsPath=true in path slot) vs DynamicSkip (IsPath=false)
+- [x] Relative path joining against `BashParserOptions.WorkingDirectory`
+- [x] `LooksLikePath` heuristic per §8 with curated extension list
+- [x] SPEC §8 step 4/6 overlap resolved
+
+### 7. Per-verb path-arg rules (SPEC §7) — PR 4, complete
+
+- [x] Default: every non-flag positional after the verb chain is a path
+- [x] Per-verb overrides: `chmod`, `chown`, `chgrp`, `ln`, `find`,
+ `grep`, `rg`, `sed`, `awk`, `tar` (default fallback per #8),
+ `curl`/`wget`, `scp`/`rsync`, `cd`-family
+- [x] Flag-with-value handling (`-o file`, `git -C /repo`,
+ `--output=file`); `git -C /repo log` → Verb=["git", "log"]
+- [x] Flag-value path classification table (`git -C` is path; `curl -d`
+ is body data; `docker -v` is single literal IsPath=false per #8)
### 8. cd-in-compound propagation (SPEC §9)
diff --git a/SPEC.md b/SPEC.md
index 5cf5a7c..bd8b3a0 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -621,23 +621,35 @@ a normalized absolute path. Resolution order:
4. **Glob detection.** Tokens containing `*`, `?`, or `[` are marked
`ArgKind.Glob`. The resolver does **not** expand globs. The token
- stays as-is in `Raw`; `Resolved` is null. Consumers that want the
- glob's "covering directory" can use `Path.GetDirectoryName(Raw)` to
- approximate.
+ stays as-is in `Raw`; `Resolved` is null.
+
+ **In a path-arg slot:** `IsPath = true`. Consumers can apply the
+ "covering directory" heuristic (`Path.GetDirectoryName(Raw)`) to
+ reason about the directory the glob resolves under (e.g.
+ `/tmp/*.bak` → `/tmp`).
+
+ **In a non-path slot:** `IsPath = false`.
+
+ Per locked interpretation #3, glob and DynamicSkip carry **distinct**
+ signals — globs preserve a useful covering-dir hint that DynamicSkip
+ tokens lack.
5. **Relative path resolution.** Tokens not starting with `/` (or `\\` on
- Windows) are joined to `BashParserOptions.WorkingDirectory`. If
- `WorkingDirectory` is null, the token stays relative and `Resolved`
- is null with `Kind = DynamicSkip`.
-
-6. **Dynamic-skip predicates.** A token is `DynamicSkip` when:
- - It contains an unresolved env var reference (other than `$HOME`).
- - It contains glob metachars AND the resolver was asked for an
- absolute resolution.
+ Windows, or a Windows drive letter `X:`) are joined to
+ `BashParserOptions.WorkingDirectory` (lazy fallback to
+ `Environment.CurrentDirectory` when null). On
+ `IOException` / path-format exceptions during resolution, fall through
+ to `Kind = DynamicSkip, IsPath = false, Resolved = null`.
+
+6. **DynamicSkip predicates.** A token is `Kind = DynamicSkip,
+ IsPath = false, Resolved = null` when:
+ - It contains an unresolved env-var reference (other than `$HOME`)
+ in a slot the verb's rule classifies as a path.
- Resolution throws an `IOException` or path-format exception.
- `DynamicSkip` tokens have `Resolved = null`. Consumers must not use
- `Raw` as a literal path.
+ Globs do NOT downgrade to DynamicSkip — they carry their own Kind so
+ consumers can still apply the covering-dir heuristic. Consumers must
+ not use `Raw` as a literal path for `DynamicSkip` tokens.
### Path-shape heuristic
diff --git a/openspec/changes/v0.1-locked-interpretations/tasks.md b/openspec/changes/v0.1-locked-interpretations/tasks.md
index 3c19ab5..318d7e5 100644
--- a/openspec/changes/v0.1-locked-interpretations/tasks.md
+++ b/openspec/changes/v0.1-locked-interpretations/tasks.md
@@ -106,21 +106,58 @@ the SPEC.md sections that get updated alongside the implementation.
## 4. PR 4 — Resolver + per-verb rules (interpretations #3 + #8)
-- [ ] 4.1 `Internal/Resolving/BashResolver.cs` per SPEC §8
-- [ ] 4.2 Tilde + `$HOME`; other env vars → `DynamicSkip, IsPath=false`
-- [ ] 4.3 `filesystem::/path` strip; glob detection per interpretation #3
+- [x] 4.1 `Internal/Resolving/BashResolver.cs` per SPEC §8: full pipeline
+ (filesystem:: strip → tilde expansion → $HOME substitution → other
+ env-var DynamicSkip → glob detection → path resolution against
+ WorkingDirectory). Cross-platform-safe (Linux + Windows).
+- [x] 4.2 Tilde + `$HOME` (the only env var we expand); other env vars
+ in path slot → `DynamicSkip, IsPath=false` per interpretation #3
+- [x] 4.3 `filesystem::/path` strip; glob detection per interpretation #3
(`Kind=Glob, IsPath=true (in path slot), Resolved=null`)
-- [ ] 4.4 Relative-path joining against `WorkingDirectory`; `LooksLikePath`
- heuristic
-- [ ] 4.5 `Internal/Bash/Verbs/BashPerVerbRules.cs` per SPEC §7 with
- interpretation #8 fallback for tar (default rule) and docker -v
- (single literal arg, IsPath=false)
-- [ ] 4.6 Update `SPEC.md` §7 (note v0.1 limitations + reference issues),
- §8 (rewrite steps 4 & 6 to remove the overlap; explicit IsPath
- asymmetry per interpretation #3)
-- [ ] 4.7 File 2 GitHub issues: tar action-flag awareness; docker -v
- colon-split + Windows drive-letter handling
-- [ ] 4.8 Open OpenSpec change `path-resolver-rules` for the §7/§8 deltas
+- [x] 4.4 Relative-path joining against `WorkingDirectory` (lazy fallback
+ to `Environment.CurrentDirectory`); `LooksLikePath` heuristic with
+ curated extension list
+- [x] 4.5 `Internal/Bash/Verbs/BashPerVerbRules.cs` per SPEC §7 + flag-value
+ classification table:
+ - Per-positional rules for `chmod`, `chown`, `chgrp`, `ln`, `find`,
+ `grep`, `rg`, `sed`, `awk`, `tar`, `curl`, `wget`, `scp`/`rsync`,
+ `cd`/`chdir`/`pushd`/`popd`/`push-location`/`set-location`
+ - Default rule (all non-flag positionals → paths) for other FileVerbs
+ - `LooksLikePath` fallback for non-FileVerbs
+ - Flag-with-value path classification: `git -C` is path; `curl -d`
+ is not; `docker -v` value is single literal IsPath=false
+ (interpretation #8)
+- [x] 4.6 `BashCommandParser` updates:
+ - Flag-with-value-aware verb-chain probe so `git -C /repo log`
+ produces `Verb.Tokens=["git", "log"]` per SPEC §12
+ - Resolver wired into Arg + Redirect building
+ - `Redirect.Target = Resolved` when resolvable; `IsDynamicSkip=true`
+ when not
+- [x] 4.7 SPEC.md §7 already updated in PR 3 with FlagsWithValue compat
+ note + PR 4 follow-up. PR 4 SPEC.md updates: §8 step 4/6 overlap
+ resolved (Glob ≠ DynamicSkip distinction). To be applied during
+ commit.
+- [ ] 4.8 File 2 GitHub issues: tar action-flag awareness; docker -v
+ colon-split + Windows drive-letter handling. (Tracked locally;
+ filing in PR 5/6 when the issues are easier to reference real
+ corpus repros.)
+- [x] 4.9 BashResolverTests (34) + BashPerVerbRulesTests (34) +
+ BashCommandParserTests refresh (9 new) + 12 corpus entries
+ refreshed + 20 new corpus entries (10 dynamic-skip + 10 per-verb)
+- [x] 4.10 **296/296 tests passing**; clean build; PublicApiSnapshotTests
+ still green (no API surface change)
+
+### PR 4 follow-ups (tracked for PR 5)
+
+- `Segment.FromSubshell` flag plumbed in PR 4 but unused — PR 5 hooks it
+ into IsSubshell + attribution-stack push/pop.
+- cd-attribution propagation: cleanest approach is constructing new
+ `BashParserOptions{ WorkingDirectory = /target }` for clauses
+ following `cd /target`. PR 5 wires this.
+- Locked interpretation #6 (cd $VAR propagation): when cd target is
+ DynamicSkip, subsequent clauses' relative-path args need to be
+ flagged as such. Mechanism (without adding to public BashParserOptions
+ surface): internal context state piggy-backed on the parsing pipeline.
## 5. PR 5 — cd attribution + subshells + bash -c (interpretations #4, #5, #6)
diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs
index 749ca88..d75584b 100644
--- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs
+++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs
@@ -1,4 +1,4 @@
-// -----------------------------------------------------------------------
+// -----------------------------------------------------------------------
//
// Copyright (C) 2026 - 2026 Aaron Stannard
//
@@ -7,24 +7,17 @@
using System.Collections.Generic;
using ShellSyntaxTree.Internal.Bash.Lexing;
using ShellSyntaxTree.Internal.Bash.Verbs;
+using ShellSyntaxTree.Internal.Resolving;
namespace ShellSyntaxTree.Internal.Bash.Parsing;
///
/// Translates a token stream into the public
-/// AST. The parser is intentionally narrow:
-/// PR 3 wires verb chains, args, redirects, compound splitting, and the
-/// safe-fail anomaly behavior in SPEC §11. It does not apply
-/// per-verb path classification or path resolution (PR 4) and stops short
-/// of the full subshell / bash -c recursion treatment described in
-/// SPEC §10 (PR 5 lands that surface flattening + IsBashCWrapped /
-/// IsSubshell attribution + cd-in-compound propagation).
+/// AST. PR 4 wires per-verb path classification
+/// (SPEC §7), the resolver (SPEC §8), and the flag-with-value-aware verb-
+/// chain probe on top of the PR 3 core. PR 5 will land subshell flag
+/// flipping, bash -c recursion, and cd-in-compound propagation.
///
-///
-/// PR 3 scope: subshell + bash -c framework only — see comments
-/// next to and the segment splitter for
-/// where PR 5 will land real attribution and inner-string flattening.
-///
internal static class BashCommandParser
{
///
@@ -39,10 +32,10 @@ internal static ParsedCommand Parse(string source, BashParserOptions options)
throw new ArgumentNullException(nameof(source));
}
- // options is currently unused by PR 3 — the resolver lands in PR 4
- // and consumes HomeDirectory / WorkingDirectory. Discarding here
- // keeps the call sites stable for the full pipeline.
- _ = options;
+ if (options is null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
if (source.Length == 0)
{
@@ -58,9 +51,9 @@ internal static ParsedCommand Parse(string source, BashParserOptions options)
// Step 1: lift any UnparseableSentinel to the outer ParsedCommand.
// SPEC §11 step 3 says we may also return whatever clauses were
- // parsed up to that point — for PR 3 we keep it strictly safe-fail
- // (empty Clauses) so consumers don't get a half-built AST whose
- // shape changes when PR 5 wires recursion. The reason text comes
+ // parsed up to that point — we keep it strictly safe-fail (empty
+ // Clauses) so consumers don't get a half-built AST whose shape
+ // changes when PR 5 wires recursion. The reason text comes
// straight from the lexer.
for (var i = 0; i < tokens.Count; i++)
{
@@ -78,9 +71,7 @@ internal static ParsedCommand Parse(string source, BashParserOptions options)
}
// Step 2: split the (filtered, non-whitespace) token stream into
- // clause segments at top-level &&, ||, ;, and |. Subshell and
- // bash -c boundaries are recognized as framework only — see
- // SplitIntoSegments.
+ // clause segments at top-level &&, ||, ;, and |.
var significant = FilterSignificant(tokens);
// Step 3: detect anomalies that map straight to outer IsUnparseable.
@@ -107,13 +98,11 @@ internal static ParsedCommand Parse(string source, BashParserOptions options)
};
}
- // Step 4: parse each segment into a Clause. Any per-clause anomaly
- // (control-flow keyword as the verb, function definition shape,
- // process substitution) flips the outer IsUnparseable.
+ // Step 4: parse each segment into a Clause.
var clauses = new List(segments.Count);
foreach (var segment in segments)
{
- var clauseOrError = ParseClauseSegment(segment, source);
+ var clauseOrError = ParseClauseSegment(segment, source, options);
if (clauseOrError.Error is not null)
{
return new ParsedCommand
@@ -125,8 +114,6 @@ internal static ParsedCommand Parse(string source, BashParserOptions options)
};
}
- // ParseClauseSegment produces a list because subshell framework
- // emits one Clause per inner segment; see comments inside.
foreach (var clause in clauseOrError.Clauses)
{
clauses.Add(clause);
@@ -145,9 +132,6 @@ internal static ParsedCommand Parse(string source, BashParserOptions options)
private static List FilterSignificant(IReadOnlyList tokens)
{
- // Whitespace and Continuation are source-fidelity tokens — irrelevant
- // for parser logic. Drop them. The remaining list is what every
- // subsequent step walks.
var filtered = new List(tokens.Count);
foreach (var t in tokens)
{
@@ -167,8 +151,7 @@ private static List FilterSignificant(IReadOnlyList tokens
private static bool TryDetectAnomaly(IReadOnlyList tokens, out string? reason)
{
// Function definition: `name() { ... }`. Trigger = a Word followed
- // by an immediately-adjacent `(` and `)`. SPEC §11 + locked
- // interpretation: outer IsUnparseable.
+ // by an immediately-adjacent `(` and `)`.
for (var i = 0; i + 2 < tokens.Count; i++)
{
var a = tokens[i];
@@ -185,9 +168,7 @@ private static bool TryDetectAnomaly(IReadOnlyList tokens, out string
}
}
- // Process substitution: `<(cmd)` or `>(cmd)`. The lexer doesn't
- // emit a dedicated token for these; they show up as `<` or `>`
- // operators followed *immediately* by `(` (no whitespace between).
+ // Process substitution: `<(cmd)` or `>(cmd)`.
for (var i = 0; i + 1 < tokens.Count; i++)
{
var a = tokens[i];
@@ -208,34 +189,15 @@ private static bool TryDetectAnomaly(IReadOnlyList tokens, out string
// ---------------------------------------------------------------- segment split
- ///
- /// One contiguous piece of significant tokens that becomes a single
- /// . Carries the operator that preceded
- /// the segment in the source, plus a flag for the subshell-framework
- /// pass-through (see ).
- ///
private sealed class Segment
{
public CompoundOperator PrecedingOperator { get; init; }
public List Tokens { get; init; } = new();
- ///
- /// True when this segment came from inside a subshell (...)
- /// region. PR 3 surfaces inner clauses without setting
- /// IsSubshell per the explicit note in the task — that's
- /// PR 5's job. The flag lives on the segment for forward-compat.
- ///
public bool FromSubshell { get; init; }
}
- ///
- /// Walk the significant token list and split on top-level compound
- /// operators. Tracks paren depth so operators inside a subshell stay
- /// part of the inner segments. PR 3: subshell inner clauses surface
- /// inline (no IsSubshell flag); PR 5 will land real attribution +
- /// IsBashCWrapped / IsSubshell flag flipping + bash -c recursion.
- ///
private static List SplitIntoSegments(
IReadOnlyList tokens,
string source,
@@ -245,8 +207,6 @@ private static List SplitIntoSegments(
var current = new Segment { PrecedingOperator = CompoundOperator.None };
var depth = 0;
- // SubshellRegion stack tracks "we entered a subshell" to mark inner
- // segments' FromSubshell. Empty when at top level.
var subshellDepthStack = new Stack();
for (var i = 0; i < tokens.Count; i++)
@@ -259,8 +219,6 @@ private static List SplitIntoSegments(
if (op == "(")
{
- // Open subshell: flush current segment if it has content,
- // then mark the new context as "inside a subshell."
if (current.Tokens.Count > 0)
{
segments.Add(current);
@@ -268,14 +226,11 @@ private static List SplitIntoSegments(
}
else
{
- // Inherit FromSubshell from the new (deeper) context.
current = new Segment { PrecedingOperator = current.PrecedingOperator, FromSubshell = true };
}
subshellDepthStack.Push(depth);
depth++;
- // The opening paren itself is not part of any clause's
- // tokens. Continue.
continue;
}
@@ -293,16 +248,11 @@ private static List SplitIntoSegments(
subshellDepthStack.Pop();
}
- // Flush the inner segment and start a new top-level (or
- // deeper-but-still-subshell) segment.
if (current.Tokens.Count > 0)
{
segments.Add(current);
}
- // After the close paren, the next operator/token decides
- // the next segment's preceding operator. We default to
- // None and let the operator dispatch below overwrite it.
current = new Segment
{
PrecedingOperator = CompoundOperator.None,
@@ -311,17 +261,10 @@ private static List SplitIntoSegments(
continue;
}
- // Compound operators only split when at top level relative to
- // the current "shell" — a subshell is its own scope, but its
- // operators still split the inner segments. Concretely: any
- // depth (including inside subshells) treats &&/||/;/| as a
- // splitter for the segment they live in.
if (op == "&&" || op == "||" || op == ";" || op == "|")
{
if (current.Tokens.Count == 0 && current.PrecedingOperator != CompoundOperator.None)
{
- // Two operators in a row, e.g. `&& &&`. Treat as
- // unparseable.
error = $"unexpected operator '{op}' at position {t.SourceStart}";
return segments;
}
@@ -349,8 +292,6 @@ private static List SplitIntoSegments(
if (depth != 0)
{
- // Find the position of the unmatched '(' for a useful diagnostic.
- // We don't track it precisely; use the source length as a fallback.
error = $"unbalanced parens at position {source.Length}";
return segments;
}
@@ -375,11 +316,6 @@ private static List SplitIntoSegments(
// ---------------------------------------------------------------- clause parse
- ///
- /// Either a list of clauses (success) or an error reason (failure).
- /// Multi-clause results are reserved for the subshell framework — a
- /// single segment can produce one clause for non-subshell input.
- ///
private readonly struct ClauseResult
{
public IReadOnlyList Clauses { get; }
@@ -399,33 +335,82 @@ public ClauseResult(IReadOnlyList clauses, string? error)
public static ClauseResult Fail(string reason) => new(Array.Empty(), reason);
}
- private static ClauseResult ParseClauseSegment(Segment segment, string source)
+ private static ClauseResult ParseClauseSegment(Segment segment, string source, BashParserOptions options)
{
- // Empty segment (e.g. trailing `;`): just drop it. We materialize
- // an empty clause only when the segment carries a preceding
- // operator AND tokens; the splitter already prevents the "operator
- // with no tokens" case via the `unexpected operator` error.
if (segment.Tokens.Count == 0)
{
return ClauseResult.Empty();
}
- // Verb chain extraction. Probe the first 1–3 verb-eligible tokens
- // against BashVerbs.BashArity. PR 3 keeps things simple: only
- // consecutive Word/QuotedString/OpaqueSubstitution tokens at the
- // very start qualify as verb candidates. Redirect operators or any
- // other operator immediately end the verb chain.
+ // ---- Verb-chain extraction with flag-with-value awareness ----
+ //
+ // PR 4 follow-up to the PR 3 probe: a token like `git -C /repo log`
+ // shouldn't truncate the verb chain at `-C`. We greedily consume
+ // any flag-with-value pair owned by the tentative first verb
+ // (`tokens[0]`) before probing arity. The consumed flag + value
+ // pair stays in the segment for arg-extraction; only the verb
+ // probe sees a "compressed" view of the segment.
+ //
+ // Locked interpretation #8 / SPEC §12: `git -C /repo log` →
+ // Verb=["git", "log"], Args=[-C, /repo]. The flag and its value
+ // appear in source order in the Args list, with /repo carrying
+ // IsPath=true via the FlagValueIsPath table.
var verbCandidateValues = new List(3);
var verbCandidateIndices = new List(3);
+ var consumedFlagValueIndices = new HashSet();
+
+ // Look up the would-be verb so we know which flags are
+ // "owned" by it. We only honor the flag-with-value skip when the
+ // first token is a known Word verb — quoted strings and opaque
+ // substitutions don't carry verb identity.
+ string? tentativeVerb = null;
+ if (segment.Tokens.Count > 0
+ && segment.Tokens[0].Kind == BashTokenKind.Word
+ && !IsFlagWord(segment.Tokens[0]))
+ {
+ tentativeVerb = segment.Tokens[0].Value;
+ }
+
+ var hasFlagsTable = tentativeVerb is not null
+ && BashVerbs.FlagsWithValue.TryGetValue(tentativeVerb, out _);
+
for (var i = 0; i < segment.Tokens.Count && verbCandidateValues.Count < 3; i++)
{
var t = segment.Tokens[i];
if (t.Kind == BashTokenKind.Word || t.Kind == BashTokenKind.QuotedString)
{
- // A flag-like word stops verb-chain probing — flags belong
- // to args, never to verbs.
if (IsFlagWord(t))
{
+ // Skip-through case: this is a flag-with-value pair owned
+ // by the tentative verb. Skip both the flag and its
+ // immediate value and keep probing arity. Only Word
+ // tokens qualify as flags (quoted "-x" stays literal).
+ if (hasFlagsTable
+ && tentativeVerb is not null
+ && BashVerbs.FlagsWithValue[tentativeVerb].Contains(StripEqualsValue(t.Value))
+ && i + 1 < segment.Tokens.Count
+ && (segment.Tokens[i + 1].Kind == BashTokenKind.Word
+ || segment.Tokens[i + 1].Kind == BashTokenKind.QuotedString))
+ {
+ // The two-token `-C /repo` form. Equals-form
+ // `--git-dir=/repo` is a single token and never enters
+ // this branch — but the verb-probe still ends at it
+ // (next iteration sees IsFlagWord and breaks below).
+ if (HasInlineEqualsValue(t.Value))
+ {
+ // `--flag=value` — single token. Don't consume
+ // the next, and let the normal arg-extraction
+ // path split on `=`. End the verb-probe here.
+ break;
+ }
+
+ consumedFlagValueIndices.Add(i);
+ consumedFlagValueIndices.Add(i + 1);
+ i++; // skip the value too on the next loop step
+ continue;
+ }
+
+ // Plain flag with no path-value to skip → stops the verb probe.
break;
}
@@ -439,30 +424,33 @@ private static ClauseResult ParseClauseSegment(Segment segment, string source)
if (verbCandidateValues.Count == 0)
{
- // No verb tokens at all. Could be a redirect-only clause
- // (`> /tmp/out` is rare but technically valid bash). Return an
- // empty-verb clause; consumers can detect this with
- // `Verb.Tokens.Count == 0`.
- var redirectsOnly = ExtractRedirectsAndArgs(
- segment.Tokens, 0, source, out var emptyArgs, out var emptyRedirects, out var redirectError);
+ // Redirect-only clause.
+ ExtractRedirectsAndArgs(
+ segment.Tokens,
+ 0,
+ source,
+ options,
+ verb: new VerbChain(),
+ consumedFlagValueIndices: consumedFlagValueIndices,
+ out var emptyArgs,
+ out var emptyRedirects,
+ out var redirectError);
if (redirectError is not null)
{
return ClauseResult.Fail(redirectError);
}
- _ = redirectsOnly;
return ClauseResult.Ok(new Clause
{
Operator = segment.PrecedingOperator,
Verb = new VerbChain(),
Args = emptyArgs,
Redirects = emptyRedirects,
- IsSubshell = false, // PR 5 will set this for FromSubshell segments.
- IsBashCWrapped = false, // PR 5.
+ IsSubshell = false,
+ IsBashCWrapped = false,
});
}
- // Anomaly: control-flow keyword as the leading verb.
var firstVerb = verbCandidateValues[0];
if (BashVerbs.ControlFlowKeywords.Contains(firstVerb))
{
@@ -476,25 +464,33 @@ private static ClauseResult ParseClauseSegment(Segment segment, string source)
arity = 1;
}
- // Build the verb chain.
var verbTokens = new List(arity);
for (var k = 0; k < arity; k++)
{
verbTokens.Add(verbCandidateValues[k]);
}
- // Position in segment.Tokens immediately after the verb chain.
- var argStart = verbCandidateIndices[arity - 1] + 1;
+ var verbChain = new VerbChain { Tokens = verbTokens };
- // Determine the verb name we use to drive flag-with-value lookups.
- // SPEC §7's table is keyed on the *first* token (`git`, `docker`,
- // `tar`, ...). Multi-token verbs share the first-token's table.
- var verbKeyForFlags = verbTokens[0];
+ // The arg-extraction starts immediately after the last verb-chain
+ // *position* in the original segment, so the consumed flag-value
+ // pair (which sits *before* that position when it precedes the
+ // verb-chain extension) still gets emitted as Args in source order.
+ // Concretely: for `git -C /repo log`, the verb-chain positions are
+ // 0 and 3; we walk all of segment.Tokens from position 0 and emit
+ // -C, /repo as args while skipping the verb-position tokens.
+ var argStart = 0;
+ var verbPositions = new HashSet(verbCandidateIndices.GetRange(0, arity));
- var argsAndRedirects = ExtractRedirectsAndArgs(
+ ExtractRedirectsAndArgs(
segment.Tokens,
argStart,
source,
+ options,
+ verb: verbChain,
+ consumedFlagValueIndices: consumedFlagValueIndices,
+ skipIndices: verbPositions,
+ verbKeyForFlagValuePaths: verbTokens[0],
out var args,
out var redirects,
out var argError);
@@ -503,21 +499,14 @@ private static ClauseResult ParseClauseSegment(Segment segment, string source)
return ClauseResult.Fail(argError);
}
- _ = argsAndRedirects;
-
- // Apply flag-with-value pairing. Per SPEC §7 the *value* arg's
- // IsPath classification lands in PR 4; for PR 3 both flag and
- // value remain in Args with default-literal kind.
- args = ApplyFlagsWithValue(verbKeyForFlags, args);
-
var clause = new Clause
{
Operator = segment.PrecedingOperator,
- Verb = new VerbChain { Tokens = verbTokens },
+ Verb = verbChain,
Args = args,
Redirects = redirects,
- IsSubshell = false, // PR 3: framework only; PR 5 sets this for FromSubshell segments.
- IsBashCWrapped = false, // PR 3: framework only; PR 5 lands real bash -c recursion.
+ IsSubshell = false,
+ IsBashCWrapped = false,
};
return ClauseResult.Ok(clause);
@@ -525,8 +514,6 @@ private static ClauseResult ParseClauseSegment(Segment segment, string source)
private static bool IsFlagWord(BashToken token)
{
- // A leading '-' marks a flag. QuotedString tokens are never flags
- // — quoting a leading dash is the user's signal "treat as literal."
if (token.Kind != BashTokenKind.Word)
{
return false;
@@ -535,37 +522,101 @@ private static bool IsFlagWord(BashToken token)
return token.Value.Length > 0 && token.Value[0] == '-';
}
+ ///
+ /// For an equals-form flag like --output=file.txt, return the
+ /// flag portion (--output) so the FlagsWithValue table lookup
+ /// matches. For plain flags returns the input unchanged.
+ ///
+ private static string StripEqualsValue(string flag)
+ {
+ var eq = flag.IndexOf('=');
+ return eq > 0 ? flag.Substring(0, eq) : flag;
+ }
+
+ private static bool HasInlineEqualsValue(string flag) =>
+ flag.IndexOf('=') > 0;
+
// ---------------------------------------------------------------- args + redirects
- private static int ExtractRedirectsAndArgs(
+ ///
+ /// Extract args and redirects from
+ /// starting at . Honors:
+ ///
+ /// - SPEC §7 per-verb path-arg classification via .
+ /// - SPEC §8 path resolution via .
+ /// - The flag-with-value table to decide whether a consumed value is a path.
+ ///
+ ///
+ private static void ExtractRedirectsAndArgs(
+ IReadOnlyList segmentTokens,
+ int start,
+ string source,
+ BashParserOptions options,
+ VerbChain verb,
+ HashSet consumedFlagValueIndices,
+ out IReadOnlyList args,
+ out IReadOnlyList redirects,
+ out string? error)
+ {
+ ExtractRedirectsAndArgs(
+ segmentTokens,
+ start,
+ source,
+ options,
+ verb,
+ consumedFlagValueIndices,
+ skipIndices: null,
+ verbKeyForFlagValuePaths: verb.Tokens is null || verb.Tokens.Count == 0 ? null : verb.Tokens[0],
+ out args,
+ out redirects,
+ out error);
+ }
+
+ private static void ExtractRedirectsAndArgs(
IReadOnlyList segmentTokens,
int start,
string source,
+ BashParserOptions options,
+ VerbChain verb,
+ HashSet consumedFlagValueIndices,
+ HashSet? skipIndices,
+ string? verbKeyForFlagValuePaths,
out IReadOnlyList args,
out IReadOnlyList redirects,
out string? error)
{
var argList = new List();
var redirectList = new List();
+ var positionalIndex = 0;
var i = start;
+
+ // Tracks "next non-flag arg is the value of this flag" — used to
+ // attribute path-classification to the value of a flag-with-value
+ // pair (e.g. `curl -o /tmp/out https://x` → /tmp/out gets IsPath).
+ string? pendingFlagForValue = null;
+
while (i < segmentTokens.Count)
{
+ // Skip verb-chain positions when the caller asked us to (the
+ // flag-with-value-aware verb-chain probe leaves the verb tokens
+ // interleaved with consumed flag-value pairs).
+ if (skipIndices is not null && skipIndices.Contains(i))
+ {
+ i++;
+ continue;
+ }
+
var t = segmentTokens[i];
if (t.Kind == BashTokenKind.Operator)
{
if (TryMapRedirect(t.OperatorText, out var dir))
{
- // Heredoc operators come through as a redirect operator
- // followed by a Word delimiter. PR 3 emits the redirect
- // as Direction=In, Target= with no special flag —
- // sufficient to keep clause boundaries while we ship
- // the rest of the parser.
if (i + 1 >= segmentTokens.Count)
{
error = $"redirect operator '{t.OperatorText}' missing target at position {t.SourceStart}";
args = argList;
redirects = redirectList;
- return i;
+ return;
}
var target = segmentTokens[i + 1];
@@ -574,29 +625,14 @@ private static int ExtractRedirectsAndArgs(
error = $"redirect operator '{t.OperatorText}' missing target at position {t.SourceStart}";
args = argList;
redirects = redirectList;
- return i;
+ return;
}
- var isDynamic = target.Kind == BashTokenKind.OpaqueSubstitution;
- var redirectTarget = target.Kind == BashTokenKind.OpaqueSubstitution
- ? target.Value
- : SourceSlice(source, target);
-
- redirectList.Add(new Redirect
- {
- Direction = dir,
- Target = redirectTarget,
- IsDynamicSkip = isDynamic,
- });
-
+ BuildRedirect(dir, target, source, options, redirectList);
i += 2;
continue;
}
- // Heredoc operators show up as `<<` / `<<-` from the lexer.
- // PR 3 treats them like the In redirect for the purpose of
- // pinning a placeholder; the body is already dropped by the
- // lexer so the next token is the delimiter Word.
if (t.OperatorText == "<<" || t.OperatorText == "<<-")
{
if (i + 1 >= segmentTokens.Count)
@@ -604,7 +640,7 @@ private static int ExtractRedirectsAndArgs(
error = $"heredoc operator '{t.OperatorText}' missing delimiter at position {t.SourceStart}";
args = argList;
redirects = redirectList;
- return i;
+ return;
}
var delim = segmentTokens[i + 1];
@@ -619,24 +655,32 @@ private static int ExtractRedirectsAndArgs(
continue;
}
- // Any other operator inside a clause segment is unexpected.
error = $"unexpected operator '{t.OperatorText}' at position {t.SourceStart}";
args = argList;
redirects = redirectList;
- return i;
+ return;
}
- // Args.
+ // Tokens pre-consumed by the verb-chain probe as part of a
+ // flag-with-value pair still pass through this loop and surface
+ // as args in source order. The pending-flag state machine
+ // attributes their path classification correctly without
+ // requiring a special branch here.
+ _ = consumedFlagValueIndices;
+
switch (t.Kind)
{
case BashTokenKind.Word:
{
- // Equals-form flag-with-value: `--output=file` splits on
- // the first `=`. Both halves enter Args. The path-shape
- // classification lives in PR 4.
- var raw = SourceSlice(source, t);
+ var sourceRaw = SourceSlice(source, t);
+
+ // Equals-form flag-with-value: `--output=file.txt`. The
+ // flag half is a Literal arg with IsFlag=true (Raw
+ // starts with '-'); the value half is classified per
+ // the flag-value path rule.
if (TrySplitEqualsFlag(t.Value, out var flagPart, out var valuePart))
{
+ // Flag arg.
argList.Add(new Arg
{
Raw = flagPart,
@@ -644,45 +688,128 @@ private static int ExtractRedirectsAndArgs(
Kind = ArgKind.Literal,
IsPath = false,
});
+
+ // Value arg — classify via FlagValueIsPath if the
+ // verb owns the flag, otherwise fall back to plain
+ // literal (the equals-form is its own visible split,
+ // so we don't apply LooksLikePath here).
+ var valueIsPath = verbKeyForFlagValuePaths is not null
+ && BashPerVerbRules.ValueOfFlagIsPath(verbKeyForFlagValuePaths, flagPart);
+ var (vKind, vResolved, vIsPath) = BashResolver.Resolve(valuePart, valueIsPath, options);
argList.Add(new Arg
{
Raw = valuePart,
- Resolved = null,
- Kind = ArgKind.Literal,
- IsPath = false,
+ Resolved = vResolved,
+ Kind = vKind,
+ IsPath = vIsPath,
});
+
+ // The split form doesn't propagate to a "next-arg is
+ // the value" pending-state — the value already
+ // landed in argList.
+ break;
}
- else
+
+ if (IsFlag(sourceRaw))
{
+ // Plain flag arg. Don't bump positionalIndex.
argList.Add(new Arg
{
- Raw = raw,
+ Raw = sourceRaw,
Resolved = null,
Kind = ArgKind.Literal,
IsPath = false,
});
+
+ // If this flag takes a value (per the verb's table),
+ // mark the *next* non-flag arg as that value. We
+ // do this whether or not the verb-chain probe
+ // pre-consumed it; pre-consumed pairs are also
+ // routed through this branch, so the pending state
+ // attributes correctly.
+ if (verbKeyForFlagValuePaths is not null
+ && BashVerbs.FlagsWithValue.TryGetValue(verbKeyForFlagValuePaths, out var flagsTable)
+ && flagsTable.Contains(sourceRaw))
+ {
+ pendingFlagForValue = sourceRaw;
+ }
+ else
+ {
+ pendingFlagForValue = null;
+ }
+
+ break;
+ }
+
+ // Non-flag positional. Classify path / resolve.
+ bool treatAsPath;
+ if (pendingFlagForValue is not null && verbKeyForFlagValuePaths is not null)
+ {
+ // This is the value of a preceding flag — use the
+ // flag-value rule, NOT the positional-index rule.
+ treatAsPath = BashPerVerbRules.ValueOfFlagIsPath(
+ verbKeyForFlagValuePaths, pendingFlagForValue);
+ pendingFlagForValue = null;
+ }
+ else
+ {
+ treatAsPath = BashPerVerbRules.IsPositionalPathArg(verb, positionalIndex, t.Value);
+ positionalIndex++;
}
+ var (kind, resolved, isPath) = BashResolver.Resolve(t.Value, treatAsPath, options);
+ argList.Add(new Arg
+ {
+ Raw = sourceRaw,
+ Resolved = resolved,
+ Kind = kind,
+ IsPath = isPath,
+ });
+
break;
}
case BashTokenKind.QuotedString:
{
+ var sourceRaw = SourceSlice(source, t);
+
+ // Quoted strings never act as flags (a leading dash in
+ // a quoted string is the user's signal "literal"). They
+ // still classify as positional path / non-path through
+ // the per-verb rule + resolver.
+ bool treatAsPath;
+ if (pendingFlagForValue is not null && verbKeyForFlagValuePaths is not null)
+ {
+ treatAsPath = BashPerVerbRules.ValueOfFlagIsPath(
+ verbKeyForFlagValuePaths, pendingFlagForValue);
+ pendingFlagForValue = null;
+ }
+ else
+ {
+ treatAsPath = BashPerVerbRules.IsPositionalPathArg(verb, positionalIndex, t.Value);
+ positionalIndex++;
+ }
+
+ var (kind, resolved, isPath) = BashResolver.Resolve(t.Value, treatAsPath, options);
argList.Add(new Arg
{
- Raw = SourceSlice(source, t),
- Resolved = null,
- Kind = ArgKind.Literal,
- IsPath = false,
+ Raw = sourceRaw,
+ Resolved = resolved,
+ Kind = kind,
+ IsPath = isPath,
});
break;
}
case BashTokenKind.OpaqueSubstitution:
{
- // Locked interpretation #2: opaque region collapses to
- // a single DynamicSkip arg; the surrounding clause
- // continues to parse normally.
+ // Locked interpretation #2 — opaque region collapses to
+ // a single DynamicSkip arg. Don't bump positionalIndex
+ // — the opaque region replaces what would otherwise be
+ // one positional and the IsPath signal doesn't apply.
+ // Bump the positional counter for the SPEC §12 rm
+ // example so a *subsequent* positional gets the right
+ // index, though.
argList.Add(new Arg
{
Raw = t.Value,
@@ -690,14 +817,12 @@ private static int ExtractRedirectsAndArgs(
Kind = ArgKind.DynamicSkip,
IsPath = false,
});
+ positionalIndex++;
+ pendingFlagForValue = null;
break;
}
default:
- // UnparseableSentinel is filtered earlier; Whitespace /
- // Continuation are filtered in FilterSignificant. Any
- // other kind would be a parser bug, but stay quiet —
- // dropping unknown kinds is safer than crashing.
break;
}
@@ -707,9 +832,60 @@ private static int ExtractRedirectsAndArgs(
args = argList;
redirects = redirectList;
error = null;
- return i;
}
+ private static void BuildRedirect(
+ RedirectDirection direction,
+ BashToken target,
+ string source,
+ BashParserOptions options,
+ List redirectList)
+ {
+ if (target.Kind == BashTokenKind.OpaqueSubstitution)
+ {
+ // Opaque region as redirect target → always DynamicSkip.
+ // Target carries the raw opaque slice for diagnostics.
+ redirectList.Add(new Redirect
+ {
+ Direction = direction,
+ Target = target.Value,
+ IsDynamicSkip = true,
+ });
+ return;
+ }
+
+ var raw = SourceSlice(source, target);
+
+ // Redirect targets are always treated as paths. SPEC §8 +
+ // locked interpretation #3: a glob target stays IsPath=true with
+ // Kind=Glob; an env-var target becomes DynamicSkip; a literal
+ // resolves against WorkingDirectory.
+ var (kind, resolved, _) = BashResolver.Resolve(target.Value, treatAsPath: true, options);
+
+ bool isDynamic;
+ string redirectTarget;
+ if (kind == ArgKind.DynamicSkip)
+ {
+ isDynamic = true;
+ redirectTarget = raw;
+ }
+ else
+ {
+ isDynamic = false;
+ redirectTarget = resolved ?? raw;
+ }
+
+ redirectList.Add(new Redirect
+ {
+ Direction = direction,
+ Target = redirectTarget,
+ IsDynamicSkip = isDynamic,
+ });
+ }
+
+ private static bool IsFlag(string raw) =>
+ raw.Length > 0 && raw[0] == '-';
+
private static bool TryMapRedirect(string? op, out RedirectDirection direction)
{
switch (op)
@@ -737,9 +913,6 @@ private static bool TryMapRedirect(string? op, out RedirectDirection direction)
private static bool TrySplitEqualsFlag(string raw, out string flagPart, out string valuePart)
{
- // Only split when the leading character is '-' (so `KEY=value`
- // stays a single arg, but `--output=file` becomes two). The split
- // happens at the *first* '=' to preserve values that contain '='.
if (raw.Length < 2 || raw[0] != '-')
{
flagPart = "";
@@ -750,7 +923,6 @@ private static bool TrySplitEqualsFlag(string raw, out string flagPart, out stri
var eq = raw.IndexOf('=');
if (eq <= 0 || eq == raw.Length - 1)
{
- // No '=' or trailing '=' (no value to split off).
flagPart = "";
valuePart = "";
return false;
@@ -761,24 +933,6 @@ private static bool TrySplitEqualsFlag(string raw, out string flagPart, out stri
return true;
}
- private static IReadOnlyList ApplyFlagsWithValue(string verbKey, IReadOnlyList args)
- {
- if (!BashVerbs.FlagsWithValue.TryGetValue(verbKey, out var flagsWithValue))
- {
- return args;
- }
-
- // PR 3 doesn't change Arg shape based on the pairing — both flag
- // and value remain in Args with default-literal kind. The pairing
- // matters in PR 4 for IsPath classification. We still walk the
- // list so the structural shape stays identical with what PR 4 will
- // produce; the assignment is currently a no-op but locks the loop
- // in place.
- var unused = flagsWithValue;
- _ = unused;
- return args;
- }
-
private static string SourceSlice(string source, BashToken token)
{
if (token.SourceStart < 0 || token.SourceStart >= source.Length)
diff --git a/src/ShellSyntaxTree/Internal/Bash/Verbs/BashPerVerbRules.cs b/src/ShellSyntaxTree/Internal/Bash/Verbs/BashPerVerbRules.cs
new file mode 100644
index 0000000..e912c38
--- /dev/null
+++ b/src/ShellSyntaxTree/Internal/Bash/Verbs/BashPerVerbRules.cs
@@ -0,0 +1,202 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using System;
+using System.Collections.Generic;
+using ShellSyntaxTree.Internal.Resolving;
+
+namespace ShellSyntaxTree.Internal.Bash.Verbs;
+
+///
+/// Per-verb path-arg classification rules from SPEC §7. The parser asks
+/// this class "is the i-th non-flag positional of verb a path?" and
+/// gets a boolean back. The rules are a layered fall-through:
+/// per-verb override → FileVerb default ("all positionals are paths") →
+/// heuristic for non-FileVerb verbs.
+///
+///
+/// This file also owns the per-flag value-classification table referenced
+/// in SPEC §7's flag-with-value section — e.g. git -C /repo says
+/// "/repo is a path", curl -d body says "body is *not* a path".
+///
+internal static class BashPerVerbRules
+{
+ ///
+ /// Per-verb override delegate. Returns true when the i-th
+ /// (0-based) non-flag positional arg of is a path.
+ ///
+ private delegate bool PerVerbRule(int positionalIndex);
+
+ ///
+ /// Override table keyed by the first token of the verb chain. The
+ /// FileVerb default ("all non-flag positionals are paths") covers
+ /// everything not listed here.
+ ///
+ private static readonly IReadOnlyDictionary Overrides =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ // chmod/chown/chgrp: first positional is the mode/user/group,
+ // remainder are paths.
+ ["chmod"] = i => i >= 1,
+ ["chown"] = i => i >= 1,
+ ["chgrp"] = i => i >= 1,
+
+ // ln: all positionals are paths (source then target — both
+ // file-system locations).
+ ["ln"] = _ => true,
+
+ // find: i=0 is the search root path; i>=1 are predicate args
+ // (-name, -type values, action specs). Marking predicate args
+ // as paths would create false positives on the consumer side.
+ ["find"] = i => i == 0,
+
+ // grep / rg / sed / awk: first positional is the
+ // pattern/script/program, rest are paths.
+ ["grep"] = i => i >= 1,
+ ["rg"] = i => i >= 1,
+ ["sed"] = i => i >= 1,
+ ["awk"] = i => i >= 1,
+
+ // curl / wget: first positional is a URL (not a path). The
+ // file-path comes from a flag-with-value (-o / -O), handled
+ // separately by ValueOfFlagIsPath.
+ ["curl"] = _ => false,
+ ["wget"] = _ => false,
+
+ // scp / rsync / sftp: all positionals are paths. Some are
+ // remote (user@host:/path); we still mark them IsPath=true with
+ // the resolver deciding Kind (Literal if it parses, DynamicSkip
+ // otherwise).
+ ["scp"] = _ => true,
+ ["rsync"] = _ => true,
+ ["sftp"] = _ => true,
+ };
+
+ ///
+ /// Decide whether the i-th non-flag positional arg (0-based, counted
+ /// after the verb chain) of should be
+ /// classified as a path.
+ ///
+ /// Verb chain; only the first token drives the rule.
+ /// 0-based positional index among non-flag args.
+ /// The token text itself (used for the LooksLikePath fallback).
+ /// True when this slot is a path; false otherwise.
+ internal static bool IsPositionalPathArg(VerbChain verb, int positionalIndex, string token)
+ {
+ if (verb is null || verb.Tokens is null || verb.Tokens.Count == 0)
+ {
+ // No verb (redirect-only clause). Fall back to the path-shape
+ // heuristic on the token itself.
+ return BashResolver.LooksLikePath(token ?? "");
+ }
+
+ var firstVerb = verb.Tokens[0];
+
+ // Explicit override first (chmod / find / curl / ...).
+ if (Overrides.TryGetValue(firstVerb, out var rule))
+ {
+ return rule(positionalIndex);
+ }
+
+ // FileVerb default: every non-flag positional is a path. This
+ // covers cd / ls / cat / rm / cp / mv / mkdir / tar (per locked
+ // interpretation #8) / etc.
+ if (BashVerbs.FileVerbs.Contains(firstVerb))
+ {
+ return true;
+ }
+
+ // Non-FileVerb verb: fall back to the SPEC §8 LooksLikePath
+ // heuristic on the token. Keeps `cmd /etc/foo` recognizing the path
+ // even when the verb is unknown to our tables.
+ return BashResolver.LooksLikePath(token ?? "");
+ }
+
+ ///
+ /// Per-verb table of flag-value path classification. For a flag in
+ /// , the consumed value is a path
+ /// only when this table says so. Verbs/flags not listed get the
+ /// "value is not a path" default, consistent with the safety bias
+ /// (locked interpretation #8).
+ ///
+ private static readonly IReadOnlyDictionary<(string Verb, string Flag), bool>
+ FlagValueIsPath = new Dictionary<(string Verb, string Flag), bool>(FlagKeyComparer.Instance)
+ {
+ // git: -C / --git-dir / --work-tree all consume directory paths.
+ [("git", "-C")] = true,
+ [("git", "--git-dir")] = true,
+ [("git", "--work-tree")] = true,
+
+ // curl: -o / --output is a file path; -d / --data is body text.
+ [("curl", "-o")] = true,
+ [("curl", "--output")] = true,
+ [("curl", "-d")] = false,
+ [("curl", "--data")] = false,
+
+ // wget: -O / --output-document is the saved file path.
+ [("wget", "-O")] = true,
+ [("wget", "--output-document")] = true,
+
+ // docker: -f / --file is the Dockerfile path. -v / --volume is
+ // a colon-joined host:container literal per locked interpretation #8.
+ [("docker", "-f")] = true,
+ [("docker", "--file")] = true,
+ [("docker", "-v")] = false,
+ [("docker", "--volume")] = false,
+
+ // tar: -f / --file is the archive path; -C / --directory is a
+ // directory path.
+ [("tar", "-f")] = true,
+ [("tar", "--file")] = true,
+ [("tar", "-C")] = true,
+ [("tar", "--directory")] = true,
+ };
+
+ ///
+ /// Whether the value following for
+ /// should be classified as a path.
+ ///
+ internal static bool ValueOfFlagIsPath(string verb, string flag)
+ {
+ if (string.IsNullOrEmpty(verb) || string.IsNullOrEmpty(flag))
+ {
+ return false;
+ }
+
+ return FlagValueIsPath.TryGetValue((verb, flag), out var isPath) && isPath;
+ }
+
+ // ---------------------------------------------------------------- key comparer
+
+ ///
+ /// Case-insensitive equality for the (verb, flag) tuple keys. Avoids
+ /// allocating a wrapper record while still matching the per-verb table
+ /// case-insensitivity contract.
+ ///
+ private sealed class FlagKeyComparer : IEqualityComparer<(string Verb, string Flag)>
+ {
+ internal static readonly FlagKeyComparer Instance = new();
+
+ public bool Equals((string Verb, string Flag) x, (string Verb, string Flag) y) =>
+ string.Equals(x.Verb, y.Verb, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(x.Flag, y.Flag, StringComparison.OrdinalIgnoreCase);
+
+ public int GetHashCode((string Verb, string Flag) obj)
+ {
+ // Hash combination via ordinal-ignore-case on each component.
+ // Avoid HashCode.Combine for netstandard2.0 parity.
+ unchecked
+ {
+ var h1 = obj.Verb is null
+ ? 0
+ : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Verb);
+ var h2 = obj.Flag is null
+ ? 0
+ : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Flag);
+ return (h1 * 397) ^ h2;
+ }
+ }
+ }
+}
diff --git a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs
new file mode 100644
index 0000000..b5edca2
--- /dev/null
+++ b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs
@@ -0,0 +1,621 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using System;
+using System.IO;
+using System.Text;
+
+namespace ShellSyntaxTree.Internal.Resolving;
+
+///
+/// Path-token resolver for the bash parser. Implements SPEC §8 — tilde
+/// expansion, the lone $HOME expansion, filesystem:: prefix
+/// stripping, glob detection, env-var detection (DynamicSkip), and
+/// absolute-path normalization against
+/// .
+///
+///
+///
+/// The resolver is intentionally split from BashPerVerbRules — the
+/// per-verb rule decides whether the slot is a path; the resolver
+/// decides what value to attribute to it. Callers pass the
+/// already-decided treatAsPath bit and get back a tuple the parser
+/// can fold straight into an .
+///
+///
+/// Failure modes bias toward DynamicSkip per the SPEC §8 step 6 safety
+/// rule: when in doubt, surface "unknown value" rather than a misclassified
+/// literal.
+///
+///
+internal static class BashResolver
+{
+ ///
+ /// Curated set of file-extension suffixes that mark a token as
+ /// path-shaped even without a slash. SPEC §8 LooksLikePath heuristic.
+ /// Lowercase; check via .
+ ///
+ private static readonly string[] PathExtensions =
+ {
+ // Single-segment extensions (curated set; SPEC §8).
+ ".json", ".md", ".txt", ".conf", ".yml", ".yaml", ".toml",
+ ".xml", ".ini", ".log",
+ ".sh", ".py", ".rb", ".js", ".ts", ".cs", ".go", ".rs",
+ ".java", ".html", ".css",
+
+ // Multi-segment archive suffixes (handled via lowercased EndsWith).
+ ".tar.gz", ".tar.bz2", ".tar.xz", ".tgz", ".zip",
+ };
+
+ ///
+ /// Classify and (where possible) resolve a token in a path-arg slot.
+ ///
+ ///
+ /// Verbatim token slice with outer quote delimiters already stripped by
+ /// the caller. Multi-byte values are passed through unchanged.
+ ///
+ ///
+ /// True when the per-verb rule (or caller-side discovery) classifies this
+ /// slot as a path. Drives the difference between Glob-with-IsPath and
+ /// EnvVar/Literal-without-IsPath.
+ ///
+ /// Parser options for HomeDirectory + WorkingDirectory.
+ ///
+ /// A (Kind, Resolved, IsPath) tuple the caller drops directly into
+ /// , , and
+ /// .
+ ///
+ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
+ string raw, bool treatAsPath, BashParserOptions options)
+ {
+ if (raw is null)
+ {
+ // Defensive — public surface guarantees Arg.Raw is non-null.
+ // Treat as a literal empty token.
+ return (ArgKind.Literal, null, false);
+ }
+
+ // Step 1: filesystem::/path prefix stripping. Some agent tools emit
+ // `filesystem::/path/to/file` (e.g. MCP filesystem servers). Strip
+ // the prefix and continue with the remainder. We *do not* set a
+ // separate Kind for this — it's purely a normalization step.
+ var working = raw;
+ if (working.StartsWith("filesystem::", StringComparison.Ordinal))
+ {
+ working = working.Substring("filesystem::".Length);
+ }
+
+ // Step 2: tilde expansion. `~` alone, `~/path`, and `~user/path` all
+ // begin with '~'. The tilde itself must be the leading char (bash
+ // semantics): `foo~/bar` is *not* a tilde expansion.
+ var startsWithTilde = working.Length > 0 && working[0] == '~';
+ var hadTilde = false;
+
+ if (startsWithTilde)
+ {
+ // ~user (other-user expansion). Two flavors: `~bob` and `~bob/x`.
+ // v0.1 doesn't support these.
+ if (working.Length > 1 && working[1] != '/' && working[1] != '\\')
+ {
+ // `~bob` or `~bob/...` — unsupported. SPEC §8 step 1.
+ if (treatAsPath)
+ {
+ return (ArgKind.DynamicSkip, null, false);
+ }
+
+ return (ArgKind.Tilde, null, false);
+ }
+
+ // `~` alone or `~/path` — expand.
+ var home = GetHomeDirectory(options);
+ if (working.Length == 1)
+ {
+ working = home;
+ }
+ else
+ {
+ // Drop the leading `~` and join the rest (which starts with
+ // '/' or '\') to home.
+ var rest = working.Substring(2); // skip "~/" or "~\\"
+ working = JoinPath(home, rest);
+ }
+
+ hadTilde = true;
+ }
+
+ // Step 3: $HOME / ${HOME} substitution — the *only* env var we
+ // expand. Replace literal occurrences anywhere in the token. We
+ // expand first so the resulting working string carries the literal
+ // home path; subsequent env-var detection sees no $HOME because
+ // it's gone. Note: SPEC §8 step 2 says $HOME is the only exception.
+ working = SubstituteHome(working, options, out var hadHome);
+ if (hadHome)
+ {
+ hadTilde = true; // Reuse the "expanded a home-ish token" branch — same Kind=Tilde semantics.
+ }
+
+ // Step 4: other env-var detection ($VAR or ${VAR} that isn't HOME).
+ // Locked interpretation #3 / SPEC §12 `rm $UNRESOLVED/foo` example.
+ if (ContainsEnvVarReference(working))
+ {
+ if (treatAsPath)
+ {
+ // Path slot: cannot safely resolve — DynamicSkip with no path
+ // signal so the consumer doesn't iterate it.
+ return (ArgKind.DynamicSkip, null, false);
+ }
+
+ // Non-path slot: EnvVar Kind, IsPath=false. The token carries
+ // information the consumer may want to surface, just not a path.
+ return (ArgKind.EnvVar, null, false);
+ }
+
+ // Step 5: glob detection. SPEC §8 step 4: tokens containing '*',
+ // '?', or '[' get Kind=Glob. The covering-directory heuristic from
+ // locked interpretation #3 puts IsPath=true in a path slot so the
+ // consumer keeps the signal; non-path slots stay IsPath=false.
+ if (ContainsGlobMetacharacters(working))
+ {
+ return (ArgKind.Glob, null, treatAsPath);
+ }
+
+ // Step 6 + 7: literal path resolution (if treatAsPath), else literal.
+ if (!treatAsPath)
+ {
+ // Non-path slot: literal token, no resolution. If we expanded a
+ // tilde the Kind is Tilde (so consumers can detect the expansion
+ // happened); otherwise plain Literal. Both with IsPath=false.
+ return (hadTilde ? ArgKind.Tilde : ArgKind.Literal, null, false);
+ }
+
+ // Path slot: try to normalize to an absolute path.
+ var resolved = TryResolveAbsolutePath(working, options);
+ if (resolved is null)
+ {
+ // Resolution failed (IOException / ArgumentException / format) —
+ // SPEC §8 step 6 says emit DynamicSkip rather than guess.
+ return (ArgKind.DynamicSkip, null, false);
+ }
+
+ return (hadTilde ? ArgKind.Tilde : ArgKind.Literal, resolved, true);
+ }
+
+ ///
+ /// SPEC §8 LooksLikePath heuristic. Used to fall back when no per-verb
+ /// rule applies. Conservative — when a token "looks like a path" we run
+ /// it through the resolver; when it doesn't, we leave it as a plain
+ /// Literal.
+ ///
+ internal static bool LooksLikePath(string token)
+ {
+ if (token is null || token.Length == 0)
+ {
+ return false;
+ }
+
+ // Unix absolute or root.
+ if (token[0] == '/')
+ {
+ return true;
+ }
+
+ // Windows UNC (\\server\share) or rooted path with backslash.
+ if (token[0] == '\\')
+ {
+ return true;
+ }
+
+ // Windows drive letter (X:\foo or X:foo).
+ if (token.Length >= 2 && IsAsciiLetter(token[0]) && token[1] == ':')
+ {
+ return true;
+ }
+
+ // Unix-relative shorthands.
+ if (token.StartsWith("./", StringComparison.Ordinal)
+ || token.StartsWith("../", StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ // Tilde — bash home reference.
+ if (token[0] == '~')
+ {
+ return true;
+ }
+
+ // Any directory separator.
+ if (token.IndexOf('/') >= 0 || token.IndexOf('\\') >= 0)
+ {
+ return true;
+ }
+
+ // File-extension suffix match (case-insensitive).
+ var lower = token.ToLowerInvariant();
+ foreach (var ext in PathExtensions)
+ {
+ if (lower.EndsWith(ext, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // ---------------------------------------------------------------- helpers
+
+ private static string GetHomeDirectory(BashParserOptions options)
+ {
+ if (!string.IsNullOrEmpty(options.HomeDirectory))
+ {
+ return options.HomeDirectory!;
+ }
+
+ // Lazy fallback. SPEC §2 / §8: defaults to UserProfile. May be the
+ // empty string in pathological environments — callers tolerate that
+ // because JoinPath / Path.GetFullPath fall back accordingly.
+ return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ }
+
+ private static string GetWorkingDirectory(BashParserOptions options)
+ {
+ if (!string.IsNullOrEmpty(options.WorkingDirectory))
+ {
+ return options.WorkingDirectory!;
+ }
+
+ return Environment.CurrentDirectory;
+ }
+
+ ///
+ /// Replace literal $HOME and ${HOME} occurrences in
+ /// with the configured home directory. Sets
+ /// when at least one replacement occurred.
+ ///
+ private static string SubstituteHome(string input, BashParserOptions options, out bool hadHome)
+ {
+ hadHome = false;
+ if (input.Length == 0 || input.IndexOf('$') < 0)
+ {
+ return input;
+ }
+
+ string? home = null;
+ var sb = new StringBuilder(input.Length);
+ var i = 0;
+ while (i < input.Length)
+ {
+ var c = input[i];
+ if (c == '$' && i + 1 < input.Length)
+ {
+ // ${HOME}
+ if (input[i + 1] == '{')
+ {
+ var close = input.IndexOf('}', i + 2);
+ if (close > 0)
+ {
+ var name = input.Substring(i + 2, close - (i + 2));
+ if (string.Equals(name, "HOME", StringComparison.Ordinal))
+ {
+ home ??= GetHomeDirectory(options);
+ sb.Append(home);
+ hadHome = true;
+ i = close + 1;
+ continue;
+ }
+ }
+ }
+ else if (TryReadIdentifier(input, i + 1, out var end, out var name))
+ {
+ if (string.Equals(name, "HOME", StringComparison.Ordinal))
+ {
+ home ??= GetHomeDirectory(options);
+ sb.Append(home);
+ hadHome = true;
+ i = end;
+ continue;
+ }
+ }
+ }
+
+ sb.Append(c);
+ i++;
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Detect any remaining $VAR or ${VAR} reference. Caller
+ /// should have already substituted $HOME; any survivor here is
+ /// an unresolved-and-not-HOME env var.
+ ///
+ private static bool ContainsEnvVarReference(string input)
+ {
+ if (input.Length == 0)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < input.Length; i++)
+ {
+ var c = input[i];
+ if (c != '$' || i + 1 >= input.Length)
+ {
+ continue;
+ }
+
+ var next = input[i + 1];
+ if (next == '{')
+ {
+ // ${...} — any non-empty body counts. (Empty ${} is a bash
+ // error, but we don't validate that here.)
+ if (i + 2 < input.Length && input[i + 2] != '}')
+ {
+ return true;
+ }
+ }
+ else if (IsIdentifierStart(next))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool ContainsGlobMetacharacters(string input)
+ {
+ for (var i = 0; i < input.Length; i++)
+ {
+ var c = input[i];
+ if (c == '*' || c == '?' || c == '[')
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryReadIdentifier(string input, int start, out int end, out string name)
+ {
+ if (start >= input.Length || !IsIdentifierStart(input[start]))
+ {
+ end = start;
+ name = "";
+ return false;
+ }
+
+ var j = start;
+ while (j < input.Length && IsIdentifierContinuation(input[j]))
+ {
+ j++;
+ }
+
+ end = j;
+ name = input.Substring(start, j - start);
+ return true;
+ }
+
+ private static bool IsIdentifierStart(char c) =>
+ c == '_' || IsAsciiLetter(c);
+
+ private static bool IsIdentifierContinuation(char c) =>
+ c == '_' || IsAsciiLetter(c) || (c >= '0' && c <= '9');
+
+ private static bool IsAsciiLetter(char c) =>
+ (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
+
+ ///
+ /// Combine a base directory with a relative or rooted sub-path using
+ /// bash semantics — forward slashes everywhere, regardless of host OS.
+ /// Strips a single leading separator from the sub-path so the combine
+ /// doesn't treat the sub-path as rooted.
+ ///
+ private static string JoinPath(string baseDir, string sub)
+ {
+ if (string.IsNullOrEmpty(sub))
+ {
+ return baseDir;
+ }
+
+ // Sub-paths may be rooted (e.g. `~/rest` produces `/rest` after
+ // tilde expansion) — strip exactly one leading separator before
+ // combining so we don't lose baseDir.
+ var s = sub;
+ if (s.Length > 0 && (s[0] == '/' || s[0] == '\\'))
+ {
+ s = s.Substring(1);
+ }
+
+ // Always forward-slash, always bash semantics.
+ return baseDir.TrimEnd('/', '\\') + "/" + s.Replace('\\', '/');
+ }
+
+ ///
+ /// Resolve to an absolute path against the
+ /// supplied options. Returns null on resolution failure (SPEC §8 step 6).
+ /// Always produces bash-style (forward-slash, no drive letter)
+ /// absolute paths regardless of host OS — `Path.GetFullPath` is
+ /// platform-aware and would produce `D:\foo` for `/foo` on Windows,
+ /// which is wrong for our bash-parsing semantics.
+ ///
+ private static string? TryResolveAbsolutePath(string token, BashParserOptions options)
+ {
+ if (string.IsNullOrEmpty(token))
+ {
+ return null;
+ }
+
+ try
+ {
+ string combined;
+ if (IsRootedPath(token))
+ {
+ combined = NormalizeToForwardSlashes(token);
+ }
+ else
+ {
+ var wd = GetWorkingDirectory(options);
+ if (string.IsNullOrEmpty(wd))
+ {
+ // No working directory available — surface as DynamicSkip
+ // rather than guess.
+ return null;
+ }
+ combined = JoinPath(wd, token);
+ }
+
+ return NormalizePath(combined);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (NotSupportedException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Normalize backslashes to forward slashes; preserve bash semantics
+ /// for `\\server\share` UNC paths by collapsing the leading `\\` to a
+ /// single `//`. (UNC paths are rare in bash but the heuristic preserves
+ /// them in a recognizable form for consumers.)
+ ///
+ private static string NormalizeToForwardSlashes(string token)
+ {
+ if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\')
+ {
+ // UNC: \\server\share -> //server/share
+ return "//" + token.Substring(2).Replace('\\', '/');
+ }
+ return token.Replace('\\', '/');
+ }
+
+ ///
+ /// Bash-style path normalization: collapse `.`/`..` segments, deduplicate
+ /// adjacent slashes, preserve a leading `/` (or `//` for UNC), use
+ /// forward slashes throughout. Operates string-only — no filesystem I/O.
+ ///
+ private static string NormalizePath(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ return path;
+ }
+
+ var normalized = path.Replace('\\', '/');
+
+ // Detect leading "//" (UNC-like) vs single "/" vs Windows drive
+ // letter prefix (e.g. "C:/foo" — bash semantics still treat the
+ // drive prefix as opaque, but we keep it).
+ string prefix;
+ string rest;
+ if (normalized.Length >= 2 && normalized[0] == '/' && normalized[1] == '/')
+ {
+ prefix = "//";
+ rest = normalized.Substring(2);
+ }
+ else if (normalized.Length > 0 && normalized[0] == '/')
+ {
+ prefix = "/";
+ rest = normalized.Substring(1);
+ }
+ else if (normalized.Length >= 2 && IsAsciiLetter(normalized[0]) && normalized[1] == ':')
+ {
+ // Drive-letter prefix; keep as-is for non-bash-shaped inputs.
+ prefix = normalized.Substring(0, 2);
+ if (normalized.Length > 2 && normalized[2] == '/')
+ {
+ prefix += "/";
+ rest = normalized.Substring(3);
+ }
+ else
+ {
+ rest = normalized.Substring(2);
+ }
+ }
+ else
+ {
+ prefix = string.Empty;
+ rest = normalized;
+ }
+
+ var segments = rest.Split('/');
+ var stack = new System.Collections.Generic.List();
+ foreach (var segment in segments)
+ {
+ if (segment.Length == 0 || segment == ".")
+ {
+ continue;
+ }
+ if (segment == "..")
+ {
+ if (stack.Count > 0 && stack[stack.Count - 1] != "..")
+ {
+ stack.RemoveAt(stack.Count - 1);
+ }
+ else if (string.IsNullOrEmpty(prefix))
+ {
+ // Relative path with leading `..`: keep it.
+ stack.Add("..");
+ }
+ // Absolute path with leading `..`: silently drop (matches
+ // bash and POSIX `cd /; cd ..` -> `/`).
+ continue;
+ }
+ stack.Add(segment);
+ }
+
+ var joined = string.Join("/", stack);
+ if (prefix.Length == 0)
+ {
+ return joined.Length == 0 ? "." : joined;
+ }
+ return prefix + joined;
+ }
+
+ ///
+ /// Check whether a path is "rooted" — absolute Unix, Windows UNC, or
+ /// Windows drive-letter — without invoking Path.IsPathRooted,
+ /// which is platform-aware and would, e.g., treat /foo as
+ /// non-rooted on Windows for the purposes of bash path semantics.
+ ///
+ private static bool IsRootedPath(string token)
+ {
+ if (token.Length == 0)
+ {
+ return false;
+ }
+
+ // Unix absolute.
+ if (token[0] == '/')
+ {
+ return true;
+ }
+
+ // Windows UNC.
+ if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\')
+ {
+ return true;
+ }
+
+ // Windows drive letter X: (with or without trailing separator).
+ if (token.Length >= 2 && IsAsciiLetter(token[0]) && token[1] == ':')
+ {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs
index 355eda8..c598719 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs
+++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs
@@ -30,7 +30,15 @@ public void Corpus_entry_parses_to_expected_ast(string fileName, CorpusEntry ent
Assert.False(string.IsNullOrEmpty(entry.Name), $"Corpus entry {fileName} has no name.");
Assert.NotNull(entry.Expected);
- var parser = new BashParser();
+ // Pin HomeDirectory and WorkingDirectory so corpus entries with
+ // relative-path resolution have stable expected values across
+ // hosts (Linux CI, Windows CI, dev machines). The values mirror
+ // the BashCommandParserTests harness.
+ var parser = new BashParser(new BashParserOptions
+ {
+ HomeDirectory = "/home/test",
+ WorkingDirectory = "/work",
+ });
var actual = parser.Parse(entry.Input);
AssertParsedCommandEqual(entry.Expected!, actual, fileName);
@@ -168,6 +176,21 @@ private static void AssertArgEqual(ExpectedArg expected, Arg actual, string diff
{
Assert.True(expected.IsFlag.Value == actual.IsFlag, diffPrefix + $"IsFlag mismatch. expected={expected.IsFlag}, actual={actual.IsFlag}");
}
+
+ // Resolved comparison: opt-in via the corpus author. Use the
+ // sentinel "__NULL__" to assert that Resolved is null; omit the
+ // field entirely (default null) to skip the check.
+ if (expected.Resolved is not null)
+ {
+ if (expected.Resolved == "__NULL__")
+ {
+ Assert.True(actual.Resolved is null, diffPrefix + $"Resolved expected null, actual='{actual.Resolved}'");
+ }
+ else
+ {
+ Assert.True(expected.Resolved == actual.Resolved, diffPrefix + $"Resolved mismatch. expected='{expected.Resolved}', actual='{actual.Resolved}'");
+ }
+ }
}
private static void AssertRedirectEqual(ExpectedRedirect expected, Redirect actual, string diffPrefix)
@@ -244,6 +267,14 @@ public sealed record ExpectedArg
public bool IsPath { get; init; }
public bool? IsFlag { get; init; }
+
+ ///
+ /// Expected value. Omit (leave null) to skip
+ /// the comparison; provide explicitly (including empty string) to pin
+ /// a literal value. The corpus author may use the special sentinel
+ /// "__NULL__" to assert that Resolved is null.
+ ///
+ public string? Resolved { get; init; }
}
public sealed record ExpectedRedirect
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/02_ls_la_tmp.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/02_ls_la_tmp.json
index 91a3055..4ab33e6 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/02_ls_la_tmp.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/02_ls_la_tmp.json
@@ -9,7 +9,7 @@
"verb": ["ls"],
"args": [
{ "raw": "-la", "kind": "Literal", "isPath": false, "isFlag": true },
- { "raw": "/tmp", "kind": "Literal", "isPath": false, "isFlag": false }
+ { "raw": "/tmp", "kind": "Literal", "isPath": true, "isFlag": false, "resolved": "/tmp" }
],
"redirects": [],
"isSubshell": false,
@@ -17,5 +17,5 @@
}
]
},
- "notes": "PR 3 keeps isPath=false; path classification arrives in PR 4."
+ "notes": "PR 4: /tmp is an absolute path arg → IsPath=true, Resolved=/tmp."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/05_cat_etc_hostname.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/05_cat_etc_hostname.json
index 81b65bb..45407d0 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/05_cat_etc_hostname.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/05_cat_etc_hostname.json
@@ -8,7 +8,7 @@
"operator": "None",
"verb": ["cat"],
"args": [
- { "raw": "/etc/hostname", "kind": "Literal", "isPath": false }
+ { "raw": "/etc/hostname", "kind": "Literal", "isPath": true, "resolved": "/etc/hostname" }
],
"redirects": [],
"isSubshell": false,
@@ -16,5 +16,5 @@
}
]
},
- "notes": "isPath=false in PR 3; PR 4 will mark as path."
+ "notes": "PR 4: /etc/hostname is an absolute path arg for `cat` (FileVerb)."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/06_mkdir_tmp_foo.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/06_mkdir_tmp_foo.json
index 87321c2..45c8cba 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/06_mkdir_tmp_foo.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/06_mkdir_tmp_foo.json
@@ -8,7 +8,7 @@
"operator": "None",
"verb": ["mkdir"],
"args": [
- { "raw": "/tmp/foo", "kind": "Literal", "isPath": false }
+ { "raw": "/tmp/foo", "kind": "Literal", "isPath": true, "resolved": "/tmp/foo" }
],
"redirects": [],
"isSubshell": false,
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/07_rm_tmp_foo.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/07_rm_tmp_foo.json
index 2797396..7999262 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/07_rm_tmp_foo.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/07_rm_tmp_foo.json
@@ -8,7 +8,7 @@
"operator": "None",
"verb": ["rm"],
"args": [
- { "raw": "/tmp/foo", "kind": "Literal", "isPath": false }
+ { "raw": "/tmp/foo", "kind": "Literal", "isPath": true, "resolved": "/tmp/foo" }
],
"redirects": [],
"isSubshell": false,
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/08_touch_file.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/08_touch_file.json
index eef095a..b70b59f 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/08_touch_file.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/08_touch_file.json
@@ -8,12 +8,13 @@
"operator": "None",
"verb": ["touch"],
"args": [
- { "raw": "file", "kind": "Literal", "isPath": false }
+ { "raw": "file", "kind": "Literal", "isPath": true, "resolved": "/work/file" }
],
"redirects": [],
"isSubshell": false,
"isBashCWrapped": false
}
]
- }
+ },
+ "notes": "PR 4: touch is a FileVerb; relative path `file` resolves against the test WorkingDirectory /work."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/25_cd_then_ls.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/25_cd_then_ls.json
index 1e5c611..15d6dad 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/25_cd_then_ls.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/25_cd_then_ls.json
@@ -8,7 +8,7 @@
"operator": "None",
"verb": ["cd"],
"args": [
- { "raw": "/tmp", "kind": "Literal", "isPath": false }
+ { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp" }
],
"redirects": [],
"isSubshell": false,
@@ -24,5 +24,5 @@
}
]
},
- "notes": "PR 3: no cd-attribution arg. PR 5 will append the synthetic /tmp arg to clause 1."
+ "notes": "PR 4: cd target resolves. PR 5 will append the synthetic /tmp arg to clause 1."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/28_cd_status_push.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/28_cd_status_push.json
index 7fed9a6..c00a2f3 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/28_cd_status_push.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/28_cd_status_push.json
@@ -8,7 +8,7 @@
"operator": "None",
"verb": ["cd"],
"args": [
- { "raw": "/repo", "kind": "Literal", "isPath": false }
+ { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo" }
],
"redirects": [],
"isSubshell": false,
@@ -32,5 +32,5 @@
}
]
},
- "notes": "PR 3: no cd-attribution. PR 5 will append /repo to subsequent clauses."
+ "notes": "PR 4: cd target resolves. PR 5 will append /repo to subsequent clauses."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/34_cd_then_rm.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/34_cd_then_rm.json
index 8b6b552..8b4e79a 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/34_cd_then_rm.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/34_cd_then_rm.json
@@ -8,7 +8,7 @@
"operator": "None",
"verb": ["cd"],
"args": [
- { "raw": "/tmp", "kind": "Literal", "isPath": false }
+ { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp" }
],
"redirects": [],
"isSubshell": false,
@@ -18,7 +18,7 @@
"operator": "AndIf",
"verb": ["rm"],
"args": [
- { "raw": "temp.log", "kind": "Literal", "isPath": false }
+ { "raw": "temp.log", "kind": "Literal", "isPath": true, "resolved": "/work/temp.log" }
],
"redirects": [],
"isSubshell": false,
@@ -26,5 +26,5 @@
}
]
},
- "notes": "Glob handling lives in PR 4; uses literal filename here."
+ "notes": "PR 4: temp.log is a relative path arg for rm (FileVerb) resolving against the test cwd /work. PR 5 will swap that to /tmp via cd-attribution."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/35_cd_pipe_grep.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/35_cd_pipe_grep.json
index ddf3aa6..a88f4b6 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/35_cd_pipe_grep.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/35_cd_pipe_grep.json
@@ -8,7 +8,7 @@
"operator": "None",
"verb": ["cd"],
"args": [
- { "raw": "/repo", "kind": "Literal", "isPath": false }
+ { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo" }
],
"redirects": [],
"isSubshell": false,
@@ -33,5 +33,6 @@
"isBashCWrapped": false
}
]
- }
+ },
+ "notes": "PR 4: cd target /repo resolves; grep i=0 is the pattern (IsPath=false)."
}
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 dbc4937..a89f0ee 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
@@ -9,12 +9,13 @@
"verb": ["cmd"],
"args": [],
"redirects": [
- { "direction": "Out", "target": "out" },
- { "direction": "ErrOut", "target": "err" }
+ { "direction": "Out", "target": "/work/out" },
+ { "direction": "ErrOut", "target": "/work/err" }
],
"isSubshell": false,
"isBashCWrapped": false
}
]
- }
+ },
+ "notes": "PR 4: redirect targets resolve against WorkingDirectory (/work in tests)."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/44_in_and_out_redirects.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/44_in_and_out_redirects.json
index 5481d51..ffd3d9c 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/44_in_and_out_redirects.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/44_in_and_out_redirects.json
@@ -9,12 +9,13 @@
"verb": ["cat"],
"args": [],
"redirects": [
- { "direction": "In", "target": "input.txt" },
- { "direction": "Out", "target": "output.txt" }
+ { "direction": "In", "target": "/work/input.txt" },
+ { "direction": "Out", "target": "/work/output.txt" }
],
"isSubshell": false,
"isBashCWrapped": false
}
]
- }
+ },
+ "notes": "PR 4: redirect targets resolve against WorkingDirectory (/work in tests)."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/45_echo_append_log.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/45_echo_append_log.json
index a516fe2..f114505 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/bash/45_echo_append_log.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/45_echo_append_log.json
@@ -11,11 +11,12 @@
{ "raw": "hi", "kind": "Literal", "isPath": false }
],
"redirects": [
- { "direction": "Append", "target": "log.txt" }
+ { "direction": "Append", "target": "/work/log.txt" }
],
"isSubshell": false,
"isBashCWrapped": false
}
]
- }
+ },
+ "notes": "PR 4: redirect target resolves; `echo` is not a FileVerb so `hi` stays IsPath=false."
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/51_dynamic_skip_unresolved_var.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/51_dynamic_skip_unresolved_var.json
new file mode 100644
index 0000000..38e0ec2
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/51_dynamic_skip_unresolved_var.json
@@ -0,0 +1,20 @@
+{
+ "name": "DynamicSkip: rm $UNRESOLVED/foo (SPEC §12 example)",
+ "input": "rm $UNRESOLVED/foo",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["rm"],
+ "args": [
+ { "raw": "$UNRESOLVED/foo", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §12 dynamic-skip worked example. Locked interpretation #3 — env var in path slot → DynamicSkip with IsPath=false."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/52_dynamic_skip_cd_var.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/52_dynamic_skip_cd_var.json
new file mode 100644
index 0000000..4df68b7
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/52_dynamic_skip_cd_var.json
@@ -0,0 +1,28 @@
+{
+ "name": "DynamicSkip: cd $REPO && cmd",
+ "input": "cd $REPO && cmd",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["cd"],
+ "args": [
+ { "raw": "$REPO", "kind": "DynamicSkip", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ },
+ {
+ "operator": "AndIf",
+ "verb": ["cmd"],
+ "args": [],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "cd target is an unresolved env var → DynamicSkip. PR 5 will append the matching attribution arg."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/53_dynamic_skip_cat_log_file.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/53_dynamic_skip_cat_log_file.json
new file mode 100644
index 0000000..e2452ce
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/53_dynamic_skip_cat_log_file.json
@@ -0,0 +1,20 @@
+{
+ "name": "DynamicSkip: cat $LOG_FILE",
+ "input": "cat $LOG_FILE",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["cat"],
+ "args": [
+ { "raw": "$LOG_FILE", "kind": "DynamicSkip", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "Bare env var in a path slot → DynamicSkip per locked interpretation #3."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/54_home_expands_in_path_slot.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/54_home_expands_in_path_slot.json
new file mode 100644
index 0000000..e0edd3c
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/54_home_expands_in_path_slot.json
@@ -0,0 +1,20 @@
+{
+ "name": "$HOME expansion: ls $HOME/Downloads",
+ "input": "ls $HOME/Downloads",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["ls"],
+ "args": [
+ { "raw": "$HOME/Downloads", "kind": "Tilde", "isPath": true, "resolved": "/home/test/Downloads" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "$HOME is the ONE env var that expands (SPEC §8 step 2). Kind=Tilde reflects the tilde-equivalent semantics."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/55_dynamic_skip_cp_src_dst.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/55_dynamic_skip_cp_src_dst.json
new file mode 100644
index 0000000..d8b9b23
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/55_dynamic_skip_cp_src_dst.json
@@ -0,0 +1,21 @@
+{
+ "name": "DynamicSkip: cp $SRC $DST",
+ "input": "cp $SRC $DST",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["cp"],
+ "args": [
+ { "raw": "$SRC", "kind": "DynamicSkip", "isPath": false },
+ { "raw": "$DST", "kind": "DynamicSkip", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "Both cp args are env vars in path slots → DynamicSkip with IsPath=false."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/56_dynamic_skip_mkdir_dir_sub.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/56_dynamic_skip_mkdir_dir_sub.json
new file mode 100644
index 0000000..0b93548
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/56_dynamic_skip_mkdir_dir_sub.json
@@ -0,0 +1,20 @@
+{
+ "name": "DynamicSkip: mkdir $DIR/sub",
+ "input": "mkdir $DIR/sub",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["mkdir"],
+ "args": [
+ { "raw": "$DIR/sub", "kind": "DynamicSkip", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "Env var prefix in a path arg → DynamicSkip; the literal /sub suffix doesn't make it resolvable."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/57_dynamic_skip_mv_a_b.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/57_dynamic_skip_mv_a_b.json
new file mode 100644
index 0000000..00a0b90
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/57_dynamic_skip_mv_a_b.json
@@ -0,0 +1,21 @@
+{
+ "name": "DynamicSkip: mv $A $B",
+ "input": "mv $A $B",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["mv"],
+ "args": [
+ { "raw": "$A", "kind": "DynamicSkip", "isPath": false },
+ { "raw": "$B", "kind": "DynamicSkip", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "Single-letter env vars trigger DynamicSkip just like multi-letter ones."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/58_dynamic_skip_brace_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/58_dynamic_skip_brace_path.json
new file mode 100644
index 0000000..a7d8165
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/58_dynamic_skip_brace_path.json
@@ -0,0 +1,20 @@
+{
+ "name": "DynamicSkip: cmd ${PATH}/bin/foo",
+ "input": "cmd ${PATH}/bin/foo",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["cmd"],
+ "args": [
+ { "raw": "${PATH}/bin/foo", "kind": "DynamicSkip", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "Brace form ${PATH} is recognized the same as $PATH. `cmd` falls back to LooksLikePath which sees the / and treats as path-shaped → DynamicSkip."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/59_chmod_mode_then_var.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/59_chmod_mode_then_var.json
new file mode 100644
index 0000000..7d52542
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/59_chmod_mode_then_var.json
@@ -0,0 +1,21 @@
+{
+ "name": "chmod with mode then $TARGET",
+ "input": "chmod 755 $TARGET",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["chmod"],
+ "args": [
+ { "raw": "755", "kind": "Literal", "isPath": false },
+ { "raw": "$TARGET", "kind": "DynamicSkip", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "chmod i=0 is mode (IsPath=false); i=1 is the path slot — env var → DynamicSkip."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/60_glob_in_path_slot.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/60_glob_in_path_slot.json
new file mode 100644
index 0000000..9798ef1
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/60_glob_in_path_slot.json
@@ -0,0 +1,20 @@
+{
+ "name": "Glob in path slot: rm /tmp/*.bak",
+ "input": "rm /tmp/*.bak",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["rm"],
+ "args": [
+ { "raw": "/tmp/*.bak", "kind": "Glob", "isPath": true, "resolved": "__NULL__" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "Locked interpretation #3: glob in path slot stays IsPath=true so the consumer can apply the covering-directory heuristic (Path.GetDirectoryName)."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/61_chmod_mode_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/61_chmod_mode_path.json
new file mode 100644
index 0000000..75c1dc9
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/61_chmod_mode_path.json
@@ -0,0 +1,21 @@
+{
+ "name": "chmod: mode then path",
+ "input": "chmod 755 /etc/passwd",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["chmod"],
+ "args": [
+ { "raw": "755", "kind": "Literal", "isPath": false },
+ { "raw": "/etc/passwd", "kind": "Literal", "isPath": true, "resolved": "/etc/passwd" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §7: chmod i=0 is mode, i>=1 are paths."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/62_chown_user_group_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/62_chown_user_group_path.json
new file mode 100644
index 0000000..c35e209
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/62_chown_user_group_path.json
@@ -0,0 +1,21 @@
+{
+ "name": "chown: user:group then path",
+ "input": "chown user:group /var/log",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["chown"],
+ "args": [
+ { "raw": "user:group", "kind": "Literal", "isPath": false },
+ { "raw": "/var/log", "kind": "Literal", "isPath": true, "resolved": "/var/log" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §7: chown i=0 is user[:group], i>=1 are paths."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/63_find_root_with_predicate.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/63_find_root_with_predicate.json
new file mode 100644
index 0000000..0814b91
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/63_find_root_with_predicate.json
@@ -0,0 +1,22 @@
+{
+ "name": "find: root path then predicate",
+ "input": "find /var/log -name \"*.log\"",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["find"],
+ "args": [
+ { "raw": "/var/log", "kind": "Literal", "isPath": true, "resolved": "/var/log" },
+ { "raw": "-name", "kind": "Literal", "isPath": false, "isFlag": true },
+ { "raw": "\"*.log\"", "kind": "Glob", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §7: find i=0 is the path root (IsPath=true); subsequent args are predicate args (IsPath=false). The quoted glob inside `-name` still gets Kind=Glob via the resolver but with IsPath=false because find's predicate slot is not a path."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/64_grep_pattern_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/64_grep_pattern_path.json
new file mode 100644
index 0000000..76ad910
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/64_grep_pattern_path.json
@@ -0,0 +1,21 @@
+{
+ "name": "grep: pattern then path",
+ "input": "grep pattern /etc/hosts",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["grep"],
+ "args": [
+ { "raw": "pattern", "kind": "Literal", "isPath": false },
+ { "raw": "/etc/hosts", "kind": "Literal", "isPath": true, "resolved": "/etc/hosts" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §7: grep i=0 is pattern, i>=1 are paths."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/65_sed_script_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/65_sed_script_path.json
new file mode 100644
index 0000000..bc634c9
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/65_sed_script_path.json
@@ -0,0 +1,21 @@
+{
+ "name": "sed: script then path",
+ "input": "sed 's/foo/bar/' file.txt",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["sed"],
+ "args": [
+ { "raw": "'s/foo/bar/'", "kind": "Literal", "isPath": false },
+ { "raw": "file.txt", "kind": "Literal", "isPath": true, "resolved": "/work/file.txt" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §7: sed i=0 is the script (single-quoted), i>=1 are paths."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/66_awk_program_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/66_awk_program_path.json
new file mode 100644
index 0000000..87b803a
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/66_awk_program_path.json
@@ -0,0 +1,21 @@
+{
+ "name": "awk: program then path",
+ "input": "awk '{print $1}' input.txt",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["awk"],
+ "args": [
+ { "raw": "'{print $1}'", "kind": "Literal", "isPath": false },
+ { "raw": "input.txt", "kind": "Literal", "isPath": true, "resolved": "/work/input.txt" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §7: awk i=0 is the program (single-quoted), i>=1 are paths."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/67_curl_output_path_then_url.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/67_curl_output_path_then_url.json
new file mode 100644
index 0000000..40f6115
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/67_curl_output_path_then_url.json
@@ -0,0 +1,22 @@
+{
+ "name": "curl: -o /tmp/out https://example.com",
+ "input": "curl -o /tmp/out https://example.com",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["curl"],
+ "args": [
+ { "raw": "-o", "kind": "Literal", "isPath": false, "isFlag": true },
+ { "raw": "/tmp/out", "kind": "Literal", "isPath": true, "resolved": "/tmp/out" },
+ { "raw": "https://example.com", "kind": "Literal", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §7 flag-with-value: -o consumes a path value (/tmp/out is the saved file). The URL positional is IsPath=false per the curl override."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/68_wget_output_document.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/68_wget_output_document.json
new file mode 100644
index 0000000..93e81cf
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/68_wget_output_document.json
@@ -0,0 +1,22 @@
+{
+ "name": "wget: -O /tmp/file https://example.com",
+ "input": "wget -O /tmp/file https://example.com",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["wget"],
+ "args": [
+ { "raw": "-O", "kind": "Literal", "isPath": false, "isFlag": true },
+ { "raw": "/tmp/file", "kind": "Literal", "isPath": true, "resolved": "/tmp/file" },
+ { "raw": "https://example.com", "kind": "Literal", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §7: wget -O is the saved file path; URL positional is IsPath=false."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/69_git_dash_C_repo_log.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/69_git_dash_C_repo_log.json
new file mode 100644
index 0000000..974dc52
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/69_git_dash_C_repo_log.json
@@ -0,0 +1,21 @@
+{
+ "name": "git -C /repo log (SPEC §12 example)",
+ "input": "git -C /repo log",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["git", "log"],
+ "args": [
+ { "raw": "-C", "kind": "Literal", "isPath": false, "isFlag": true },
+ { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "SPEC §12 worked example. PR 4 flag-with-value-aware verb-chain probe: `-C /repo` is consumed before the arity probe, so the chain captures both `git` and `log`."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/70_tar_extract_archive_target.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/70_tar_extract_archive_target.json
new file mode 100644
index 0000000..1f1d7d1
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/70_tar_extract_archive_target.json
@@ -0,0 +1,22 @@
+{
+ "name": "tar: -xf archive.tar.gz /target",
+ "input": "tar -xf archive.tar.gz /target",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["tar"],
+ "args": [
+ { "raw": "-xf", "kind": "Literal", "isPath": false, "isFlag": true },
+ { "raw": "archive.tar.gz", "kind": "Literal", "isPath": true, "resolved": "/work/archive.tar.gz" },
+ { "raw": "/target", "kind": "Literal", "isPath": true, "resolved": "/target" }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "Locked interpretation #8: tar default rule treats all non-flag positionals as paths. -xf is *not* in the FlagsWithValue table (only -f / --file are), so positional classification handles archive.tar.gz."
+}
diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs
index 7760462..98fa61d 100644
--- a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs
+++ b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs
@@ -9,17 +9,28 @@
namespace ShellSyntaxTree.Tests.Parsing;
///
-/// Unit tests for the PR 3 BashCommandParser core. These focus on the
-/// shape that the parser produces — verb chains, args, redirects,
-/// compound splitting, and SPEC §11 anomaly safe-fail. Path classification
-/// (PR 4) and cd-attribution / subshell-flagging (PR 5) are intentionally
-/// out of scope for this test file.
+/// Unit tests for the BashCommandParser core. PR 3 wired verb chains,
+/// args, redirects, compound splitting, and SPEC §11 anomaly safe-fail;
+/// PR 4 layers per-verb path classification, the resolver, and the
+/// flag-with-value-aware verb-chain probe on top. cd-attribution and
+/// subshell-flagging arrive in PR 5.
///
public class BashCommandParserTests
{
+ ///
+ /// Default-Parse helper that pins a fixed WorkingDirectory so resolved
+ /// paths in the test assertions are stable across host environments.
+ /// (The default falls back to
+ /// Environment.CurrentDirectory, which the test runner picks
+ /// up as the test binary's working dir.)
+ ///
private static ParsedCommand Parse(string input)
{
- var parser = new BashParser();
+ var parser = new BashParser(new BashParserOptions
+ {
+ HomeDirectory = "/home/test",
+ WorkingDirectory = "/work",
+ });
return parser.Parse(input);
}
@@ -249,11 +260,13 @@ public void Redirect_Out()
[Fact]
public void Redirect_Append()
{
+ // PR 4: relative redirect target resolves against WorkingDirectory.
var result = Parse("cmd >> log");
var clause = Assert.Single(result.Clauses);
var redirect = Assert.Single(clause.Redirects);
Assert.Equal(RedirectDirection.Append, redirect.Direction);
- Assert.Equal("log", redirect.Target);
+ Assert.Equal("/work/log", redirect.Target);
+ Assert.False(redirect.IsDynamicSkip);
}
[Fact]
@@ -263,7 +276,7 @@ public void Redirect_In()
var clause = Assert.Single(result.Clauses);
var redirect = Assert.Single(clause.Redirects);
Assert.Equal(RedirectDirection.In, redirect.Direction);
- Assert.Equal("input", redirect.Target);
+ Assert.Equal("/work/input", redirect.Target);
}
[Fact]
@@ -273,7 +286,7 @@ public void Redirect_ErrOut()
var clause = Assert.Single(result.Clauses);
var redirect = Assert.Single(clause.Redirects);
Assert.Equal(RedirectDirection.ErrOut, redirect.Direction);
- Assert.Equal("err", redirect.Target);
+ Assert.Equal("/work/err", redirect.Target);
}
[Fact]
@@ -283,7 +296,7 @@ public void Redirect_ErrAppend()
var clause = Assert.Single(result.Clauses);
var redirect = Assert.Single(clause.Redirects);
Assert.Equal(RedirectDirection.ErrAppend, redirect.Direction);
- Assert.Equal("err", redirect.Target);
+ Assert.Equal("/work/err", redirect.Target);
}
[Fact]
@@ -293,9 +306,9 @@ public void Multiple_redirects_on_one_clause()
var clause = Assert.Single(result.Clauses);
Assert.Equal(2, clause.Redirects.Count);
Assert.Equal(RedirectDirection.Out, clause.Redirects[0].Direction);
- Assert.Equal("out", clause.Redirects[0].Target);
+ Assert.Equal("/work/out", clause.Redirects[0].Target);
Assert.Equal(RedirectDirection.ErrOut, clause.Redirects[1].Direction);
- Assert.Equal("err", clause.Redirects[1].Target);
+ Assert.Equal("/work/err", clause.Redirects[1].Target);
}
[Fact]
@@ -568,23 +581,133 @@ public void Trailing_semicolon_does_not_create_empty_clause()
Assert.Equal(new[] { "ls" }, result.Clauses[0].Verb.Tokens);
}
- // ---------------- Git -C flag-with-value retained as args ----------------
+ // ---------------- Git -C flag-with-value verb-chain probe ----------------
[Fact]
- public void Git_dash_C_keeps_flag_and_value_as_separate_args()
+ public void Git_dash_C_yields_two_token_verb_chain_per_spec_12_example()
{
- // PR 3: pairing exists but does not change Arg shape; PR 4 will
- // mark the value as IsPath=true.
+ // PR 4 + locked interpretation #8 + SPEC §12 worked example:
+ // `git -C /repo log` skips the `-C /repo` flag-with-value pair while
+ // probing arity, so the verb chain captures both `git` and `log`.
+ // The flag and value still surface in Args in source order — and
+ // /repo carries IsPath=true via the FlagValueIsPath table.
var result = Parse("git -C /repo log");
var clause = Assert.Single(result.Clauses);
- Assert.Equal(new[] { "git", "-C" }, clause.Verb.Tokens.Take(1).Concat(new[] { clause.Args[0].Raw }).ToArray());
- // git's verb chain probe: first token "git", second token "-C" — but
- // -C is a flag-shaped token and stops verb-chain probing. So verb
- // chain is just ["git"], and -C / /repo / log are all args.
- Assert.Equal(new[] { "git" }, clause.Verb.Tokens);
- Assert.Equal(3, clause.Args.Count);
+ Assert.Equal(new[] { "git", "log" }, clause.Verb.Tokens);
+ Assert.Equal(2, clause.Args.Count);
Assert.Equal("-C", clause.Args[0].Raw);
+ Assert.True(clause.Args[0].IsFlag);
Assert.Equal("/repo", clause.Args[1].Raw);
- Assert.Equal("log", clause.Args[2].Raw);
+ Assert.True(clause.Args[1].IsPath);
+ Assert.Equal("/repo", clause.Args[1].Resolved);
+ Assert.Equal(ArgKind.Literal, clause.Args[1].Kind);
+ }
+
+ // ---------------- Path classification + resolution ----------------
+
+ [Fact]
+ public void Absolute_path_arg_resolves_to_itself()
+ {
+ var result = Parse("cat /etc/hostname");
+ var clause = Assert.Single(result.Clauses);
+ var arg = Assert.Single(clause.Args);
+ Assert.True(arg.IsPath);
+ Assert.Equal("/etc/hostname", arg.Resolved);
+ Assert.Equal(ArgKind.Literal, arg.Kind);
+ }
+
+ [Fact]
+ public void Tilde_path_arg_expands_to_home()
+ {
+ var result = Parse("cat ~/file.txt");
+ var clause = Assert.Single(result.Clauses);
+ var arg = Assert.Single(clause.Args);
+ Assert.True(arg.IsPath);
+ Assert.Equal("/home/test/file.txt", arg.Resolved);
+ Assert.Equal(ArgKind.Tilde, arg.Kind);
+ }
+
+ [Fact]
+ public void Env_var_in_path_slot_becomes_dynamic_skip()
+ {
+ // SPEC §12 example: `rm $UNRESOLVED/foo`.
+ var result = Parse("rm $UNRESOLVED/foo");
+ var clause = Assert.Single(result.Clauses);
+ var arg = Assert.Single(clause.Args);
+ Assert.Equal(ArgKind.DynamicSkip, arg.Kind);
+ Assert.False(arg.IsPath);
+ Assert.Null(arg.Resolved);
+ }
+
+ [Fact]
+ public void Glob_in_path_slot_is_glob_kind_is_path_true()
+ {
+ // Locked interpretation #3: covering-directory signal preserved.
+ var result = Parse("rm /tmp/*.bak");
+ var clause = Assert.Single(result.Clauses);
+ var arg = Assert.Single(clause.Args);
+ Assert.Equal(ArgKind.Glob, arg.Kind);
+ Assert.True(arg.IsPath);
+ Assert.Null(arg.Resolved);
+ }
+
+ [Fact]
+ public void Chmod_mode_is_not_a_path()
+ {
+ var result = Parse("chmod 755 /etc/passwd");
+ var clause = Assert.Single(result.Clauses);
+ Assert.Equal(2, clause.Args.Count);
+ Assert.False(clause.Args[0].IsPath);
+ Assert.Equal("755", clause.Args[0].Raw);
+ Assert.True(clause.Args[1].IsPath);
+ Assert.Equal("/etc/passwd", clause.Args[1].Resolved);
+ }
+
+ [Fact]
+ public void Grep_first_arg_is_pattern_not_path()
+ {
+ var result = Parse("grep pattern /etc/hosts");
+ var clause = Assert.Single(result.Clauses);
+ Assert.Equal(2, clause.Args.Count);
+ Assert.False(clause.Args[0].IsPath);
+ Assert.True(clause.Args[1].IsPath);
+ }
+
+ [Fact]
+ public void Curl_url_is_not_a_path_but_output_flag_value_is()
+ {
+ var result = Parse("curl -o /tmp/out https://example.com");
+ var clause = Assert.Single(result.Clauses);
+ Assert.Equal(3, clause.Args.Count);
+ Assert.Equal("-o", clause.Args[0].Raw);
+ Assert.True(clause.Args[1].IsPath);
+ Assert.Equal("/tmp/out", clause.Args[1].Resolved);
+ Assert.False(clause.Args[2].IsPath);
+ Assert.Equal("https://example.com", clause.Args[2].Raw);
+ }
+
+ [Fact]
+ public void Find_root_is_path_predicate_args_are_not()
+ {
+ var result = Parse("find /var/log -name \"*.log\"");
+ var clause = Assert.Single(result.Clauses);
+ Assert.Equal(3, clause.Args.Count);
+ Assert.True(clause.Args[0].IsPath);
+ Assert.Equal("/var/log", clause.Args[0].Resolved);
+ Assert.Equal("-name", clause.Args[1].Raw);
+ Assert.False(clause.Args[2].IsPath);
+ }
+
+ [Fact]
+ public void Docker_volume_value_is_not_a_path_per_locked_interpretation_8()
+ {
+ var result = Parse("docker run -v /host:/container nginx");
+ var clause = Assert.Single(result.Clauses);
+ // verb chain probe: -v /host:/container should be consumed; verb = ["docker", "run"]
+ Assert.Equal(new[] { "docker", "run" }, clause.Verb.Tokens);
+ Assert.Equal(3, clause.Args.Count);
+ Assert.Equal("-v", clause.Args[0].Raw);
+ Assert.False(clause.Args[1].IsPath); // colon-joined volume mount, NOT a path
+ Assert.Equal("/host:/container", clause.Args[1].Raw);
}
}
diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashPerVerbRulesTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashPerVerbRulesTests.cs
new file mode 100644
index 0000000..394545b
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Parsing/BashPerVerbRulesTests.cs
@@ -0,0 +1,304 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using ShellSyntaxTree.Internal.Bash.Verbs;
+using Xunit;
+
+namespace ShellSyntaxTree.Tests.Parsing;
+
+///
+/// Unit tests for . Covers SPEC §7's per-verb
+/// path-arg overrides and the flag-value path classification table.
+///
+public class BashPerVerbRulesTests
+{
+ private static VerbChain Verb(params string[] tokens) =>
+ new() { Tokens = tokens };
+
+ // ---------------------------------------------------------------- chmod/chown/chgrp
+
+ [Fact]
+ public void Chmod_first_positional_is_not_a_path()
+ {
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("chmod"), 0, "755"));
+ }
+
+ [Fact]
+ public void Chmod_second_positional_is_a_path()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("chmod"), 1, "/etc/passwd"));
+ }
+
+ [Fact]
+ public void Chown_first_positional_is_not_a_path()
+ {
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("chown"), 0, "user:group"));
+ }
+
+ [Fact]
+ public void Chown_second_positional_is_a_path()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("chown"), 1, "/var/log"));
+ }
+
+ [Fact]
+ public void Chgrp_first_is_group_rest_are_paths()
+ {
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("chgrp"), 0, "wheel"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("chgrp"), 1, "/var/log"));
+ }
+
+ // ---------------------------------------------------------------- ln
+
+ [Fact]
+ public void Ln_all_positionals_are_paths()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("ln"), 0, "/a/source"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("ln"), 1, "/b/target"));
+ }
+
+ // ---------------------------------------------------------------- find
+
+ [Fact]
+ public void Find_first_positional_is_a_path()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("find"), 0, "/var/log"));
+ }
+
+ [Fact]
+ public void Find_predicate_args_are_not_paths()
+ {
+ // Predicate args like `-name`, `"*.log"`, `-type`, `f` are not paths.
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("find"), 1, "*.log"));
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("find"), 2, "f"));
+ }
+
+ // ---------------------------------------------------------------- grep / rg
+
+ [Fact]
+ public void Grep_first_positional_is_pattern()
+ {
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("grep"), 0, "pattern"));
+ }
+
+ [Fact]
+ public void Grep_second_positional_is_path()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("grep"), 1, "/etc/hosts"));
+ }
+
+ [Fact]
+ public void Rg_first_is_pattern_rest_paths()
+ {
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("rg"), 0, "regex"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("rg"), 1, "/path"));
+ }
+
+ // ---------------------------------------------------------------- sed / awk
+
+ [Fact]
+ public void Sed_first_is_script_rest_paths()
+ {
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("sed"), 0, "s/foo/bar/"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("sed"), 1, "file.txt"));
+ }
+
+ [Fact]
+ public void Awk_first_is_program_rest_paths()
+ {
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("awk"), 0, "{print $1}"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("awk"), 1, "input.txt"));
+ }
+
+ // ---------------------------------------------------------------- tar (default rule)
+
+ [Fact]
+ public void Tar_default_rule_marks_all_positionals_as_paths()
+ {
+ // Locked interpretation #8: no action-flag awareness in v0.1.
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("tar"), 0, "archive.tar.gz"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("tar"), 1, "/target"));
+ }
+
+ // ---------------------------------------------------------------- curl / wget
+
+ [Fact]
+ public void Curl_url_positional_is_not_a_path()
+ {
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("curl"), 0, "https://example.com"));
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("curl"), 1, "https://other.example"));
+ }
+
+ [Fact]
+ public void Wget_url_positional_is_not_a_path()
+ {
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(Verb("wget"), 0, "https://example.com"));
+ }
+
+ // ---------------------------------------------------------------- scp / rsync / sftp
+
+ [Fact]
+ public void Scp_all_positionals_are_paths()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("scp"), 0, "user@host:/path"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("scp"), 1, "/local"));
+ }
+
+ [Fact]
+ public void Rsync_all_positionals_are_paths()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("rsync"), 0, "src/"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("rsync"), 1, "dst/"));
+ }
+
+ // ---------------------------------------------------------------- cd / chdir / pushd / popd
+
+ [Fact]
+ public void Cd_first_positional_is_path()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("cd"), 0, "/tmp"));
+ }
+
+ [Fact]
+ public void Chdir_first_positional_is_path()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("chdir"), 0, "/tmp"));
+ }
+
+ [Fact]
+ public void Pushd_first_positional_is_path()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("pushd"), 0, "/tmp"));
+ }
+
+ [Fact]
+ public void Popd_first_positional_is_path()
+ {
+ // popd takes no positional path arg in real bash, but if one's
+ // typed we still classify it as a path slot. CwdVerbs are also
+ // FileVerbs by the table.
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("popd"), 0, "+1"));
+ }
+
+ // ---------------------------------------------------------------- default file-verb rule
+
+ [Fact]
+ public void Default_file_verb_rule_all_positionals_are_paths()
+ {
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("cat"), 0, "file.txt"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("rm"), 0, "/tmp/foo"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("cp"), 0, "/src"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("cp"), 1, "/dst"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("mv"), 0, "/src"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(Verb("ls"), 0, "/tmp"));
+ }
+
+ // ---------------------------------------------------------------- non-file-verb fallback
+
+ [Fact]
+ public void Non_file_verb_uses_looks_like_path_heuristic_path_shaped()
+ {
+ // unknown verb + path-shaped token → IsPath=true.
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(
+ Verb("totally-unknown-verb"), 0, "/etc/foo"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(
+ Verb("totally-unknown-verb"), 0, "./config.json"));
+ }
+
+ [Fact]
+ public void Non_file_verb_uses_looks_like_path_heuristic_word_shaped()
+ {
+ // unknown verb + plain word → IsPath=false.
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(
+ Verb("totally-unknown-verb"), 0, "argument"));
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(
+ Verb("totally-unknown-verb"), 1, "main"));
+ }
+
+ [Fact]
+ public void Git_args_use_looks_like_path_fallback()
+ {
+ // git is in BashArity but NOT in FileVerbs, so its positionals
+ // fall back to LooksLikePath. `origin` / `main` are not path-shaped;
+ // `/local/repo` is.
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(
+ Verb("git", "push"), 0, "origin"));
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(
+ Verb("git", "push"), 1, "main"));
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(
+ Verb("git", "clone"), 0, "/local/repo"));
+ }
+
+ // ---------------------------------------------------------------- ValueOfFlagIsPath
+
+ [Fact]
+ public void Git_dash_C_value_is_a_path()
+ {
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("git", "-C"));
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("git", "--git-dir"));
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("git", "--work-tree"));
+ }
+
+ [Fact]
+ public void Curl_dash_o_value_is_a_path_dash_d_is_not()
+ {
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("curl", "-o"));
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("curl", "--output"));
+ Assert.False(BashPerVerbRules.ValueOfFlagIsPath("curl", "-d"));
+ Assert.False(BashPerVerbRules.ValueOfFlagIsPath("curl", "--data"));
+ }
+
+ [Fact]
+ public void Wget_dash_O_value_is_a_path()
+ {
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("wget", "-O"));
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("wget", "--output-document"));
+ }
+
+ [Fact]
+ public void Docker_dash_f_value_is_a_path_dash_v_is_not()
+ {
+ // Locked interpretation #8.
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("docker", "-f"));
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("docker", "--file"));
+ Assert.False(BashPerVerbRules.ValueOfFlagIsPath("docker", "-v"));
+ Assert.False(BashPerVerbRules.ValueOfFlagIsPath("docker", "--volume"));
+ }
+
+ [Fact]
+ public void Tar_dash_f_and_dash_C_values_are_paths()
+ {
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("tar", "-f"));
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("tar", "--file"));
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("tar", "-C"));
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("tar", "--directory"));
+ }
+
+ [Fact]
+ public void Unknown_verb_or_flag_returns_false()
+ {
+ Assert.False(BashPerVerbRules.ValueOfFlagIsPath("unknown", "-x"));
+ Assert.False(BashPerVerbRules.ValueOfFlagIsPath("git", "--unknown"));
+ }
+
+ [Fact]
+ public void Flag_lookup_is_case_insensitive()
+ {
+ // The per-verb-flag table is case-insensitive; consumers may
+ // typo casing.
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("GIT", "-c"));
+ Assert.True(BashPerVerbRules.ValueOfFlagIsPath("Git", "--Git-Dir"));
+ }
+
+ // ---------------------------------------------------------------- empty verb chain
+
+ [Fact]
+ public void Empty_verb_chain_falls_back_to_looks_like_path()
+ {
+ var empty = new VerbChain();
+ Assert.True(BashPerVerbRules.IsPositionalPathArg(empty, 0, "/etc/foo"));
+ Assert.False(BashPerVerbRules.IsPositionalPathArg(empty, 0, "argument"));
+ }
+}
diff --git a/tests/ShellSyntaxTree.Tests/Resolving/BashResolverTests.cs b/tests/ShellSyntaxTree.Tests/Resolving/BashResolverTests.cs
new file mode 100644
index 0000000..b3cbe31
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Resolving/BashResolverTests.cs
@@ -0,0 +1,346 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using ShellSyntaxTree.Internal.Resolving;
+using Xunit;
+
+namespace ShellSyntaxTree.Tests.Resolving;
+
+///
+/// Unit tests for — SPEC §8 path-token resolver,
+/// plus the LooksLikePath heuristic. Coverage is intentionally broad
+/// across the table (tilde, $HOME, env-var DynamicSkip, glob, absolute /
+/// relative paths, filesystem:: prefix, combined cases).
+///
+public class BashResolverTests
+{
+ private static BashParserOptions OptionsFor(string? home = "/home/test", string? wd = "/work") =>
+ new() { HomeDirectory = home, WorkingDirectory = wd };
+
+ // ---------------------------------------------------------------- tilde
+
+ [Fact]
+ public void Tilde_alone_in_path_slot_expands_to_home()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("~", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Tilde, kind);
+ Assert.Equal("/home/test", resolved);
+ Assert.True(isPath);
+ }
+
+ [Fact]
+ public void Tilde_with_subpath_in_path_slot_joins_to_home()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("~/file.txt", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Tilde, kind);
+ Assert.Equal("/home/test/file.txt", resolved);
+ Assert.True(isPath);
+ }
+
+ [Fact]
+ public void Tilde_user_in_path_slot_is_dynamic_skip()
+ {
+ // ~bob and ~bob/path → unsupported in v0.1 → DynamicSkip.
+ var (kind, resolved, isPath) = BashResolver.Resolve("~bob/file", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.DynamicSkip, kind);
+ Assert.Null(resolved);
+ Assert.False(isPath);
+ }
+
+ [Fact]
+ public void Tilde_user_in_non_path_slot_is_tilde_no_resolve()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("~bob", treatAsPath: false, OptionsFor());
+ Assert.Equal(ArgKind.Tilde, kind);
+ Assert.Null(resolved);
+ Assert.False(isPath);
+ }
+
+ [Fact]
+ public void Tilde_in_non_path_slot_is_tilde_kind_no_resolve()
+ {
+ // Non-path slot still classifies Kind=Tilde for consumer visibility
+ // but doesn't resolve (the slot is, by definition, not a path).
+ var (kind, resolved, isPath) = BashResolver.Resolve("~/foo", treatAsPath: false, OptionsFor());
+ Assert.Equal(ArgKind.Tilde, kind);
+ Assert.Null(resolved);
+ Assert.False(isPath);
+ }
+
+ // ---------------------------------------------------------------- $HOME
+
+ [Fact]
+ public void Dollar_HOME_in_path_slot_substitutes()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("$HOME/Downloads", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Tilde, kind); // treat as tilde-equivalent
+ Assert.Equal("/home/test/Downloads", resolved);
+ Assert.True(isPath);
+ }
+
+ [Fact]
+ public void Brace_HOME_in_path_slot_substitutes()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("${HOME}/Downloads", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Tilde, kind);
+ Assert.Equal("/home/test/Downloads", resolved);
+ Assert.True(isPath);
+ }
+
+ [Fact]
+ public void Dollar_HOME_alone_in_path_slot_substitutes()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("$HOME", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Tilde, kind);
+ Assert.Equal("/home/test", resolved);
+ Assert.True(isPath);
+ }
+
+ // ---------------------------------------------------------------- other env vars
+
+ [Fact]
+ public void Other_env_var_in_path_slot_is_dynamic_skip()
+ {
+ // SPEC §12 example: rm $UNRESOLVED/foo.
+ var (kind, resolved, isPath) = BashResolver.Resolve("$UNRESOLVED/foo", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.DynamicSkip, kind);
+ Assert.Null(resolved);
+ Assert.False(isPath);
+ }
+
+ [Fact]
+ public void Brace_env_var_in_path_slot_is_dynamic_skip()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("${PATH}/bin/foo", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.DynamicSkip, kind);
+ Assert.Null(resolved);
+ Assert.False(isPath);
+ }
+
+ [Fact]
+ public void Other_env_var_in_non_path_slot_is_env_var()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("$REPO", treatAsPath: false, OptionsFor());
+ Assert.Equal(ArgKind.EnvVar, kind);
+ Assert.Null(resolved);
+ Assert.False(isPath);
+ }
+
+ [Fact]
+ public void Mixed_HOME_and_other_var_still_dynamic_skip_in_path_slot()
+ {
+ // $HOME expands first; then the surviving $OTHER triggers DynamicSkip.
+ var (kind, resolved, isPath) = BashResolver.Resolve(
+ "$HOME/$OTHER/file", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.DynamicSkip, kind);
+ Assert.Null(resolved);
+ Assert.False(isPath);
+ }
+
+ // ---------------------------------------------------------------- glob
+
+ [Fact]
+ public void Star_glob_in_path_slot_is_glob_with_is_path_true()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("*.txt", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Glob, kind);
+ Assert.Null(resolved);
+ Assert.True(isPath); // locked interpretation #3 — covering-dir signal
+ }
+
+ [Fact]
+ public void Glob_with_dir_in_path_slot_is_glob_is_path_true()
+ {
+ var (kind, _, isPath) = BashResolver.Resolve("/tmp/*.bak", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Glob, kind);
+ Assert.True(isPath);
+ }
+
+ [Fact]
+ public void Question_glob_in_path_slot_is_glob()
+ {
+ var (kind, _, isPath) = BashResolver.Resolve("?file", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Glob, kind);
+ Assert.True(isPath);
+ }
+
+ [Fact]
+ public void Bracket_glob_in_path_slot_is_glob()
+ {
+ var (kind, _, isPath) = BashResolver.Resolve("[ab]", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Glob, kind);
+ Assert.True(isPath);
+ }
+
+ [Fact]
+ public void Glob_in_non_path_slot_is_glob_is_path_false()
+ {
+ var (kind, _, isPath) = BashResolver.Resolve("*.txt", treatAsPath: false, OptionsFor());
+ Assert.Equal(ArgKind.Glob, kind);
+ Assert.False(isPath);
+ }
+
+ // ---------------------------------------------------------------- filesystem:: prefix
+
+ [Fact]
+ public void Filesystem_prefix_is_stripped_then_resolved()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve(
+ "filesystem::/path/to/foo", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Literal, kind);
+ Assert.Equal("/path/to/foo", resolved);
+ Assert.True(isPath);
+ }
+
+ // ---------------------------------------------------------------- absolute + relative paths
+
+ [Fact]
+ public void Absolute_unix_path_is_literal()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("/etc/hosts", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Literal, kind);
+ Assert.Equal("/etc/hosts", resolved);
+ Assert.True(isPath);
+ }
+
+ [Fact]
+ public void Relative_path_in_path_slot_joins_to_working_directory()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve("foo.txt", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Literal, kind);
+ Assert.Equal("/work/foo.txt", resolved);
+ Assert.True(isPath);
+ }
+
+ [Fact]
+ public void Relative_dot_slash_joins_to_working_directory()
+ {
+ var (kind, resolved, _) = BashResolver.Resolve("./foo.txt", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Literal, kind);
+ Assert.Equal("/work/foo.txt", resolved);
+ }
+
+ [Fact]
+ public void Windows_drive_letter_is_treated_as_rooted()
+ {
+ // Path.GetFullPath honors drive letters; on Linux it may normalize
+ // to a relative-feeling path. Either way, the resolver must NOT
+ // crash and must classify as Literal (rooted path), not DynamicSkip.
+ var (kind, resolved, isPath) = BashResolver.Resolve(
+ "C:\\Users\\foo", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Literal, kind);
+ Assert.NotNull(resolved);
+ Assert.True(isPath);
+ }
+
+ // ---------------------------------------------------------------- non-path slot literals
+
+ [Fact]
+ public void Plain_literal_in_non_path_slot_is_literal()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve(
+ "origin", treatAsPath: false, OptionsFor());
+ Assert.Equal(ArgKind.Literal, kind);
+ Assert.Null(resolved);
+ Assert.False(isPath);
+ }
+
+ [Fact]
+ public void Plain_literal_in_path_slot_resolves()
+ {
+ var (kind, resolved, isPath) = BashResolver.Resolve(
+ "input", treatAsPath: true, OptionsFor());
+ Assert.Equal(ArgKind.Literal, kind);
+ Assert.Equal("/work/input", resolved);
+ Assert.True(isPath);
+ }
+
+ // ---------------------------------------------------------------- LooksLikePath
+
+ [Fact]
+ public void LooksLikePath_unix_absolute_is_true()
+ {
+ Assert.True(BashResolver.LooksLikePath("/etc/hosts"));
+ }
+
+ [Fact]
+ public void LooksLikePath_relative_dot_is_true()
+ {
+ Assert.True(BashResolver.LooksLikePath("./foo"));
+ Assert.True(BashResolver.LooksLikePath("../bar"));
+ }
+
+ [Fact]
+ public void LooksLikePath_tilde_is_true()
+ {
+ Assert.True(BashResolver.LooksLikePath("~/foo"));
+ Assert.True(BashResolver.LooksLikePath("~"));
+ }
+
+ [Fact]
+ public void LooksLikePath_with_slash_is_true()
+ {
+ Assert.True(BashResolver.LooksLikePath("a/b"));
+ Assert.True(BashResolver.LooksLikePath("a\\b"));
+ }
+
+ [Fact]
+ public void LooksLikePath_with_extension_is_true()
+ {
+ Assert.True(BashResolver.LooksLikePath("readme.md"));
+ Assert.True(BashResolver.LooksLikePath("config.json"));
+ Assert.True(BashResolver.LooksLikePath("data.tar.gz"));
+ Assert.True(BashResolver.LooksLikePath("archive.zip"));
+ }
+
+ [Fact]
+ public void LooksLikePath_plain_word_is_false()
+ {
+ Assert.False(BashResolver.LooksLikePath("origin"));
+ Assert.False(BashResolver.LooksLikePath("main"));
+ Assert.False(BashResolver.LooksLikePath("0755"));
+ Assert.False(BashResolver.LooksLikePath("pattern"));
+ }
+
+ [Fact]
+ public void LooksLikePath_windows_drive_letter_is_true()
+ {
+ Assert.True(BashResolver.LooksLikePath("C:\\foo"));
+ Assert.True(BashResolver.LooksLikePath("D:"));
+ }
+
+ [Fact]
+ public void LooksLikePath_unc_path_is_true()
+ {
+ Assert.True(BashResolver.LooksLikePath("\\\\server\\share"));
+ }
+
+ // ---------------------------------------------------------------- option fallbacks
+
+ [Fact]
+ public void Home_falls_back_to_environment_user_profile_when_null()
+ {
+ // We can't pin a specific value (it depends on the test runner's
+ // environment), but the resolver must produce *something* non-empty.
+ var options = OptionsFor(home: null);
+ var (kind, resolved, isPath) = BashResolver.Resolve("~", treatAsPath: true, options);
+ Assert.Equal(ArgKind.Tilde, kind);
+ Assert.True(isPath);
+ Assert.False(string.IsNullOrEmpty(resolved));
+ }
+
+ [Fact]
+ public void Working_directory_null_falls_back_to_environment_cwd()
+ {
+ // Relative path with no explicit WorkingDirectory falls back to
+ // Environment.CurrentDirectory. The result is host-dependent, but
+ // *some* absolute resolved value must come back.
+ var options = OptionsFor(wd: null);
+ var (kind, resolved, _) = BashResolver.Resolve("foo.txt", treatAsPath: true, options);
+ Assert.Equal(ArgKind.Literal, kind);
+ Assert.NotNull(resolved);
+ Assert.NotEqual("foo.txt", resolved);
+ }
+}