aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Runs/Scripts/ScriptParser.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Godosa.Core/Runs/Scripts/ScriptParser.cs')
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptParser.cs231
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;
+ }
+}