using System; using System.Collections.Generic; using System.Text.RegularExpressions; using GitHub.Runner.Common; namespace GitHub.Runner.Worker.Dap.StepCommands { /// /// Output format for step command responses. /// public enum OutputFormat { /// Human-readable text output (default) Text, /// JSON output for programmatic use Json } /// /// Interface for parsing step commands from REPL strings. /// [ServiceLocator(Default = typeof(StepCommandParser))] public interface IStepCommandParser : IRunnerService { /// /// Parses a command string into a structured StepCommand. /// /// The input string (e.g., "steps list --verbose") /// Parsed StepCommand /// If parsing fails StepCommand Parse(string input); /// /// Checks if the input is a step command. /// /// The input string to check /// True if this is a step command (starts with "steps") bool IsStepCommand(string input); } #region Command Classes /// /// Base class for all step commands. /// public abstract class StepCommand { /// /// Output format for the command response. /// public OutputFormat Output { get; set; } = OutputFormat.Text; } /// /// steps list [--verbose] /// public class ListCommand : StepCommand { public bool Verbose { get; set; } } /// /// steps add run "script" [options] /// public class AddRunCommand : StepCommand { public string Script { get; set; } public string Name { get; set; } public string Shell { get; set; } public string WorkingDirectory { get; set; } public Dictionary Env { get; set; } public string Condition { get; set; } public bool ContinueOnError { get; set; } public int? Timeout { get; set; } public StepPosition Position { get; set; } = StepPosition.Last(); } /// /// steps add uses "action@ref" [options] /// public class AddUsesCommand : StepCommand { public string Action { get; set; } public string Name { get; set; } public Dictionary With { get; set; } public Dictionary Env { get; set; } public string Condition { get; set; } public bool ContinueOnError { get; set; } public int? Timeout { get; set; } public StepPosition Position { get; set; } = StepPosition.Last(); } /// /// steps edit <index> [modifications] /// public class EditCommand : StepCommand { public int Index { get; set; } public string Name { get; set; } public string Script { get; set; } public string Action { get; set; } public string Shell { get; set; } public string WorkingDirectory { get; set; } public string Condition { get; set; } public Dictionary With { get; set; } public Dictionary Env { get; set; } public List RemoveWith { get; set; } public List RemoveEnv { get; set; } public bool? ContinueOnError { get; set; } public int? Timeout { get; set; } } /// /// steps remove <index> /// public class RemoveCommand : StepCommand { public int Index { get; set; } } /// /// steps move <from> [position options] /// public class MoveCommand : StepCommand { public int FromIndex { get; set; } public StepPosition Position { get; set; } } /// /// steps export [--changes-only] [--with-comments] /// public class ExportCommand : StepCommand { public bool ChangesOnly { get; set; } public bool WithComments { get; set; } } #endregion #region Position Types /// /// Types of position specifications for inserting/moving steps. /// public enum PositionType { /// Insert at specific index (1-based) At, /// Insert after specific index (1-based) After, /// Insert before specific index (1-based) Before, /// Insert at first pending position First, /// Insert at end (default) Last } /// /// Represents a position for inserting or moving steps. /// public class StepPosition { public PositionType Type { get; set; } public int? Index { get; set; } public static StepPosition At(int index) => new StepPosition { Type = PositionType.At, Index = index }; public static StepPosition After(int index) => new StepPosition { Type = PositionType.After, Index = index }; public static StepPosition Before(int index) => new StepPosition { Type = PositionType.Before, Index = index }; public static StepPosition First() => new StepPosition { Type = PositionType.First }; public static StepPosition Last() => new StepPosition { Type = PositionType.Last }; public override string ToString() { return Type switch { PositionType.At => $"at {Index}", PositionType.After => $"after {Index}", PositionType.Before => $"before {Index}", PositionType.First => "first", PositionType.Last => "last", _ => "unknown" }; } } #endregion /// /// Parser implementation for step commands. /// public sealed class StepCommandParser : RunnerService, IStepCommandParser { // Regex to match quoted strings (handles escaped quotes) private static readonly Regex QuotedStringRegex = new Regex( @"""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'", RegexOptions.Compiled); public bool IsStepCommand(string input) { if (string.IsNullOrWhiteSpace(input)) return false; var trimmed = input.Trim(); // Command format: steps ... if (trimmed.StartsWith("steps ", StringComparison.OrdinalIgnoreCase) || trimmed.Equals("steps", StringComparison.OrdinalIgnoreCase)) return true; return false; } public StepCommand Parse(string input) { var trimmed = input?.Trim() ?? ""; return ParseReplCommand(trimmed); } #region REPL Parsing private StepCommand ParseReplCommand(string input) { // Tokenize the input, respecting quoted strings var tokens = Tokenize(input); if (tokens.Count < 2 || !tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase)) { throw new StepCommandException(StepCommandErrors.ParseError, "Invalid command format. Expected: steps [args...]"); } var subCommand = tokens[1].ToLower(); return subCommand switch { "list" => ParseReplListCommand(tokens), "add" => ParseReplAddCommand(tokens), "edit" => ParseReplEditCommand(tokens), "remove" => ParseReplRemoveCommand(tokens), "move" => ParseReplMoveCommand(tokens), "export" => ParseReplExportCommand(tokens), _ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown sub-command: {subCommand}") }; } private List Tokenize(string input) { var tokens = new List(); var remaining = input; while (!string.IsNullOrEmpty(remaining)) { remaining = remaining.TrimStart(); if (string.IsNullOrEmpty(remaining)) break; // Check for quoted string var match = QuotedStringRegex.Match(remaining); if (match.Success && match.Index == 0) { // Extract the quoted content (without quotes) var quoted = match.Value; var content = quoted.Substring(1, quoted.Length - 2); // Unescape content = content.Replace("\\\"", "\"").Replace("\\'", "'").Replace("\\\\", "\\"); tokens.Add(content); remaining = remaining.Substring(match.Length); } else { // Non-quoted token var spaceIndex = remaining.IndexOfAny(new[] { ' ', '\t' }); if (spaceIndex == -1) { tokens.Add(remaining); break; } tokens.Add(remaining.Substring(0, spaceIndex)); remaining = remaining.Substring(spaceIndex); } } return tokens; } private ListCommand ParseReplListCommand(List tokens) { // Extract --output flag before processing other options var outputFormat = ExtractOutputFlag(tokens); var cmd = new ListCommand { Output = outputFormat }; for (int i = 2; i < tokens.Count; i++) { var token = tokens[i].ToLower(); if (token == "--verbose" || token == "-v") { cmd.Verbose = true; } else { throw new StepCommandException(StepCommandErrors.InvalidOption, $"Unknown option for list: {tokens[i]}"); } } return cmd; } private StepCommand ParseReplAddCommand(List tokens) { // Extract --output flag before processing other options var outputFormat = ExtractOutputFlag(tokens); if (tokens.Count < 3) { throw new StepCommandException(StepCommandErrors.ParseError, "Usage: steps add [options]"); } var type = tokens[2].ToLower(); StepCommand cmd; if (type == "run") { cmd = ParseReplAddRunCommand(tokens); } else if (type == "uses") { cmd = ParseReplAddUsesCommand(tokens); } else { throw new StepCommandException(StepCommandErrors.InvalidType, $"Invalid step type: '{type}'. Must be 'run' or 'uses'."); } cmd.Output = outputFormat; return cmd; } private AddRunCommand ParseReplAddRunCommand(List tokens) { // steps add run "script" [options] if (tokens.Count < 4) { throw new StepCommandException(StepCommandErrors.ParseError, "Usage: steps add run \"