From 852e8721d0807ec9c13990d474042bb026f39e33 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 10:58:35 +0000 Subject: [PATCH] Add DAP REPL command model and parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a typed command model and hand-rolled parser for the debug console DSL. The parser turns REPL input into HelpCommand or RunCommand objects, keeping parsing separate from execution. Ruby-like DSL syntax: help → general help help("run") → command-specific help run("echo hello") → run with default shell run("echo $X", shell: "bash", env: { X: "1" }) → run with explicit shell and env Parser features: - Handles escaped quotes, nested braces, and mixed arguments - Keyword arguments: shell, env, working_directory - Env blocks parsed as { KEY: "value", KEY2: "value2" } - Returns null for non-DSL input (falls through to expression eval) - Descriptive error messages for malformed input - Help text scaffolding for discoverability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Runner.Worker/Dap/DapReplParser.cs | 409 +++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/Runner.Worker/Dap/DapReplParser.cs diff --git a/src/Runner.Worker/Dap/DapReplParser.cs b/src/Runner.Worker/Dap/DapReplParser.cs new file mode 100644 index 000000000..b6385c34d --- /dev/null +++ b/src/Runner.Worker/Dap/DapReplParser.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GitHub.Runner.Worker.Dap +{ + /// + /// Base type for all REPL DSL commands. + /// + internal abstract class DapReplCommand + { + } + + /// + /// help or help("run") + /// + internal sealed class HelpCommand : DapReplCommand + { + public string Topic { get; set; } + } + + /// + /// run("echo hello") or + /// run("echo hello", shell: "bash", env: { FOO: "bar" }, working_directory: "/tmp") + /// + internal sealed class RunCommand : DapReplCommand + { + public string Script { get; set; } + public string Shell { get; set; } + public Dictionary Env { get; set; } + public string WorkingDirectory { get; set; } + } + + /// + /// Parses REPL input into typed objects. + /// + /// Grammar (intentionally minimal — extend as the DSL grows): + /// + /// help → HelpCommand { Topic = null } + /// help("run") → HelpCommand { Topic = "run" } + /// run("script body") → RunCommand { Script = "script body" } + /// run("script", shell: "bash") → RunCommand { Shell = "bash" } + /// run("script", env: { K: "V" }) → RunCommand { Env = { K → V } } + /// run("script", working_directory: "p")→ RunCommand { WorkingDirectory = "p" } + /// + /// + /// Parsing is intentionally hand-rolled rather than regex-based so it can + /// handle nested braces, quoted strings with escapes, and grow to support + /// future commands without accumulating regex complexity. + /// + internal static class DapReplParser + { + /// + /// Attempts to parse REPL input into a command. Returns null if the + /// input does not match any known DSL command (i.e. it should be + /// treated as an expression instead). + /// + internal static DapReplCommand TryParse(string input, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + var trimmed = input.Trim(); + + // help / help("topic") + if (trimmed.Equals("help", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("help(", StringComparison.OrdinalIgnoreCase)) + { + return ParseHelp(trimmed, out error); + } + + // run("...") + if (trimmed.StartsWith("run(", StringComparison.OrdinalIgnoreCase)) + { + return ParseRun(trimmed, out error); + } + + // Not a DSL command + return null; + } + + internal static string GetGeneralHelp() + { + var sb = new StringBuilder(); + sb.AppendLine("Actions Debug Console"); + sb.AppendLine(); + sb.AppendLine("Commands:"); + sb.AppendLine(" help Show this help"); + sb.AppendLine(" help(\"run\") Show help for the run command"); + sb.AppendLine(" run(\"script\") Execute a script (like a workflow run step)"); + sb.AppendLine(); + sb.AppendLine("Anything else is evaluated as a GitHub Actions expression."); + sb.AppendLine(" Example: github.repository"); + sb.AppendLine(" Example: ${{ github.event_name }}"); + return sb.ToString(); + } + + internal static string GetRunHelp() + { + var sb = new StringBuilder(); + sb.AppendLine("run command — execute a script in the job context"); + sb.AppendLine(); + sb.AppendLine("Usage:"); + sb.AppendLine(" run(\"echo hello\")"); + sb.AppendLine(" run(\"echo $FOO\", shell: \"bash\")"); + sb.AppendLine(" run(\"echo $FOO\", env: { FOO: \"bar\" })"); + sb.AppendLine(" run(\"ls\", working_directory: \"/tmp\")"); + sb.AppendLine(" run(\"echo $X\", shell: \"bash\", env: { X: \"1\" }, working_directory: \"/tmp\")"); + sb.AppendLine(); + sb.AppendLine("Options:"); + sb.AppendLine(" shell: Shell to use (default: job default, e.g. bash)"); + sb.AppendLine(" env: Extra environment variables as { KEY: \"value\" }"); + sb.AppendLine(" working_directory: Working directory for the command"); + sb.AppendLine(); + sb.AppendLine("Behavior:"); + sb.AppendLine(" - Equivalent to a workflow `run:` step"); + sb.AppendLine(" - Expressions in the script body are expanded (${{ ... }})"); + sb.AppendLine(" - Output is streamed in real time and secrets are masked"); + return sb.ToString(); + } + + #region Parsers + + private static HelpCommand ParseHelp(string input, out string error) + { + error = null; + if (input.Equals("help", StringComparison.OrdinalIgnoreCase)) + { + return new HelpCommand(); + } + + // help("topic") + var inner = ExtractParenthesizedArgs(input, "help", out error); + if (error != null) return null; + + var topic = ExtractQuotedString(inner.Trim(), out error); + if (error != null) return null; + + return new HelpCommand { Topic = topic }; + } + + private static RunCommand ParseRun(string input, out string error) + { + error = null; + + var inner = ExtractParenthesizedArgs(input, "run", out error); + if (error != null) return null; + + // Split into argument list respecting quotes and braces + var args = SplitArguments(inner, out error); + if (error != null) return null; + if (args.Count == 0) + { + error = "run() requires a script argument. Example: run(\"echo hello\")"; + return null; + } + + // First arg must be the script body (a quoted string) + var script = ExtractQuotedString(args[0].Trim(), out error); + if (error != null) + { + error = $"First argument to run() must be a quoted string. {error}"; + return null; + } + + var cmd = new RunCommand { Script = script }; + + // Parse remaining keyword arguments + for (int i = 1; i < args.Count; i++) + { + var kv = args[i].Trim(); + var colonIdx = kv.IndexOf(':'); + if (colonIdx <= 0) + { + error = $"Expected keyword argument (e.g. shell: \"bash\"), got: {kv}"; + return null; + } + + var key = kv.Substring(0, colonIdx).Trim(); + var value = kv.Substring(colonIdx + 1).Trim(); + + switch (key.ToLowerInvariant()) + { + case "shell": + cmd.Shell = ExtractQuotedString(value, out error); + if (error != null) { error = $"shell: {error}"; return null; } + break; + + case "working_directory": + cmd.WorkingDirectory = ExtractQuotedString(value, out error); + if (error != null) { error = $"working_directory: {error}"; return null; } + break; + + case "env": + cmd.Env = ParseEnvBlock(value, out error); + if (error != null) { error = $"env: {error}"; return null; } + break; + + default: + error = $"Unknown option: {key}. Valid options: shell, env, working_directory"; + return null; + } + } + + return cmd; + } + + #endregion + + #region Low-level parsing helpers + + /// + /// Given "cmd(...)" returns the inner content between the outer parens. + /// + private static string ExtractParenthesizedArgs(string input, string prefix, out string error) + { + error = null; + var start = prefix.Length; // skip "cmd" + if (start >= input.Length || input[start] != '(') + { + error = $"Expected '(' after {prefix}"; + return null; + } + + if (input[input.Length - 1] != ')') + { + error = $"Expected ')' at end of {prefix}(...)"; + return null; + } + + return input.Substring(start + 1, input.Length - start - 2); + } + + /// + /// Extracts a double-quoted string value, handling escaped quotes. + /// + internal static string ExtractQuotedString(string input, out string error) + { + error = null; + if (string.IsNullOrEmpty(input)) + { + error = "Expected a quoted string, got empty input"; + return null; + } + + if (input[0] != '"') + { + error = $"Expected a quoted string starting with \", got: {Truncate(input, 40)}"; + return null; + } + + var sb = new StringBuilder(); + for (int i = 1; i < input.Length; i++) + { + if (input[i] == '\\' && i + 1 < input.Length) + { + sb.Append(input[i + 1]); + i++; + } + else if (input[i] == '"') + { + // Check nothing meaningful follows the closing quote + var rest = input.Substring(i + 1).Trim(); + if (rest.Length > 0) + { + error = $"Unexpected content after closing quote: {Truncate(rest, 40)}"; + return null; + } + return sb.ToString(); + } + else + { + sb.Append(input[i]); + } + } + + error = "Unterminated string (missing closing \")"; + return null; + } + + /// + /// Splits a comma-separated argument list, respecting quoted strings + /// and nested braces so that "a, b", env: { K: "V, W" } is + /// correctly split into two arguments. + /// + internal static List SplitArguments(string input, out string error) + { + error = null; + var result = new List(); + var current = new StringBuilder(); + int depth = 0; + bool inQuote = false; + + for (int i = 0; i < input.Length; i++) + { + var ch = input[i]; + + if (ch == '\\' && inQuote && i + 1 < input.Length) + { + current.Append(ch); + current.Append(input[++i]); + continue; + } + + if (ch == '"') + { + inQuote = !inQuote; + current.Append(ch); + continue; + } + + if (!inQuote) + { + if (ch == '{') + { + depth++; + current.Append(ch); + continue; + } + if (ch == '}') + { + depth--; + current.Append(ch); + continue; + } + if (ch == ',' && depth == 0) + { + result.Add(current.ToString()); + current.Clear(); + continue; + } + } + + current.Append(ch); + } + + if (inQuote) + { + error = "Unterminated string in arguments"; + return null; + } + if (depth != 0) + { + error = "Unmatched braces in arguments"; + return null; + } + + if (current.Length > 0) + { + result.Add(current.ToString()); + } + + return result; + } + + /// + /// Parses { KEY: "value", KEY2: "value2" } into a dictionary. + /// + internal static Dictionary ParseEnvBlock(string input, out string error) + { + error = null; + var trimmed = input.Trim(); + if (!trimmed.StartsWith("{") || !trimmed.EndsWith("}")) + { + error = "Expected env block in the form { KEY: \"value\" }"; + return null; + } + + var inner = trimmed.Substring(1, trimmed.Length - 2).Trim(); + if (string.IsNullOrEmpty(inner)) + { + return new Dictionary(); + } + + var pairs = SplitArguments(inner, out error); + if (error != null) return null; + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in pairs) + { + var colonIdx = pair.IndexOf(':'); + if (colonIdx <= 0) + { + error = $"Expected KEY: \"value\" pair, got: {Truncate(pair.Trim(), 40)}"; + return null; + } + + var key = pair.Substring(0, colonIdx).Trim(); + var val = ExtractQuotedString(pair.Substring(colonIdx + 1).Trim(), out error); + if (error != null) return null; + + result[key] = val; + } + + return result; + } + + private static string Truncate(string value, int maxLength) + { + if (value == null) return "(null)"; + return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "..."; + } + + #endregion + } +}