diff --git a/Directory.Packages.props b/Directory.Packages.props index 7aedbe6..7cdfe60 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,8 @@ 2.9.2 2.8.2 + + 8.0.26 @@ -16,4 +18,10 @@ + + + + + + diff --git a/README.md b/README.md index b093a0c..a060243 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,98 @@ # ShellSyntaxTree -A focused .NET library that parses shell command strings into a structured AST. -Purpose-built for **security gate evaluators** — tools that inspect agent-emitted -shell commands and decide whether to allow, prompt for, or deny execution. +[![NuGet](https://img.shields.io/nuget/v/ShellSyntaxTree.svg)](https://www.nuget.org/packages/ShellSyntaxTree/) -ShellSyntaxTree is **not** a shell interpreter. It does not execute, expand, -or evaluate commands. It returns an AST (verb chain, args with path -classification, redirects, compound operators, `cd`-in-compound propagation, -`bash -c` recursion) that consumers walk to make policy decisions. +A focused .NET library that parses bash command strings into a structured +AST. Purpose-built for tools that need to **reason about shell commands +without running them** — approval gates for LLM-emitted commands, CI/CD +script auditors, sandbox policy generators, audit-log analytics. -The original consumer is [Netclaw](https://github.com/netclaw-dev/netclaw)'s -approval policy. Any tool that needs to reason about the shape of an -agent-emitted shell command — without running it — is welcome to consume it. +Hand-rolled, AOT-trim friendly, zero native dependencies. Multi-targets +`netstandard2.0` and `net8.0`. -## Status +```bash +dotnet add package ShellSyntaxTree --version 0.1.0-alpha +``` + +## What you get + +For an input like `cd /repo && rm /etc/passwd`, ShellSyntaxTree produces: + +```mermaid +flowchart TD + classDef bad fill:#fee,stroke:#b00,stroke-width:2px + A[cd /repo
📁 /repo] -- "&&" --> B[rm
📁 /etc/passwd
cwd: /repo] + class B bad +``` -**v0.1 — pre-alpha.** Public API surface and behavior are specified in -[`SPEC.md`](./SPEC.md). Implementation is in progress. +A two-clause AST where the second clause's `Args` includes a synthetic +`/repo` attribution arg (so consumers can see "this `rm` is implicitly +operating in `/repo`") *and* `/etc/passwd` is resolved and marked +`IsPath = true`. Hard-deny rules over `/etc/*` fire immediately; no +substring matching, no shelling out, no false positives. + +## Things you can do with it + +- **Approval gates for AI agents** — given a command emitted by an LLM, + decide ALLOW / PROMPT / DENY before invoking the shell. +- **CI/CD pipeline audits** — scan shell steps in GitHub Actions / + Jenkinsfile / Azure Pipelines for writes outside the workspace, + `curl | bash` from non-allowlisted hosts, hardcoded credential + echoes. +- **Sandbox / container policy** — derive the minimum-viable volume + mount set or AppArmor profile from a build script. +- **Pre-commit linters** — flag dangerous patterns (`rm -rf /`, + `chmod 777 /etc/*`) in shell scripts at commit time. +- **Shell history / audit-log analytics** — ingest `~/.bash_history` or + `auditd` records into structured form for SIEM-style insights. +- **Documentation / explainers** — convert complex one-liners into + readable structure for tutorials and runbooks. + +The original consumer is +[Netclaw](https://github.com/netclaw-dev/netclaw)'s approval policy; +the library is built to be reusable beyond that. + +## Quick start -## Why not `tree-sitter-bash`? +```csharp +using ShellSyntaxTree; + +var parser = new BashParser(); +var parsed = parser.Parse("cd /repo && rm /etc/passwd"); + +if (parsed.IsUnparseable) +{ + // Safe-fail: prompt the user, deny the command, etc. + Console.WriteLine($"can't model: {parsed.UnparseableReason}"); + return; +} + +foreach (var clause in parsed.Clauses) +{ + Console.WriteLine($"{clause.Operator} {clause.Verb.Joined}"); + + foreach (var arg in clause.Args.Where(a => a.IsPath)) + { + var marker = arg.IsCwdAttribution ? "↳ cwd" : " path"; + Console.WriteLine($" {marker}: {arg.Resolved}"); + } + + foreach (var redirect in clause.Redirects.Where(r => !r.IsDynamicSkip)) + { + Console.WriteLine($" {redirect.Direction}: {redirect.Target}"); + } +} +``` + +Run that against the example input and you get: -Native dependencies, AOT trim concerns, and IDE-grade fidelity we don't need. -We want unsupported constructs to mark `IsUnparseable = true` so consumers -route to safe-fail. See [`SPEC.md` Appendix B](./SPEC.md) for the full -trade-off analysis. +``` +None cd + path: /repo +AndIf rm + ↳ cwd: /repo + path: /etc/passwd +``` ## Public API surface (locked for v0.1) @@ -31,14 +100,14 @@ trade-off analysis. namespace ShellSyntaxTree; public interface IShellParser { ParsedCommand Parse(string command); } -public sealed class BashParser : IShellParser { /* ... */ } +public sealed class BashParser : IShellParser { /* … */ } public sealed record BashParserOptions { /* HomeDirectory, WorkingDirectory */ } -public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, ... */ } -public sealed record Clause { /* Operator, Verb, Args, Redirects, ... */ } -public sealed record VerbChain { /* Tokens */ } -public sealed record Arg { /* Raw, Resolved, Kind, IsPath, ... */ } -public sealed record Redirect { /* Direction, Target */ } +public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, … */ } +public sealed record Clause { /* Operator, Verb, Args, Redirects, … */ } +public sealed record VerbChain { /* Tokens, Joined */ } +public sealed record Arg { /* Raw, Resolved, Kind, IsPath, IsCwdAttribution, IsFlag */ } +public sealed record Redirect { /* Direction, Target, IsDynamicSkip */ } public enum ArgKind { Literal, EnvVar, Glob, Tilde, DynamicSkip } public enum RedirectDirection { In, Out, Append, ErrOut, ErrAppend } @@ -46,28 +115,51 @@ public enum CompoundOperator { None, AndIf, OrIf, Sequence, Pipe } ``` PowerShell and Windows `cmd` parsers are deferred to later versions; the -`IShellParser` seam is already in place so consumers don't have to refactor -when they ship. +`IShellParser` seam is in place so consumers don't refactor when they +ship. + +Full behavioral contract: [`SPEC.md`](./SPEC.md). -## Multi-targeting +## Samples -`netstandard2.0` for broad consumer compatibility, `net8.0` for modern -runtimes. Tests target `net10.0`. +Two runnable samples live under [`samples/`](./samples). -## Repository layout +### `ShellSyntaxTree.Cli.Sample` — terminal explainer + audit policy +```bash +dotnet run --project samples/ShellSyntaxTree.Cli.Sample -- explain "cd /repo && rm /etc/passwd" +dotnet run --project samples/ShellSyntaxTree.Cli.Sample -- audit "cd /repo && rm /etc/passwd" ``` -src/ShellSyntaxTree/ # library (TBD) -tests/ShellSyntaxTree.Tests/ # xunit tests + corpus runner (TBD) -tests/ShellSyntaxTree.Tests/Corpus/ # JSON test corpus (acceptance contract) -SPEC.md # the implementation specification -PROJECT_CONTEXT.md # what this is, who it serves -TOOLING.md # available tooling and how to access it -AGENTS.md / CLAUDE.md # agent operating constitution -IMPLEMENTATION_PLAN.md # NOW / NEXT / LATER work tracker + +`explain` pretty-prints the AST with `[flag]` / `[path]` / `[cwd-attr]` / +`[dyn-skip]` / `[glob]` markers per arg. `audit` runs a small built-in +policy ("deny writes in `/etc`, `/usr`, `/bin`, `/sbin`, `/lib`", +"warn on `curl | bash`", "warn on dynamic args in path slots") and +exits 0 / 1 / 2 by severity. See +[`samples/ShellSyntaxTree.Cli.Sample/Commands/AuditPolicy.cs`](./samples/ShellSyntaxTree.Cli.Sample/Commands/AuditPolicy.cs) +for the policy code — ~50 lines. + +### `ShellSyntaxTree.Web.Sample` — Blazor WebAssembly Mermaid visualizer + +Paste a bash script, watch the parsed AST render as a Mermaid flowchart +in your browser. Everything runs client-side — pasted scripts never +leave your machine. Useful for "what does this script actually do?" +moments and for understanding how the library models constructs like +subshells and `bash -c` recursion. + +```bash +dotnet run --project samples/ShellSyntaxTree.Web.Sample +# → http://localhost:5239 ``` -## Building +![Build script preset](./assets/sample-web-build-script.png) + +The visualizer ships preset scripts demonstrating compound commands, +subshell isolation, `bash -c` recursion, dynamic-cwd attribution, and +unparseable inputs (control-flow, function definitions). Each preset +shows what the library produces in a single click. + +## Building from source ```bash dotnet tool restore @@ -76,9 +168,34 @@ dotnet test -c Release dotnet pack -c Release -o ./bin/nuget ``` -`global.json` pins the SDK version. Targeting requires .NET 10 SDK or later -(`.slnx` solution format). +`global.json` pins the SDK; you need .NET 10 SDK or later for the +`.slnx` solution format. + +## Versioning + +- **v0.1.0-alpha** — first publishable cut. Bash-only. +- **v0.1.x** — additive (more verb table entries, more corpus, bug + fixes). +- **v0.2.0** — first PowerShell parser. +- **v1.0.0** — when an external consumer beyond Netclaw ships against + it without finding API gaps. ## License [Apache-2.0](./LICENSE). Copyright © 2026 Aaron Stannard. + +--- + +**Repository layout** — for contributors and curious agents: + +| Path | What | +|---|---| +| `src/ShellSyntaxTree/` | The library | +| `tests/ShellSyntaxTree.Tests/` | xUnit unit tests + corpus runner | +| `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` | 115 corpus entries — the acceptance contract | +| `samples/ShellSyntaxTree.Cli.Sample/` | Console explainer + audit policy | +| `samples/ShellSyntaxTree.Web.Sample/` | Blazor WASM Mermaid visualizer | +| `SPEC.md` | Locked v0.1 contract | +| `openspec/` | Change-proposal history (rationale for v0.1 design decisions) | +| `PROJECT_CONTEXT.md`, `TOOLING.md`, `AGENTS.md` | Repo governance — for autonomous agents | +| `IMPLEMENTATION_PLAN.md` | NOW / NEXT / LATER work tracker | diff --git a/ShellSyntaxTree.slnx b/ShellSyntaxTree.slnx index 5ffa787..ffb6b86 100644 --- a/ShellSyntaxTree.slnx +++ b/ShellSyntaxTree.slnx @@ -21,4 +21,8 @@ + + + + diff --git a/assets/sample-web-bash-c.png b/assets/sample-web-bash-c.png new file mode 100644 index 0000000..b328e2a Binary files /dev/null and b/assets/sample-web-bash-c.png differ diff --git a/assets/sample-web-build-script.png b/assets/sample-web-build-script.png new file mode 100644 index 0000000..b78681e Binary files /dev/null and b/assets/sample-web-build-script.png differ diff --git a/assets/sample-web-custom.png b/assets/sample-web-custom.png new file mode 100644 index 0000000..4b5fcdb Binary files /dev/null and b/assets/sample-web-custom.png differ diff --git a/assets/sample-web-dynamic-cwd.png b/assets/sample-web-dynamic-cwd.png new file mode 100644 index 0000000..fb4c6e1 Binary files /dev/null and b/assets/sample-web-dynamic-cwd.png differ diff --git a/assets/sample-web-empty.png b/assets/sample-web-empty.png new file mode 100644 index 0000000..f7accfb Binary files /dev/null and b/assets/sample-web-empty.png differ diff --git a/assets/sample-web-source-tab.png b/assets/sample-web-source-tab.png new file mode 100644 index 0000000..29b73b1 Binary files /dev/null and b/assets/sample-web-source-tab.png differ diff --git a/assets/sample-web-subshell.png b/assets/sample-web-subshell.png new file mode 100644 index 0000000..fcbbc95 Binary files /dev/null and b/assets/sample-web-subshell.png differ diff --git a/assets/sample-web-unparseable.png b/assets/sample-web-unparseable.png new file mode 100644 index 0000000..197cc85 Binary files /dev/null and b/assets/sample-web-unparseable.png differ diff --git a/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditCommand.cs b/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditCommand.cs new file mode 100644 index 0000000..63d6674 --- /dev/null +++ b/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditCommand.cs @@ -0,0 +1,87 @@ +using System.Text; +using ShellSyntaxTree; + +namespace ShellSyntaxTree.Cli.Sample.Commands; + +internal static class AuditCommand +{ + public static int Run(string command) + { + var parser = new BashParser(); + var parsed = parser.Parse(command); + + var anyDeny = false; + var anyWarn = false; + + if (parsed.IsUnparseable) + { + var decision = AuditPolicy.Evaluate(new Clause(), parsed, null); + EmitLine(0, "(unparseable input)", decision); + return decision.Outcome == AuditOutcome.Deny ? 1 : 2; + } + + for (var i = 0; i < parsed.Clauses.Count; i++) + { + var clause = parsed.Clauses[i]; + var next = i + 1 < parsed.Clauses.Count ? parsed.Clauses[i + 1] : null; + + var decision = AuditPolicy.Evaluate(clause, parsed, next); + EmitLine(i, SummarizeClause(clause), decision); + + switch (decision.Outcome) + { + case AuditOutcome.Deny: anyDeny = true; break; + case AuditOutcome.Warn: anyWarn = true; break; + } + } + + if (anyDeny) + { + return 1; + } + + if (anyWarn) + { + return 2; + } + + return 0; + } + + private static void EmitLine(int index, string summary, AuditDecision decision) + { + var label = decision.Outcome switch + { + AuditOutcome.Ok => "OK ", + AuditOutcome.Warn => "WARN", + AuditOutcome.Deny => "DENY", + _ => "? ", + }; + + var sb = new StringBuilder(); + sb.Append(label).Append(" | Clause ").Append(index).Append(": ").Append(summary); + if (!string.IsNullOrEmpty(decision.Reason)) + { + sb.Append(" — ").Append(decision.Reason); + } + + Console.WriteLine(sb.ToString()); + } + + private static string SummarizeClause(Clause clause) + { + var verb = clause.Verb.Tokens.Count == 0 ? "(no verb)" : clause.Verb.Joined; + var args = new StringBuilder(); + foreach (var arg in clause.Args) + { + if (arg.IsCwdAttribution) + { + continue; + } + + args.Append(' ').Append(arg.Raw); + } + + return args.Length == 0 ? verb : verb + args; + } +} diff --git a/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditPolicy.cs b/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditPolicy.cs new file mode 100644 index 0000000..1e2cb92 --- /dev/null +++ b/samples/ShellSyntaxTree.Cli.Sample/Commands/AuditPolicy.cs @@ -0,0 +1,180 @@ +using ShellSyntaxTree; + +namespace ShellSyntaxTree.Cli.Sample.Commands; + +internal enum AuditOutcome +{ + Ok, + Warn, + Deny, +} + +internal readonly record struct AuditDecision(AuditOutcome Outcome, string? Reason) +{ + public static AuditDecision Ok() => new(AuditOutcome.Ok, null); + + public static AuditDecision Warn(string reason) => new(AuditOutcome.Warn, reason); + + public static AuditDecision Deny(string reason) => new(AuditOutcome.Deny, reason); +} + +/// +/// A deliberately small, illustrative policy. Not a substitute for a real +/// allow-list — its job is to demonstrate the shape of consuming +/// from an evaluator. +/// +internal static class AuditPolicy +{ + // Zones that should never be touched by a sandboxed command. + private static readonly string[] ProtectedPrefixes = + { + "/etc/", "/usr/", "/bin/", "/sbin/", "/lib/", + }; + + // Workspace prefixes considered "safe" for destructive verbs. + private static readonly string[] WorkspacePrefixes = + { + "/tmp/", "/work/", + }; + + public static AuditDecision Evaluate(Clause clause, ParsedCommand parent, Clause? nextClause) + { + if (parent.IsUnparseable) + { + return AuditDecision.Warn($"unparseable: {parent.UnparseableReason ?? "unknown"} — consumer should safe-fail"); + } + + // Rule 1: rm -rf outside workspace. + if (IsRmRf(clause)) + { + foreach (var arg in clause.Args) + { + if (!arg.IsPath || arg.IsCwdAttribution) + { + continue; + } + + var resolved = arg.Resolved ?? arg.Raw; + if (resolved.StartsWith('/') && !StartsWithAny(resolved, WorkspacePrefixes)) + { + return AuditDecision.Deny($"rm -rf outside workspace: {resolved}"); + } + } + } + + // Rule 2: any path resolved into a protected zone. + foreach (var arg in clause.Args) + { + if (!arg.IsPath || arg.IsCwdAttribution) + { + continue; + } + + var resolved = arg.Resolved ?? arg.Raw; + if (StartsWithAny(resolved, ProtectedPrefixes)) + { + return AuditDecision.Deny($"protected zone: {resolved}"); + } + } + + // Rule 3: curl | bash / sh — classic remote-code-execution shape. + if (clause.Verb.Joined == "curl" && nextClause is not null + && nextClause.Operator == CompoundOperator.Pipe + && (nextClause.Verb.Joined == "bash" || nextClause.Verb.Joined == "sh")) + { + return AuditDecision.Warn("curl pipe to shell: review carefully"); + } + + // Rule 4: dynamic content masquerading as a path-shaped argument. + // We can't perfectly reconstruct intent, but a DynamicSkip arg that + // wasn't classified as a path AND sits next to verbs that take paths + // is enough to flag for review. + if (VerbTakesPaths(clause.Verb.Joined)) + { + foreach (var arg in clause.Args) + { + if (arg.Kind == ArgKind.DynamicSkip && !arg.IsPath && !arg.IsFlag) + { + return AuditDecision.Warn("dynamic content in path-arg slot — context unknown"); + } + } + } + + return AuditDecision.Ok(); + } + + private static bool IsRmRf(Clause clause) + { + if (clause.Verb.Joined != "rm") + { + return false; + } + + foreach (var arg in clause.Args) + { + if (!arg.IsFlag) + { + continue; + } + + // Match either combined (-rf, -fr) or doubled (--recursive) forms. + if (arg.Raw is "-rf" or "-fr" or "-rfv" or "-fvr" or "-Rf") + { + return true; + } + + if (arg.Raw.StartsWith("-") && !arg.Raw.StartsWith("--") + && arg.Raw.Contains('r', StringComparison.OrdinalIgnoreCase) + && arg.Raw.Contains('f', StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (arg.Raw is "--recursive" or "--force") + { + // Either alone is insufficient — only combined --recursive --force counts. + // Cheap check: see if any other flag in the clause is the other half. + foreach (var other in clause.Args) + { + if (ReferenceEquals(other, arg)) + { + continue; + } + + if (arg.Raw == "--recursive" && other.Raw == "--force") + { + return true; + } + + if (arg.Raw == "--force" && other.Raw == "--recursive") + { + return true; + } + } + } + } + + return false; + } + + private static bool VerbTakesPaths(string verb) => verb switch + { + "cd" or "rm" or "cp" or "mv" or "cat" or "ls" or "mkdir" or "rmdir" + or "touch" or "chmod" or "chown" or "tar" or "zip" or "unzip" + or "find" or "grep" => true, + _ => false, + }; + + private static bool StartsWithAny(string value, string[] prefixes) + { + foreach (var prefix in prefixes) + { + if (value.StartsWith(prefix, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs b/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs new file mode 100644 index 0000000..1a5d88d --- /dev/null +++ b/samples/ShellSyntaxTree.Cli.Sample/Commands/ExplainCommand.cs @@ -0,0 +1,140 @@ +using System.Text; +using ShellSyntaxTree; + +namespace ShellSyntaxTree.Cli.Sample.Commands; + +/// +/// Pretty-prints the AST returned by . +/// Marker tokens ([flag], [path], [cwd-attr], +/// [dyn-skip], [glob], [literal]) are stable so that +/// downstream tooling can grep the output without re-parsing the AST. +/// +internal static class ExplainCommand +{ + public static int Run(string command) + { + var parser = new BashParser(); + var parsed = parser.Parse(command); + + var sb = new StringBuilder(); + sb.Append("Source: ").AppendLine(parsed.Source); + sb.AppendLine(); + + if (parsed.IsUnparseable) + { + sb.Append("UNPARSEABLE: ").AppendLine(parsed.UnparseableReason ?? "(no reason given)"); + Console.Write(sb.ToString()); + return 0; + } + + for (var i = 0; i < parsed.Clauses.Count; i++) + { + var clause = parsed.Clauses[i]; + sb.Append("Clause ").Append(i) + .Append(" (Operator: ").Append(clause.Operator).Append(')'); + + if (clause.IsSubshell || clause.IsBashCWrapped) + { + sb.Append(" IsSubshell=").Append(clause.IsSubshell) + .Append(" IsBashCWrapped=").Append(clause.IsBashCWrapped); + } + + sb.AppendLine(); + + var verbLabel = clause.Verb.Tokens.Count == 0 ? "(none)" : clause.Verb.Joined; + sb.Append(" Verb: ").AppendLine(verbLabel); + + if (clause.Args.Count > 0) + { + sb.AppendLine(" Args:"); + foreach (var arg in clause.Args) + { + AppendArgLine(sb, arg); + } + } + + if (clause.Redirects.Count > 0) + { + sb.AppendLine(" Redirects:"); + foreach (var redirect in clause.Redirects) + { + sb.Append(" ").Append(FormatDirection(redirect.Direction)) + .Append(" -> "); + if (redirect.IsDynamicSkip) + { + sb.Append("[dyn-skip] ").AppendLine(redirect.Target); + } + else + { + sb.AppendLine(redirect.Target); + } + } + } + + if (i + 1 < parsed.Clauses.Count) + { + sb.AppendLine(); + } + } + + Console.Write(sb.ToString()); + return 0; + } + + private static void AppendArgLine(StringBuilder sb, Arg arg) + { + var marker = ClassifyArg(arg); + sb.Append(" ").Append(marker.PadRight(11)).Append(' ').Append(arg.Raw); + + if (arg.Resolved is not null && !string.Equals(arg.Resolved, arg.Raw, StringComparison.Ordinal)) + { + sb.Append(" -> ").Append(arg.Resolved); + } + else if (arg.IsPath && arg.Resolved is not null) + { + sb.Append(" -> ").Append(arg.Resolved); + } + + sb.AppendLine(); + } + + private static string ClassifyArg(Arg arg) + { + if (arg.IsCwdAttribution) + { + return "[cwd-attr]"; + } + + if (arg.Kind == ArgKind.DynamicSkip) + { + return "[dyn-skip]"; + } + + if (arg.Kind == ArgKind.Glob) + { + return "[glob]"; + } + + if (arg.IsFlag) + { + return "[flag]"; + } + + if (arg.IsPath) + { + return "[path]"; + } + + return "[literal]"; + } + + private static string FormatDirection(RedirectDirection direction) => direction switch + { + RedirectDirection.In => "In ", + RedirectDirection.Out => "Out ", + RedirectDirection.Append => "App ", + RedirectDirection.ErrOut => "Err ", + RedirectDirection.ErrAppend => "ErrA", + _ => direction.ToString(), + }; +} diff --git a/samples/ShellSyntaxTree.Cli.Sample/Program.cs b/samples/ShellSyntaxTree.Cli.Sample/Program.cs new file mode 100644 index 0000000..5f3ec90 --- /dev/null +++ b/samples/ShellSyntaxTree.Cli.Sample/Program.cs @@ -0,0 +1,31 @@ +using System.CommandLine; +using ShellSyntaxTree.Cli.Sample.Commands; + +// System.CommandLine 2.0.7 (stable) — the 2.0.0-beta5.25558.101 referenced in +// internal notes was never published to nuget.org. The 2.x stable API exposes +// SetAction(parseResult => ...) and parseResult.GetValue(arg) instead of the +// older SetHandler(...) pattern used in pre-beta5 builds. +var commandArg = new Argument("command") +{ + Description = "The shell command (or single line) to analyze.", +}; + +var explainCmd = new Command("explain", "Pretty-print the parsed AST.") +{ + commandArg, +}; +explainCmd.SetAction(parseResult => ExplainCommand.Run(parseResult.GetValue(commandArg) ?? string.Empty)); + +var auditCmd = new Command("audit", "Run the built-in policy against the command.") +{ + commandArg, +}; +auditCmd.SetAction(parseResult => AuditCommand.Run(parseResult.GetValue(commandArg) ?? string.Empty)); + +var root = new RootCommand("ShellSyntaxTree sample CLI") +{ + explainCmd, + auditCmd, +}; + +return root.Parse(args).Invoke(); diff --git a/samples/ShellSyntaxTree.Cli.Sample/ShellSyntaxTree.Cli.Sample.csproj b/samples/ShellSyntaxTree.Cli.Sample/ShellSyntaxTree.Cli.Sample.csproj new file mode 100644 index 0000000..0c17f58 --- /dev/null +++ b/samples/ShellSyntaxTree.Cli.Sample/ShellSyntaxTree.Cli.Sample.csproj @@ -0,0 +1,28 @@ + + + + Exe + $(NetLibVersion) + ShellSyntaxTree.Cli.Sample + + false + enable + enable + + + + + + + + + + + diff --git a/samples/ShellSyntaxTree.Web.Sample/App.razor b/samples/ShellSyntaxTree.Web.Sample/App.razor new file mode 100644 index 0000000..d0289fa --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/App.razor @@ -0,0 +1,10 @@ +@using Microsoft.AspNetCore.Components.Routing + + + + + + +

