aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Core/Runs/Scripts/ScriptLoader.cs
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Core/Runs/Scripts/ScriptLoader.cs')
-rw-r--r--src/Godosa.Core/Runs/Scripts/ScriptLoader.cs218
1 files changed, 218 insertions, 0 deletions
diff --git a/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs b/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs
new file mode 100644
index 0000000..d123539
--- /dev/null
+++ b/src/Godosa.Core/Runs/Scripts/ScriptLoader.cs
@@ -0,0 +1,218 @@
+// Provenance: own design — shared run scripts (spec §3.4, §3.7 structural checks)
+namespace Godosa.Core.Runs.Scripts;
+
+public static class ScriptLoader
+{
+ private sealed class State(IScriptSource source)
+ {
+ public IScriptSource Source { get; } = source;
+ public List<Instr> Code { get; } = [];
+ public List<ScriptError> Errors { get; } = [];
+ public Dictionary<string, (DefNode Def, string File, int Format)> Defs { get; } = new(StringComparer.Ordinal);
+ public List<string> Includes { get; } = [];
+ public List<string> Calls { get; } = [];
+ public List<SourceRef> Chain { get; } = [];
+ public HashSet<string> Checkpoints { get; } = new(StringComparer.Ordinal);
+ public List<HashSet<string>> Known { get; } = [];
+ public Dictionary<string, string> Vars { get; } = new(StringComparer.Ordinal);
+ public HashSet<string> Locked { get; } = new(StringComparer.Ordinal);
+ public int TryDepth { get; set; }
+ }
+
+ public static ScriptProgram Load(string entry, IScriptSource source, IReadOnlyDictionary<string, string> cmdVars)
+ {
+ var s = new State(source);
+ foreach (var (k, v) in cmdVars)
+ {
+ s.Vars[k] = v;
+ s.Locked.Add(k);
+ }
+ s.Known.Add([.. cmdVars.Keys]);
+ var text = source.Read(entry);
+ if (text == null)
+ {
+ s.Errors.Add(new ScriptError(new SourceRef(entry, 1), $"script not found: {entry}"));
+ return new ScriptProgram(entry, RunMode.Debug, null, s.Vars, s.Locked, s.Code, s.Errors);
+ }
+ var file = ScriptParser.Parse(entry, text);
+ s.Errors.AddRange(file.Errors);
+ if (file.Mode == null)
+ s.Errors.Add(new ScriptError(new SourceRef(entry, 1), "top-level file needs 'mode'"));
+ LoadVarFiles(s, file);
+ s.Includes.Add(entry);
+ for (var i = 0; i < file.Body.Count; i++)
+ {
+ if (file.Body[i] is BlockNode { Kind: BlockKind.Setup } setup && i > 0)
+ s.Errors.Add(new ScriptError(setup.At, "setup must be the first block"));
+ }
+ Emit(s, file.Body, entry, file.Format, top: true);
+ return new ScriptProgram(entry, file.Mode ?? RunMode.Debug, file.Seed, s.Vars, s.Locked, s.Code, s.Errors);
+ }
+
+ private static void LoadVarFiles(State s, ScriptFile file)
+ {
+ foreach (var raw in file.VarFiles)
+ {
+ string path;
+ try
+ {
+ path = ScriptPaths.Resolve(file.Path, VarExpander.Expand(raw, n => s.Vars.GetValueOrDefault(n), null));
+ }
+ catch (ScriptFault e)
+ {
+ s.Errors.Add(new ScriptError(new SourceRef(file.Path, 1), e.Message));
+ continue;
+ }
+ if (s.Source.Read(path) is not { } text)
+ {
+ s.Errors.Add(new ScriptError(new SourceRef(file.Path, 1), $"vars file not found: {path}"));
+ continue;
+ }
+ var (values, errors) = VarsFile.Parse(path, text);
+ s.Errors.AddRange(errors);
+ foreach (var (k, v) in values)
+ {
+ if (!s.Locked.Contains(k))
+ s.Vars[k] = v;
+ s.Known[0].Add(k);
+ }
+ }
+ }
+
+ private static void Emit(State s, IReadOnlyList<Node> nodes, string file, int format, bool top)
+ {
+ foreach (var node in nodes)
+ {
+ Instr I(Op op) => new(op, node.At, node.Text, [.. s.Chain], format);
+ switch (node)
+ {
+ case VerbNode v:
+ CheckVars(s, v.At, v.Args);
+ s.Code.Add(I(Op.Verb) with { A = v.Verb, B = v.Args });
+ break;
+ case WaitNode w:
+ CheckVars(s, w.At, w.Condition);
+ s.Code.Add(I(Op.Wait) with { A = w.Condition, Timeout = w.Timeout });
+ break;
+ case ExpectNode e:
+ CheckVars(s, e.At, e.Condition);
+ s.Code.Add(I(Op.Expect) with { A = e.Condition });
+ break;
+ case SetNode set:
+ CheckVars(s, set.At, set.Value);
+ if (s.Locked.Contains(set.Name))
+ s.Errors.Add(new ScriptError(set.At, $"variable {set.Name} is set on the command line"));
+ s.Known[^1].Add(set.Name);
+ if (s.Calls.Count == 0)
+ s.Known[0].Add(set.Name);
+ s.Code.Add(I(Op.Set) with { A = set.Name, B = set.Value });
+ break;
+ case CheckpointNode cp:
+ if (s.TryDepth > 0)
+ s.Errors.Add(new ScriptError(cp.At, "checkpoint inside try"));
+ if (!s.Checkpoints.Add(cp.Name))
+ s.Errors.Add(new ScriptError(cp.At, $"duplicate checkpoint {cp.Name}"));
+ s.Code.Add(I(Op.Checkpoint) with { A = cp.Name });
+ break;
+ case BlockNode { Kind: BlockKind.Setup } b:
+ if (!top)
+ s.Errors.Add(new ScriptError(b.At, "setup only in the top-level file"));
+ s.Code.Add(I(Op.SetupBegin));
+ Emit(s, b.Body, file, format, false);
+ s.Code.Add(I(Op.SetupEnd));
+ break;
+ case BlockNode { Kind: BlockKind.Step } b:
+ s.Code.Add(I(Op.LabelBegin) with { A = b.Label! });
+ Emit(s, b.Body, file, format, false);
+ s.Code.Add(I(Op.LabelEnd) with { A = b.Label! });
+ break;
+ case BlockNode { Kind: BlockKind.Try } b:
+ var begin = s.Code.Count;
+ s.Code.Add(I(Op.TryBegin));
+ s.TryDepth++;
+ Emit(s, b.Body, file, format, false);
+ s.TryDepth--;
+ s.Code[begin] = s.Code[begin] with { Jump = s.Code.Count };
+ s.Code.Add(I(Op.TryEnd));
+ break;
+ case DefNode d:
+ if (!s.Defs.TryAdd(d.Name, (d, file, format)))
+ s.Errors.Add(new ScriptError(d.At, $"def {d.Name} already defined"));
+ break;
+ case CallNode c:
+ Call(s, c, format);
+ break;
+ case IncludeNode inc:
+ Include(s, inc);
+ break;
+ }
+ }
+ }
+
+ private static void Call(State s, CallNode c, int format)
+ {
+ foreach (var arg in c.Args)
+ CheckVars(s, c.At, arg);
+ if (!s.Defs.TryGetValue(c.Name, out var def))
+ {
+ s.Errors.Add(new ScriptError(c.At, $"unknown def {c.Name}"));
+ return;
+ }
+ if (def.Def.Params.Count != c.Args.Count)
+ {
+ s.Errors.Add(new ScriptError(c.At, $"def {c.Name} takes {def.Def.Params.Count} arguments, got {c.Args.Count}"));
+ return;
+ }
+ if (s.Calls.Contains(c.Name))
+ {
+ s.Errors.Add(new ScriptError(c.At, $"recursive call {c.Name}"));
+ return;
+ }
+ var bindings = def.Def.Params.Zip(c.Args, (p, a) => new KeyValuePair<string, string>(p, a)).ToList();
+ s.Code.Add(new Instr(Op.ScopeBegin, c.At, c.Text, [.. s.Chain], format) { Bindings = bindings, LocalVars = true });
+ s.Calls.Add(c.Name);
+ s.Chain.Add(c.At);
+ s.Known.Add([.. def.Def.Params]);
+ Emit(s, def.Def.Body, def.File, def.Format, false);
+ s.Known.RemoveAt(s.Known.Count - 1);
+ s.Chain.RemoveAt(s.Chain.Count - 1);
+ s.Calls.RemoveAt(s.Calls.Count - 1);
+ s.Code.Add(new Instr(Op.ScopeEnd, c.At, c.Text, [.. s.Chain], format));
+ }
+
+ private static void Include(State s, IncludeNode inc)
+ {
+ var path = ScriptPaths.Resolve(inc.At.File, inc.Path);
+ if (s.Includes.Contains(path))
+ {
+ s.Errors.Add(new ScriptError(inc.At, $"include cycle: {string.Join(" → ", s.Includes)} → {path}"));
+ return;
+ }
+ if (s.Source.Read(path) is not { } text)
+ {
+ s.Errors.Add(new ScriptError(inc.At, $"include not found: {path}"));
+ return;
+ }
+ var file = ScriptParser.Parse(path, text);
+ s.Errors.AddRange(file.Errors);
+ if (file.Mode != null)
+ s.Errors.Add(new ScriptError(new SourceRef(path, 2), "mode only in the top-level file"));
+ LoadVarFiles(s, file);
+ s.Code.Add(new Instr(Op.ScopeBegin, inc.At, inc.Text, [.. s.Chain], file.Format));
+ s.Includes.Add(path);
+ s.Chain.Add(inc.At);
+ Emit(s, file.Body, path, file.Format, false);
+ s.Chain.RemoveAt(s.Chain.Count - 1);
+ s.Includes.RemoveAt(s.Includes.Count - 1);
+ s.Code.Add(new Instr(Op.ScopeEnd, inc.At, inc.Text, [.. s.Chain], file.Format));
+ }
+
+ private static void CheckVars(State s, SourceRef at, string text)
+ {
+ foreach (var name in VarExpander.Names(text))
+ {
+ if (!s.Known.Any(k => k.Contains(name)))
+ s.Errors.Add(new ScriptError(at, $"unresolved variable ${{{name}}}"));
+ }
+ }
+}