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); + } +}