From d73c336d8edec5d2ed94c13f96d74bf2af93f712 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 19 May 2026 04:11:23 +0000 Subject: [PATCH 1/9] docs: add v0.2.0 PowerShell phases to IMPLEMENTATION_PLAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate SPEC.POWERSHELL.md §16 (build order) plus §15/§17 (release and Netclaw integration) into 16 NOW phases for the v0.2.0 PowerShell parser, now that the spec is merged (PR #38). - New "NOW (0.2.0 — PowerShell parser)" section: phases 1–16, each citing its spec section, all unchecked. Phase 11 carries the §16 ordering fix (multi-shell corpus refactor must precede corpus authoring). - Retitle the completed "NOW (0.1.0-alpha shipping path)" section to "SHIPPED (0.1.0-alpha → 0.1.5 — complete)"; fix three stale "in progress" labels on long-finished phases. - Refresh LATER for post-0.2.0 scope: drop the now-elaborated PowerShell bullet, add the SPEC.POWERSHELL.md §18 deferred items. - Intro now names both SPEC.md §16 and SPEC.POWERSHELL.md §16. --- IMPLEMENTATION_PLAN.md | 197 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 182 insertions(+), 15 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 0653053..8fd4986 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,15 +1,176 @@ # 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 / SHIPPED / +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) +## NOW (0.2.0 — PowerShell parser) + +> **Spec:** `SPEC.POWERSHELL.md` (v0.2.0), merged 2026-05-19 via PR #38 +> (squash commit `c7380b6`). Implementation was gated on that spec — the +> gate is now satisfied. Phases below translate `SPEC.POWERSHELL.md` §16 +> (build order) plus §15 / §17 (release + Netclaw); each is a single PR +> unless noted. `SPEC.md` still wins on any conflict — fix the conflict +> before implementing (`AGENTS.md` Discovery Rules). Phase numbering is +> scoped to this section (the v0.2.0 §16 sequence), independent of the +> SHIPPED section's 1–18. + +### 1. Public-API surface change (lock first) — SPEC.PWSH §16.1 + +- [ ] Add `ShellParserOptions` base record; reparent `BashParserOptions` + onto it (stays source-compatible per SPEC.PWSH §2) +- [ ] Add `PwshParserOptions` (empty record, `: ShellParserOptions`) and a + `PwshParser` skeleton — `Parse` throws `NotImplementedException`, + `Parse(null)` throws `ArgumentNullException` +- [ ] Add additive `VerbChain.CanonicalVerb` and `VerbChain.IsDynamic` + (SPEC.PWSH §3) +- [ ] Rename `Clause.IsBashCWrapped` → `IsCommandStringWrapped`; flip the + `isBashCWrapped` key in every existing bash corpus JSON entry +- [ ] Update `PublicApiSnapshotTests` and the corpus DTOs; all existing + bash tests stay green + +### 2. PowerShell verb & binding tables (data only) — SPEC.PWSH §16.2 + +- [ ] `Internal/Pwsh/Verbs/PwshApprovedVerbs` — the `Get-Verb` set (§6.1) +- [ ] `PwshAliases` — the **complete** default alias set, not a subset + (§6.3) +- [ ] `PwshVerbs` — Cwd / File / control-flow tables (§6.4) +- [ ] `PwshValueParameters` / `PwshSwitchParameters` — the §6.5 binding + tables +- [ ] `PwshPerVerbRules` — per-verb / per-parameter path rules (§7) + +### 3. PwshLexer — SPEC.PWSH §16.3 + +- [ ] `Internal/Pwsh/Lexing/` — quoting (single, double, here-string), + backtick escape, `$var` / `$env:` / `${name}`, `-Name` parameters, + stream redirects, statement separators, `#` / `<# #>` comments +- [ ] Opaque regions via the shared `OpaqueRegionScanner` + a new + backtick-escape mode (shared file — see the shared-internals note) +- [ ] `--%` stop-parsing token — opaque to end of line (§4) +- [ ] Heavy lexer unit tests + +### 4. PwshCommandParser core — SPEC.PWSH §16.4 + +- [ ] Pipeline / statement splitting (`|`, `;`, `&&`, `||`, newline) +- [ ] Verb-chain extraction — cmdlet (1-token), native (greedy walk, + case-sensitive predicate per §6.2), alias +- [ ] §6.5 parameter-binding decision (switch vs. value-binding) +- [ ] Args, parameters, redirects per clause +- [ ] `VerbChain.IsDynamic` for `& $exe` / subexpression / script-block + command names (§3) +- [ ] Grouped sub-pipeline `( ... )` → `IsSubshell` structural marker + +### 5. Alias resolution — SPEC.PWSH §16.5 + +- [ ] Populate `VerbChain.CanonicalVerb` unconditionally; the raw token + stays verbatim in `Tokens` +- [ ] `[Fact]` diffing `PwshAliases` against live `Get-Alias` output + +### 6. PwshResolver — SPEC.PWSH §16.6 / §8 + +- [ ] `~` / `$HOME` / `$env:USERPROFILE` expansion; every other `$var` / + `$env:` → `DynamicSkip` (path slot) / `EnvVar` (non-path slot) +- [ ] Provider-qualifier strip; drive-qualified + UNC paths; non-FileSystem + PSDrive → `IsPath=false` +- [ ] Glob detection; relative-path resolution +- [ ] Comma-array path token → `DynamicSkip` +- [ ] Redirect stream → `RedirectDirection` lossy mapping + +### 7. Per-verb / per-parameter path rules — SPEC.PWSH §16.7 / §7 + +- [ ] Path-typed parameter classification, operating on the §6.5 token + roles +- [ ] Positional path rules + per-cmdlet overrides +- [ ] Native commands reuse the bash per-verb table (shared) + +### 8. Set-Location-in-compound propagation — SPEC.PWSH §16.8 / §9 + +- [ ] `Set-Location` attributes cwd to subsequent clauses; literal vs. + `DynamicSkip` (dynamic / non-FS-drive / `-` / no-arg-home cases) +- [ ] Attribution propagates through `( ... )` — no subshell isolation + +### 9. pwsh -Command / -EncodedCommand recursion — SPEC.PWSH §16.9 / §10 + +- [ ] `-Command` recursion: quoted-string, script-block, and + bare/multi-token forms; inner clauses surface with + `IsCommandStringWrapped=true` +- [ ] `-EncodedCommand` base64 + UTF-16LE decode, BOM strip, recurse +- [ ] Depth-5 cap; inner-`IsUnparseable` propagates to the outer command +- [ ] `Invoke-Expression` / `iex` is **not** recursed + +### 10. Anomaly safe-fail — SPEC.PWSH §16.10 / §11 + +- [ ] `IsUnparseable` for control-flow / definition / block keywords, + assignment, type-literal, trailing `&`, recursion overflow +- [ ] 64 KiB input cap (top-level input + each decoded payload) +- [ ] Diagnostic-precedence ordering + +### 11. Multi-shell refactor of corpus runner + PII audit — SPEC.PWSH §16.11 + +- [ ] Corpus runner enumerates every `Corpus//` directory; + directory-routed parser selection (`bash/` → `BashParser`, + `powershell/` → `PwshParser`) +- [ ] PII audit scans every `Corpus//` +- [ ] **Must precede phase 12** — the runner cannot execute a + `Corpus/powershell/` entry until this lands + +### 12. Hand-author the PowerShell corpus (≥170 entries) — SPEC.PWSH §16.12 + +- [ ] `tests/.../Corpus/powershell/*.json`, §13 schema (`canonicalVerb`, + `oracleExpectation`) +- [ ] Coverage per the §13 category table — parameter binding ≥25, + unparseable ≥20, recursion ≥15 +- [ ] Every entry parses to its expected AST + +### 13. pwsh validation gate + tools/PwshCorpusTool — SPEC.PWSH §16.13 + +- [ ] CI test feeds every PowerShell corpus `input` to real `pwsh` + (`[System.Management.Automation.Language.Parser]::ParseInput`); + enforces the §13 oracle matrix +- [ ] `tools/PwshCorpusTool` authoring aid; register it in `TOOLING.md` +- [ ] CI installs `pwsh` (7.x); an explicit step fails loudly if absent + +### 14. SPEC.md edits + wire CI — SPEC.PWSH §16.14 + +- [ ] Update `SPEC.md` §1 / §2 / §3 / §6.4 / §15 to the shipped v0.2.0 + surface +- [ ] `Directory.Build.props` `VersionPrefix` → `0.2.0`, + `VersionSuffix` → `alpha` +- [ ] CI test job runs the bash corpus + PowerShell corpus + `pwsh` gate + + multi-shell PII audit on Linux **and** Windows + +### 15. Release 0.2.0 (alpha → beta → stable) — SPEC.PWSH §15 / §17 + +- [ ] `RELEASE_NOTES.md` v0.2.0 section — the breaking `Clause` rename + old→new mapping (SPEC.md Appendix A requires it) + the new + `PwshParser` / `PwshParserOptions` / `ShellParserOptions` / + `VerbChain.CanonicalVerb` / `VerbChain.IsDynamic` surface +- [ ] Tag `0.2.0-alpha`; `publish_nuget.yml` produces the `.nupkg` +- [ ] `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 +- [ ] ≥1 Netclaw integration test exercises a real PowerShell corpus entry + through the live matcher and gets the expected gate decision + +**Shared-internals note (SPEC.PWSH §16):** phases 3 and 7 touch shared +surface — `OpaqueRegionScanner` gains a backtick-escape mode, and the +native greedy walk + per-verb rules are reused from the bash +implementation. Promote the reused pieces into `Internal/Shared/` and cover +every shared path in the PowerShell corpus, so a bash-side PR cannot +silently regress PowerShell without a red test. + +--- + +## SHIPPED (0.1.0-alpha → 0.1.5 — complete) > **OpenSpec change `v0.1-locked-interpretations`:** archived > 2026-05-11 (post-alpha housekeeping PR) under @@ -17,7 +178,7 @@ bulldoze priorities. > Captured the eight planning-interview decisions; superseded by > shipped 0.1.0-alpha behavior. -### 1. Bootstrap projects (PR 1, in progress) +### 1. Bootstrap projects (PR 1, complete) - [x] Create `src/ShellSyntaxTree/ShellSyntaxTree.csproj` (library, multi-target `netstandard2.0;net8.0`, `IsAotCompatible=true`) @@ -27,7 +188,7 @@ bulldoze priorities. - [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 +### 2. Public API skeleton (lock surface first) — PR 1, complete - [x] Implement public types from `SPEC.md` §2/§3 verbatim: `IShellParser`, `BashParser`, `BashParserOptions`, `ParsedCommand`, @@ -43,7 +204,7 @@ bulldoze priorities. - [x] Update SPEC.md §3 to enumerate `Arg.IsCwdAttribution`; cross-tfm note on `VerbChain.Joined` -### 3. BashLexer + opaque-region scanner — PR 2, in progress +### 3. BashLexer + opaque-region scanner — PR 2, complete - [x] `Internal/Lexing/OpaqueRegionScanner.cs` — shared, grammar-agnostic; `Scan` for `(`/`)` style + `ScanSymmetric` for backtick; quote-aware, @@ -281,15 +442,21 @@ bulldoze priorities. - 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 +## 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 once two parsers exist — deferred + until the seam is designed from real duplication (`SPEC.POWERSHELL.md` + §18) - 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 +- 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 From c8dd2b02c20ee19653cfae037c0f5a6d7ceaa808 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 19 May 2026 04:16:47 +0000 Subject: [PATCH 2/9] docs: remove completed 0.1.x phases from IMPLEMENTATION_PLAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.1.0-alpha → 0.1.5 work is shipped; its phase-by-phase record is preserved in git history and RELEASE_NOTES.md. Drop the SHIPPED section so the plan shows only active v0.2.0 work — 464 lines down to 200. The intro bucket list and the NOW spec note no longer reference it. --- IMPLEMENTATION_PLAN.md | 271 +---------------------------------------- 1 file changed, 4 insertions(+), 267 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 8fd4986..170e2d9 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,9 +1,9 @@ # Implementation Plan — ShellSyntaxTree Source of truth for active work. Translates `SPEC.md` §16 (bash — shipped) -and `SPEC.POWERSHELL.md` §16 (PowerShell — v0.2.0) into NOW / SHIPPED / -NEXT / LATER buckets. Park items aggressively — autonomous loops will -otherwise bulldoze priorities. +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. @@ -17,9 +17,7 @@ otherwise bulldoze priorities. > gate is now satisfied. Phases below translate `SPEC.POWERSHELL.md` §16 > (build order) plus §15 / §17 (release + Netclaw); each is a single PR > unless noted. `SPEC.md` still wins on any conflict — fix the conflict -> before implementing (`AGENTS.md` Discovery Rules). Phase numbering is -> scoped to this section (the v0.2.0 §16 sequence), independent of the -> SHIPPED section's 1–18. +> before implementing (`AGENTS.md` Discovery Rules). ### 1. Public-API surface change (lock first) — SPEC.PWSH §16.1 @@ -170,267 +168,6 @@ silently regress PowerShell without a red test. --- -## SHIPPED (0.1.0-alpha → 0.1.5 — complete) - -> **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, complete) - -- [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, complete - -- [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, complete - -- [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 - ---- - ## NEXT (0.1.x — additive, post-alpha) - Seed 50–100 corpus entries from sanitized real-world dogfood logs From 8a0e625915e156a01730940cac322faea0d82d31 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 19 May 2026 05:04:57 +0000 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20PowerShell=20parser=20core=20?= =?UTF-8?q?=E2=80=94=20v0.2.0=20phases=201-10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements SPEC.POWERSHELL.md §1-§11: the public-API surface change, verb/binding tables, lexer, command parser, resolver, Set-Location propagation, pwsh -Command/-EncodedCommand recursion, and anomaly safe-fail. Phase 1 — public API: add ShellParserOptions base record; reparent BashParserOptions; add PwshParserOptions + PwshParser; add additive VerbChain.CanonicalVerb / VerbChain.IsDynamic; rename Clause.IsBashCWrapped -> IsCommandStringWrapped (breaking, per SPEC Appendix A). Bash corpus JSON key flipped; PublicApiSnapshotTests and corpus DTOs updated. Phase 2 — Internal/Pwsh/Verbs: PwshApprovedVerbs (Get-Verb set), PwshAliases (complete default alias superset), PwshVerbs (Cwd/File/ control-flow), PwshBindingTables (§6.5), PwshPerVerbRules (§7). Phase 3 — PwshLexer + PwshToken/PwshTokenKind; OpaqueRegionScanner gains a backtick-escape mode (shared). Phases 4-10 — PwshCommandParser: pipeline/statement splitting, verb- chain extraction (cmdlet/alias/native/dynamic), §6.5 parameter binding, PwshResolver (§8), per-verb path rules, Set-Location-in-compound propagation (§9), pwsh recursion with depth-5 cap (§10), and the §11 anomaly contract incl. the 64 KiB input cap. All 550 tests pass (149 bash corpus non-regression + 44 lexer + 71 parser + updated API snapshot). --- .../Commands/ExplainCommand.cs | 4 +- .../Pages/Home.razor | 2 +- .../Services/MermaidRenderer.cs | 4 +- src/ShellSyntaxTree/BashParserOptions.cs | 27 +- src/ShellSyntaxTree/Clause.cs | 9 +- .../Bash/Parsing/BashCommandParser.cs | 12 +- .../Internal/Lexing/OpaqueRegionScanner.cs | 99 +- .../Internal/Pwsh/Lexing/PwshLexer.cs | 863 ++++++++++ .../Internal/Pwsh/Lexing/PwshToken.cs | 61 + .../Internal/Pwsh/Lexing/PwshTokenKind.cs | 69 + .../Pwsh/Parsing/PwshCommandParser.cs | 1394 +++++++++++++++++ .../Pwsh/Parsing/PwshSetLocationContext.cs | 47 + .../Internal/Pwsh/Verbs/PwshAliases.cs | 223 +++ .../Internal/Pwsh/Verbs/PwshApprovedVerbs.cs | 97 ++ .../Internal/Pwsh/Verbs/PwshBindingTables.cs | 165 ++ .../Internal/Pwsh/Verbs/PwshPerVerbRules.cs | 144 ++ .../Internal/Pwsh/Verbs/PwshVerbs.cs | 144 ++ .../Internal/Resolving/PwshResolver.cs | 503 ++++++ src/ShellSyntaxTree/PwshParser.cs | 51 + src/ShellSyntaxTree/PwshParserOptions.cs | 15 + src/ShellSyntaxTree/ShellParserOptions.cs | 27 + src/ShellSyntaxTree/VerbChain.cs | 32 + .../ShellSyntaxTree.Tests/Corpus/AstAssert.cs | 24 +- .../Corpus/CorpusRunnerTests.cs | 42 +- .../Corpus/bash/01_simple_ls.json | 2 +- .../Corpus/bash/02_ls_la_tmp.json | 2 +- .../Corpus/bash/03_simple_pwd.json | 2 +- .../Corpus/bash/04_echo_hello.json | 2 +- .../Corpus/bash/05_cat_etc_hostname.json | 2 +- .../Corpus/bash/06_mkdir_tmp_foo.json | 2 +- .../Corpus/bash/07_rm_tmp_foo.json | 2 +- .../Corpus/bash/08_touch_file.json | 2 +- .../Corpus/bash/09_whoami.json | 2 +- .../bash/100_bash_c_nested_depth_3.json | 2 +- .../bash/101_echo_single_quoted_arg.json | 2 +- .../bash/102_echo_double_quoted_arg.json | 2 +- .../bash/103_echo_double_quoted_home.json | 2 +- .../104_echo_single_quoted_literal_home.json | 2 +- .../bash/105_git_commit_quoted_message.json | 2 +- .../bash/106_grep_quoted_pattern_path.json | 2 +- .../107_cat_quoted_filename_with_spaces.json | 2 +- ...108_echo_escaped_quotes_inside_double.json | 2 +- .../109_echo_trailing_backslash_escaped.json | 2 +- .../Corpus/bash/10_date.json | 2 +- .../bash/110_cmd_mixed_quote_styles.json | 2 +- .../116_redirect_fd_dup_stderr_to_stdout.json | 2 +- .../bash/117_redirect_out_with_fd_dup.json | 2 +- .../Corpus/bash/118_redirect_fd_close.json | 2 +- .../119_cat_single_quoted_absolute_path.json | 2 +- .../Corpus/bash/11_git_push_origin_main.json | 2 +- .../bash/120_cd_trailing_forward_slash.json | 2 +- .../121_rm_single_quoted_var_pattern.json | 2 +- .../124_leading_comment_then_command.json | 2 +- .../bash/125_inline_trailing_comment.json | 2 +- .../126_comment_between_two_commands.json | 4 +- ...127_hash_in_double_quotes_not_comment.json | 2 +- ...128_hash_in_single_quotes_not_comment.json | 2 +- .../bash/129_hash_mid_word_not_comment.json | 2 +- .../Corpus/bash/12_git_log_oneline.json | 2 +- ...etclaw_repro_leading_comment_pipeline.json | 8 +- ...1_netclaw_repro_compound_with_comment.json | 6 +- ...132_freshdesk_ticket_list_status_open.json | 2 +- ...33_git_C_repo_worktree_list_porcelain.json | 2 +- .../Corpus/bash/134_kubectl_get_pods.json | 2 +- .../bash/135_kubectl_get_pods_my_pod.json | 2 +- .../Corpus/bash/136_aws_s3_cp_src_dst.json | 2 +- .../137_dotnet_ef_migrations_add_initial.json | 2 +- .../138_cat_bare_name_filevervb_carveout.json | 2 +- .../139_newline_separates_two_commands.json | 4 +- .../Corpus/bash/13_git_checkout_dev.json | 2 +- .../140_newline_separates_three_commands.json | 6 +- .../Corpus/bash/141_blank_lines_collapse.json | 4 +- .../142_leading_and_trailing_newlines.json | 4 +- .../Corpus/bash/143_newline_after_andif.json | 4 +- .../Corpus/bash/144_newline_after_pipe.json | 4 +- ..._newline_inside_double_quotes_literal.json | 2 +- .../bash/146_newline_inside_subshell.json | 4 +- ...47_continuation_newline_not_separator.json | 2 +- .../148_comment_then_newline_separates.json | 4 +- .../Corpus/bash/14_dotnet_build.json | 2 +- .../Corpus/bash/15_dotnet_test.json | 2 +- .../Corpus/bash/16_npm_install.json | 2 +- .../Corpus/bash/17_docker_run_nginx.json | 2 +- .../Corpus/bash/18_docker_compose_up.json | 2 +- .../Corpus/bash/19_docker_compose_down_v.json | 2 +- .../Corpus/bash/20_bun_run_my_script.json | 2 +- .../Corpus/bash/21_compound_andif.json | 4 +- .../Corpus/bash/22_compound_orif.json | 4 +- .../Corpus/bash/23_compound_sequence.json | 4 +- .../Corpus/bash/24_compound_pipe.json | 4 +- .../Corpus/bash/25_cd_then_ls.json | 4 +- .../Corpus/bash/26_git_fetch_pull.json | 4 +- .../Corpus/bash/27_make_install.json | 4 +- .../Corpus/bash/28_cd_status_push.json | 6 +- .../Corpus/bash/29_three_andif.json | 6 +- .../Corpus/bash/30_three_orif.json | 6 +- .../Corpus/bash/31_three_sequence.json | 6 +- .../Corpus/bash/32_three_pipe.json | 6 +- .../Corpus/bash/33_mixed_andif_orif.json | 6 +- .../Corpus/bash/34_cd_then_rm.json | 4 +- .../Corpus/bash/35_cd_pipe_grep.json | 6 +- .../Corpus/bash/36_redirect_out.json | 2 +- .../Corpus/bash/37_redirect_append.json | 2 +- .../Corpus/bash/38_redirect_in.json | 2 +- .../Corpus/bash/39_redirect_errout.json | 2 +- .../Corpus/bash/40_redirect_errappend.json | 2 +- .../Corpus/bash/41_redirect_out_and_err.json | 2 +- .../Corpus/bash/42_pipe_then_redirect.json | 4 +- .../Corpus/bash/43_redirect_then_andif.json | 4 +- .../Corpus/bash/44_in_and_out_redirects.json | 2 +- .../Corpus/bash/45_echo_append_log.json | 2 +- .../bash/51_dynamic_skip_unresolved_var.json | 2 +- .../Corpus/bash/52_dynamic_skip_cd_var.json | 4 +- .../bash/53_dynamic_skip_cat_log_file.json | 2 +- .../bash/54_home_expands_in_path_slot.json | 2 +- .../bash/55_dynamic_skip_cp_src_dst.json | 2 +- .../bash/56_dynamic_skip_mkdir_dir_sub.json | 2 +- .../Corpus/bash/57_dynamic_skip_mv_a_b.json | 2 +- .../bash/58_dynamic_skip_brace_path.json | 2 +- .../Corpus/bash/59_chmod_mode_then_var.json | 2 +- .../Corpus/bash/60_glob_in_path_slot.json | 2 +- .../Corpus/bash/61_chmod_mode_path.json | 2 +- .../Corpus/bash/62_chown_user_group_path.json | 2 +- .../bash/63_find_root_with_predicate.json | 2 +- .../Corpus/bash/64_grep_pattern_path.json | 2 +- .../Corpus/bash/65_sed_script_path.json | 2 +- .../Corpus/bash/66_awk_program_path.json | 2 +- .../bash/67_curl_output_path_then_url.json | 2 +- .../Corpus/bash/68_wget_output_document.json | 2 +- .../Corpus/bash/69_git_dash_C_repo_log.json | 2 +- .../bash/70_tar_extract_archive_target.json | 2 +- .../bash/71_cd_attribution_relative_cat.json | 4 +- .../bash/72_cd_three_subsequent_clauses.json | 8 +- .../bash/73_cd_replaces_attribution.json | 8 +- .../74_cd_relative_inside_attribution.json | 4 +- .../Corpus/bash/75_cd_dynamic_var.json | 4 +- .../bash/76_cd_dash_treated_dynamic.json | 4 +- .../bash/77_pushd_does_not_propagate.json | 4 +- .../bash/78_popd_does_not_propagate.json | 4 +- .../bash/79_chdir_propagates_like_cd.json | 4 +- .../bash/80_cd_then_mkdir_relative.json | 4 +- .../Corpus/bash/81_subshell_simple.json | 4 +- .../Corpus/bash/82_subshell_isolates_cd.json | 8 +- .../Corpus/bash/83_subshell_with_pipe.json | 4 +- .../Corpus/bash/84_subshell_nested.json | 2 +- .../85_subshell_outer_compound_after.json | 4 +- .../bash/86_subshell_outer_cd_inherited.json | 6 +- .../bash/87_subshell_only_cd_inside.json | 4 +- .../bash/88_subshell_three_clauses.json | 6 +- .../bash/89_subshell_first_then_outer.json | 6 +- ...hell_inner_cd_attribution_only_inside.json | 6 +- .../Corpus/bash/91_bash_c_simple.json | 2 +- .../Corpus/bash/92_bash_c_with_inner_cd.json | 4 +- .../Corpus/bash/93_sh_c_recurses.json | 2 +- ...94_bash_c_no_quoted_body_stays_clause.json | 2 +- .../Corpus/bash/95_bash_c_inner_compound.json | 6 +- .../Corpus/bash/96_bash_c_nested_depth_2.json | 2 +- .../bash/97_bash_c_with_sh_c_inside.json | 2 +- ...98_bash_c_outer_cd_does_not_propagate.json | 4 +- .../bash/99_bash_c_after_outer_compound.json | 4 +- .../Lexing/PwshLexerTests.cs | 305 ++++ .../Parsing/BashCommandParserTests.cs | 14 +- .../Parsing/PwshCommandParserTests.cs | 517 ++++++ .../PublicApiSnapshotTests.cs | 134 +- 164 files changed, 5137 insertions(+), 311 deletions(-) create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshToken.cs create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshTokenKind.cs create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshSetLocationContext.cs create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshApprovedVerbs.cs create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshBindingTables.cs create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshPerVerbRules.cs create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshVerbs.cs create mode 100644 src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs create mode 100644 src/ShellSyntaxTree/PwshParser.cs create mode 100644 src/ShellSyntaxTree/PwshParserOptions.cs create mode 100644 src/ShellSyntaxTree/ShellParserOptions.cs create mode 100644 tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs create mode 100644 tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs diff --git a/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs b/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs index 1a5d88d..bdda9f6 100644 --- a/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs +++ b/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs @@ -33,10 +33,10 @@ 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(); diff --git a/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor b/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor index c719126..d330b39 100644 --- a/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor +++ b/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor @@ -180,7 +180,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/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..5d4a952 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -0,0 +1,1394 @@ +// ----------------------------------------------------------------------- +// +// 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, + }); + } + + var head = body[start]; + + // Classify the command. + var classified = ClassifyVerb(body, start, source); + 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, + }; + + _ = head; + 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; } + + /// Index in body where args begin. + public int ArgStart { get; init; } + + /// Body indices that are verb-chain tokens (skipped as args). + public HashSet VerbPositions { get; init; } + + /// The effective verb for per-verb rules (canonical ?? raw). + public string EffectiveVerb { get; init; } + } + + private static ClassifiedVerb ClassifyVerb(List body, int start, string source) + { + var head = body[start]; + var verbPositions = new HashSet { start }; + + if (head.Kind == PwshTokenKind.Operator) + { + // Redirect-only clause — no verb. + return new ClassifiedVerb + { + Kind = PwshCommandKind.NoVerb, + VerbTokens = new List(), + ArgStart = start, + VerbPositions = new HashSet(), + EffectiveVerb = string.Empty, + }; + } + + if (head.Kind is PwshTokenKind.ScriptBlock or PwshTokenKind.Subexpression + or PwshTokenKind.Splat) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.DynamicCommand, + VerbTokens = new List { head.Value }, + IsDynamic = true, + ArgStart = start + 1, + VerbPositions = verbPositions, + EffectiveVerb = head.Value, + }; + } + + if (head.Kind == PwshTokenKind.Parameter || head.Kind == PwshTokenKind.StopParsing) + { + // A clause starting with a flag or stop-parsing token has no verb. + return new ClassifiedVerb + { + Kind = PwshCommandKind.NoVerb, + VerbTokens = new List(), + ArgStart = start, + VerbPositions = new HashSet(), + EffectiveVerb = string.Empty, + }; + } + + if (head.Kind == PwshTokenKind.QuotedString) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.QuotedCommand, + VerbTokens = new List { head.Value }, + ArgStart = start + 1, + VerbPositions = verbPositions, + EffectiveVerb = head.Value, + }; + } + + // head.Kind == Word. + var word = head.Value; + + // A $-variable command name is dynamic (§3). + if (word.Length > 0 && word[0] == '$') + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.DynamicCommand, + VerbTokens = new List { word }, + IsDynamic = true, + ArgStart = start + 1, + VerbPositions = verbPositions, + EffectiveVerb = word, + }; + } + + if (PwshApprovedVerbs.IsCmdletShaped(word)) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.Cmdlet, + VerbTokens = new List { word }, + CanonicalVerb = null, + ArgStart = start + 1, + VerbPositions = verbPositions, + EffectiveVerb = word, + }; + } + + var alias = PwshAliases.Resolve(word); + if (alias is not null) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.Alias, + VerbTokens = new List { word }, + CanonicalVerb = alias, + ArgStart = start + 1, + VerbPositions = verbPositions, + EffectiveVerb = alias, + }; + } + + if (PwshVerbs.IsPwshHost(word)) + { + return new ClassifiedVerb + { + Kind = PwshCommandKind.PwshInvocation, + VerbTokens = new List { word }, + ArgStart = start + 1, + VerbPositions = verbPositions, + EffectiveVerb = word, + }; + } + + // 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 }; + var argStart = start + 1; + 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++; + argStart = i; + } + + argStart = start + 1; // args iterate the whole body minus verb positions + } + + return new ClassifiedVerb + { + Kind = PwshCommandKind.NativeCommand, + VerbTokens = verbTokens, + ArgStart = argStart, + VerbPositions = verbPositions, + EffectiveVerb = word, + }; + } + + 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 "-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..76ac50d --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs @@ -0,0 +1,223 @@ +// ----------------------------------------------------------------------- +// +// 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", + }; + + /// + /// 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