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 @@
latestenabletrue
- 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-treehttps://github.com/Aaronontheweb/ShellSyntaxTreehttps://github.com/Aaronontheweb/ShellSyntaxTreegit
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 @@
[](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

-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