From 39066900773e7857faf2d02e7ed51b71d219d97d Mon Sep 17 00:00:00 2001 From: godosa Date: Tue, 6 Oct 2026 23:39:36 +0200 Subject: godosa-engine: initial public history --- src/Godosa.Core/Runs/Scripts/ScriptLoader.cs | 218 +++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/Godosa.Core/Runs/Scripts/ScriptLoader.cs (limited to 'src/Godosa.Core/Runs/Scripts/ScriptLoader.cs') 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 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}}}")); + } + } +} -- cgit