diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 9732270..78fecbf 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -40,6 +40,15 @@ jobs: shell: pwsh run: ./scripts/Add-FileHeaders.ps1 -Verify + - name: "Verify pwsh is available (SPEC.POWERSHELL.md §13 oracle gate)" + shell: bash + run: | + if ! command -v pwsh >/dev/null 2>&1; then + echo "::error::pwsh is not on PATH — the PowerShell corpus oracle gate (PwshOracleTests) would silently skip." + exit 1 + fi + pwsh --version + - name: "dotnet restore" run: dotnet restore diff --git a/Directory.Build.props b/Directory.Build.props index af98c6a..3348124 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,8 +7,8 @@ latest enable true - 0.1.5 - + 0.2.0 + alpha @@ -29,7 +29,7 @@ - bash;shell;parser;ast;security;agent;syntax-tree + bash;powershell;pwsh;shell;parser;ast;security;agent;syntax-tree https://github.com/Aaronontheweb/ShellSyntaxTree https://github.com/Aaronontheweb/ShellSyntaxTree git diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 0653053..d6f5bf5 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,295 +1,95 @@ # Implementation Plan — ShellSyntaxTree -Source of truth for active work. Translates `SPEC.md` §16 into NOW / NEXT / -LATER buckets. Park items aggressively — autonomous loops will otherwise -bulldoze priorities. +Source of truth for active work. Translates `SPEC.md` §16 (bash — shipped) +and `SPEC.POWERSHELL.md` §16 (PowerShell — v0.2.0) into NOW / NEXT / LATER +buckets. Park items aggressively — autonomous loops will otherwise bulldoze +priorities. > **Hard rule:** every PR ends with this file updated (item moved / > completed / parked) so the plan reflects reality. --- -## NOW (0.1.0-alpha shipping path) - -> **OpenSpec change `v0.1-locked-interpretations`:** archived -> 2026-05-11 (post-alpha housekeeping PR) under -> `openspec/changes/archive/2026-05-11-v0.1-locked-interpretations/`. -> Captured the eight planning-interview decisions; superseded by -> shipped 0.1.0-alpha behavior. - -### 1. Bootstrap projects (PR 1, in progress) - -- [x] Create `src/ShellSyntaxTree/ShellSyntaxTree.csproj` (library, - multi-target `netstandard2.0;net8.0`, `IsAotCompatible=true`) -- [x] Create `tests/ShellSyntaxTree.Tests/ShellSyntaxTree.Tests.csproj` - (xunit, target `net10.0`) -- [x] Add both to `ShellSyntaxTree.slnx` -- [x] `dotnet build -c Release` clean, `dotnet test -c Release` clean - (18 PublicApiSnapshotTests passing) - -### 2. Public API skeleton (lock surface first) — PR 1, in progress - -- [x] Implement public types from `SPEC.md` §2/§3 verbatim: - `IShellParser`, `BashParser`, `BashParserOptions`, `ParsedCommand`, - `Clause`, `VerbChain`, `Arg`, `Redirect`, and the three enums -- [x] `BashParser.Parse` throws `NotImplementedException`; `Parse(null)` - throws `ArgumentNullException` -- [x] `Arg` includes `IsCwdAttribution` per SPEC §9 (locked interpretation #1) -- [x] PublicApiSnapshotTests reflection-based `[Fact]`s assert the surface - shape with strict namespace closure -- [x] Bootstrap OpenSpec scaffolding (config, root spec stub, change - proposal/design/tasks/specs delta for `v0.1-locked-interpretations`) -- [x] Copy OpenSpec authoring skills into `.claude/skills/` -- [x] Update SPEC.md §3 to enumerate `Arg.IsCwdAttribution`; cross-tfm - note on `VerbChain.Joined` - -### 3. BashLexer + opaque-region scanner — PR 2, in progress - -- [x] `Internal/Lexing/OpaqueRegionScanner.cs` — shared, grammar-agnostic; - `Scan` for `(`/`)` style + `ScanSymmetric` for backtick; quote-aware, - escape-aware, nesting-aware -- [x] `Internal/Bash/Lexing/{BashLexer,BashToken,BashTokenKind}.cs` -- [x] Token kinds: Word, QuotedString, Operator, Whitespace, Continuation, - OpaqueSubstitution, UnparseableSentinel -- [x] Quote handling (single literal, double with `\"`, `\\`, `\$`, - `\` + newline) -- [x] Escape handling outside quotes -- [x] Operator boundaries (no whitespace required); `<<-` heredoc variant -- [x] `$(…)` and backticks → `OpaqueSubstitution` (locked interpretation #2) -- [x] `$((expr))` and `${var//pat/repl}` → `UnparseableSentinel` -- [x] Heredoc body skip per SPEC §4 -- [x] 78 lexer + scanner unit tests (combined with PR 1's 18 → 96/96 - passing) -- [x] SPEC §1 / §5 / §11 updated for token kinds + non-goal additions -- [x] OpenSpec change `v0.1-locked-interpretations` tasks.md updated - (Phase 2 marked [x]) - -### 4. Verb tables (SPEC §6, data only) — PR 3, complete - -- [x] `BashArity` (multi-token verbs) per SPEC §6.1 -- [x] `CwdVerbs` (`cd`, `chdir`, `popd`, `pushd`, `push-location`, - `set-location`) -- [x] `FileVerbs` (full SPEC §6.3 list) -- [x] `FlagsWithValue` (`git`, `curl`, `wget`, `docker`, `tar`) -- [x] `ControlFlowKeywords` (for IsUnparseable detection) -- [x] Probe order: longest-match-first (3-token, then 2-token, then - 1-token); SPEC note added that flag-with-value-aware probing - arrives in PR 4 - -### 5. BashParser core (SPEC §4) — PR 3, complete - -- [x] Compound splitting on `&&`, `||`, `;`, `|` -- [x] Verb chain extraction using `BashArity` (PR 4 will refine for - flag-with-value pairs) -- [x] Args + redirects per clause (literal-only mode; path classification - arrives in PR 4) -- [x] Subshell `( ... )` framework (inner clauses parse inline; - `IsSubshell` flag stays false until PR 5) -- [x] `bash -c "..."` framework (single-clause mode in PR 3; PR 5 wires - recursion + `IsBashCWrapped`) -- [x] Anomaly safe-fail (SPEC §11): never throw on well-formed input; - strict empty-Clauses on anomaly (PR 6 may relax to partial recovery) -- [x] CorpusRunnerTests skeleton + 50 corpus entries -- [x] `BashParser.Parse` delegates to `BashCommandParser.Parse` - -### 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) — PR 5, complete - -- [x] First-clause `cd`/`chdir` sets attributed cwd; only those two verbs - propagate (interp #5) -- [x] Subsequent clauses receive synthetic `Arg` with - `IsCwdAttribution=true`; `Kind=Literal, IsPath=true` when cd - target resolved, `Kind=DynamicSkip, IsPath=false` when dynamic - (interp #6) -- [x] Subsequent `cd` in the same compound replaces attribution -- [x] Subshell boundaries isolate attribution via push/pop stack - -### 9. Subshell + bash -c surfacing (SPEC §10) — PR 5, complete - -- [x] Flatten subshell clauses into parent's `Clauses` with - `IsSubshell=true`; sibling subshells handled via SubshellStack IDs -- [x] Surface `bash -c "..."` / `sh -c "..."` inner clauses inline with - `IsBashCWrapped=true`; outer cd attribution doesn't leak into - inner shell (v0.1 decision) -- [x] Recursion depth cap at 5 → outer - `ParsedCommand.IsUnparseable=true` (interp #4) - -### 10. Hand-authored corpus (SPEC §13 — minimum 105 entries) — PR 6, complete - -- [x] 10 simple-verb cases (01-10) — PR 3 -- [x] 10 multi-token-verb cases (11-20) — PR 3 -- [x] 15 compound cases (21-35) — PR 3 -- [x] 10 cd-in-compound cases (71-80) — PR 5 -- [x] 10 quote-handling cases (101-110) — PR 6 -- [x] 10 redirect cases (36-45) — PR 3 -- [x] 10 subshell cases (81-90) — PR 5 -- [x] 10 `bash -c` cases (91-100) — PR 5 -- [x] 10 dynamic-skip cases (51-60) — PR 4 -- [x] 10 per-verb path-rule cases (61-70) — PR 4 -- [x] 10 unparseable cases (46-50, 111-115) — PRs 3 + 6 -- [x] **115 total corpus entries** (target was ≥105) - -### 11. Corpus runner test — PR 6, complete - -- [x] Single `[Theory] [MemberData]` enumerating - `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` (skeleton in PR 3) -- [x] Per-entry test name (file name) so failures point at the specific - case -- [x] Polished structural-equality helper `AstAssert.Equal` with - diff-friendly messages (e.g. - `clauses[1].args[2].kind: expected DynamicSkip, actual Literal`) - -### 12. PII audit (SPEC §14) — PR 6, complete - -- [x] Single `[Fact]` that scans - `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` for SPEC §14 - forbidden patterns; allowlists generic placeholders; reports all - hits in one failure -- [x] Wired into `pr_validation.yml` via standard `dotnet test` - (no separate job) - -### 13. Release 0.1.0-alpha — shipped 2026-05-11 - -- [x] `RELEASE_NOTES.md` updated with 0.1.0-alpha section -- [x] `dotnet pack -c Release -o ./bin/nuget` produces clean - `ShellSyntaxTree.0.1.0-alpha.nupkg` + `.snupkg` with embedded - README, icon, SourceLink metadata -- [x] User go-ahead received; tag pushed: `0.1.0-alpha` → commit - `41e9433` (PR #19) on 2026-05-11 -- [x] `publish_nuget.yml` workflow run completed `success` at - 2026-05-11T13:58:20Z; GitHub Release "ShellSyntaxTree 0.1.0-alpha" - cut at 14:08:47Z (Pre-release); package live on nuget.org -- **Post-alpha CI follow-ups** (improve future releases, not retroactive): - - PR #19: `chore(ci): assert tags are bare version numbers (no v prefix)` - - PR #20: `chore(ci): strip section heading from extracted release notes` - -### 14. Netclaw integration smoke (SPEC §17 #7-#8) — complete - -- [x] In netclaw repo: `dotnet add package ShellSyntaxTree --version 0.1.0-alpha` -- [x] Wire `IShellParser` into Netclaw DI; replace minimal call site in - `src/Netclaw.Security/ShellApprovalSemantics.cs` -- [x] One Netclaw integration test exercises a real corpus entry through - the live matcher and gets the expected gate decision -- [x] SPEC §17 #7–#8 satisfied (confirmed by Aaron 2026-05-15: all shipped - alpha versions are working in Netclaw production) - -### 15. NuGet package icon — PR 7, complete - -- [x] Icon already generated as `assets/icon.png` (ShellSyntaxTree-themed - AST tree on dark green background with `>_` shell prompt motif — - 512x512 PNG, ~98 KB; created during template bootstrap) -- [x] `Directory.Build.props` wires `icon.png` - + a packed `` item (already present from bootstrap) -- [x] `dotnet pack` validation: icon embedded in `.nupkg` confirmed -- [x] Re-pack to validate icon embeds - -### 16. Bash line comments (#25) — 0.1.3-alpha - -- [x] `BashTokenKind.Comment` enum member (internal) -- [x] `BashLexer.ConsumeLineComment` helper; `#` dispatch in main scan loop -- [x] `BashCommandParser.FilterSignificant` drops Comment tokens -- [x] SPEC.md §4 BNF note + §5 "Comment handling" subsection -- [x] 10 new lexer unit tests + 8 new parser unit tests -- [x] 9 new corpus entries (123–131) including both Netclaw repros - (sanitized paths per SPEC §14) -- [x] `Directory.Build.props` `VersionPrefix` 0.1.2 → 0.1.3 -- [x] `RELEASE_NOTES.md` 0.1.3-alpha section -- [x] Cut 0.1.3-alpha tag once branch is merged - -### 17. Greedy verb-chain extraction (#27) — 0.1.4-alpha - -- [x] Remove `BashArity` static table and `ProbeArity()` method from - `BashVerbs.cs` -- [x] Add `BashVerbs.IsVerbLikeToken` predicate (strict allow-list: - Word kind, length 1–64, leading `[a-z]`, body `[a-z0-9._-]`) -- [x] Rewrite verb-extraction loop in - `BashCommandParser.ParseClauseSegment` (greedy walk + FileVerb - 1-token carveout + flag-with-value consumption) -- [x] 7 new corpus entries (132–138) for the issue #27 headline cases: - `freshdesk ticket list`, `git -C /repo worktree list --porcelain`, - `kubectl get pods`, `kubectl get pods my-pod`, `aws s3 cp src dst`, - `dotnet ef migrations add InitialCreate`, `cat README` - (FileVerb-carveout proof) -- [x] 11 existing corpus entries flipped to new shape: `04_echo_hello`, - `11_git_push_origin_main`, `13_git_checkout_dev`, - `17_docker_run_nginx`, `27_make_install`, `45_echo_append_log`, - `84_subshell_nested`, `91_bash_c_simple`, - `96_bash_c_nested_depth_2`, `100_bash_c_nested_depth_3`, - `130_netclaw_repro_leading_comment_pipeline` -- [x] 8 unit-test cases updated in `BashCommandParserTests.cs` to match - the new expected verb chains -- [x] SPEC.md updates: §3 `VerbChain`, §4 grammar, §6.1 rewritten end-to-end, - new §6.1.1 consumer pattern-matching guidance, §7 flag-with-value - note, §12 worked examples, §15 versioning, §16 sequencing -- [x] `Directory.Build.props` `VersionPrefix` 0.1.3 → 0.1.4 -- [x] `RELEASE_NOTES.md` 0.1.4-alpha section -- [x] Cut 0.1.4-alpha tag once branch is merged - -### 18. Newline-as-statement-separator — 0.1.5-beta - -- [x] `BashToken.IsStatementSeparator` init-property (internal) -- [x] `BashLexer` flags the newline-branch Whitespace token and the - heredoc-terminator Whitespace token -- [x] `BashCommandParser`: `FilterSignificant` retains flagged - Whitespace; `SplitIntoSegments` splits on it as a `Sequence` - boundary (an empty pending segment collapses — no empty clause); - `TryDetectAnomaly` treats it as a verb-slot boundary -- [x] SPEC.md §4 grammar + notes, §5 `WHITESPACE` bullet, §15 - versioning, §16 sequencing note -- [x] 8 new `BashLexerTests` + 14 new `BashCommandParserTests`; stale - comment in `Comment_between_two_statements_preserves_both_clauses` - corrected -- [x] 11 new corpus entries (139–149); entry 126 note corrected -- [x] `Directory.Build.props` `VersionPrefix` 0.1.4 → 0.1.5, - `VersionSuffix` → `beta` -- [x] `RELEASE_NOTES.md` 0.1.5-beta section -- [x] Cut 0.1.5-beta tag once branch is merged; promote to stable - 0.1.5 after Netclaw validates the behavior change +## NOW (0.2.0 — PowerShell parser) + +> **Spec:** `SPEC.POWERSHELL.md` (v0.2.0). The PowerShell parser is +> implemented — phases 1–14 of `SPEC.POWERSHELL.md` §16 are complete (see +> below). What remains is the release flow and the downstream Netclaw +> integration, both of which need actions outside this repository. + +### Implemented (SPEC.POWERSHELL.md §16 phases 1–14) — done + +- [x] **1. Public-API surface** — `ShellParserOptions` base; `BashParserOptions` + reparented; `PwshParserOptions` + `PwshParser`; additive + `VerbChain.CanonicalVerb` / `IsDynamic`; `Clause.IsBashCWrapped` → + `IsCommandStringWrapped`. `PublicApiSnapshotTests`, corpus DTOs, and the + bash corpus JSON key all updated. +- [x] **2. Verb & binding tables** — `PwshApprovedVerbs`, `PwshAliases` + (complete default set), `PwshVerbs`, `PwshBindingTables`, + `PwshPerVerbRules`. +- [x] **3. `PwshLexer`** — quoting, backtick escape, `$var` / `$env:` / + `${name}`, parameters, stream redirects, statement separators, + comments, opaque regions, `--%`; `OpaqueRegionScanner` backtick mode. +- [x] **4–10. `PwshCommandParser`** — pipeline / statement splitting, + verb-chain extraction, the §6.5 binding model, `PwshResolver` (§8), + per-verb path rules (§7), `Set-Location` propagation (§9), `pwsh + -Command` / `-EncodedCommand` recursion (§10), anomaly safe-fail and + the 64 KiB cap (§11). +- [x] **11. Multi-shell corpus runner + PII audit** — directory-routed by + `Corpus//`. +- [x] **12. PowerShell corpus** — 211 entries under `Corpus/powershell/`, + every §13 category minimum exceeded. +- [x] **13. `pwsh` validation gate + `tools/PwshCorpusTool`** — + `PwshOracleTests` enforces the §13 oracle matrix + the `PwshAliases` + completeness `[Fact]`; the tool is registered in `TOOLING.md`. +- [x] **14. `SPEC.md` edits + CI + version bump** — `SPEC.md` §1 / §2 / §3 / + §6.4 / §15 updated; `VersionPrefix` → `0.2.0`, `VersionSuffix` → + `alpha`; CI verifies `pwsh` and runs both corpora on Linux + Windows; + `RELEASE_NOTES.md` v0.2.0 section; CLI + Web samples gain a shell + selector; `README.md` updated. + +### 15. Release 0.2.0 (alpha → beta → stable) — SPEC.PWSH §15 / §17 + +- [ ] Tag `0.2.0-alpha`; confirm `publish_nuget.yml` produces the `.nupkg` + on nuget.org *(needs a tag push — maintainer action)* +- [ ] `0.2.0-beta` so Netclaw validates the parser + the breaking rename +- [ ] Promote to stable `0.2.0` after Netclaw validation + +### 16. Netclaw v0.2.0 integration — SPEC.PWSH §17 #9 + +- [ ] Netclaw consumes the v0.2.0 package; absorbs the `Clause` rename + *(separate repository — cannot be done here)* +- [ ] ≥1 Netclaw integration test exercises a real PowerShell corpus entry + through the live matcher and gets the expected gate decision --- -## NEXT (0.1.x — additive, post-alpha) +## NEXT (0.1.x / 0.2.x — additive) -- Seed 50–100 corpus entries from sanitized real-world dogfood logs - (SPEC §14 workflow) -- Expand verb tables as corpus surfaces real commands -- Document the "consumer's algorithm" — given a `ParsedCommand`, here is - how a security gate walks it (likely a section in `SPEC.md` Appendix - or a separate `docs/CONSUMER_GUIDE.md`) +- Seed corpus entries from sanitized real-world dogfood logs (SPEC §14 + workflow) — both shells. +- Expand verb / cmdlet / alias tables as the corpus surfaces real commands. +- Document the "consumer's algorithm" — given a `ParsedCommand`, how a + security gate walks it (a `docs/CONSUMER_GUIDE.md` or `SPEC.md` appendix). - Performance sanity check (~1 ms typical) with a tiny BenchmarkDotNet - harness — only if anything in the daemon hot path complains - -## LATER (0.2+ — out of 0.1 scope) - -- PowerShell parser (`PwshParser : IShellParser`) — first time we exercise - the multi-shell seam -- Windows `cmd` parser -- Source-mapping (line/column on AST nodes) — only if an IDE consumer - asks -- Heredoc body extraction, process substitution, function definitions — - only if a real consumer need surfaces + harness — only if anything in the daemon hot path complains. + +## LATER (post-0.2.0) + +- v0.2.x candidates from `SPEC.POWERSHELL.md` §18 — lossless redirect-stream + identity (grow the `RedirectDirection` enum), per-element comma-array + path extraction (`-Path a,b,c`). +- PowerShell script-level constructs — control flow, `function` / `class` / + `enum` definitions, `param()` / `begin` / `process` / `end` blocks, + `.ps1` file parsing (`SPEC.POWERSHELL.md` §18). +- Extract a shared lexer/parser core now that two parsers exist — the seam + can be designed from real duplication (`SPEC.POWERSHELL.md` §18); the + path-normalization helpers duplicated between `BashResolver` and + `PwshResolver` are the first candidate. +- Windows `cmd` parser. +- Source-mapping (line/column on AST nodes) — only if an IDE consumer asks. +- Heredoc body extraction, process substitution, bash function definitions + — only if a real consumer need surfaces. ## Parked diff --git a/README.md b/README.md index db3cda9..373d1e7 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ [![NuGet](https://img.shields.io/nuget/v/ShellSyntaxTree.svg)](https://www.nuget.org/packages/ShellSyntaxTree/) -A focused .NET library that parses bash command strings into a structured -AST. Purpose-built for tools that need to **reason about shell commands -without running them** — approval gates for LLM-emitted commands, CI/CD -script auditors, sandbox policy generators, audit-log analytics. +A focused .NET library that parses **bash and PowerShell** command strings +into a structured AST. Purpose-built for tools that need to **reason about +shell commands without running them** — approval gates for LLM-emitted +commands, CI/CD script auditors, sandbox policy generators, audit-log +analytics. Hand-rolled, AOT-trim friendly, zero native dependencies. Multi-targets `netstandard2.0` and `net8.0`. ```bash -dotnet add package ShellSyntaxTree --version 0.1.0-alpha +dotnet add package ShellSyntaxTree --version 0.2.0-alpha ``` ## What you get @@ -94,18 +95,22 @@ AndIf rm path: /etc/passwd ``` -## Public API surface (locked for v0.1) +## Public API surface ```csharp namespace ShellSyntaxTree; public interface IShellParser { ParsedCommand Parse(string command); } public sealed class BashParser : IShellParser { /* … */ } -public sealed record BashParserOptions { /* HomeDirectory, WorkingDirectory */ } +public sealed class PwshParser : IShellParser { /* … */ } // v0.2.0 + +public abstract record ShellParserOptions { /* HomeDirectory, WorkingDirectory */ } +public sealed record BashParserOptions : ShellParserOptions; +public sealed record PwshParserOptions : ShellParserOptions; public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, … */ } -public sealed record Clause { /* Operator, Verb, Args, Redirects, … */ } -public sealed record VerbChain { /* Tokens, Joined */ } +public sealed record Clause { /* Operator, Verb, Args, Redirects, IsSubshell, IsCommandStringWrapped */ } +public sealed record VerbChain { /* Tokens, Joined, CanonicalVerb, IsDynamic */ } public sealed record Arg { /* Raw, Resolved, Kind, IsPath, IsCwdAttribution, IsFlag */ } public sealed record Redirect { /* Direction, Target, IsDynamicSkip */ } @@ -114,11 +119,12 @@ public enum RedirectDirection { In, Out, Append, ErrOut, ErrAppend } public enum CompoundOperator { None, AndIf, OrIf, Sequence, Pipe } ``` -PowerShell and Windows `cmd` parsers are deferred to later versions; the -`IShellParser` seam is in place so consumers don't refactor when they -ship. +Both parsers emit the **same** `ParsedCommand` AST — a consumer walks a +PowerShell parse exactly as it walks a bash one. A Windows `cmd` parser +remains deferred. -Full behavioral contract: [`SPEC.md`](./SPEC.md). +Behavioral contract: [`SPEC.md`](./SPEC.md) (bash + shared surface) and +[`SPEC.POWERSHELL.md`](./SPEC.POWERSHELL.md) (PowerShell). ## Samples @@ -129,6 +135,9 @@ Two runnable samples live under [`samples/`](./samples). ```bash dotnet run --project samples/ShellSyntaxTree.Cli.Sample -- explain "cd /repo && rm /etc/passwd" dotnet run --project samples/ShellSyntaxTree.Cli.Sample -- audit "cd /repo && rm /etc/passwd" + +# --shell pwsh routes the same explain / audit logic through PwshParser: +dotnet run --project samples/ShellSyntaxTree.Cli.Sample -- explain --shell pwsh "gci C:\logs | rm" ``` `explain` pretty-prints the AST with `[flag]` / `[path]` / `[cwd-attr]` / @@ -141,11 +150,11 @@ for the policy code — ~50 lines. ### `ShellSyntaxTree.Web.Sample` — Blazor WebAssembly Mermaid visualizer -Paste a bash script, watch the parsed AST render as a Mermaid flowchart -in your browser. Everything runs client-side — pasted scripts never -leave your machine. Useful for "what does this script actually do?" -moments and for understanding how the library models constructs like -subshells and `bash -c` recursion. +Paste a bash or PowerShell script, watch the parsed AST render as a +Mermaid flowchart in your browser. Everything runs client-side — pasted +scripts never leave your machine. Useful for "what does this script +actually do?" moments and for understanding how the library models +constructs like subshells and `bash -c` / `pwsh -Command` recursion. ```bash dotnet run --project samples/ShellSyntaxTree.Web.Sample @@ -154,10 +163,11 @@ dotnet run --project samples/ShellSyntaxTree.Web.Sample ![Build script preset](./assets/sample-web-build-script.png) -The visualizer ships preset scripts demonstrating compound commands, -subshell isolation, `bash -c` recursion, dynamic-cwd attribution, and -unparseable inputs (control-flow, function definitions). Each preset -shows what the library produces in a single click. +A shell selector switches between the bash and PowerShell parsers; each +ships preset scripts demonstrating compound commands, subshell isolation, +command-string recursion, alias resolution, dynamic-cwd attribution, and +unparseable inputs. Each preset shows what the library produces in a +single click. ## Building from source @@ -176,10 +186,12 @@ dotnet pack -c Release -o ./bin/nuget Tags are bare SemVer version numbers — no `v` prefix. The release workflow asserts this and fails fast on misformatted tags. -- **0.1.0-alpha** — first publishable cut. Bash-only. -- **0.1.x** — additive (more verb table entries, more corpus, bug - fixes). -- **0.2.0** — first PowerShell parser. +- **0.1.x** — bash parser. Additive after `0.1.0` (more verb table + entries, more corpus, bug fixes). +- **0.2.0** — first PowerShell parser (`PwshParser`). Adds the shared + `ShellParserOptions` base and the additive `VerbChain.CanonicalVerb` / + `VerbChain.IsDynamic` fields; renames `Clause.IsBashCWrapped` → + `IsCommandStringWrapped` (breaking — see [`RELEASE_NOTES.md`](./RELEASE_NOTES.md)). - **1.0.0** — when an external consumer beyond Netclaw ships against it without finding API gaps. @@ -193,12 +205,13 @@ workflow asserts this and fails fast on misformatted tags. | Path | What | |---|---| -| `src/ShellSyntaxTree/` | The library | +| `src/ShellSyntaxTree/` | The library (bash + PowerShell parsers) | | `tests/ShellSyntaxTree.Tests/` | xUnit unit tests + corpus runner | -| `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` | 115 corpus entries — the acceptance contract | +| `tests/ShellSyntaxTree.Tests/Corpus//*.json` | Corpus entries — the acceptance contract (bash + powershell) | | `samples/ShellSyntaxTree.Cli.Sample/` | Console explainer + audit policy | | `samples/ShellSyntaxTree.Web.Sample/` | Blazor WASM Mermaid visualizer | -| `SPEC.md` | Locked v0.1 contract | -| `openspec/` | Change-proposal history (rationale for v0.1 design decisions) | +| `tools/PwshCorpusTool/` | PowerShell corpus authoring aid | +| `SPEC.md`, `SPEC.POWERSHELL.md` | The behavioral contract | +| `openspec/` | Change-proposal history (rationale for design decisions) | | `PROJECT_CONTEXT.md`, `TOOLING.md`, `AGENTS.md` | Repo governance — for autonomous agents | | `IMPLEMENTATION_PLAN.md` | NOW / NEXT / LATER work tracker | diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0557707..6e272e6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,87 @@ +#### 0.2.0-alpha May 19th 2026 #### + +First **PowerShell** parser. ShellSyntaxTree now ships two `IShellParser` +implementations — `BashParser` (unchanged) and the new `PwshParser` — both +emitting the same `ParsedCommand` AST a consumer already walks for bash. +Shipped as an **alpha** prerelease so Netclaw can validate the new parser +and the breaking `Clause` rename before promotion to a stable `0.2.0`. + +**BREAKING: `Clause.IsBashCWrapped` renamed to `Clause.IsCommandStringWrapped`** + +The v0.1 field `Clause.IsBashCWrapped` is renamed `Clause.IsCommandStringWrapped`. +The meaning is unchanged and now shell-neutral — *true when the clause is the +result of recursing into a command-string wrapper*: bash `bash -c "..."` / +`sh -c "..."`, or PowerShell `pwsh -Command "..."` / `pwsh -EncodedCommand ...`. + +| Old (v0.1) | New (v0.2.0) | +|---|---| +| `Clause.IsBashCWrapped` | `Clause.IsCommandStringWrapped` | + +A breaking AST change on a `0.x` minor is permitted by `SPEC.md` Appendix A +when `RELEASE_NOTES.md` carries the old→new mapping (above) and Netclaw is +updated in lockstep. Consumers: rename every `IsBashCWrapped` reference; +there is no behavior change beyond the identifier. + +**BREAKING (source-compatible): `BashParserOptions` reparented** + +`BashParserOptions` is now a sealed record deriving from the new abstract +`ShellParserOptions` base; `HomeDirectory` / `WorkingDirectory` move to the +base. The object-initializer shape is unchanged — +`new BashParserOptions { HomeDirectory = ..., WorkingDirectory = ... }` +still compiles. Only code that named `BashParserOptions` as a *base type* +or reflected over its declared members is affected. + +**New public surface** + +- `PwshParser : IShellParser` — the PowerShell parser. `Parse` throws + `ArgumentNullException` on null and never throws on a well-formed string, + exactly like `BashParser`. +- `PwshParserOptions` — configuration record for `PwshParser` (empty in + v0.2.0; resolver knobs live on `ShellParserOptions`). +- `ShellParserOptions` — the shared, abstract resolver-configuration base. +- `VerbChain.CanonicalVerb` (additive) — the alias-resolved canonical verb, + non-null only when an alias was rewritten (`ls` → `Get-ChildItem`). Null + for every bash clause. Consumers gate on `CanonicalVerb ?? Tokens[0]`. +- `VerbChain.IsDynamic` (additive) — true when the command name is a + dynamic token the parser cannot statically identify (`& $exe`, + `& { ... }`). Always false for bash clauses; a consumer MUST route a + dynamic clause to safe-fail. + +**PowerShell parser capabilities (SPEC.POWERSHELL.md)** + +- Parses PowerShell command pipelines into the shared `ParsedCommand` AST — + per-clause verbs, args, parameters, redirects, and the `&&` / `||` / `;` + / `|` / newline compound operators. +- Recognizes cmdlets (`Verb-Noun`), native commands, and the complete + built-in alias set; resolves aliases to their canonical cmdlet while + preserving the verbatim typed token. +- The §6.5 parameter-binding model — switch vs. value-binding decisions + from static tables, colon-form `-Name:value`, prefix matching. +- Per-cmdlet / per-parameter path-arg extraction (`-Path`, `-LiteralPath`, + `-Destination`, positional rules). +- `Set-Location ; cmd` cwd propagation, including through `( ... )` + grouping (PowerShell `( )` is not a subshell). +- Recursion into `pwsh -Command ""`, `pwsh -c`, and + `pwsh -EncodedCommand ` (base64 / UTF-16LE decode, BOM strip), + depth-5 capped; inner clauses surface with `IsCommandStringWrapped=true`. +- Marks dynamic-content tokens (`$var`, subexpressions, script blocks, + splatting, comma-arrays) `DynamicSkip`; control flow, definitions, and + other script-level constructs safe-fail to `IsUnparseable=true`. +- A 64 KiB input cap guards the per-shell-call hot path. + +**Corpus & validation** + +- 211 hand-authored PowerShell corpus entries under + `Corpus/powershell/`, exceeding every SPEC.POWERSHELL.md §13 category + minimum. The corpus runner and PII audit are directory-routed by shell. +- A real-`pwsh` validation gate (`PwshOracleTests`) feeds every PowerShell + corpus input to `[Parser]::ParseInput` and enforces the §13 oracle + matrix; a `PwshAliases`-vs-live-`Get-Alias` completeness `[Fact]` + confirms the alias table has no gaps. +- `tools/PwshCorpusTool` — the corpus authoring aid (see `TOOLING.md`). + +--- + #### 0.1.5 May 16th 2026 #### Stable promotion of 0.1.5-beta. No code changes from the beta; this release diff --git a/SPEC.md b/SPEC.md index 22f4d54..ff6c624 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,9 +1,12 @@ -# ShellSyntaxTree — v0.1 Specification +# ShellSyntaxTree — bash & shared-contract Specification -**Status:** Draft for v0.1. Approved decisions; implementation pending. -**Audience:** Whoever (human or agent) implements ShellSyntaxTree v0.1. +**Status:** Shipped. The bash parser is implemented (v0.1.x); v0.2.0 adds the +PowerShell parser and the shared multi-shell surface. +**Audience:** Whoever (human or agent) works on ShellSyntaxTree. **Read this end-to-end before writing any code.** -**PowerShell support is specified separately in `SPEC.POWERSHELL.md` (v0.2.0).** +**PowerShell support is specified separately in `SPEC.POWERSHELL.md` (v0.2.0); +this document is the canonical home of the public API, AST, sanitization +workflow, and consumer contract that PowerShell reuses.** This document specifies the public API, AST, grammar, verb tables, resolver semantics, and corpus contract for ShellSyntaxTree v0.1. The library is a @@ -90,8 +93,31 @@ public sealed class BashParser : IShellParser public ParsedCommand Parse(string command); } -/// Configuration knobs for BashParser. -public sealed record BashParserOptions +/// PowerShell implementation of IShellParser (v0.2.0). The +/// PowerShell grammar, tables, and resolver are specified in +/// SPEC.POWERSHELL.md. +public sealed class PwshParser : IShellParser +{ + public PwshParser(); + public PwshParser(PwshParserOptions options); + public ParsedCommand Parse(string command); +} + +/// Shell-neutral resolver configuration shared by every parser +/// (added v0.2.0). HomeDirectory / WorkingDirectory live here. +public abstract record ShellParserOptions { ... } + +/// Configuration knobs for BashParser. As of v0.2.0 a sealed +/// record deriving from ShellParserOptions; the v0.1 object-initializer +/// shape is unchanged. +public sealed record BashParserOptions : ShellParserOptions; + +/// Configuration knobs for PwshParser (v0.2.0). Empty — the +/// resolver knobs live on ShellParserOptions. +public sealed record PwshParserOptions : ShellParserOptions; + +// The pre-v0.2.0 BashParserOptions body, now hoisted onto ShellParserOptions: +public abstract record ShellParserOptions { /// /// User home directory used to expand `~` and `$HOME` tokens during @@ -192,11 +218,15 @@ public sealed record Clause public bool IsSubshell { get; init; } /// - /// True when this clause is the result of recursing into a `bash -c` - /// or `sh -c` wrapper. Useful for consumers that want to surface - /// "this came from a wrapped invocation" in UI. + /// True when this clause is the result of recursing into a + /// command-string wrapper — `bash -c "..."` / `sh -c "..."`, or (v0.2.0) + /// PowerShell `pwsh -Command "..."` / `pwsh -EncodedCommand ...`. Useful + /// for consumers that want to surface "this came from a wrapped + /// invocation" in UI. /// - public bool IsBashCWrapped { get; init; } + /// Renamed from `IsCommandStringWrapped` in v0.2.0 — see RELEASE_NOTES.md + /// and SPEC.POWERSHELL.md §3 for the old→new mapping. + public bool IsCommandStringWrapped { get; init; } } ``` @@ -217,6 +247,20 @@ public sealed record VerbChain /// public IReadOnlyList Tokens { get; init; } = []; + /// + /// The canonical, alias-resolved verb identity (added v0.2.0). Non-null + /// only when the parser rewrote a built-in alias — `ls` → `Get-ChildItem`. + /// Null for every bash clause. See SPEC.POWERSHELL.md §3. + /// + public string? CanonicalVerb { get; init; } + + /// + /// True when the clause's command name is a dynamic token the parser + /// cannot statically identify — `& $exe`, `& { ... }` (added v0.2.0). + /// Always false for bash clauses. See SPEC.POWERSHELL.md §3. + /// + public bool IsDynamic { get; init; } + /// Convenience: tokens joined with spaces. public string Joined => string.Join(" ", Tokens); } @@ -659,20 +703,23 @@ internal static readonly HashSet FileVerbs = }; ``` -### 6.4 CMD_FILE verbs (Windows cmd; deferred for v0.1 implementation) +### 6.4 CMD_FILE verbs (Windows cmd / PowerShell file utilities) -Reserved for future PowerShell/cmd parser support. Document the table -shape now so the seam is clear. +The Windows native file utilities. As of v0.2.0 the PowerShell parser's +`PwshVerbs.FileVerbs` table consumes this reserved set +(`type`, `copy`, `move`, `del`, `xcopy`, `robocopy`, `findstr`) so a +native Windows file tool in a PowerShell command still gets path +classification. PowerShell *cmdlet* file verbs (`Get-Content`, +`Remove-Item`, `Copy-Item`, ...) are owned by `SPEC.POWERSHELL.md` §6.4 — +they are recognized by cmdlet shape and alias resolution, not by this +table. A Windows `cmd` parser remains deferred (§18). ```csharp internal static readonly HashSet CmdFileVerbs = new(StringComparer.OrdinalIgnoreCase) { - "type", "copy", "move", "del", "erase", "ren", "ren", + "type", "copy", "move", "del", "erase", "ren", "xcopy", "robocopy", "findstr", - // PowerShell cmdlets - "get-content", "set-content", "remove-item", "copy-item", - "move-item", "new-item", }; ``` @@ -943,18 +990,18 @@ the agent emits. The parser: 1. Recognizes the `bash -c` or `sh -c` prefix. 2. Parses the quoted argument as a fresh `ParsedCommand`. 3. Surfaces the inner command's clauses inline in the outer's `Clauses` - list, each with `IsBashCWrapped=true`. + list, each with `IsCommandStringWrapped=true`. Example: `bash -c "cd /a && cmd"` produces: ``` -Clause 0: Op=None, Verb=cd, Args=[/a], IsBashCWrapped=true -Clause 1: Op=AndIf, Verb=cmd, Args=[/a attribution], IsBashCWrapped=true +Clause 0: Op=None, Verb=cd, Args=[/a], IsCommandStringWrapped=true +Clause 1: Op=AndIf, Verb=cmd, Args=[/a attribution], IsCommandStringWrapped=true ``` The outer `bash -c` itself does not appear as a clause — it's "consumed" by the recursion. Consumers that care that this came from a wrapper can -inspect `IsBashCWrapped` on the surfaced clauses. +inspect `IsCommandStringWrapped` on the surfaced clauses. **Recursion limit:** parse `bash -c "bash -c ..."` chains up to depth 5. Deeper nesting → set the outer `ParsedCommand.IsUnparseable = true` with @@ -1034,7 +1081,7 @@ ParsedCommand { ], Redirects = [], IsSubshell = false, - IsBashCWrapped = false + IsCommandStringWrapped = false } ] } @@ -1347,7 +1394,12 @@ Adapt for ShellSyntaxTree: parsed-AST shape when the prior shape violated this SPEC — e.g. v0.1.5 makes a bare newline a statement separator per §4. The §2 public API surface stays locked. -- **v0.2.0** — first PowerShell parser implementation. +- **v0.2.0** — first PowerShell parser implementation (`PwshParser`). Adds + the shared `ShellParserOptions` base, the additive `VerbChain.CanonicalVerb` + / `VerbChain.IsDynamic` fields, and the breaking `Clause.IsBashCWrapped` → + `IsCommandStringWrapped` rename. A breaking AST change on a `0.x` minor is + permitted by Appendix A when `RELEASE_NOTES.md` carries the old→new mapping + and Netclaw is updated in lockstep. See `SPEC.POWERSHELL.md`. - **v1.0.0** — ready when at least one external consumer beyond Netclaw ships against it without finding API gaps. @@ -1472,7 +1524,7 @@ What Netclaw expects from this library: The contract is stable — additive changes to AST records (new fields with default values) are compatible; renaming or removing fields is breaking. Before v1.0.0, while the library is in its `0.x` line, a breaking AST change -MAY ship in a minor bump (e.g. the `Clause.IsBashCWrapped` → +MAY ship in a minor bump (e.g. the `Clause.IsCommandStringWrapped` → `IsCommandStringWrapped` rename in v0.2.0) provided `RELEASE_NOTES.md` documents the old→new mapping and the consumer (Netclaw) is updated in lockstep. From v1.0.0 onward, renaming or removing a field requires a major diff --git a/ShellSyntaxTree.slnx b/ShellSyntaxTree.slnx index ffb6b86..cbbf535 100644 --- a/ShellSyntaxTree.slnx +++ b/ShellSyntaxTree.slnx @@ -14,6 +14,7 @@ + @@ -25,4 +26,7 @@ + + + diff --git a/TOOLING.md b/TOOLING.md index 8f17fff..fcdb4ec 100644 --- a/TOOLING.md +++ b/TOOLING.md @@ -23,8 +23,26 @@ removed — this repo does not need them. | `coverlet.collector` | NuGet | code coverage | The corpus runner (SPEC §13) is a single `[Theory] [MemberData]` test that -enumerates `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` and asserts each -parses to its declared `expected` AST. +enumerates every `tests/ShellSyntaxTree.Tests/Corpus//*.json` +directory, routes each entry to the matching parser (`bash/` → `BashParser`, +`powershell/` → `PwshParser`), and asserts it parses to its declared +`expected` AST. `PwshOracleTests` is the SPEC.POWERSHELL.md §13 validation +gate — it feeds every PowerShell corpus input to real `pwsh` and enforces +the oracle matrix. + +### PwshCorpusTool + +`tools/PwshCorpusTool` is the PowerShell corpus authoring aid +(SPEC.POWERSHELL.md §13). It is a dev-only console app (not packed). + +| Command | Purpose | +|---|---| +| `dotnet run --project tools/PwshCorpusTool -- generate` | Regenerate every `Corpus/powershell/NNN_slug.json` from the curated `CorpusManifest`. Run after any parser change that shifts PowerShell AST output. | +| `dotnet run --project tools/PwshCorpusTool -- check ""` | Print the parser's expected-AST JSON block for a command beside the real-`pwsh` oracle verdict — the fastest way to author or debug a binding-category entry. | + +The curated inputs live in `tools/PwshCorpusTool/CorpusManifest.cs`; the +`expected` AST is generated from `PwshParser`, and `PwshOracleTests` +independently validates every input against real `pwsh`. ## Source Control and CI diff --git a/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditCommand.cs b/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditCommand.cs index 63d6674..d2a151c 100644 --- a/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditCommand.cs +++ b/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditCommand.cs @@ -5,9 +5,9 @@ namespace ShellSyntaxTree.Cli.Sample.Commands; internal static class AuditCommand { - public static int Run(string command) + public static int Run(string command, string shell) { - var parser = new BashParser(); + var parser = ShellParserFactory.Create(shell); var parsed = parser.Parse(command); var anyDeny = false; diff --git a/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs b/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs index 1a5d88d..1310d9f 100644 --- a/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs +++ b/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs @@ -4,16 +4,16 @@ namespace ShellSyntaxTree.Cli.Sample.Commands; /// -/// Pretty-prints the AST returned by . +/// Pretty-prints the AST returned by an . /// Marker tokens ([flag], [path], [cwd-attr], /// [dyn-skip], [glob], [literal]) are stable so that /// downstream tooling can grep the output without re-parsing the AST. /// internal static class ExplainCommand { - public static int Run(string command) + public static int Run(string command, string shell) { - var parser = new BashParser(); + var parser = ShellParserFactory.Create(shell); var parsed = parser.Parse(command); var sb = new StringBuilder(); @@ -33,16 +33,27 @@ public static int Run(string command) sb.Append("Clause ").Append(i) .Append(" (Operator: ").Append(clause.Operator).Append(')'); - if (clause.IsSubshell || clause.IsBashCWrapped) + if (clause.IsSubshell || clause.IsCommandStringWrapped) { sb.Append(" IsSubshell=").Append(clause.IsSubshell) - .Append(" IsBashCWrapped=").Append(clause.IsBashCWrapped); + .Append(" IsCommandStringWrapped=").Append(clause.IsCommandStringWrapped); } sb.AppendLine(); var verbLabel = clause.Verb.Tokens.Count == 0 ? "(none)" : clause.Verb.Joined; - sb.Append(" Verb: ").AppendLine(verbLabel); + sb.Append(" Verb: ").Append(verbLabel); + if (clause.Verb.CanonicalVerb is not null) + { + sb.Append(" [canonical: ").Append(clause.Verb.CanonicalVerb).Append(']'); + } + + if (clause.Verb.IsDynamic) + { + sb.Append(" [dynamic]"); + } + + sb.AppendLine(); if (clause.Args.Count > 0) { diff --git a/samples/ShellSyntaxTree.Cli.Sample/Commands/ShellParserFactory.cs b/samples/ShellSyntaxTree.Cli.Sample/Commands/ShellParserFactory.cs new file mode 100644 index 0000000..46c42c7 --- /dev/null +++ b/samples/ShellSyntaxTree.Cli.Sample/Commands/ShellParserFactory.cs @@ -0,0 +1,18 @@ +using ShellSyntaxTree; + +namespace ShellSyntaxTree.Cli.Sample.Commands; + +/// +/// Picks the for a --shell choice. The +/// rest of the sample works against the shell-neutral +/// / surface — adding PowerShell required no +/// change to the explain / audit logic. +/// +internal static class ShellParserFactory +{ + public static IShellParser Create(string shell) => shell switch + { + "pwsh" => new PwshParser(), + _ => new BashParser(), + }; +} diff --git a/samples/ShellSyntaxTree.Cli.Sample/Program.cs b/samples/ShellSyntaxTree.Cli.Sample/Program.cs index 5f3ec90..fbdc211 100644 --- a/samples/ShellSyntaxTree.Cli.Sample/Program.cs +++ b/samples/ShellSyntaxTree.Cli.Sample/Program.cs @@ -10,19 +10,32 @@ Description = "The shell command (or single line) to analyze.", }; +var shellOption = new Option("--shell") +{ + Description = "Which parser to use: 'bash' (default) or 'pwsh'.", + DefaultValueFactory = _ => "bash", +}; +shellOption.AcceptOnlyFromAmong("bash", "pwsh"); + var explainCmd = new Command("explain", "Pretty-print the parsed AST.") { commandArg, + shellOption, }; -explainCmd.SetAction(parseResult => ExplainCommand.Run(parseResult.GetValue(commandArg) ?? string.Empty)); +explainCmd.SetAction(parseResult => ExplainCommand.Run( + parseResult.GetValue(commandArg) ?? string.Empty, + parseResult.GetValue(shellOption) ?? "bash")); var auditCmd = new Command("audit", "Run the built-in policy against the command.") { commandArg, + shellOption, }; -auditCmd.SetAction(parseResult => AuditCommand.Run(parseResult.GetValue(commandArg) ?? string.Empty)); +auditCmd.SetAction(parseResult => AuditCommand.Run( + parseResult.GetValue(commandArg) ?? string.Empty, + parseResult.GetValue(shellOption) ?? "bash")); -var root = new RootCommand("ShellSyntaxTree sample CLI") +var root = new RootCommand("ShellSyntaxTree sample CLI — bash & PowerShell") { explainCmd, auditCmd, diff --git a/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor b/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor index c719126..32bda4e 100644 --- a/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor +++ b/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor @@ -7,14 +7,22 @@

