// Provenance: own design โ€” shared run scripts (spec ยง3) using System.Globalization; using System.Text.RegularExpressions; namespace Godosa.Core.Runs.Scripts; /// Format v1 parser: one file โ†’ ; every error collected (never throws). 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(); var lines = new List(); 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(); 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 errors, ref RunMode? mode, ref ulong? seed, List 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 Block(List lines, ref int pos, int indent, string path, List errors) { var nodes = new List(); 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; } /// A line ending in ':' is a block header when it has an indented body or starts with a block keyword; /// otherwise a statement (log Done:). 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 body, List 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 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 errors, SourceRef at, string message) { errors.Add(new ScriptError(at, message)); return null; } }