// 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 Code { get; } = []; public List Errors { get; } = []; public Dictionary Defs { get; } = new(StringComparer.Ordinal); public List Includes { get; } = []; public List Calls { get; } = []; public List Chain { get; } = []; public HashSet Checkpoints { get; } = new(StringComparer.Ordinal); public List> Known { get; } = []; public Dictionary Vars { get; } = new(StringComparer.Ordinal); public HashSet Locked { get; } = new(StringComparer.Ordinal); public int TryDepth { get; set; } } public static ScriptProgram Load(string entry, IScriptSource source, IReadOnlyDictionary 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 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(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}}}")); } } }