Page not found.

+
+
diff --git a/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor b/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor new file mode 100644 index 0000000..c719126 --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/Pages/Home.razor @@ -0,0 +1,216 @@ +@page "/" +@inject IJSRuntime JS +@implements IDisposable + +ShellSyntaxTree Visualizer + +
+
+

ShellSyntaxTree Mermaid Visualizer

+

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

+
+ +
+
+

Input

+
+ @foreach (var preset in Presets.All) + { + + } +
+ +
+ +
+
+ +
+

Output

+
+ + + + +
+
+ @if (_tab == Tab.Diagram) + { + @if (string.IsNullOrWhiteSpace(_mermaidSource)) + { +
No script yet. Pick a preset or paste a command.
+ } + else + { +
+ } + } + else if (_tab == Tab.Source) + { +
@_mermaidSource
+ } + else if (_tab == Tab.Ast) + { +
@_astText
+ } + else + { + @if (_unparseableLines.Count == 0) + { +
No unparseable lines.
+ } + else + { +
    + @foreach (var line in _unparseableLines) + { +
  • @line.Source — @(line.Reason ?? "no reason given")
  • + } +
+ } + } +
+
+ + +
+
+
+
+ +@code { + private const string DiagramHostId = "sst-mermaid"; + private const int DebounceMs = 300; + + private enum Tab { Diagram, Source, Ast, Unparseable } + + private string _script = string.Empty; + private string _mermaidSource = string.Empty; + private string _astText = string.Empty; + private int _unparseableCount; + private List<(string Source, string? Reason)> _unparseableLines = new(); + private Tab _tab = Tab.Diagram; + + private System.Threading.Timer? _debounce; + private bool _pendingRender; + + protected override void OnInitialized() + { + UsePreset(Presets.All[0].Script); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_pendingRender && !string.IsNullOrWhiteSpace(_mermaidSource) && _tab == Tab.Diagram) + { + _pendingRender = false; + await JS.InvokeVoidAsync("shellSyntaxTreeInterop.renderMermaid", DiagramHostId, _mermaidSource); + } + } + + private void UsePreset(string script) + { + _script = script; + RenderNow(); + } + + private void OnInput(ChangeEventArgs e) + { + _script = e.Value?.ToString() ?? string.Empty; + ScheduleRender(); + } + + private void ScheduleRender() + { + _debounce?.Dispose(); + _debounce = new System.Threading.Timer(_ => + { + InvokeAsync(RenderNow); + }, null, DebounceMs, System.Threading.Timeout.Infinite); + } + + private void RenderNow() + { + var parser = new BashParser(); + var commands = ScriptSplitter.Split(_script).ToList(); + var parses = new List(commands.Count); + + var astSb = new System.Text.StringBuilder(); + _unparseableLines = new List<(string, string?)>(); + + foreach (var cmd in commands) + { + var parsed = parser.Parse(cmd); + parses.Add(parsed); + + if (parsed.IsUnparseable) + { + _unparseableLines.Add((parsed.Source, parsed.UnparseableReason)); + } + + astSb.Append("# ").AppendLine(cmd); + DumpAst(astSb, parsed); + astSb.AppendLine(); + } + + _unparseableCount = _unparseableLines.Count; + _mermaidSource = MermaidRenderer.Render(parses); + _astText = astSb.ToString(); + _pendingRender = true; + + StateHasChanged(); + } + + private static void DumpAst(System.Text.StringBuilder sb, ParsedCommand parsed) + { + if (parsed.IsUnparseable) + { + sb.Append(" UNPARSEABLE: ").AppendLine(parsed.UnparseableReason ?? "(no reason)"); + return; + } + + for (var i = 0; i < parsed.Clauses.Count; i++) + { + var clause = parsed.Clauses[i]; + sb.Append(" Clause ").Append(i) + .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"); + sb.AppendLine(); + + foreach (var arg in clause.Args) + { + sb.Append(" arg raw=\"").Append(arg.Raw).Append('"'); + if (arg.Resolved is not null && arg.Resolved != arg.Raw) + { + sb.Append(" resolved=\"").Append(arg.Resolved).Append('"'); + } + + sb.Append(" kind=").Append(arg.Kind); + if (arg.IsPath) sb.Append(" path"); + if (arg.IsCwdAttribution) sb.Append(" cwd-attr"); + if (arg.IsFlag) sb.Append(" flag"); + sb.AppendLine(); + } + + foreach (var redirect in clause.Redirects) + { + sb.Append(" redirect dir=").Append(redirect.Direction) + .Append(" target=\"").Append(redirect.Target).Append('"'); + if (redirect.IsDynamicSkip) sb.Append(" dyn-skip"); + sb.AppendLine(); + } + } + } + + private async Task CopyMermaid() => await JS.InvokeVoidAsync("shellSyntaxTreeInterop.copyText", _mermaidSource); + + private async Task CopyAst() => await JS.InvokeVoidAsync("shellSyntaxTreeInterop.copyText", _astText); + + public void Dispose() => _debounce?.Dispose(); +} diff --git a/samples/ShellSyntaxTree.Web.Sample/Program.cs b/samples/ShellSyntaxTree.Web.Sample/Program.cs new file mode 100644 index 0000000..aadf56a --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/Program.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using ShellSyntaxTree.Web.Sample; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +await builder.Build().RunAsync(); diff --git a/samples/ShellSyntaxTree.Web.Sample/Properties/launchSettings.json b/samples/ShellSyntaxTree.Web.Sample/Properties/launchSettings.json new file mode 100644 index 0000000..be57895 --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ShellSyntaxTree.Web.Sample": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:5239", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/ShellSyntaxTree.Web.Sample/Samples/Presets.cs b/samples/ShellSyntaxTree.Web.Sample/Samples/Presets.cs new file mode 100644 index 0000000..5119ed4 --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/Samples/Presets.cs @@ -0,0 +1,13 @@ +namespace ShellSyntaxTree.Web.Sample.Samples; + +public static class Presets +{ + public static readonly (string Name, string Script)[] All = + { + ("Build script", "set -e\ncd /repo\ngit pull origin main\ndocker build -t myapp .\ndocker push myapp:latest > /tmp/push.log 2>&1\necho done"), + ("With subshell", "cd /a && (cd /b && cmd1) && cmd2"), + ("With bash -c", "cd /repo && bash -c \"git status && git push\""), + ("Unparseable (control flow)", "for i in 1 2 3; do echo $i; done"), + ("Dynamic cwd", "cd $REPO_DIR && rm -rf node_modules && npm install"), + }; +} diff --git a/samples/ShellSyntaxTree.Web.Sample/Services/MermaidRenderer.cs b/samples/ShellSyntaxTree.Web.Sample/Services/MermaidRenderer.cs new file mode 100644 index 0000000..fb57464 --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/Services/MermaidRenderer.cs @@ -0,0 +1,354 @@ +using System.Text; +using ShellSyntaxTree; + +namespace ShellSyntaxTree.Web.Sample.Services; + +/// +/// Renders a list of (one per script line) +/// as a Mermaid flowchart source string. Keeps escaping concerns local +/// so the rest of the sample can think in terms of ParsedCommand. +/// +public static class MermaidRenderer +{ + public static string Render(IReadOnlyList parses) + { + // Return empty so the host page can show its own placeholder UI + // instead of a fake "(no commands)" mermaid node. + if (parses.Count == 0 || parses.All(p => !p.IsUnparseable && p.Clauses.Count == 0)) + { + return string.Empty; + } + + var sb = new StringBuilder(); + + // LR if any single parse has more than 4 clauses, else TD. + var orientation = "TD"; + foreach (var parse in parses) + { + if (parse.Clauses.Count > 4) + { + orientation = "LR"; + break; + } + } + + sb.Append("flowchart ").Append(orientation).Append('\n'); + sb.Append(" classDef unparseable fill:#fee,stroke:#b00,stroke-width:2px\n"); + sb.Append(" classDef dynamic fill:#ffe,stroke:#b80\n"); + sb.Append(" classDef redirect fill:#eef,stroke:#88a\n"); + + var subshellSerial = 0; + var bashCSerial = 0; + + // First pass: emit nodes (and their subgraph groupings). + for (var lineIdx = 0; lineIdx < parses.Count; lineIdx++) + { + var parse = parses[lineIdx]; + + if (parse.IsUnparseable) + { + var nodeId = NodeId(lineIdx, 0); + var reason = Truncate(parse.UnparseableReason ?? "unknown", 40); + sb.Append(" ").Append(nodeId).Append('[').Append('"') + .Append("?
UNPARSEABLE
").Append(EscapeLabel(reason)) + .Append("\"]\n"); + sb.Append(" class ").Append(nodeId).Append(" unparseable\n"); + continue; + } + + // Group consecutive same-flag clauses into a single subgraph so we + // don't open / close subgraphs around every clause. + var c = 0; + while (c < parse.Clauses.Count) + { + var clause = parse.Clauses[c]; + + if (clause.IsSubshell) + { + subshellSerial++; + sb.Append(" subgraph SH").Append(subshellSerial).Append(" [\"( ... )\"]\n"); + var groupEnd = c; + while (groupEnd < parse.Clauses.Count && parse.Clauses[groupEnd].IsSubshell) + { + EmitClauseNode(sb, parse, lineIdx, groupEnd); + groupEnd++; + } + + sb.Append(" end\n"); + c = groupEnd; + } + else if (clause.IsBashCWrapped) + { + bashCSerial++; + sb.Append(" subgraph BC").Append(bashCSerial).Append(" [\"bash -c\"]\n"); + var groupEnd = c; + while (groupEnd < parse.Clauses.Count && parse.Clauses[groupEnd].IsBashCWrapped) + { + EmitClauseNode(sb, parse, lineIdx, groupEnd); + groupEnd++; + } + + sb.Append(" end\n"); + c = groupEnd; + } + else + { + EmitClauseNode(sb, parse, lineIdx, c); + c++; + } + } + } + + // Second pass: edges within a line + redirects. + for (var lineIdx = 0; lineIdx < parses.Count; lineIdx++) + { + var parse = parses[lineIdx]; + if (parse.IsUnparseable) + { + continue; + } + + for (var i = 0; i + 1 < parse.Clauses.Count; i++) + { + var from = NodeId(lineIdx, i); + var to = NodeId(lineIdx, i + 1); + var op = OperatorLabel(parse.Clauses[i + 1].Operator); + sb.Append(" ").Append(from).Append(" -->|\"").Append(op).Append("\"| ").Append(to).Append('\n'); + } + + for (var i = 0; i < parse.Clauses.Count; i++) + { + var clause = parse.Clauses[i]; + for (var r = 0; r < clause.Redirects.Count; r++) + { + var redirect = clause.Redirects[r]; + if (redirect.IsDynamicSkip) + { + continue; + } + + // `2>&1` and similar `N>&M` fd-dup targets aren't files; + // the parser resolves `&1` against the cwd into something + // like `/work/&1`, so check the basename, not the prefix. + // v0.1.x candidate: have the parser surface fd-dup targets + // distinctly (e.g. IsFdDup) so consumers don't have to + // string-match. + var basename = redirect.Target.LastIndexOf('/') is var lastSlash and >= 0 + ? redirect.Target[(lastSlash + 1)..] + : redirect.Target; + if (basename.StartsWith('&')) + { + continue; + } + + var fileNode = $"F{NodeId(lineIdx, i)}R{r}"; + sb.Append(" ").Append(fileNode).Append("[(\"") + .Append(EscapeLabel(redirect.Target)).Append("\")]\n"); + sb.Append(" class ").Append(fileNode).Append(" redirect\n"); + + var label = redirect.Direction switch + { + RedirectDirection.In => "stdin", + RedirectDirection.Out => "stdout", + RedirectDirection.Append => "append", + RedirectDirection.ErrOut => "stderr-out", + RedirectDirection.ErrAppend => "stderr-append", + _ => redirect.Direction.ToString(), + }; + + sb.Append(" ").Append(NodeId(lineIdx, i)) + .Append(" -.->|\"").Append(label).Append("\"| ").Append(fileNode).Append('\n'); + } + } + } + + // Third pass: dotted edges connecting sequential lines (last clause -> first clause). + for (var lineIdx = 0; lineIdx + 1 < parses.Count; lineIdx++) + { + var thisParse = parses[lineIdx]; + var nextParse = parses[lineIdx + 1]; + + if (thisParse.Clauses.Count == 0 && !thisParse.IsUnparseable) + { + continue; + } + + if (nextParse.Clauses.Count == 0 && !nextParse.IsUnparseable) + { + continue; + } + + var fromIdx = thisParse.IsUnparseable ? 0 : thisParse.Clauses.Count - 1; + var from = NodeId(lineIdx, fromIdx); + var to = NodeId(lineIdx + 1, 0); + sb.Append(" ").Append(from).Append(" -.-> ").Append(to).Append('\n'); + } + + return sb.ToString(); + } + + private static void EmitClauseNode(StringBuilder sb, ParsedCommand parse, int lineIdx, int clauseIdx) + { + var clause = parse.Clauses[clauseIdx]; + var nodeId = NodeId(lineIdx, clauseIdx); + + var label = BuildLabel(clause); + sb.Append(" ").Append(nodeId).Append('[').Append('"').Append(label).Append("\"]\n"); + + if (HasDynamicSkip(clause)) + { + sb.Append(" class ").Append(nodeId).Append(" dynamic\n"); + } + } + + private static string BuildLabel(Clause clause) + { + var sb = new StringBuilder(); + var verb = clause.Verb.Tokens.Count == 0 ? "(no verb)" : clause.Verb.Joined; + sb.Append(EscapeLabel(verb)); + + // First 2 path args (preferred) or first 2 non-flag args as fallback + // so verbs like `echo done` or `set -e` don't render as bare labels. + var pathArgs = new List(); + var nonFlagArgs = new List(); + string? cwdAttr = null; + foreach (var arg in clause.Args) + { + if (arg.IsCwdAttribution) + { + cwdAttr = arg.Resolved ?? arg.Raw; + continue; + } + + if (arg.IsPath) + { + pathArgs.Add(arg.Resolved ?? arg.Raw); + } + else if (!arg.IsFlag) + { + nonFlagArgs.Add(arg.Resolved ?? arg.Raw); + } + } + + var labelExtras = pathArgs.Count > 0 ? pathArgs : nonFlagArgs; + if (labelExtras.Count > 0) + { + sb.Append("
"); + for (var i = 0; i < Math.Min(2, labelExtras.Count); i++) + { + if (i > 0) + { + sb.Append(' '); + } + + sb.Append(EscapeLabel(labelExtras[i])); + } + + if (labelExtras.Count > 2) + { + sb.Append(" ..."); + } + } + + if (cwdAttr is not null) + { + sb.Append("
cwd: ").Append(EscapeLabel(cwdAttr)); + } + else if (HasDynamicCwdSignal(clause)) + { + sb.Append("
cwd: ?"); + } + + return sb.ToString(); + } + + private static bool HasDynamicCwdSignal(Clause clause) + { + // A clause whose verb is cd-ish but whose first arg is DynamicSkip + // is the canonical "cwd: ?" shape. Worth showing in the label. + if (clause.Verb.Joined != "cd") + { + return false; + } + + foreach (var arg in clause.Args) + { + if (arg.IsCwdAttribution) + { + continue; + } + + return arg.Kind == ArgKind.DynamicSkip; + } + + return false; + } + + private static bool HasDynamicSkip(Clause clause) + { + foreach (var arg in clause.Args) + { + if (arg.Kind == ArgKind.DynamicSkip) + { + return true; + } + } + + foreach (var r in clause.Redirects) + { + if (r.IsDynamicSkip) + { + return true; + } + } + + return false; + } + + private static string OperatorLabel(CompoundOperator op) => op switch + { + CompoundOperator.AndIf => "&&", + CompoundOperator.OrIf => "||", + CompoundOperator.Sequence => ";", + CompoundOperator.Pipe => "\\|", + _ => "", + }; + + private static string NodeId(int line, int clause) => $"L{line}C{clause}"; + + private static string EscapeLabel(string s) + { + // Inside mermaid node labels, the safest path is to strip / escape the + // small set of chars that bite. Keep this in lock-step with what + // mermaid.js 11 accepts inside ["..."] double-quoted labels. + var sb = new StringBuilder(s.Length); + foreach (var c in s) + { + switch (c) + { + case '"': sb.Append("""); break; + case '<': sb.Append("<"); break; + case '>': sb.Append(">"); break; + case '|': sb.Append("|"); break; + case '(': sb.Append("("); break; + case ')': sb.Append(")"); break; + case '[': sb.Append("["); break; + case ']': sb.Append("]"); break; + case '{': sb.Append("{"); break; + case '}': sb.Append("}"); break; + case '&': sb.Append("&"); break; + case '\\': sb.Append("\"); break; + case '`': sb.Append("`"); break; + // Apostrophe is safe inside `["..."]` double-quoted labels and + // mermaid 11 doesn't decode `'` cleanly there — leave bare. + case '\'': sb.Append('\''); break; + default: sb.Append(c); break; + } + } + + return sb.ToString(); + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..max] + "..."; +} diff --git a/samples/ShellSyntaxTree.Web.Sample/Services/ScriptSplitter.cs b/samples/ShellSyntaxTree.Web.Sample/Services/ScriptSplitter.cs new file mode 100644 index 0000000..56fe67a --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/Services/ScriptSplitter.cs @@ -0,0 +1,141 @@ +using System.Text; + +namespace ShellSyntaxTree.Web.Sample.Services; + +/// +/// Split a multi-line script into individual top-level commands. +/// Skips blank lines and # comments. Folds backslash + newline +/// continuations. Preserves heredoc bodies as part of their command. +/// Edge cases (control flow, function bodies) are passed through +/// unchanged — the parser will mark them IsUnparseable. +/// +internal static class ScriptSplitter +{ + public static IEnumerable Split(string script) + { + if (string.IsNullOrEmpty(script)) + { + yield break; + } + + var lines = script.Replace("\r\n", "\n").Split('\n'); + var pending = new StringBuilder(); + string? heredocDelim = null; + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + + // Heredoc body: keep collecting verbatim until the delimiter line. + if (heredocDelim is not null) + { + pending.Append('\n').Append(line); + if (line.Trim() == heredocDelim) + { + heredocDelim = null; + yield return pending.ToString(); + pending.Clear(); + } + + continue; + } + + // Skip blank lines and pure comment lines only when we are + // not in the middle of collecting a continued command. + if (pending.Length == 0) + { + var trimmed = line.TrimStart(); + if (trimmed.Length == 0 || trimmed.StartsWith('#')) + { + continue; + } + } + + // Fold backslash continuation. + if (line.EndsWith('\\')) + { + pending.Append(line.AsSpan(0, line.Length - 1)); + continue; + } + + pending.Append(line); + + // Look for a starting heredoc (`< 0) + { + yield return pending.ToString(); + } + } + + private static string? TryExtractHeredocDelimiter(string command) + { + // Minimal heredoc detection: look for << (optionally <<-) followed by a + // token. Good enough for the common shapes seen in build scripts; + // anything weirder will still parse, just as one logical command. + var idx = command.IndexOf("<<", StringComparison.Ordinal); + if (idx < 0) + { + return null; + } + + var after = command.AsSpan(idx + 2).TrimStart(); + if (after.Length == 0) + { + return null; + } + + // Strip leading '-' (heredoc dash form) and optional quotes. + if (after[0] == '-') + { + after = after[1..].TrimStart(); + } + + if (after.Length == 0) + { + return null; + } + + var quote = after[0] is '"' or '\'' ? after[0] : (char?)null; + if (quote is not null) + { + after = after[1..]; + var end = after.IndexOf(quote.Value); + if (end < 0) + { + return null; + } + + return after[..end].ToString(); + } + + // Unquoted: read identifier-shaped chars. + var len = 0; + while (len < after.Length && (char.IsLetterOrDigit(after[len]) || after[len] == '_')) + { + len++; + } + + if (len == 0) + { + return null; + } + + return after[..len].ToString(); + } +} diff --git a/samples/ShellSyntaxTree.Web.Sample/ShellSyntaxTree.Web.Sample.csproj b/samples/ShellSyntaxTree.Web.Sample/ShellSyntaxTree.Web.Sample.csproj new file mode 100644 index 0000000..f6a9def --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/ShellSyntaxTree.Web.Sample.csproj @@ -0,0 +1,20 @@ + + + + $(NetLibVersion) + ShellSyntaxTree.Web.Sample + false + enable + enable + + + + + + + + + + + + diff --git a/samples/ShellSyntaxTree.Web.Sample/_Imports.razor b/samples/ShellSyntaxTree.Web.Sample/_Imports.razor new file mode 100644 index 0000000..b76a17f --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using ShellSyntaxTree +@using ShellSyntaxTree.Web.Sample +@using ShellSyntaxTree.Web.Sample.Pages +@using ShellSyntaxTree.Web.Sample.Services +@using ShellSyntaxTree.Web.Sample.Samples diff --git a/samples/ShellSyntaxTree.Web.Sample/wwwroot/css/app.css b/samples/ShellSyntaxTree.Web.Sample/wwwroot/css/app.css new file mode 100644 index 0000000..6667173 --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/wwwroot/css/app.css @@ -0,0 +1,181 @@ +:root { + --bg: #ffffff; + --fg: #1c1c1c; + --muted: #666; + --border: #d8d8d8; + --accent: #2b5fb1; + --accent-fg: #ffffff; + --code-bg: #f7f7f7; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + line-height: 1.45; +} + +a { color: var(--accent); } + +.app-shell { + max-width: 1400px; + margin: 0 auto; + padding: 24px; +} + +.app-header h1 { + margin: 0 0 4px; + font-size: 22px; +} + +.app-header p { + margin: 0 0 16px; + color: var(--muted); +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 24px; + align-items: stretch; +} + +@media (max-width: 900px) { + .layout { grid-template-columns: 1fr; } +} + +.panel { + border: 1px solid var(--border); + border-radius: 6px; + padding: 16px; + background: #fff; + display: flex; + flex-direction: column; + min-height: 360px; +} + +.panel h2 { + margin: 0 0 12px; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +.preset-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.preset-row button, +.action-row button { + appearance: none; + border: 1px solid var(--border); + background: #f3f3f3; + color: var(--fg); + padding: 6px 10px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; +} + +.preset-row button:hover, +.action-row button:hover { + background: #e7e7e7; +} + +.script-input { + flex: 1; + width: 100%; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--code-bg); + color: var(--fg); + resize: vertical; + min-height: 240px; +} + +.tab-row { + display: flex; + gap: 4px; + margin-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.tab-row button { + appearance: none; + border: none; + background: transparent; + padding: 6px 12px; + cursor: pointer; + color: var(--muted); + font-size: 13px; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} + +.tab-row button.active { + color: var(--fg); + border-bottom-color: var(--accent); +} + +.tab-body { + flex: 1; + min-height: 240px; + overflow: auto; +} + +.diagram-host { + width: 100%; + overflow: auto; + padding: 8px 0; +} + +.diagram-host svg { + max-width: 100%; + height: auto; +} + +.code-block { + margin: 0; + padding: 12px; + background: var(--code-bg); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; +} + +.action-row { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.unparseable-list { + margin: 0; + padding-left: 18px; +} + +.unparseable-list li { + color: #b00; + margin-bottom: 4px; +} + +.empty-state { + color: var(--muted); + padding: 16px; + font-style: italic; +} diff --git a/samples/ShellSyntaxTree.Web.Sample/wwwroot/index.html b/samples/ShellSyntaxTree.Web.Sample/wwwroot/index.html new file mode 100644 index 0000000..a9348aa --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/wwwroot/index.html @@ -0,0 +1,20 @@ + + + + + + ShellSyntaxTree — Mermaid Visualizer + + + + +
Loading...
+ + + + + + + diff --git a/samples/ShellSyntaxTree.Web.Sample/wwwroot/js/interop.js b/samples/ShellSyntaxTree.Web.Sample/wwwroot/js/interop.js new file mode 100644 index 0000000..6768b6f --- /dev/null +++ b/samples/ShellSyntaxTree.Web.Sample/wwwroot/js/interop.js @@ -0,0 +1,20 @@ +window.shellSyntaxTreeInterop = { + renderMermaid: async function (containerId, source) { + const el = document.getElementById(containerId); + if (!el) return; + try { + const { svg } = await mermaid.render(containerId + '-svg', source); + el.innerHTML = svg; + } catch (e) { + el.innerHTML = '
Mermaid render error: ' + (e.message || e) + '
'; + } + }, + copyText: async function (text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (e) { + return false; + } + } +};