diff options
Diffstat (limited to 'src/Godosa.Core/Runs/Scripts/ScriptParser.cs')
| -rw-r--r-- | src/Godosa.Core/Runs/Scripts/ScriptParser.cs | 231 |
1 files changed, 231 insertions, 0 deletions
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptParser.cs b/src/Godosa.Core/Runs/Scripts/ScriptParser.cs new file mode 100644 index 0000000..f9f964f --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/ScriptParser.cs @@ -0,0 +1,231 @@ +// Provenance: own design โ shared run scripts (spec ยง3) +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Godosa.Core.Runs.Scripts; + +/// <summary>Format v1 parser: one file โ <see cref="ScriptFile"/>; every error collected (never throws).</summary> +public static partial class ScriptParser +{ + private sealed record Line(int Number, int Indent, string Text); + + [GeneratedRegex(@"^set\s+([A-Za-z_][\w.\-]*)\s*=\s*(.*)$")] + private static partial Regex SetLine(); + + [GeneratedRegex(@"^def\s+([A-Za-z_][\w\-]*)\s*\(([^)]*)\)\s*:$")] + private static partial Regex DefLine(); + + [GeneratedRegex(@"^([A-Za-z_][\w\-]*)\s*(?:\(([^)]*)\))?$")] + private static partial Regex CallTarget(); + + public static ScriptFile Parse(string path, string text) + { + var errors = new List<ScriptError>(); + var lines = new List<Line>(); + var raw = text.TrimStart('\uFEFF').Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < raw.Length; i++) + { + var at = new SourceRef(path, i + 1); + var stripped = StepLexer.StripComment(raw[i]).TrimEnd(); + if (stripped.Trim().Length == 0) + continue; + var indent = stripped.Length - stripped.TrimStart(' ', '\t').Length; + if (stripped[..indent].Contains('\t')) + { + errors.Add(new ScriptError(at, "tab in indentation")); + continue; + } + if (indent % 2 != 0) + { + errors.Add(new ScriptError(at, "indent not a multiple of 2")); + continue; + } + lines.Add(new Line(i + 1, indent / 2, stripped.Trim())); + } + + var format = 0; + RunMode? mode = null; + ulong? seed = null; + var varFiles = new List<string>(); + var pos = 0; + if (lines.Count == 0 || !lines[0].Text.StartsWith("format ", StringComparison.Ordinal)) + errors.Add(new ScriptError(new SourceRef(path, lines.Count > 0 ? lines[0].Number : 1), "first line must be 'format N'")); + else + { + var value = lines[0].Text["format ".Length..].Trim(); + if (!int.TryParse(value, CultureInfo.InvariantCulture, out format) || format < 1 || format > ScriptFormat.Latest) + { + errors.Add(new ScriptError(new SourceRef(path, lines[0].Number), $"unknown format {value} (latest {ScriptFormat.Latest})")); + format = 0; + } + pos = 1; + } + for (; pos < lines.Count && lines[pos].Indent == 0 && IsHeader(lines[pos].Text); pos++) + Header(lines[pos], path, errors, ref mode, ref seed, varFiles); + var body = Block(lines, ref pos, 0, path, errors); + return new ScriptFile(path, format, mode, seed, varFiles, body, errors); + } + + private static bool IsHeader(string text) => + text.StartsWith("mode ", StringComparison.Ordinal) || text.StartsWith("seed ", StringComparison.Ordinal) + || text.StartsWith("vars ", StringComparison.Ordinal) || text.StartsWith("format ", StringComparison.Ordinal); + + private static void Header(Line line, string path, List<ScriptError> errors, ref RunMode? mode, ref ulong? seed, List<string> varFiles) + { + var at = new SourceRef(path, line.Number); + var value = line.Text[(line.Text.IndexOf(' ') + 1)..].Trim(); + switch (line.Text[..line.Text.IndexOf(' ')]) + { + case "mode": + mode = value switch { "player" => RunMode.Player, "debug" => RunMode.Debug, _ => null }; + if (mode == null) + errors.Add(new ScriptError(at, "mode must be player or debug")); + break; + case "seed": + if (ulong.TryParse(value, CultureInfo.InvariantCulture, out var s)) + seed = s; + else + errors.Add(new ScriptError(at, $"bad seed '{value}'")); + break; + case "vars": + varFiles.Add(value); + break; + default: + errors.Add(new ScriptError(at, "format must be the first line only")); + break; + } + } + + private static List<Node> Block(List<Line> lines, ref int pos, int indent, string path, List<ScriptError> errors) + { + var nodes = new List<Node>(); + while (pos < lines.Count) + { + var line = lines[pos]; + if (line.Indent < indent) + break; + var at = new SourceRef(path, line.Number); + if (line.Indent > indent) + { + errors.Add(new ScriptError(at, "unexpected indent")); + pos++; + continue; + } + pos++; + if (indent == 0 && IsHeader(line.Text)) + { + errors.Add(new ScriptError(at, "header line after steps")); + continue; + } + var hasBody = pos < lines.Count && lines[pos].Indent == indent + 1; + if (line.Text.EndsWith(':') && (hasBody || IsBlockKeyword(line.Text))) + { + var body = hasBody ? Block(lines, ref pos, indent + 1, path, errors) : []; + if (!hasBody) + errors.Add(new ScriptError(at, "empty block")); + if (BlockHeader(line.Text, at, body, errors) is { } node) + nodes.Add(node); + continue; + } + if (Statement(line.Text, at, errors) is { } statement) + nodes.Add(statement); + } + return nodes; + } + + /// <summary>A line ending in ':' is a block header when it has an indented body or starts with a block keyword; + /// otherwise a statement (<c>log Done:</c>).</summary> + private static bool IsBlockKeyword(string text) => + text is "setup:" or "try:" || text.StartsWith("step ", StringComparison.Ordinal) || text.StartsWith("def ", StringComparison.Ordinal); + + private static Node? BlockHeader(string text, SourceRef at, List<Node> body, List<ScriptError> errors) + { + if (text == "setup:") + return new BlockNode(at, text, BlockKind.Setup, null, body); + if (text == "try:") + return new BlockNode(at, text, BlockKind.Try, null, body); + if (text.StartsWith("step ", StringComparison.Ordinal) && text.Length > "step :".Length) + return new BlockNode(at, text, BlockKind.Step, text["step ".Length..^1].Trim(), body); + if (DefLine().Match(text) is { Success: true } def) + { + var parameters = def.Groups[2].Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + return new DefNode(at, text, def.Groups[1].Value, parameters, body); + } + errors.Add(new ScriptError(at, $"unknown block '{text}'")); + return null; + } + + private static Node? Statement(string text, SourceRef at, List<ScriptError> errors) + { + var space = text.IndexOf(' '); + var head = space < 0 ? text : text[..space]; + var rest = space < 0 ? "" : text[(space + 1)..].Trim(); + try + { + // Validate quoting once for every line (log "abc โ unterminated quote). + StepLexer.Tokens(rest); + } + catch (FormatException e) + { + errors.Add(new ScriptError(at, e.Message)); + return null; + } + switch (head) + { + case "set": + if (SetLine().Match(text) is not { Success: true } set) + { + errors.Add(new ScriptError(at, "bad set (set name = value)")); + return null; + } + var value = set.Groups[2].Value.Trim(); + if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') + value = StepLexer.Tokens(value)[0].Text; + return new SetNode(at, text, set.Groups[1].Value, value); + case "wait": + var last = rest.LastIndexOf(' '); + if (last < 0) + { + errors.Add(new ScriptError(at, "wait needs a condition and a timeout")); + return null; + } + var timeout = rest[(last + 1)..]; + if (Durations.Frames(timeout) is not { } frames) + { + errors.Add(new ScriptError(at, $"bad timeout '{timeout}'")); + return null; + } + return new WaitNode(at, text, rest[..last].Trim(), frames); + case "expect": + if (rest.Length == 0) + { + errors.Add(new ScriptError(at, "expect needs a condition")); + return null; + } + return new ExpectNode(at, text, rest); + case "include": + return rest.Length == 0 || rest.Contains(' ') + ? Error(errors, at, "include takes one path") + : new IncludeNode(at, text, rest); + case "checkpoint": + return rest.Length == 0 || rest.Contains(' ') + ? Error(errors, at, "checkpoint takes one name") + : new CheckpointNode(at, text, rest); + case "call": + if (CallTarget().Match(rest) is not { Success: true } call) + return Error(errors, at, "bad call (call name(args))"); + var args = call.Groups[2].Success + ? call.Groups[2].Value.Split(',', StringSplitOptions.TrimEntries).Where(a => a.Length > 0).ToArray() + : []; + return new CallNode(at, text, call.Groups[1].Value, args); + default: + return new VerbNode(at, text, head, rest); + } + } + + private static Node? Error(List<ScriptError> errors, SourceRef at, string message) + { + errors.Add(new ScriptError(at, message)); + return null; + } +} |
