// 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;
}
}