ShellSyntaxTree Mermaid Visualizer

-

Paste a bash script — see what ShellSyntaxTree parses out. Everything runs in your browser; nothing is sent anywhere.

+

Paste a bash or PowerShell script — see what ShellSyntaxTree parses out. Everything runs in your browser; nothing is sent anywhere.

Input

+
+ +
- @foreach (var preset in Presets.All) + @foreach (var preset in Presets.For(_shell)) { } @@ -89,6 +97,7 @@ private enum Tab { Diagram, Source, Ast, Unparseable } private string _script = string.Empty; + private string _shell = "bash"; private string _mermaidSource = string.Empty; private string _astText = string.Empty; private int _unparseableCount; @@ -100,7 +109,13 @@ protected override void OnInitialized() { - UsePreset(Presets.All[0].Script); + UsePreset(Presets.For(_shell)[0].Script); + } + + private void OnShellChanged(ChangeEventArgs e) + { + _shell = e.Value?.ToString() == "pwsh" ? "pwsh" : "bash"; + UsePreset(Presets.For(_shell)[0].Script); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -135,7 +150,7 @@ private void RenderNow() { - var parser = new BashParser(); + IShellParser parser = _shell == "pwsh" ? new PwshParser() : new BashParser(); var commands = ScriptSplitter.Split(_script).ToList(); var parses = new List(commands.Count); @@ -180,7 +195,7 @@ .Append(" op=").Append(clause.Operator) .Append(" verb=\"").Append(clause.Verb.Joined).Append('"'); if (clause.IsSubshell) sb.Append(" subshell"); - if (clause.IsBashCWrapped) sb.Append(" bash-c"); + if (clause.IsCommandStringWrapped) sb.Append(" cmd-wrapped"); sb.AppendLine(); foreach (var arg in clause.Args) diff --git a/samples/ShellSyntaxTree.Web.Sample/Samples/Presets.cs b/samples/ShellSyntaxTree.Web.Sample/Samples/Presets.cs index 5119ed4..8569536 100644 --- a/samples/ShellSyntaxTree.Web.Sample/Samples/Presets.cs +++ b/samples/ShellSyntaxTree.Web.Sample/Samples/Presets.cs @@ -2,7 +2,7 @@ namespace ShellSyntaxTree.Web.Sample.Samples; public static class Presets { - public static readonly (string Name, string Script)[] All = + public static readonly (string Name, string Script)[] Bash = { ("Build script", "set -e\ncd /repo\ngit pull origin main\ndocker build -t myapp .\ndocker push myapp:latest > /tmp/push.log 2>&1\necho done"), ("With subshell", "cd /a && (cd /b && cmd1) && cmd2"), @@ -10,4 +10,17 @@ public static readonly (string Name, string Script)[] All = ("Unparseable (control flow)", "for i in 1 2 3; do echo $i; done"), ("Dynamic cwd", "cd $REPO_DIR && rm -rf node_modules && npm install"), }; + + public static readonly (string Name, string Script)[] Pwsh = + { + ("Cmdlet pipeline", "Get-ChildItem -Path C:\\logs -Recurse\ngci | ? { $_.Length -gt 1mb } | rm"), + ("Alias resolution", "ls C:\\temp\ndel C:\\temp\\old.txt"), + ("Set-Location propagation", "cd C:\\repo\ngit status"), + ("With pwsh -Command", "pwsh -Command \"Remove-Item C:\\tmp\\x\""), + ("Unparseable (control flow)", "foreach ($f in 1, 2, 3) { Write-Output $f }"), + ("Dynamic call operator", "& $exe arg1"), + }; + + public static (string Name, string Script)[] For(string shell) => + shell == "pwsh" ? Pwsh : Bash; } diff --git a/samples/ShellSyntaxTree.Web.Sample/Services/MermaidRenderer.cs b/samples/ShellSyntaxTree.Web.Sample/Services/MermaidRenderer.cs index ad9bd98..e01176f 100644 --- a/samples/ShellSyntaxTree.Web.Sample/Services/MermaidRenderer.cs +++ b/samples/ShellSyntaxTree.Web.Sample/Services/MermaidRenderer.cs @@ -77,12 +77,12 @@ public static string Render(IReadOnlyList parses) sb.Append(" end\n"); c = groupEnd; } - else if (clause.IsBashCWrapped) + else if (clause.IsCommandStringWrapped) { bashCSerial++; sb.Append(" subgraph BC").Append(bashCSerial).Append(" [\"bash -c\"]\n"); var groupEnd = c; - while (groupEnd < parse.Clauses.Count && parse.Clauses[groupEnd].IsBashCWrapped) + while (groupEnd < parse.Clauses.Count && parse.Clauses[groupEnd].IsCommandStringWrapped) { EmitClauseNode(sb, parse, lineIdx, groupEnd); groupEnd++; diff --git a/src/ShellSyntaxTree/BashParserOptions.cs b/src/ShellSyntaxTree/BashParserOptions.cs index baeb572..6dcf088 100644 --- a/src/ShellSyntaxTree/BashParserOptions.cs +++ b/src/ShellSyntaxTree/BashParserOptions.cs @@ -1,23 +1,16 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Aaron Stannard // // ----------------------------------------------------------------------- namespace ShellSyntaxTree; -/// Configuration knobs for . -public sealed record BashParserOptions -{ - /// - /// User home directory used to expand ~ and $HOME tokens - /// during resolution. Defaults to - /// . - /// - public string? HomeDirectory { get; init; } - - /// - /// Working directory used to resolve relative path tokens during - /// resolution. Defaults to the daemon-process cwd. - /// - public string? WorkingDirectory { get; init; } -} +/// +/// Configuration knobs for . The resolver knobs +/// ( / +/// ) live on the shared +/// base; the shape stays source-compatible +/// with v0.1 — new BashParserOptions { HomeDirectory = ... } still +/// compiles. See SPEC.POWERSHELL.md §2. +/// +public sealed record BashParserOptions : ShellParserOptions; diff --git a/src/ShellSyntaxTree/Clause.cs b/src/ShellSyntaxTree/Clause.cs index 22fcaad..330c6ac 100644 --- a/src/ShellSyntaxTree/Clause.cs +++ b/src/ShellSyntaxTree/Clause.cs @@ -47,8 +47,11 @@ public sealed record Clause /// /// True when this clause is the result of recursing into a - /// bash -c or sh -c wrapper. Useful for consumers that - /// want to surface "this came from a wrapped invocation" in UI. + /// command-string wrapper — bash bash -c "..." / sh -c "...", + /// or PowerShell pwsh -Command "..." / pwsh -c "..." / + /// pwsh -EncodedCommand .... Useful for consumers that want to + /// surface "this came from a wrapped invocation" in UI. See + /// SPEC.POWERSHELL.md §3 / §10. /// - public bool IsBashCWrapped { get; init; } + public bool IsCommandStringWrapped { get; init; } } diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs index 6511414..3dec324 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs @@ -54,7 +54,7 @@ internal static ParsedCommand Parse(string source, BashParserOptions options) /// Recursion entry point. counts how many /// bash -c wrappers we've unwrapped to reach this call; the /// outer caller passes 0. sets - /// on every emitted clause and + /// on every emitted clause and /// fires only on recursive calls (the outer top-level command doesn't /// pretend to be wrapped). /// @@ -221,7 +221,7 @@ private static ParsedCommand ParseInternal( { Operator = op, IsSubshell = isSubshell, - IsBashCWrapped = true, + IsCommandStringWrapped = true, }); } @@ -270,12 +270,12 @@ private static ParsedCommand ParseInternal( foreach (var clause in clauseOrError.Clauses) { - // Apply the IsSubshell / IsBashCWrapped flags first; both + // Apply the IsSubshell / IsCommandStringWrapped flags first; both // are properties of the *segment*, not the clause body. var withFlags = clause with { IsSubshell = segment.SubshellDepth > 0, - IsBashCWrapped = markBashCWrapped, + IsCommandStringWrapped = markBashCWrapped, }; // Inspect the verb to decide whether this clause updates @@ -901,7 +901,7 @@ private static ClauseResult ParseClauseSegment( Args = emptyArgs, Redirects = emptyRedirects, IsSubshell = false, - IsBashCWrapped = false, + IsCommandStringWrapped = false, }); } @@ -938,7 +938,7 @@ private static ClauseResult ParseClauseSegment( Args = args, Redirects = redirects, IsSubshell = false, - IsBashCWrapped = false, + IsCommandStringWrapped = false, }; return ClauseResult.Ok(clause); diff --git a/src/ShellSyntaxTree/Internal/Lexing/OpaqueRegionScanner.cs b/src/ShellSyntaxTree/Internal/Lexing/OpaqueRegionScanner.cs index a23dc0a..9ca4e91 100644 --- a/src/ShellSyntaxTree/Internal/Lexing/OpaqueRegionScanner.cs +++ b/src/ShellSyntaxTree/Internal/Lexing/OpaqueRegionScanner.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Aaron Stannard // @@ -10,20 +10,24 @@ namespace ShellSyntaxTree.Internal.Lexing; /// /// Grammar-agnostic boundary scanner for "opaque regions" — input slices /// that the lexer must skip over verbatim because their interior follows -/// rules the outer lexer does not model. In v0.1 this is bash's -/// $(…) command substitution and backtick `…`; in v0.2 it -/// will additionally serve PowerShell's $( … ) and @( … ). +/// rules the outer lexer does not model. Bash uses it for $(…) +/// command substitution and backtick `…`; PowerShell uses it for +/// $( … ) / @( … ) / @{ … } subexpressions and +/// { … } script blocks. /// -/// The scanner is intentionally permissive about interior content — it -/// does not parse the inside, it only finds the matching close. The -/// outer parser treats the whole region as a single -/// Arg{ Kind = DynamicSkip, IsPath = false } per the locked -/// interpretation in the v0.1 OpenSpec change -/// (proposal §"2. Command substitution + arithmetic + complex param -/// expansion"). +/// The scanner is intentionally permissive about interior content — it does +/// not parse the inside, it only finds the matching close. The escape +/// character is a parameter (SPEC.POWERSHELL.md §16): bash escapes with +/// backslash, PowerShell with backtick. /// internal static class OpaqueRegionScanner { + /// The bash escape character — backslash. + internal const char BashEscape = '\\'; + + /// The PowerShell escape character — backtick. + internal const char PwshEscape = '`'; + /// /// Result of a scan. is the index of the /// closing delimiter character (inclusive) when @@ -35,22 +39,30 @@ internal static class OpaqueRegionScanner /// /// Find the matching close delimiter for an asymmetric opaque region - /// (e.g. (/)) starting at - /// — which must point at the opening delimiter character. Handles: - /// - /// - /// nested same-kind regions (depth tracking), - /// single- and double-quoted nested strings (delimiters - /// inside quotes don't count), - /// \X escapes outside of single quotes (the next char - /// is consumed verbatim). - /// + /// (e.g. (/)) starting at — + /// which must point at the opening delimiter character. Uses the bash + /// escape character (backslash). /// internal static ScanResult Scan( ReadOnlySpan input, int startIndex, char openChar, char closeChar) + => Scan(input, startIndex, openChar, closeChar, BashEscape); + + /// + /// Find the matching close delimiter for an asymmetric opaque region, + /// honoring as the escape character. + /// Handles nested same-kind regions (depth tracking), single- and + /// double-quoted nested strings, and escape+char escapes outside + /// single quotes. + /// + internal static ScanResult Scan( + ReadOnlySpan input, + int startIndex, + char openChar, + char closeChar, + char escapeChar) { // Caller contract: startIndex points at the opening delimiter. // We start scanning at startIndex+1 with depth=1 already counted. @@ -65,10 +77,10 @@ internal static ScanResult Scan( { var c = input[i]; - // Backslash escapes the next character (outside single quotes). + // Escape: consume the escape char and the next char verbatim. // The single-quote branch below short-circuits before this code // ever runs while inside '...'. - if (c == '\\' && i + 1 < input.Length) + if (c == escapeChar && i + 1 < input.Length) { i += 2; continue; @@ -83,7 +95,7 @@ internal static ScanResult Scan( if (c == '"') { - i = SkipDoubleQuoted(input, i + 1); + i = SkipDoubleQuoted(input, i + 1, escapeChar); continue; } @@ -107,12 +119,9 @@ internal static ScanResult Scan( } /// - /// Variant for symmetric opaque regions (e.g. backtick-quoted command - /// substitution) where the open and close delimiters are the same - /// character. points at the opening - /// delimiter; the scan returns at the next unescaped occurrence of - /// . There is no nesting — the next - /// unescaped match wins. + /// Variant for symmetric opaque regions (e.g. backtick-quoted bash + /// command substitution) where the open and close delimiters are the + /// same character. Uses the bash escape character. /// internal static ScanResult ScanSymmetric( ReadOnlySpan input, @@ -129,11 +138,9 @@ internal static ScanResult ScanSymmetric( { var c = input[i]; - if (c == '\\' && i + 1 < input.Length) + if (c == BashEscape && i + 1 < input.Length) { - // Escape: skip backslash + next char verbatim. Bash treats - // \` inside a backtick context as a literal backtick — the - // skip is the right behavior either way. + // Escape: skip backslash + next char verbatim. i += 2; continue; } @@ -150,11 +157,11 @@ internal static ScanResult ScanSymmetric( } /// - /// Skip past a single-quoted string. points at - /// the first char after the opening quote. Returns the index of the - /// char after the closing quote, or input.Length if no close - /// was found. Single-quoted strings preserve bytes literally — no - /// escape processing per SPEC §5. + /// Skip past a single-quoted string. points at the + /// first char after the opening quote. Returns the index of the char + /// after the closing quote, or input.Length if no close was + /// found. Single-quoted strings preserve bytes literally — no escape + /// processing (bash SPEC §5, PowerShell SPEC.POWERSHELL.md §5). /// private static int SkipSingleQuoted(ReadOnlySpan input, int i) { @@ -172,18 +179,18 @@ private static int SkipSingleQuoted(ReadOnlySpan input, int i) } /// - /// Skip past a double-quoted string. points at - /// the first char after the opening quote. Backslash escapes the - /// next character (covers \" in particular). Returns the - /// index of the char after the closing quote, or input.Length - /// if no close was found. + /// Skip past a double-quoted string. points at the + /// first char after the opening quote. + /// escapes the next character (covers \" in bash and `" + /// in PowerShell). Returns the index of the char after the closing + /// quote, or input.Length if no close was found. /// - private static int SkipDoubleQuoted(ReadOnlySpan input, int i) + private static int SkipDoubleQuoted(ReadOnlySpan input, int i, char escapeChar) { while (i < input.Length) { var c = input[i]; - if (c == '\\' && i + 1 < input.Length) + if (c == escapeChar && i + 1 < input.Length) { i += 2; continue; diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs new file mode 100644 index 0000000..7862e84 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs @@ -0,0 +1,863 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.Text; +using ShellSyntaxTree.Internal.Lexing; + +namespace ShellSyntaxTree.Internal.Pwsh.Lexing; + +/// +/// Tokenizer for the PowerShell subset described in SPEC.POWERSHELL.md §4 / +/// §5. Emits a flat list of ; the parser groups them +/// into statements, pipelines, clauses, verb chains, args, and redirects. +/// +/// Design notes: +/// +/// The lexer never expands variables. $var, ${name}, +/// and $env:NAME stay literal inside a Word token; the +/// resolver classifies them. +/// Opaque regions — $( … ), @( … ), @{ … }, +/// { … } — are bounded by +/// (in PowerShell backtick-escape mode) and emitted whole. +/// Malformed regions surface as +/// tokens the +/// parser lifts into ParsedCommand.IsUnparseable. +/// +/// +internal static class PwshLexer +{ + /// Tokenize per SPEC.POWERSHELL.md §5. + internal static IReadOnlyList Tokenize(string input) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + if (input.Length == 0) + { + return Array.Empty(); + } + + var tokens = new List(); + var src = input.AsSpan(); + var i = 0; + + while (i < src.Length) + { + var c = src[i]; + + // ---- whitespace ---- + if (c == ' ' || c == '\t') + { + var start = i; + while (i < src.Length && (src[i] == ' ' || src[i] == '\t')) + { + i++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.Whitespace, "", null, start, i - start, null)); + continue; + } + + // ---- newline run (statement separator, SPEC §4) ---- + if (c == '\n' || c == '\r') + { + var start = i; + while (i < src.Length && (src[i] == '\n' || src[i] == '\r')) + { + i++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.Whitespace, "", null, start, i - start, null) + { IsStatementSeparator = true }); + continue; + } + + // ---- backtick + newline = line continuation ---- + if (c == '`' && i + 1 < src.Length && (src[i + 1] == '\n' || src[i + 1] == '\r')) + { + var start = i; + i += 2; + if (i - start == 2 && start + 1 < src.Length && src[start + 1] == '\r' + && i < src.Length && src[i] == '\n') + { + i++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.Continuation, "", null, start, i - start, null)); + continue; + } + + // ---- block comment <# ... #> ---- (before the '<' redirect check) + if (c == '<' && i + 1 < src.Length && src[i + 1] == '#') + { + i = ConsumeBlockComment(src, i, tokens); + continue; + } + + // ---- line comment ---- (reaching here implies a token boundary) + if (c == '#') + { + i = ConsumeLineComment(src, i, tokens); + continue; + } + + // ---- --% stop-parsing token ---- + if (c == '-' && IsStopParsingToken(src, i)) + { + i = ConsumeStopParsing(src, i, tokens); + continue; + } + + // ---- operators (incl. redirects) ---- + if (TryReadOperator(src, i, out var opLen, out var opText)) + { + tokens.Add(new PwshToken( + PwshTokenKind.Operator, "", opText, i, opLen, null)); + i += opLen; + continue; + } + + // ---- quoted strings ---- + if (c == '\'') + { + i = ReadSingleQuoted(src, i, tokens); + continue; + } + + if (c == '"') + { + i = ReadDoubleQuoted(src, i, tokens); + continue; + } + + // ---- @ dispatch: here-strings, @( @{ subexpressions, splat ---- + if (c == '@') + { + var afterAt = TryReadAtConstruct(src, i, tokens); + if (afterAt >= 0) + { + i = afterAt; + continue; + } + + // Bare '@' — fall through to the word reader. + } + + // ---- $( ... ) subexpression ---- + if (c == '$' && i + 1 < src.Length && src[i + 1] == '(') + { + i = ConsumeBalancedRegion( + src, i, openAt: i + 1, '(', ')', PwshTokenKind.Subexpression, + "unbalanced '$(' subexpression", tokens); + continue; + } + + // ---- { ... } script block ---- + if (c == '{') + { + i = ConsumeBalancedRegion( + src, i, openAt: i, '{', '}', PwshTokenKind.ScriptBlock, + "unbalanced '{' script block", tokens); + continue; + } + + // ---- stray closing brace ---- + if (c == '}') + { + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(i).ToString(), + null, + i, + src.Length - i, + $"unbalanced '}}' at position {i}")); + return tokens; + } + + // ---- parameter -Name ---- + if (c == '-' && IsParameterStart(src, i)) + { + i = ReadParameter(src, i, tokens); + continue; + } + + // ---- word ---- + i = ReadWord(src, i, tokens); + } + + return tokens; + } + + // ---------------------------------------------------------------- operators + + private static bool TryReadOperator( + ReadOnlySpan src, int i, out int length, out string? text) + { + var c0 = src[i]; + + // Pipeline-chain operators (longest match first). + if (i + 1 < src.Length) + { + var c1 = src[i + 1]; + if (c0 == '&' && c1 == '&') { length = 2; text = "&&"; return true; } + if (c0 == '|' && c1 == '|') { length = 2; text = "||"; return true; } + } + + // Redirects (incl. stream-prefixed and merge forms). + if (TryReadRedirect(src, i, out length, out text)) + { + return true; + } + + switch (c0) + { + case '|': length = 1; text = "|"; return true; + case '&': length = 1; text = "&"; return true; + case ';': length = 1; text = ";"; return true; + case '(': length = 1; text = "("; return true; + case ')': length = 1; text = ")"; return true; + default: + length = 0; text = null; return false; + } + } + + /// + /// SPEC.POWERSHELL.md §5: recognize redirect operators, longest-match + /// first — >, >>, <; N> / + /// N>> for stream N in {1..6}; *> / *>>; + /// and the stream-merge form N>&M. + /// + private static bool TryReadRedirect( + ReadOnlySpan src, int i, out int length, out string? text) + { + length = 0; + text = null; + + var prefixed = false; + var p = i; + if (src[i] == '*' || (src[i] >= '1' && src[i] <= '6')) + { + if (i + 1 < src.Length && src[i + 1] == '>') + { + prefixed = true; + p = i + 1; + } + else + { + // A bare digit / '*' not followed by '>' is not a redirect. + return false; + } + } + + if (p >= src.Length) + { + return false; + } + + var op = src[p]; + if (op == '<') + { + // '<' takes no stream prefix. + if (prefixed) + { + return false; + } + + length = 1; + text = "<"; + return true; + } + + if (op != '>') + { + return false; + } + + // Stream-merge: '>' '&' digit. + if (p + 2 < src.Length && src[p + 1] == '&' + && src[p + 2] >= '1' && src[p + 2] <= '6') + { + length = (p + 3) - i; + text = src.Slice(i, length).ToString(); + return true; + } + + // Append: '>>'. + if (p + 1 < src.Length && src[p + 1] == '>') + { + length = (p + 2) - i; + text = src.Slice(i, length).ToString(); + return true; + } + + // Plain '>'. + length = (p + 1) - i; + text = src.Slice(i, length).ToString(); + return true; + } + + // ---------------------------------------------------------------- quoted + + private static int ReadSingleQuoted( + ReadOnlySpan src, int start, List tokens) + { + // Single quotes preserve bytes literally (SPEC §5). A doubled '' is + // an escaped single quote. + var sb = new StringBuilder(); + var i = start + 1; + while (i < src.Length) + { + if (src[i] == '\'') + { + if (i + 1 < src.Length && src[i + 1] == '\'') + { + sb.Append('\''); + i += 2; + continue; + } + + tokens.Add(new PwshToken( + PwshTokenKind.QuotedString, sb.ToString(), null, + start, (i - start) + 1, null) { IsSingleQuoted = true }); + return i + 1; + } + + sb.Append(src[i]); + i++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), null, start, src.Length - start, + $"unbalanced single quote at position {start}")); + return src.Length; + } + + private static int ReadDoubleQuoted( + ReadOnlySpan src, int start, List tokens) + { + // Double quotes allow backtick escapes and recognize $var / $(...) + // interpolation, but the parser does NOT expand — $var stays literal + // in the value (SPEC §5). + var sb = new StringBuilder(); + var i = start + 1; + while (i < src.Length) + { + var c = src[i]; + if (c == '"') + { + // A doubled "" is an escaped double quote. + if (i + 1 < src.Length && src[i + 1] == '"') + { + sb.Append('"'); + i += 2; + continue; + } + + tokens.Add(new PwshToken( + PwshTokenKind.QuotedString, sb.ToString(), null, + start, (i - start) + 1, null)); + return i + 1; + } + + if (c == '`' && i + 1 < src.Length) + { + var n = src[i + 1]; + sb.Append(n switch + { + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + '0' => '\0', + 'a' => '\a', + 'b' => '\b', + 'f' => '\f', + 'v' => '\v', + _ => n, + }); + i += 2; + continue; + } + + sb.Append(c); + i++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), null, start, src.Length - start, + $"unbalanced double quote at position {start}")); + return src.Length; + } + + // ---------------------------------------------------------------- @ constructs + + /// + /// Handle a token starting with @: here-strings (@" … "@ / + /// @' … '@), array/hash subexpressions (@( … ) / + /// @{ … }), and splatting (@identifier). Returns the new + /// scan index, or -1 when the @ is a bare word character. + /// + private static int TryReadAtConstruct( + ReadOnlySpan src, int start, List tokens) + { + if (start + 1 >= src.Length) + { + return -1; + } + + var next = src[start + 1]; + + // Here-strings: @" or @' followed (after optional ws) by a newline. + if (next == '"' || next == '\'') + { + var afterHere = TryReadHereString(src, start, next, tokens); + return afterHere; // -1 when not a here-string. + } + + // Array / hash subexpression. + if (next == '(') + { + return ConsumeBalancedRegion( + src, start, openAt: start + 1, '(', ')', PwshTokenKind.Subexpression, + "unbalanced '@(' array subexpression", tokens); + } + + if (next == '{') + { + return ConsumeBalancedRegion( + src, start, openAt: start + 1, '{', '}', PwshTokenKind.Subexpression, + "unbalanced '@{' hash literal", tokens); + } + + // Splatting: @identifier. + if (IsIdentifierStart(next)) + { + var i = start + 1; + while (i < src.Length && IsIdentifierContinuation(src[i])) + { + i++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.Splat, src.Slice(start, i - start).ToString(), + null, start, i - start, null)); + return i; + } + + return -1; + } + + private static int TryReadHereString( + ReadOnlySpan src, int start, char quote, List tokens) + { + // start points at '@'; src[start+1] is the quote. After the quote + // only whitespace is permitted before the newline. + var j = start + 2; + while (j < src.Length && (src[j] == ' ' || src[j] == '\t')) + { + j++; + } + + if (j >= src.Length || (src[j] != '\n' && src[j] != '\r')) + { + return -1; // Not a here-string. + } + + // Skip the opening newline. + if (src[j] == '\r' && j + 1 < src.Length && src[j + 1] == '\n') + { + j += 2; + } + else + { + j += 1; + } + + var bodyStart = j; + + // Find a line whose first two chars are quote + '@'. + var k = j; + while (k < src.Length) + { + var atLineStart = k == bodyStart || src[k - 1] == '\n' + || (src[k - 1] == '\r' && (k < 2 || src[k - 2] != '\n')); + if (atLineStart && k + 1 < src.Length && src[k] == quote && src[k + 1] == '@') + { + // Body ends at the newline preceding this terminator line. + var bodyEnd = k; + if (bodyEnd > bodyStart && src[bodyEnd - 1] == '\n') + { + bodyEnd--; + } + + if (bodyEnd > bodyStart && src[bodyEnd - 1] == '\r') + { + bodyEnd--; + } + + var body = bodyEnd >= bodyStart + ? src.Slice(bodyStart, bodyEnd - bodyStart).ToString() + : string.Empty; + var end = k + 2; // past quote + '@' + tokens.Add(new PwshToken( + PwshTokenKind.QuotedString, body, null, + start, end - start, null) + { IsHereString = true, IsSingleQuoted = quote == '\'' }); + return end; + } + + k++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), null, start, src.Length - start, + $"unterminated here-string at position {start}")); + return src.Length; + } + + // ---------------------------------------------------------------- regions + + /// + /// Consume a balanced opaque region ($( ), @( ), + /// @{ }, or { }) and emit a single token of + /// . is where the token + /// begins (the $ / @ / {); + /// is the opening bracket. + /// + private static int ConsumeBalancedRegion( + ReadOnlySpan src, int start, int openAt, + char openChar, char closeChar, PwshTokenKind kind, + string unbalancedReason, List tokens) + { + var scan = OpaqueRegionScanner.Scan( + src, openAt, openChar, closeChar, OpaqueRegionScanner.PwshEscape); + if (!scan.Closed) + { + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), null, start, src.Length - start, + unbalancedReason)); + return src.Length; + } + + var length = scan.EndIndex - start + 1; + tokens.Add(new PwshToken( + kind, src.Slice(start, length).ToString(), null, start, length, null)); + return start + length; + } + + // ---------------------------------------------------------------- comments + + private static int ConsumeLineComment( + ReadOnlySpan src, int start, List tokens) + { + var i = start; + while (i < src.Length && src[i] != '\n' && src[i] != '\r') + { + i++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.Comment, "", null, start, i - start, null)); + return i; + } + + private static int ConsumeBlockComment( + ReadOnlySpan src, int start, List tokens) + { + // start points at '<', src[start+1] == '#'. Scan for '#>'. + var i = start + 2; + while (i + 1 < src.Length) + { + if (src[i] == '#' && src[i + 1] == '>') + { + var length = (i + 2) - start; + tokens.Add(new PwshToken( + PwshTokenKind.Comment, "", null, start, length, null)); + return i + 2; + } + + i++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(start).ToString(), null, start, src.Length - start, + $"unterminated block comment '<# … #>' at position {start}")); + return src.Length; + } + + // ---------------------------------------------------------------- --% + + private static bool IsStopParsingToken(ReadOnlySpan src, int i) + { + if (i + 2 >= src.Length || src[i] != '-' || src[i + 1] != '-' || src[i + 2] != '%') + { + return false; + } + + // `--%` must be a standalone token: followed by whitespace, newline, + // or end of input. + if (i + 3 >= src.Length) + { + return true; + } + + var after = src[i + 3]; + return after == ' ' || after == '\t' || after == '\n' || after == '\r'; + } + + private static int ConsumeStopParsing( + ReadOnlySpan src, int start, List tokens) + { + // SPEC §4 / §10: the remainder of the line — including any |, ;, &&, + // || — becomes one opaque token. + var i = start; + while (i < src.Length && src[i] != '\n' && src[i] != '\r') + { + i++; + } + + tokens.Add(new PwshToken( + PwshTokenKind.StopParsing, + src.Slice(start, i - start).ToString(), null, start, i - start, null)); + return i; + } + + // ---------------------------------------------------------------- parameters + + /// + /// True when the - at begins a + /// -Name parameter: after one or two dashes there is an ASCII + /// letter, _, or ?. A bare - / -- and a + /// negative number (-5) are words, not parameters. + /// + private static bool IsParameterStart(ReadOnlySpan src, int i) + { + var p = i + 1; + if (p < src.Length && src[p] == '-') + { + p++; + } + + if (p >= src.Length) + { + return false; + } + + var c = src[p]; + return IsAsciiLetter(c) || c == '_' || c == '?'; + } + + private static int ReadParameter( + ReadOnlySpan src, int start, List tokens) + { + var i = start + 1; + if (i < src.Length && src[i] == '-') + { + i++; + } + + // Parameter name: letters, digits, underscores. + while (i < src.Length && IsIdentifierContinuation(src[i])) + { + i++; + } + + // Colon form -Name:value — consume the value word-style. The parser + // splits the token on the first ':'. + if (i < src.Length && src[i] == ':') + { + i++; + i = ScanWordRun(src, i); + } + + tokens.Add(new PwshToken( + PwshTokenKind.Parameter, src.Slice(start, i - start).ToString(), + null, start, i - start, null)); + return i; + } + + // ---------------------------------------------------------------- words + + private static int ReadWord( + ReadOnlySpan src, int start, List tokens) + { + var sb = new StringBuilder(); + var i = start; + while (i < src.Length) + { + var c = src[i]; + + if (IsWordBoundary(c)) + { + break; + } + + // Backtick escape outside quotes: `X takes X literally. + if (c == '`') + { + if (i + 1 >= src.Length) + { + sb.Append('`'); + i++; + break; + } + + var n = src[i + 1]; + if (n == '\n' || n == '\r') + { + break; // line continuation — handled by the outer loop + } + + sb.Append(n); + i += 2; + continue; + } + + // $( terminates the word (subexpression is its own token). + if (c == '$' && i + 1 < src.Length && src[i + 1] == '(') + { + break; + } + + // ${name} is absorbed verbatim into the word. + if (c == '$' && i + 1 < src.Length && src[i + 1] == '{') + { + var scan = OpaqueRegionScanner.Scan( + src, i + 1, '{', '}', OpaqueRegionScanner.PwshEscape); + if (!scan.Closed) + { + // Unbalanced — emit the $ literally and let the outer + // loop reach the '{' and produce a sentinel. + sb.Append('$'); + i++; + continue; + } + + var braceLen = scan.EndIndex - i + 1; + for (var k = 0; k < braceLen; k++) + { + sb.Append(src[i + k]); + } + + i += braceLen; + continue; + } + + sb.Append(c); + i++; + } + + if (sb.Length == 0) + { + // Defensive: make progress on a char the dispatcher missed. + return start + 1; + } + + tokens.Add(new PwshToken( + PwshTokenKind.Word, sb.ToString(), null, start, i - start, null)); + return i; + } + + /// + /// Scan a word-style run (used for the colon-form parameter value), + /// returning the index just past the run. Honors backtick escapes and + /// ${name} absorption; stops at a word boundary. + /// + private static int ScanWordRun(ReadOnlySpan src, int i) + { + while (i < src.Length) + { + var c = src[i]; + if (IsWordBoundary(c)) + { + break; + } + + if (c == '`') + { + if (i + 1 >= src.Length) + { + i++; + break; + } + + if (src[i + 1] == '\n' || src[i + 1] == '\r') + { + break; + } + + i += 2; + continue; + } + + if (c == '$' && i + 1 < src.Length && src[i + 1] == '(') + { + break; + } + + if (c == '$' && i + 1 < src.Length && src[i + 1] == '{') + { + var scan = OpaqueRegionScanner.Scan( + src, i + 1, '{', '}', OpaqueRegionScanner.PwshEscape); + if (scan.Closed) + { + i = scan.EndIndex + 1; + continue; + } + } + + i++; + } + + return i; + } + + private static bool IsWordBoundary(char c) + { + switch (c) + { + case ' ': + case '\t': + case '\n': + case '\r': + case '\'': + case '"': + case '{': + case '}': + case '(': + case ')': + case ';': + case '|': + case '&': + case '<': + case '>': + return true; + default: + return false; + } + } + + // ---------------------------------------------------------------- helpers + + private static bool IsAsciiLetter(char c) => + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + + private static bool IsIdentifierStart(char c) => + IsAsciiLetter(c) || c == '_'; + + private static bool IsIdentifierContinuation(char c) => + IsAsciiLetter(c) || (c >= '0' && c <= '9') || c == '_'; +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs new file mode 100644 index 0000000..3a734c5 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs @@ -0,0 +1,61 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +namespace ShellSyntaxTree.Internal.Pwsh.Lexing; + +/// +/// One token emitted by . +/// +/// Token class — see . +/// +/// The token's logical content, with quote delimiters stripped for +/// . For +/// this is the text after backtick-escape processing. For +/// this is the verbatim -Name +/// or -Name:value text. For , +/// , , +/// and this is the full verbatim +/// source slice. Empty for kinds that carry no content. +/// +/// For , the +/// literal operator text. Null otherwise. +/// 0-based index into the original input where +/// this token starts. +/// Length of the original-input slice this token +/// covers, in chars. +/// For +/// , the human-readable +/// reason. Null otherwise. +internal readonly record struct PwshToken( + PwshTokenKind Kind, + string Value, + string? OperatorText, + int SourceStart, + int SourceLength, + string? UnparseableReason) +{ + /// + /// True when this is a single-quoted + /// or a literal (@'...'@) here-string. PowerShell semantics: + /// contents are literal bytes — no variable expansion, no escape + /// processing. The resolver consults this to bypass meta-character + /// handling (SPEC.POWERSHELL.md §8 step 0). + /// + public bool IsSingleQuoted { get; init; } + + /// + /// True when this came from a + /// here-string (@" ... "@ or @' ... '@). + /// + public bool IsHereString { get; init; } + + /// + /// True when this token contains + /// a newline and therefore acts as a statement separator equivalent to + /// ; (SPEC.POWERSHELL.md §4). The parser retains these past the + /// significant-token filter and splits statements on them. + /// + public bool IsStatementSeparator { get; init; } +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshTokenKind.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshTokenKind.cs new file mode 100644 index 0000000..7eb84f6 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshTokenKind.cs @@ -0,0 +1,69 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +namespace ShellSyntaxTree.Internal.Pwsh.Lexing; + +/// +/// Token classes emitted by . See +/// SPEC.POWERSHELL.md §5 for the canonical definitions. +/// +internal enum PwshTokenKind +{ + /// A bare token — command name, native arg, path, number, + /// $var, ${name}, $env:PATH, drive-qualified + /// C:\x. Backtick escapes processed; simple $x / + /// ${x} absorbed. + Word, + + /// A -Name parameter token. A -Name:value colon + /// form keeps the value in ; the parser + /// splits on the first :. + Parameter, + + /// Single-quoted, double-quoted, or here-string. Delimiters + /// stripped from the value. Carries + /// and . + QuotedString, + + /// One of ;, &&, ||, |, + /// &, (, ), or a redirect operator. The literal + /// text is in . + Operator, + + /// Spaces/tabs, or a newline run. A newline-bearing run carries + /// = true. + Whitespace, + + /// Backtick + newline line continuation. Treated as + /// whitespace by the parser. + Continuation, + + /// A # line comment or a <# ... #> block + /// comment. Dropped by the significant-token filter. + Comment, + + /// A balanced { ... } script-block region, emitted + /// whole. Parser → DynamicSkip arg. + ScriptBlock, + + /// A balanced $( ... ), @( ... ), or + /// @{ ... } region, emitted whole. Parser → DynamicSkip + /// arg. + Subexpression, + + /// @identifier splatting. Parser → DynamicSkip + /// arg. + Splat, + + /// The --% stop-parsing token; + /// carries the verbatim line remainder. Parser → one DynamicSkip + /// arg. + StopParsing, + + /// An unbalanced region or a lex-time-detected unsupported + /// construct. The reason is in ; + /// the parser lifts it to ParsedCommand.IsUnparseable. + UnparseableSentinel, +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs new file mode 100644 index 0000000..8eb2589 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -0,0 +1,1347 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.Text; +using ShellSyntaxTree.Internal.Bash.Verbs; +using ShellSyntaxTree.Internal.Pwsh.Lexing; +using ShellSyntaxTree.Internal.Pwsh.Verbs; +using ShellSyntaxTree.Internal.Resolving; + +namespace ShellSyntaxTree.Internal.Pwsh.Parsing; + +/// +/// Translates a token stream into the public +/// AST. Implements SPEC.POWERSHELL.md §4–§11: +/// pipeline / statement splitting, verb-chain extraction, the §6.5 +/// parameter-binding model, the resolver, Set-Location propagation, +/// pwsh -Command / -EncodedCommand recursion, and the +/// safe-fail anomaly contract. +/// +internal static class PwshCommandParser +{ + /// Maximum pwsh -Command recursion depth (§10 / §11). + private const int MaxRecursionDepth = 5; + + /// Input cap — 64 KiB of UTF-16 chars (§11 item 10). + private const int InputCapChars = 64 * 1024; + + internal static ParsedCommand Parse(string source, PwshParserOptions options) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return ParseInternal(source, options, recursionDepth: 0, markWrapped: false); + } + + private static ParsedCommand ParseInternal( + string source, PwshParserOptions options, int recursionDepth, bool markWrapped) + { + // §11 item 10: the size cap is checked before lexing, on the + // top-level input and on every decoded payload. + if (source.Length > InputCapChars) + { + return Unparseable(source, $"input exceeds the 64 KiB parser cap ({source.Length} chars)"); + } + + if (source.Length == 0) + { + return new ParsedCommand { Source = source, Clauses = Array.Empty() }; + } + + var tokens = PwshLexer.Tokenize(source); + + // Lift the first lexer sentinel (unbalanced quote / region, etc.). + foreach (var t in tokens) + { + if (t.Kind == PwshTokenKind.UnparseableSentinel) + { + return Unparseable(source, t.UnparseableReason); + } + } + + var significant = FilterSignificant(tokens); + if (significant.Count == 0) + { + // Comment-only / whitespace-only input. + return new ParsedCommand { Source = source, Clauses = Array.Empty() }; + } + + if (TryDetectAnomaly(significant, out var anomalyReason)) + { + return Unparseable(source, anomalyReason); + } + + var segments = SplitIntoSegments(significant, source, out var splitError); + if (splitError is not null) + { + return Unparseable(source, splitError); + } + + var clauses = new List(segments.Count); + var attribution = new PwshSetLocationContext(); + + foreach (var segment in segments) + { + if (segment.Tokens.Count == 0) + { + continue; + } + + // Effective resolver options reflect Set-Location attribution. + var effectiveOptions = options; + var workingDirectoryUnknown = false; + if (attribution.HasAttribution && !attribution.IsDynamic) + { + effectiveOptions = new PwshParserOptions + { + HomeDirectory = options.HomeDirectory, + WorkingDirectory = attribution.ResolvedCwd, + }; + } + else if (attribution.IsDynamic) + { + workingDirectoryUnknown = true; + } + + var built = BuildSegment( + segment, source, options, effectiveOptions, workingDirectoryUnknown, + recursionDepth, markWrapped); + if (built.Error is not null) + { + return Unparseable(source, built.Error); + } + + for (var k = 0; k < built.Clauses.Count; k++) + { + var clause = built.Clauses[k]; + + // pwsh-recursion clauses already carry IsCommandStringWrapped; + // they do not receive the outer attribution arg (a recursed + // command runs in a fresh runspace). + if (!built.IsRecursion) + { + clause = AttachAttributionArg(clause, attribution); + } + + clauses.Add(clause); + } + + // A Set-Location clause updates the attributed cwd for the + // clauses that follow it (§9). Recursion expansion never carries + // a Set-Location at the compound level. + if (!built.IsRecursion && built.Clauses.Count == 1) + { + UpdateAttribution(built.Clauses[0], options, attribution); + } + } + + return new ParsedCommand { Source = source, Clauses = clauses }; + } + + private static ParsedCommand Unparseable(string source, string? reason) => new() + { + Source = source, + Clauses = Array.Empty(), + IsUnparseable = true, + UnparseableReason = reason, + }; + + // ---------------------------------------------------------------- filtering + + private static List FilterSignificant(IReadOnlyList tokens) + { + var filtered = new List(tokens.Count); + foreach (var t in tokens) + { + if ((t.Kind == PwshTokenKind.Whitespace && !t.IsStatementSeparator) + || t.Kind == PwshTokenKind.Continuation + || t.Kind == PwshTokenKind.Comment) + { + continue; + } + + filtered.Add(t); + } + + return filtered; + } + + // ---------------------------------------------------------------- anomalies + + private static bool TryDetectAnomaly(IReadOnlyList tokens, out string? reason) + { + // Item 3: control-flow / definition / block keyword at a verb slot. + if (TryDetectKeywordAnomaly(tokens, out reason)) + { + return true; + } + + // Item 4: a trailing '&' background-job operator. + if (TryDetectTrailingAmp(tokens, out reason)) + { + return true; + } + + // Item 5: an assignment or bare type-literal statement. + if (TryDetectAssignmentOrTypeLiteral(tokens, out reason)) + { + return true; + } + + reason = null; + return false; + } + + private static bool TryDetectKeywordAnomaly(IReadOnlyList tokens, out string? reason) + { + var verbSlot = true; + for (var i = 0; i < tokens.Count; i++) + { + var t = tokens[i]; + if (t.Kind == PwshTokenKind.Whitespace) + { + verbSlot = true; + continue; + } + + if (t.Kind == PwshTokenKind.Operator) + { + verbSlot = t.OperatorText is "&&" or "||" or ";" or "|" or "(" or "&"; + continue; + } + + if (verbSlot && t.Kind == PwshTokenKind.Word + && PwshVerbs.ControlFlowKeywords.Contains(t.Value)) + { + if (string.Equals(t.Value, "foreach", StringComparison.OrdinalIgnoreCase)) + { + // `foreach (` is the loop keyword; `foreach {` is the + // ForEach-Object alias (§6.3 collision rule). + if (NextSignificantIsOpenParen(tokens, i)) + { + reason = "control-flow keyword 'foreach' is not supported in v0.2"; + return true; + } + } + else + { + reason = $"control-flow / definition keyword '{t.Value}' is not supported in v0.2"; + return true; + } + } + + verbSlot = false; + } + + reason = null; + return false; + } + + private static bool TryDetectTrailingAmp(IReadOnlyList tokens, out string? reason) + { + var verbSlot = true; + foreach (var t in tokens) + { + if (t.Kind == PwshTokenKind.Whitespace) + { + verbSlot = true; + continue; + } + + if (t.Kind == PwshTokenKind.Operator) + { + if (t.OperatorText == "&" && !verbSlot) + { + // `& cmd` is the call operator (verb slot); a `&` after a + // command is a background-job operator (§11 item 6). + reason = "trailing '&' background-job operator is not supported in v0.2"; + return true; + } + + verbSlot = t.OperatorText is "&&" or "||" or ";" or "|" or "(" or "&"; + continue; + } + + verbSlot = false; + } + + reason = null; + return false; + } + + private static bool TryDetectAssignmentOrTypeLiteral( + IReadOnlyList tokens, out string? reason) + { + var verbSlot = true; + for (var i = 0; i < tokens.Count; i++) + { + var t = tokens[i]; + if (t.Kind == PwshTokenKind.Whitespace) + { + verbSlot = true; + continue; + } + + if (t.Kind == PwshTokenKind.Operator) + { + verbSlot = t.OperatorText is "&&" or "||" or ";" or "|" or "(" or "&"; + continue; + } + + if (verbSlot && t.Kind == PwshTokenKind.Word) + { + var v = t.Value; + if (v.Length > 0 && v[0] == '[') + { + reason = "a bare type-literal / .NET method-call statement is not supported in v0.2"; + return true; + } + + if (v.Length > 0 && v[0] == '$' + && (v.IndexOf('=') > 0 || NextIsAssignmentOperator(tokens, i))) + { + reason = "an assignment statement is not supported in v0.2"; + return true; + } + } + + verbSlot = false; + } + + reason = null; + return false; + } + + private static bool NextSignificantIsOpenParen(IReadOnlyList tokens, int i) + { + for (var j = i + 1; j < tokens.Count; j++) + { + if (tokens[j].Kind == PwshTokenKind.Whitespace) + { + continue; + } + + return tokens[j].Kind == PwshTokenKind.Operator && tokens[j].OperatorText == "("; + } + + return false; + } + + private static bool NextIsAssignmentOperator(IReadOnlyList tokens, int i) + { + for (var j = i + 1; j < tokens.Count; j++) + { + if (tokens[j].Kind == PwshTokenKind.Whitespace) + { + continue; + } + + if (tokens[j].Kind != PwshTokenKind.Word) + { + return false; + } + + var v = tokens[j].Value; + return v is "=" or "+=" or "-=" or "*=" or "/=" or "%="; + } + + return false; + } + + // ---------------------------------------------------------------- segments + + private sealed class Segment + { + public CompoundOperator PrecedingOperator { get; init; } + + public List Tokens { get; } = new(); + + public int Depth { get; init; } + } + + private static List SplitIntoSegments( + IReadOnlyList tokens, string source, out string? error) + { + var segments = new List(); + var depth = 0; + + Segment current = new() { PrecedingOperator = CompoundOperator.None, Depth = 0 }; + + void Flush() + { + if (current.Tokens.Count > 0) + { + segments.Add(current); + } + } + + for (var i = 0; i < tokens.Count; i++) + { + var t = tokens[i]; + + if (t.Kind == PwshTokenKind.Whitespace) + { + // A retained whitespace token is a newline statement + // separator. A separator on an empty pending segment + // collapses (blank lines, a newline after `|` / `&&` / `||`). + if (current.Tokens.Count == 0) + { + continue; + } + + segments.Add(current); + current = new Segment { PrecedingOperator = CompoundOperator.Sequence, Depth = depth }; + continue; + } + + if (t.Kind == PwshTokenKind.Operator) + { + var op = t.OperatorText; + if (op == "(") + { + Flush(); + depth++; + current = new Segment { PrecedingOperator = CompoundOperator.None, Depth = depth }; + continue; + } + + if (op == ")") + { + if (depth == 0) + { + error = $"unbalanced ')' at position {t.SourceStart}"; + return segments; + } + + depth--; + Flush(); + current = new Segment { PrecedingOperator = CompoundOperator.None, Depth = depth }; + continue; + } + + if (op is "&&" or "||" or ";" or "|") + { + if (current.Tokens.Count == 0 && current.PrecedingOperator != CompoundOperator.None) + { + error = $"unexpected operator '{op}' at position {t.SourceStart}"; + return segments; + } + + Flush(); + current = new Segment { PrecedingOperator = MapOperator(op), Depth = depth }; + continue; + } + + // Redirect operators and a bare '&' stay inside the segment. + current.Tokens.Add(t); + continue; + } + + current.Tokens.Add(t); + } + + if (depth != 0) + { + error = $"unbalanced '(' grouping at position {source.Length}"; + return segments; + } + + Flush(); + error = null; + return segments; + } + + private static CompoundOperator MapOperator(string? op) => op switch + { + "&&" => CompoundOperator.AndIf, + "||" => CompoundOperator.OrIf, + ";" => CompoundOperator.Sequence, + "|" => CompoundOperator.Pipe, + _ => CompoundOperator.None, + }; + + // ---------------------------------------------------------------- segment build + + private readonly struct BuildResult + { + public IReadOnlyList Clauses { get; } + + public string? Error { get; } + + public bool IsRecursion { get; } + + private BuildResult(IReadOnlyList clauses, string? error, bool isRecursion) + { + Clauses = clauses; + Error = error; + IsRecursion = isRecursion; + } + + public static BuildResult Ok(Clause c) => new(new[] { c }, null, false); + + public static BuildResult Recursion(IReadOnlyList clauses) => + new(clauses, null, true); + + public static BuildResult Fail(string? reason) => + new(Array.Empty(), reason ?? "inner parse failed", false); + } + + private static BuildResult BuildSegment( + Segment segment, string source, PwshParserOptions baseOptions, + PwshParserOptions effectiveOptions, bool workingDirectoryUnknown, + int recursionDepth, bool markWrapped) + { + var body = segment.Tokens; + var start = 0; + if (body.Count > 0 && body[0].Kind == PwshTokenKind.Operator + && body[0].OperatorText == "&") + { + // Leading call operator `& cmd` — the command follows. + start = 1; + } + + if (start >= body.Count) + { + // A lone call operator (`& ( ... )` — the group followed in a + // separate segment). Emit a dynamic, verb-less clause. + return BuildResult.Ok(new Clause + { + Operator = segment.PrecedingOperator, + Verb = new VerbChain { IsDynamic = true }, + IsSubshell = segment.Depth > 0, + IsCommandStringWrapped = markWrapped, + }); + } + + // Classify the command. + var classified = ClassifyVerb(body, start); + if (classified.Kind == PwshCommandKind.PwshInvocation) + { + var recursion = TryRecurseIntoPwsh( + body, start, classified, source, baseOptions, recursionDepth, + segment, markWrapped, out var recursionResult); + if (recursion) + { + return recursionResult; + } + } + + // Extract args + redirects. Iteration begins at `start` so a leading + // call operator `&` is not mistaken for a trailing background `&`. + var argResult = ExtractArgsAndRedirects( + body, start, classified, source, effectiveOptions, workingDirectoryUnknown); + if (argResult.Error is not null) + { + return BuildResult.Fail(argResult.Error); + } + + var verb = new VerbChain + { + Tokens = classified.VerbTokens, + CanonicalVerb = classified.CanonicalVerb, + IsDynamic = classified.IsDynamic, + }; + + return BuildResult.Ok(new Clause + { + Operator = segment.PrecedingOperator, + Verb = verb, + Args = argResult.Args, + Redirects = argResult.Redirects, + IsSubshell = segment.Depth > 0, + IsCommandStringWrapped = markWrapped, + }); + } + + // ---------------------------------------------------------------- verb chain + + private enum PwshCommandKind + { + Cmdlet, + Alias, + NativeCommand, + PwshInvocation, + DynamicCommand, + QuotedCommand, + NoVerb, + } + + private readonly struct ClassifiedVerb + { + public PwshCommandKind Kind { get; init; } + + public List VerbTokens { get; init; } + + public string? CanonicalVerb { get; init; } + + public bool IsDynamic { get; init; } + + /// Body indices that are verb-chain tokens (skipped as args). + public HashSet VerbPositions { get; init; } + } + + private static ClassifiedVerb ClassifyVerb(List body, int start) + { + var head = body[start]; + var verbPositions = new HashSet { start }; + + // A clause that starts with a redirect operator, a flag, or a + // stop-parsing token has no verb. + if (head.Kind is PwshTokenKind.Operator or PwshTokenKind.Parameter + or PwshTokenKind.StopParsing) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.NoVerb, + VerbTokens = new List(), + VerbPositions = new HashSet(), + }; + } + + // A script block, subexpression, splat, or $-variable at command + // position is a dynamic command name (§3). + var isVariableWord = head.Kind == PwshTokenKind.Word + && head.Value.Length > 0 && head.Value[0] == '$'; + if (isVariableWord + || head.Kind is PwshTokenKind.ScriptBlock or PwshTokenKind.Subexpression + or PwshTokenKind.Splat) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.DynamicCommand, + VerbTokens = new List { head.Value }, + IsDynamic = true, + VerbPositions = verbPositions, + }; + } + + if (head.Kind == PwshTokenKind.QuotedString) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.QuotedCommand, + VerbTokens = new List { head.Value }, + VerbPositions = verbPositions, + }; + } + + // head.Kind == Word. + var word = head.Value; + + if (PwshApprovedVerbs.IsCmdletShaped(word)) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.Cmdlet, + VerbTokens = new List { word }, + VerbPositions = verbPositions, + }; + } + + var alias = PwshAliases.Resolve(word); + if (alias is not null) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.Alias, + VerbTokens = new List { word }, + CanonicalVerb = alias, + VerbPositions = verbPositions, + }; + } + + if (PwshVerbs.IsPwshHost(word)) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.PwshInvocation, + VerbTokens = new List { word }, + VerbPositions = verbPositions, + }; + } + + // Native command — the bash greedy verb-chain walk (§6.2). A native + // file utility (xcopy, robocopy, ...) gets a 1-token chain so its + // arguments classify as paths. + var verbTokens = new List { word }; + if (!PwshVerbs.FileVerbs.Contains(word)) + { + BashVerbs.FlagsWithValue.TryGetValue(word, out var flagsForVerb); + var i = start + 1; + while (i < body.Count) + { + var t = body[i]; + if (t.Kind == PwshTokenKind.Parameter) + { + if (flagsForVerb is null || !flagsForVerb.Contains(StripColon(t.Value))) + { + break; + } + + if (i + 1 >= body.Count + || (body[i + 1].Kind != PwshTokenKind.Word + && body[i + 1].Kind != PwshTokenKind.QuotedString)) + { + break; + } + + // Flag-with-value pair — transparently consumed by the + // walk but still surfaced as args. + i += 2; + continue; + } + + if (t.Kind != PwshTokenKind.Word || !PwshVerbs.IsNativeVerbLikeToken(t.Value)) + { + break; + } + + verbTokens.Add(t.Value); + verbPositions.Add(i); + i++; + } + } + + return new ClassifiedVerb + { + Kind = PwshCommandKind.NativeCommand, + VerbTokens = verbTokens, + VerbPositions = verbPositions, + }; + } + + private static string StripColon(string paramToken) + { + var colon = paramToken.IndexOf(':'); + return colon > 0 ? paramToken.Substring(0, colon) : paramToken; + } + + // ---------------------------------------------------------------- args + + private readonly struct ArgResult + { + public IReadOnlyList Args { get; } + + public IReadOnlyList Redirects { get; } + + public string? Error { get; } + + public ArgResult(IReadOnlyList args, IReadOnlyList redirects, string? error) + { + Args = args; + Redirects = redirects; + Error = error; + } + } + + private static ArgResult ExtractArgsAndRedirects( + List body, int scanStart, ClassifiedVerb verb, string source, + PwshParserOptions options, bool workingDirectoryUnknown) + { + var args = new List(); + var redirects = new List(); + var positionalIndex = 0; + string? pendingValueParam = null; // cmdlet/alias §6.5 value-binding + string? pendingNativeFlag = null; // native flag-with-value + var cmdletStyle = verb.Kind is PwshCommandKind.Cmdlet or PwshCommandKind.Alias + or PwshCommandKind.PwshInvocation; + var canonical = verb.CanonicalVerb ?? (verb.VerbTokens.Count > 0 ? verb.VerbTokens[0] : null); + var isFileVerb = canonical is not null && PwshVerbs.FileVerbs.Contains(canonical); + var nativeVerbChain = new VerbChain { Tokens = verb.VerbTokens }; + + for (var i = scanStart; i < body.Count; i++) + { + if (verb.VerbPositions.Contains(i)) + { + continue; + } + + var t = body[i]; + + // ---- redirect operators ---- + if (t.Kind == PwshTokenKind.Operator) + { + if (t.OperatorText == "&") + { + // A non-leading '&' is a trailing background-job operator; + // the anomaly detector already lifts this, but guard here. + return new ArgResult(args, redirects, + "trailing '&' background-job operator is not supported in v0.2"); + } + + pendingValueParam = null; + pendingNativeFlag = null; + var consumed = BuildRedirect( + body, i, source, options, workingDirectoryUnknown, redirects, out var redirectError); + if (redirectError is not null) + { + return new ArgResult(args, redirects, redirectError); + } + + i += consumed - 1; + continue; + } + + // ---- parameter tokens ---- + if (t.Kind == PwshTokenKind.Parameter) + { + pendingValueParam = null; + pendingNativeFlag = null; + + var raw = t.Value; + var colon = raw.IndexOf(':'); + var paramName = colon > 0 ? raw.Substring(0, colon) : raw; + var colonValue = colon > 0 ? raw.Substring(colon + 1) : null; + + args.Add(new Arg { Raw = paramName, Kind = ArgKind.Literal, IsPath = false }); + + if (cmdletStyle) + { + if (colonValue is not null) + { + // Colon form always binds (§6.5.3 rule 1). + var valueIsPath = PwshPerVerbRules.ParameterValueIsPath(canonical, paramName); + args.Add(ResolveValue(colonValue, valueIsPath, options, workingDirectoryUnknown, false)); + } + else if (PwshBindingTables.ResolveBinding(canonical, paramName) == PwshBinding.Value) + { + pendingValueParam = paramName; + } + } + else + { + // Native flag-with-value via the shared bash table (§7.3). + var verbKey = verb.VerbTokens.Count > 0 ? verb.VerbTokens[0] : string.Empty; + if (colonValue is null + && BashVerbs.FlagsWithValue.TryGetValue(verbKey, out var flags) + && flags.Contains(paramName)) + { + pendingNativeFlag = paramName; + } + } + + continue; + } + + // ---- opaque tokens (script block / subexpression / splat / --%) ---- + if (t.Kind is PwshTokenKind.ScriptBlock or PwshTokenKind.Subexpression + or PwshTokenKind.Splat or PwshTokenKind.StopParsing) + { + args.Add(new Arg + { + Raw = t.Value, + Kind = ArgKind.DynamicSkip, + IsPath = false, + }); + + if (pendingValueParam is not null || pendingNativeFlag is not null) + { + pendingValueParam = null; + pendingNativeFlag = null; + } + else + { + positionalIndex++; + } + + continue; + } + + // ---- value tokens (Word / QuotedString) ---- + var isLiteralBytes = t.Kind == PwshTokenKind.QuotedString && t.IsSingleQuoted; + var rawValue = SourceSlice(source, t); + + bool treatAsPath; + if (pendingValueParam is not null) + { + treatAsPath = PwshPerVerbRules.ParameterValueIsPath(canonical, pendingValueParam); + pendingValueParam = null; + } + else if (pendingNativeFlag is not null) + { + var verbKey = verb.VerbTokens.Count > 0 ? verb.VerbTokens[0] : string.Empty; + treatAsPath = BashPerVerbRules.ValueOfFlagIsPath(verbKey, pendingNativeFlag); + pendingNativeFlag = null; + } + else + { + treatAsPath = cmdletStyle + ? PwshPerVerbRules.IsPositionalPathArg(canonical, isFileVerb, positionalIndex, t.Value) + : BashPerVerbRules.IsPositionalPathArg(nativeVerbChain, positionalIndex, t.Value); + positionalIndex++; + } + + args.Add(ResolveValueToken( + rawValue, t.Value, treatAsPath, options, workingDirectoryUnknown, isLiteralBytes)); + } + + return new ArgResult(args, redirects, null); + } + + private static Arg ResolveValueToken( + string raw, string logicalValue, bool treatAsPath, + PwshParserOptions options, bool workingDirectoryUnknown, bool isLiteralBytes) + { + // §8 comma-array: an unquoted top-level comma in a path slot marks + // the whole token DynamicSkip — the v0.2.0 parser neither splits nor + // resolves a comma-joined array path. + if (treatAsPath && !isLiteralBytes && PwshResolver.LooksLikeCommaArray(logicalValue)) + { + return new Arg { Raw = raw, Kind = ArgKind.DynamicSkip, IsPath = false }; + } + + var (kind, resolved, isPath) = PwshResolver.Resolve( + logicalValue, treatAsPath, options, workingDirectoryUnknown, isLiteralBytes); + return new Arg { Raw = raw, Resolved = resolved, Kind = kind, IsPath = isPath }; + } + + private static Arg ResolveValue( + string logicalValue, bool treatAsPath, + PwshParserOptions options, bool workingDirectoryUnknown, bool isLiteralBytes) + => ResolveValueToken(logicalValue, logicalValue, treatAsPath, options, workingDirectoryUnknown, isLiteralBytes); + + // ---------------------------------------------------------------- redirects + + /// + /// Build a redirect from the operator at and, + /// for non-merge forms, the following target token. Returns the number + /// of body tokens consumed. + /// + private static int BuildRedirect( + List body, int opIndex, string source, + PwshParserOptions options, bool workingDirectoryUnknown, + List redirects, out string? error) + { + error = null; + var op = body[opIndex].OperatorText ?? string.Empty; + var direction = MapRedirect(op, out var isMerge, out var mergeTarget); + + if (isMerge) + { + redirects.Add(new Redirect + { + Direction = direction, + Target = mergeTarget ?? op, + IsDynamicSkip = true, + }); + return 1; + } + + if (opIndex + 1 >= body.Count || body[opIndex + 1].Kind == PwshTokenKind.Operator) + { + error = $"redirect operator '{op}' is missing a target"; + return 1; + } + + var target = body[opIndex + 1]; + if (target.Kind is PwshTokenKind.ScriptBlock or PwshTokenKind.Subexpression + or PwshTokenKind.Splat or PwshTokenKind.StopParsing) + { + redirects.Add(new Redirect + { + Direction = direction, + Target = target.Value, + IsDynamicSkip = true, + }); + return 2; + } + + // $null is the discard sink — not a file (§8). + if (target.Kind == PwshTokenKind.Word + && string.Equals(target.Value, "$null", StringComparison.OrdinalIgnoreCase)) + { + redirects.Add(new Redirect + { + Direction = direction, + Target = "$null", + IsDynamicSkip = true, + }); + return 2; + } + + var isLiteralBytes = target.Kind == PwshTokenKind.QuotedString && target.IsSingleQuoted; + var raw = SourceSlice(source, target); + var (kind, resolved, _) = PwshResolver.Resolve( + target.Value, treatAsPath: true, options, workingDirectoryUnknown, isLiteralBytes); + + redirects.Add(new Redirect + { + Direction = direction, + Target = kind == ArgKind.DynamicSkip ? raw : resolved ?? raw, + IsDynamicSkip = kind == ArgKind.DynamicSkip, + }); + return 2; + } + + /// SPEC.POWERSHELL.md §8: map a PowerShell redirect operator + /// onto the (lossy) enum. + private static RedirectDirection MapRedirect(string op, out bool isMerge, out string? mergeTarget) + { + isMerge = false; + mergeTarget = null; + + if (op == "<") + { + return RedirectDirection.In; + } + + var ampIndex = op.IndexOf('&'); + if (ampIndex >= 0) + { + // Stream merge N>&M. + isMerge = true; + mergeTarget = op.Substring(ampIndex); + var streamChar = op.Length > 0 ? op[0] : '1'; + return streamChar == '2' ? RedirectDirection.ErrOut : RedirectDirection.Out; + } + + // Optional leading stream prefix. + var prefix = '\0'; + var rest = op; + if (op.Length > 0 && (op[0] == '*' || (op[0] >= '1' && op[0] <= '6'))) + { + prefix = op[0]; + rest = op.Substring(1); + } + + var append = rest == ">>"; + if (prefix == '2') + { + return append ? RedirectDirection.ErrAppend : RedirectDirection.ErrOut; + } + + // 1>, 3>-6>, *> all map (lossily) to Out / Append. + return append ? RedirectDirection.Append : RedirectDirection.Out; + } + + // ---------------------------------------------------------------- recursion + + private static bool TryRecurseIntoPwsh( + List body, int start, ClassifiedVerb verb, string source, + PwshParserOptions options, int recursionDepth, Segment segment, bool markWrapped, + out BuildResult result) + { + result = default; + + // Scan for -Command / -EncodedCommand among the args. + for (var i = start + 1; i < body.Count; i++) + { + var t = body[i]; + if (t.Kind != PwshTokenKind.Parameter) + { + continue; + } + + var raw = t.Value; + var colon = raw.IndexOf(':'); + var name = colon > 0 ? raw.Substring(0, colon) : raw; + var colonValue = colon > 0 ? raw.Substring(colon + 1) : null; + + string? inner = null; + string? failure = null; + var isEncoded = false; + + if (IsCommandParameter(name)) + { + inner = ResolveCommandPayload(body, i, colonValue, source); + } + else if (IsEncodedCommandParameter(name)) + { + isEncoded = true; + var payloadToken = colonValue ?? NextTokenValue(body, i); + if (payloadToken is null) + { + failure = "-EncodedCommand is missing its base64 payload"; + } + else + { + inner = TryDecodeEncodedCommand(payloadToken, out failure); + } + } + else + { + continue; + } + + if (failure is not null) + { + result = BuildResult.Fail(failure); + return true; + } + + if (inner is null) + { + result = BuildResult.Fail( + isEncoded ? "-EncodedCommand payload could not be decoded" + : "-Command is missing its payload"); + return true; + } + + if (recursionDepth + 1 > MaxRecursionDepth) + { + result = BuildResult.Fail( + "pwsh -Command / -EncodedCommand recursion depth exceeded (>5)"); + return true; + } + + var innerParsed = ParseInternal( + inner, options, recursionDepth + 1, markWrapped: true); + if (innerParsed.IsUnparseable) + { + result = BuildResult.Fail(innerParsed.UnparseableReason); + return true; + } + + var expanded = new List(innerParsed.Clauses.Count); + for (var k = 0; k < innerParsed.Clauses.Count; k++) + { + var ic = innerParsed.Clauses[k]; + expanded.Add(ic with + { + Operator = k == 0 ? segment.PrecedingOperator : ic.Operator, + IsSubshell = segment.Depth > 0 || ic.IsSubshell, + IsCommandStringWrapped = true, + }); + } + + result = BuildResult.Recursion(expanded); + return true; + } + + return false; + } + + private static bool IsCommandParameter(string name) + { + // -c, or any prefix -Comm.. of -Command. + if (string.Equals(name, "-c", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return name.Length >= 5 + && "-command".StartsWith(name, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsEncodedCommandParameter(string name) + { + if (string.Equals(name, "-e", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return name.Length >= 3 + && "-encodedcommand".StartsWith(name, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Resolve the -Command payload (§10): a quoted string is parsed + /// verbatim; a script block has its braces stripped; a bare/multi-token + /// payload is the verbatim source slice from the first following token + /// to the end of the segment body. + /// + private static string? ResolveCommandPayload( + List body, int paramIndex, string? colonValue, string source) + { + if (colonValue is not null) + { + return colonValue; + } + + if (paramIndex + 1 >= body.Count) + { + return null; + } + + var next = body[paramIndex + 1]; + if (next.Kind == PwshTokenKind.QuotedString) + { + return next.Value; + } + + if (next.Kind == PwshTokenKind.ScriptBlock) + { + // Strip the outer { }. + var v = next.Value; + if (v.Length >= 2 && v[0] == '{' && v[v.Length - 1] == '}') + { + return v.Substring(1, v.Length - 2); + } + + return v; + } + + // Bare / multi-token: verbatim slice to the end of the segment body. + var last = body[body.Count - 1]; + var sliceStart = next.SourceStart; + var sliceEnd = last.SourceStart + last.SourceLength; + if (sliceStart < 0 || sliceStart >= source.Length) + { + return next.Value; + } + + if (sliceEnd > source.Length) + { + sliceEnd = source.Length; + } + + return source.Substring(sliceStart, sliceEnd - sliceStart); + } + + private static string? NextTokenValue(List body, int paramIndex) => + paramIndex + 1 < body.Count ? body[paramIndex + 1].Value : null; + + private static string? TryDecodeEncodedCommand(string payload, out string? failure) + { + failure = null; + try + { + var bytes = Convert.FromBase64String(payload); + var decoded = Encoding.Unicode.GetString(bytes); // UTF-16LE + if (decoded.Length > 0 && decoded[0] == '') + { + decoded = decoded.Substring(1); // strip the UTF-16 BOM + } + + return decoded; + } + catch (FormatException) + { + failure = "-EncodedCommand payload is not valid base64"; + return null; + } + catch (ArgumentException) + { + failure = "-EncodedCommand payload is not valid UTF-16"; + return null; + } + } + + // ---------------------------------------------------------------- attribution + + private static Clause AttachAttributionArg(Clause clause, PwshSetLocationContext ctx) + { + if (!ctx.HasAttribution) + { + return clause; + } + + Arg synthetic = ctx.IsDynamic + ? new Arg + { + Raw = "", + Kind = ArgKind.DynamicSkip, + IsPath = false, + IsCwdAttribution = true, + } + : new Arg + { + Raw = ctx.ResolvedCwd!, + Resolved = ctx.ResolvedCwd, + Kind = ArgKind.Literal, + IsPath = true, + IsCwdAttribution = true, + }; + + var newArgs = new List(clause.Args.Count + 1); + newArgs.AddRange(clause.Args); + newArgs.Add(synthetic); + return clause with { Args = newArgs }; + } + + private static void UpdateAttribution( + Clause clause, PwshParserOptions options, PwshSetLocationContext ctx) + { + var effectiveVerb = clause.Verb.CanonicalVerb + ?? (clause.Verb.Tokens.Count > 0 ? clause.Verb.Tokens[0] : null); + if (!string.Equals(effectiveVerb, "Set-Location", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // Inspect the args (excluding the synthetic attribution arg). + Arg? target = null; + var sawPathFlag = false; + foreach (var a in clause.Args) + { + if (a.IsCwdAttribution) + { + continue; + } + + // `Set-Location -` / `+` — previous/next location, not knowable. + if (a.Raw is "-" or "+") + { + ctx.SetDynamic(); + return; + } + + if (a.IsFlag) + { + sawPathFlag = a.Raw.ToLowerInvariant() is "-path" or "-literalpath" + or "-pspath" or "-lp"; + continue; + } + + if (sawPathFlag) + { + target = a; + break; + } + + target ??= a; + } + + if (target is null) + { + // `Set-Location` with no positional and no -Path → home (§9 rule 1). + var home = string.IsNullOrEmpty(options.HomeDirectory) + ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + : options.HomeDirectory!; + ctx.SetLiteral(NormalizeForAttribution(home)); + return; + } + + if ((target.Kind == ArgKind.Literal || target.Kind == ArgKind.Tilde) + && target.Resolved is not null) + { + ctx.SetLiteral(target.Resolved); + } + else + { + ctx.SetDynamic(); + } + } + + private static string NormalizeForAttribution(string path) => + path.Replace('\\', '/'); + + // ---------------------------------------------------------------- helpers + + private static string SourceSlice(string source, PwshToken token) + { + if (token.SourceStart < 0 || token.SourceStart >= source.Length) + { + return token.Value; + } + + var len = token.SourceLength; + if (token.SourceStart + len > source.Length) + { + len = source.Length - token.SourceStart; + } + + return source.Substring(token.SourceStart, len); + } +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshSetLocationContext.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshSetLocationContext.cs new file mode 100644 index 0000000..7173c26 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshSetLocationContext.cs @@ -0,0 +1,47 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +namespace ShellSyntaxTree.Internal.Pwsh.Parsing; + +/// +/// Parser-internal mutable state tracking the attributed cwd for the +/// current compound (SPEC.POWERSHELL.md §9). Simpler than the bash +/// CdAttributionContext: PowerShell's ( ... ) is a grouping +/// operator, not a subshell, so attribution propagates through a +/// group rather than being isolated by it — there is no push/pop stack. +/// +internal sealed class PwshSetLocationContext +{ + /// + /// The resolved working directory inherited from the most recent + /// literal Set-Location in the current compound. Null when no + /// attribution is active or the target was dynamic. + /// + public string? ResolvedCwd { get; private set; } + + /// + /// True when the most recent Set-Location target was dynamic — a + /// variable, a non-FileSystem PSDrive, or - / + + /// (SPEC.POWERSHELL.md §9 rule 2). + /// + public bool IsDynamic { get; private set; } + + /// True when a Set-Location set attribution. + public bool HasAttribution => ResolvedCwd is not null || IsDynamic; + + /// Record a literal Set-Location attribution. + public void SetLiteral(string resolvedTarget) + { + ResolvedCwd = resolvedTarget; + IsDynamic = false; + } + + /// Record that the most recent Set-Location target was dynamic. + public void SetDynamic() + { + ResolvedCwd = null; + IsDynamic = true; + } +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs new file mode 100644 index 0000000..9213297 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs @@ -0,0 +1,226 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; + +namespace ShellSyntaxTree.Internal.Pwsh.Verbs; + +/// +/// The default PowerShell built-in alias table — a case-insensitive map +/// from a typed alias to its canonical cmdlet (SPEC.POWERSHELL.md §6.3). +/// Alias resolution is unconditional: the most security-relevant +/// normalization the parser performs. +/// +/// +/// +/// This table is intentionally a superset of any single platform's +/// live Get-Alias output. PowerShell 7 omits the Unix-collision +/// aliases (rm, ls, cat, cp, mv, +/// ps, kill, sleep, ...) on non-Windows hosts so the +/// native tool wins. A security parser cannot know which host a command +/// string targets, so the table carries the Windows superset: recognizing +/// rm as Remove-Item on Linux is safe (the worst case is a +/// canonical identity for a token that would have run native rm +/// anyway), whereas missing it loses file-verb path classification +/// — a false-negative-shaped failure (§6.3). +/// +/// +/// The PwshAliasCompletenessTests [Fact] diffs this table against +/// live Get-Alias and fails on any gap (a live alias absent here); +/// extra Windows-only entries are expected and allowed. +/// +/// +/// curl, wget, sc, set, start, and +/// where are deliberately absent: §6.3 collision rule 3 treats them +/// as native commands, never aliased. md / mkdir map to +/// New-Item — the effective cmdlet — rather than the thin +/// mkdir function PowerShell's own Get-Alias reports. +/// +/// +internal static class PwshAliases +{ + /// + /// Aliases §6.3 collision rule 3 forbids resolving — their cmdlet vs. + /// native-tool meaning is version-dependent. Absent from + /// ; the parser treats them as native commands. + /// + internal static readonly HashSet NeverAliased = + new(StringComparer.OrdinalIgnoreCase) + { + "curl", "wget", "sc", "set", "start", "where", + }; + + /// Alias → canonical cmdlet, matched case-insensitively. + internal static readonly IReadOnlyDictionary Map = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // ---- file / path / cwd verbs (security-relevant excerpt) ---- + ["rm"] = "Remove-Item", + ["del"] = "Remove-Item", + ["erase"] = "Remove-Item", + ["rd"] = "Remove-Item", + ["rmdir"] = "Remove-Item", + ["ri"] = "Remove-Item", + ["ls"] = "Get-ChildItem", + ["dir"] = "Get-ChildItem", + ["gci"] = "Get-ChildItem", + ["cd"] = "Set-Location", + ["chdir"] = "Set-Location", + ["sl"] = "Set-Location", + ["pushd"] = "Push-Location", + ["popd"] = "Pop-Location", + ["cat"] = "Get-Content", + ["gc"] = "Get-Content", + ["type"] = "Get-Content", + ["cp"] = "Copy-Item", + ["copy"] = "Copy-Item", + ["cpi"] = "Copy-Item", + ["mv"] = "Move-Item", + ["move"] = "Move-Item", + ["mi"] = "Move-Item", + ["ni"] = "New-Item", + ["mkdir"] = "New-Item", + ["md"] = "New-Item", + ["ren"] = "Rename-Item", + ["rni"] = "Rename-Item", + ["ac"] = "Add-Content", + ["sls"] = "Select-String", + ["ipcsv"] = "Import-Csv", + ["epcsv"] = "Export-Csv", + ["gp"] = "Get-ItemProperty", + ["sp"] = "Set-ItemProperty", + ["rp"] = "Remove-ItemProperty", + ["gpv"] = "Get-ItemPropertyValue", + ["cpp"] = "Copy-ItemProperty", + ["mp"] = "Move-ItemProperty", + ["rnp"] = "Rename-ItemProperty", + ["clc"] = "Clear-Content", + ["cli"] = "Clear-Item", + ["clp"] = "Clear-ItemProperty", + ["rvpa"] = "Resolve-Path", + ["cvpa"] = "Convert-Path", + ["gi"] = "Get-Item", + ["ii"] = "Invoke-Item", + ["si"] = "Set-Item", + ["gl"] = "Get-Location", + ["pwd"] = "Get-Location", + ["gdr"] = "Get-PSDrive", + ["ndr"] = "New-PSDrive", + ["rdr"] = "Remove-PSDrive", + ["mount"] = "New-PSDrive", + + // ---- code execution / pipeline ---- + ["iex"] = "Invoke-Expression", + ["icm"] = "Invoke-Command", + ["%"] = "ForEach-Object", + ["foreach"] = "ForEach-Object", + ["?"] = "Where-Object", + ["echo"] = "Write-Output", + ["write"] = "Write-Output", + ["irm"] = "Invoke-RestMethod", + ["iwr"] = "Invoke-WebRequest", + ["saps"] = "Start-Process", + ["spps"] = "Stop-Process", + ["gps"] = "Get-Process", + ["ps"] = "Get-Process", + ["kill"] = "Stop-Process", + ["sleep"] = "Start-Sleep", + ["spsv"] = "Stop-Service", + ["gsv"] = "Get-Service", + ["sasv"] = "Start-Service", + + // ---- object pipeline / formatting ---- + ["select"] = "Select-Object", + ["sort"] = "Sort-Object", + ["measure"] = "Measure-Object", + ["group"] = "Group-Object", + ["compare"] = "Compare-Object", + ["diff"] = "Compare-Object", + ["tee"] = "Tee-Object", + ["gu"] = "Get-Unique", + ["gm"] = "Get-Member", + ["fl"] = "Format-List", + ["ft"] = "Format-Table", + ["fw"] = "Format-Wide", + ["fc"] = "Format-Custom", + ["fhx"] = "Format-Hex", + ["oh"] = "Out-Host", + ["ogv"] = "Out-GridView", + ["lp"] = "Out-Printer", + ["man"] = "Get-Help", + ["clear"] = "Clear-Host", + ["cls"] = "Clear-Host", + ["gcb"] = "Get-Clipboard", + ["scb"] = "Set-Clipboard", + + // ---- variables / aliases / modules ---- + ["gv"] = "Get-Variable", + ["sv"] = "Set-Variable", + ["nv"] = "New-Variable", + ["rv"] = "Remove-Variable", + ["clv"] = "Clear-Variable", + ["gal"] = "Get-Alias", + ["sal"] = "Set-Alias", + ["nal"] = "New-Alias", + ["epal"] = "Export-Alias", + ["ipal"] = "Import-Alias", + ["gmo"] = "Get-Module", + ["ipmo"] = "Import-Module", + ["nmo"] = "New-Module", + ["rmo"] = "Remove-Module", + ["gcm"] = "Get-Command", + ["gerr"] = "Get-Error", + + // ---- history / sessions / jobs / breakpoints ---- + ["h"] = "Get-History", + ["history"] = "Get-History", + ["ghy"] = "Get-History", + ["chy"] = "Clear-History", + ["clhy"] = "Clear-History", + ["ihy"] = "Invoke-History", + ["r"] = "Invoke-History", + ["etsn"] = "Enter-PSSession", + ["exsn"] = "Exit-PSSession", + ["nsn"] = "New-PSSession", + ["gsn"] = "Get-PSSession", + ["rsn"] = "Remove-PSSession", + ["cnsn"] = "Connect-PSSession", + ["dnsn"] = "Disconnect-PSSession", + ["rcsn"] = "Receive-PSSession", + ["sajb"] = "Start-Job", + ["gjb"] = "Get-Job", + ["rjb"] = "Remove-Job", + ["spjb"] = "Stop-Job", + ["wjb"] = "Wait-Job", + ["rcjb"] = "Receive-Job", + ["rujb"] = "Resume-Job", + ["sujb"] = "Suspend-Job", + ["gbp"] = "Get-PSBreakpoint", + ["sbp"] = "Set-PSBreakpoint", + ["rbp"] = "Remove-PSBreakpoint", + ["dbp"] = "Disable-PSBreakpoint", + ["ebp"] = "Enable-PSBreakpoint", + ["gcs"] = "Get-PSCallStack", + + // ---- Windows-only host integrations ---- + ["shcm"] = "Show-Command", + }; + + /// + /// Resolve to its canonical cmdlet. Returns + /// null when the token is not a known alias or is one of the + /// commands. Case-insensitive. + /// + internal static string? Resolve(string token) + { + if (string.IsNullOrEmpty(token) || NeverAliased.Contains(token)) + { + return null; + } + + return Map.TryGetValue(token, out var canonical) ? canonical : null; + } +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshApprovedVerbs.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshApprovedVerbs.cs new file mode 100644 index 0000000..7e5f51d --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshApprovedVerbs.cs @@ -0,0 +1,97 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; + +namespace ShellSyntaxTree.Internal.Pwsh.Verbs; + +/// +/// The closed set of approved PowerShell verbs returned by Get-Verb +/// on the reference PowerShell 7.x build. SPEC.POWERSHELL.md §6.1: a token +/// is cmdlet-shaped only when the segment before its single - is one +/// of these verbs. Gating on the verb table — rather than "any letters +/// before a dash" — keeps hyphenated native tools (docker-compose, +/// apt-get) on the native-command path. +/// +internal static class PwshApprovedVerbs +{ + /// The 98 approved verbs, matched case-insensitively. + internal static readonly HashSet Verbs = + new(StringComparer.OrdinalIgnoreCase) + { + "Add", "Approve", "Assert", "Backup", "Block", "Build", + "Checkpoint", "Clear", "Close", "Compare", "Complete", "Compress", + "Confirm", "Connect", "Convert", "ConvertFrom", "ConvertTo", + "Copy", "Debug", "Deny", "Deploy", "Disable", "Disconnect", + "Dismount", "Edit", "Enable", "Enter", "Exit", "Expand", "Export", + "Find", "Format", "Get", "Grant", "Group", "Hide", "Import", + "Initialize", "Install", "Invoke", "Join", "Limit", "Lock", + "Measure", "Merge", "Mount", "Move", "New", "Open", "Optimize", + "Out", "Ping", "Pop", "Protect", "Publish", "Push", "Read", + "Receive", "Redo", "Register", "Remove", "Rename", "Repair", + "Request", "Reset", "Resize", "Resolve", "Restart", "Restore", + "Resume", "Revoke", "Save", "Search", "Select", "Send", "Set", + "Show", "Skip", "Split", "Start", "Step", "Stop", "Submit", + "Suspend", "Switch", "Sync", "Test", "Trace", "Unblock", "Undo", + "Uninstall", "Unlock", "Unprotect", "Unpublish", "Unregister", + "Update", "Use", "Wait", "Watch", "Write", + }; + + /// + /// SPEC.POWERSHELL.md §6.1: returns true when + /// has cmdlet shape — length in [3, 64], exactly one -, the + /// segment before - is an approved verb, and the segment after + /// - begins with an ASCII letter and is ASCII letters/digits only. + /// Case-insensitive throughout. + /// + internal static bool IsCmdletShaped(string token) + { + if (token is null || token.Length < 3 || token.Length > 64) + { + return false; + } + + var dash = token.IndexOf('-'); + if (dash <= 0 || dash == token.Length - 1) + { + return false; + } + + // Exactly one dash. + if (token.IndexOf('-', dash + 1) >= 0) + { + return false; + } + + var verb = token.Substring(0, dash); + if (!Verbs.Contains(verb)) + { + return false; + } + + // Noun: first char ASCII letter, remainder ASCII letters/digits. + var nounStart = dash + 1; + var firstNoun = token[nounStart]; + if (!IsAsciiLetter(firstNoun)) + { + return false; + } + + for (var i = nounStart + 1; i < token.Length; i++) + { + var c = token[i]; + if (!IsAsciiLetter(c) && !(c >= '0' && c <= '9')) + { + return false; + } + } + + return true; + } + + private static bool IsAsciiLetter(char c) => + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshBindingTables.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshBindingTables.cs new file mode 100644 index 0000000..7e5ec08 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshBindingTables.cs @@ -0,0 +1,165 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; + +namespace ShellSyntaxTree.Internal.Pwsh.Verbs; + +/// How a -Name parameter token binds (SPEC.POWERSHELL.md §6.5). +internal enum PwshBinding +{ + /// Consumes no following token. + Switch, + + /// Consumes exactly the next significant token as its value. + Value, +} + +/// +/// The static parameter-binding tables from SPEC.POWERSHELL.md §6.5.2. The +/// parser has no compiled cmdlet metadata, so it decides whether a +/// -Name token consumes the next token from these tables. The +/// default for an unknown parameter is — +/// the security-conservative choice (§6.5.3 rule 4): a misclassified value +/// stays a positional, where §7's positional rules can still catch a real +/// path. +/// +internal static class PwshBindingTables +{ + /// + /// Parameters that consume the next token. Stored with the leading + /// dash; matched case-insensitively. SPEC.POWERSHELL.md §6.5.2. + /// + private static readonly HashSet ValueParameters = + new(StringComparer.OrdinalIgnoreCase) + { + // Value-bearing common parameters. + "-ErrorAction", "-WarningAction", "-InformationAction", + "-ProgressAction", "-ErrorVariable", "-WarningVariable", + "-InformationVariable", "-OutVariable", "-OutBuffer", + "-PipelineVariable", + + // Every parameter in the §7.1 path-parameter table. + "-Path", "-LiteralPath", "-PSPath", "-FilePath", "-OutFile", + "-InFile", "-Destination", "-Source", "-Filter", "-Include", + "-Exclude", "-Value", "-ItemType", + + // Frequently value-bearing cmdlet parameters. + "-Name", "-Encoding", "-Depth", "-Stream", "-Delimiter", + "-Command", "-EncodedCommand", "-File", "-ArgumentList", + }; + + /// + /// Parameters known to consume nothing. SPEC.POWERSHELL.md §6.5.2. + /// + private static readonly HashSet SwitchParameters = + new(StringComparer.OrdinalIgnoreCase) + { + // Switch common parameters. + "-Verbose", "-Debug", "-WhatIf", "-Confirm", + + // Frequent cmdlet switches. + "-Recurse", "-Force", "-Append", "-NoNewline", "-PassThru", + "-Wait", "-Quiet", "-CaseSensitive", "-SimpleMatch", + "-NoClobber", "-AsByteStream", "-Hidden", "-Directory", + }; + + /// + /// Per-(canonicalVerb, parameterName) binding overrides — the + /// §6.5.4 -File collision. -File is a value-binding + /// parameter verb-agnostically (so pwsh -File script.ps1 binds), + /// but it is a switch on Get-ChildItem; the override + /// row encodes that. + /// + private static readonly IReadOnlyDictionary<(string Verb, string Name), PwshBinding> + Overrides = new Dictionary<(string, string), PwshBinding>(VerbNameComparer.Instance) + { + [("Get-ChildItem", "-File")] = PwshBinding.Switch, + }; + + /// + /// Resolve how the parameter token (with + /// its leading dash) binds for clause verb . + /// Implements the §6.5.3 decision: (verb, name) override → exact + /// table match → unambiguous prefix match → unknown defaults to switch. + /// + internal static PwshBinding ResolveBinding(string? canonicalVerb, string paramName) + { + if (string.IsNullOrEmpty(paramName)) + { + return PwshBinding.Switch; + } + + // 1. (verb, name) override row. + if (!string.IsNullOrEmpty(canonicalVerb) + && Overrides.TryGetValue((canonicalVerb!, paramName), out var overridden)) + { + return overridden; + } + + // 2. Exact table match. + if (ValueParameters.Contains(paramName)) + { + return PwshBinding.Value; + } + + if (SwitchParameters.Contains(paramName)) + { + return PwshBinding.Switch; + } + + // 3. Unambiguous prefix match. PowerShell prefix matching: the token + // must prefix exactly one entry across both tables; two or more is + // ambiguous and treated as unknown. + var valueHits = CountPrefixMatches(ValueParameters, paramName); + var switchHits = CountPrefixMatches(SwitchParameters, paramName); + if (valueHits + switchHits == 1) + { + return valueHits == 1 ? PwshBinding.Value : PwshBinding.Switch; + } + + // 4. Unknown (or ambiguous) → switch. §6.5.3 rule 4. + return PwshBinding.Switch; + } + + private static int CountPrefixMatches(HashSet table, string prefix) + { + var count = 0; + foreach (var entry in table) + { + if (entry.Length > prefix.Length + && entry.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + count++; + } + } + + return count; + } + + private sealed class VerbNameComparer : IEqualityComparer<(string Verb, string Name)> + { + internal static readonly VerbNameComparer Instance = new(); + + public bool Equals((string Verb, string Name) x, (string Verb, string Name) y) => + string.Equals(x.Verb, y.Verb, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode((string Verb, string Name) obj) + { + unchecked + { + var h1 = obj.Verb is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Verb); + var h2 = obj.Name is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name); + return (h1 * 397) ^ h2; + } + } + } +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshPerVerbRules.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshPerVerbRules.cs new file mode 100644 index 0000000..b0dd25c --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshPerVerbRules.cs @@ -0,0 +1,144 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using ShellSyntaxTree.Internal.Resolving; + +namespace ShellSyntaxTree.Internal.Pwsh.Verbs; + +/// +/// Per-cmdlet / per-parameter path-arg classification rules from +/// SPEC.POWERSHELL.md §7. PowerShell classifies path arguments in two +/// layers — a parameter-value layer (dominant) and a positional layer. +/// Rules are keyed by the canonical verb (§6.3), so rm x and +/// Remove-Item x classify identically. Native-command path rules are +/// not here: §7.3 reuses the bash per-verb table verbatim +/// (). +/// +internal static class PwshPerVerbRules +{ + /// + /// Verb-agnostic path-typed parameter names (SPEC.POWERSHELL.md §7.1) — + /// the value bound to one of these is a filesystem path. + /// + private static readonly HashSet PathParameters = + new(StringComparer.OrdinalIgnoreCase) + { + "-Path", "-LiteralPath", "-PSPath", + "-FilePath", "-OutFile", "-InFile", + "-Destination", "-Source", + }; + + /// + /// -Name is context-dependent (§7.1): a filesystem leaf for + /// New-Item / Rename-Item; not a path elsewhere + /// (Get-Process -Name). + /// + private static readonly HashSet NameIsPathVerbs = + new(StringComparer.OrdinalIgnoreCase) + { + "New-Item", "Rename-Item", + }; + + /// + /// Per-cmdlet positional path overrides (SPEC.POWERSHELL.md §7.2). + /// Returns true when the i-th non-flag positional is a path. The + /// FileVerb default ("all non-flag positionals are paths") covers + /// everything not listed here. + /// + private static readonly IReadOnlyDictionary> PositionalOverrides = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + // pos 0 = source, pos 1 = destination; no further path positionals. + ["Copy-Item"] = i => i <= 1, + ["Move-Item"] = i => i <= 1, + + // pos 0 = path, pos 1 = new name (a path fragment). + ["Rename-Item"] = i => i <= 1, + + // pos 0 = path; -Value (pos 1) is content. + ["New-Item"] = i => i == 0, + ["Set-Content"] = i => i == 0, + ["Add-Content"] = i => i == 0, + ["Clear-Content"] = i => i == 0, + + // pos 0 = pattern, rest = paths (mirrors bash grep). + ["Select-String"] = i => i >= 1, + + // pos 0 is a script block; no path positionals. + ["ForEach-Object"] = _ => false, + ["Where-Object"] = _ => false, + }; + + /// + /// SPEC.POWERSHELL.md §7.1: whether the value bound to + /// (with leading dash) for + /// should be classified as a path. + /// -Filter / -Include / -Exclude return false — + /// they are glob slots, and the resolver still tags Kind=Glob + /// from any metacharacters. + /// + internal static bool ParameterValueIsPath(string? canonicalVerb, string parameterName) + { + if (string.IsNullOrEmpty(parameterName)) + { + return false; + } + + if (PathParameters.Contains(parameterName)) + { + return true; + } + + if (string.Equals(parameterName, "-Name", StringComparison.OrdinalIgnoreCase)) + { + return !string.IsNullOrEmpty(canonicalVerb) + && NameIsPathVerbs.Contains(canonicalVerb!); + } + + // `pwsh -File