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/Vars.cs | 174 +++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/Godosa.Core/Runs/Scripts/Vars.cs (limited to 'src/Godosa.Core/Runs/Scripts/Vars.cs') diff --git a/src/Godosa.Core/Runs/Scripts/Vars.cs b/src/Godosa.Core/Runs/Scripts/Vars.cs new file mode 100644 index 0000000..a01da62 --- /dev/null +++ b/src/Godosa.Core/Runs/Scripts/Vars.cs @@ -0,0 +1,174 @@ +// Provenance: own design — shared run scripts (spec §3.3) +using System.Text; + +namespace Godosa.Core.Runs.Scripts; + +public static class VarsFile +{ + public static (Dictionary Values, List Errors) Parse(string path, string text) + { + var values = new Dictionary(StringComparer.Ordinal); + var errors = new List(); + var lines = text.TrimStart('\uFEFF').Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var line = StepLexer.StripComment(lines[i]).Trim(); + if (line.Length == 0) + continue; + var eq = line.IndexOf('='); + var name = eq > 0 ? line[..eq].Trim() : ""; + if (name.Length == 0 || name.Contains(' ')) + { + errors.Add(new ScriptError(new SourceRef(path, i + 1), "bad vars line (name = value)")); + continue; + } + values[name] = line[(eq + 1)..].Trim(); + } + return (values, errors); + } +} + +/// ${name} expansion, innermost first (${ui.rest.${hours}}); ${probe:name(args)} = a probe +/// value; a $ not followed by { is literal. +public static class VarExpander +{ + public static string Expand(string text, Func lookup, Func? probe) + { + var sb = new StringBuilder(); + var i = 0; + while (i < text.Length) + { + if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{') + { + i += 2; + sb.Append(Reference(text, ref i, lookup, probe)); + continue; + } + sb.Append(text[i++]); + } + return sb.ToString(); + } + + /// Resolves the reference opened before (nested references in its name first); the + /// value is used as is, never scanned again. + private static string Reference(string text, ref int i, Func lookup, Func? probe) + { + var name = new StringBuilder(); + while (true) + { + if (i >= text.Length) + throw new ScriptFault($"unclosed ${{ in '{text}'"); + if (text[i] == '}') + { + i++; + break; + } + if (text[i] == '$' && i + 1 < text.Length && text[i + 1] == '{') + { + i += 2; + name.Append(Reference(text, ref i, lookup, probe)); + continue; + } + name.Append(text[i++]); + } + var n = name.ToString(); + if (n.Length == 0) + throw new ScriptFault("empty ${}"); + if (n.StartsWith("probe:", StringComparison.Ordinal)) + return (probe ?? throw new ScriptFault($"no probes here: ${{{n}}}"))(n["probe:".Length..]).ToString(); + return lookup(n) ?? throw new ScriptFault($"unresolved variable ${{{n}}}"); + } + + /// Plain names inside the innermost ${…} (no nested ${, no probe:). + public static IEnumerable Names(string text) + { + for (var i = 0; i < text.Length - 1; i++) + { + if (text[i] != '$' || text[i + 1] != '{') + continue; + var close = text.IndexOf('}', i + 2); + if (close < 0) + yield break; + var inner = text[(i + 2)..close]; + if (inner.Length > 0 && !inner.Contains("${", StringComparison.Ordinal) && !inner.StartsWith("probe:", StringComparison.Ordinal)) + yield return inner; + } + } +} + +public sealed record ScopeState(Dictionary Vars, bool Local, OutputLevel Level); + +/// Variable scopes: root (globals), include scopes (vars go to root, output level scoped), def-call scopes +/// (local vars). Lookup walks from the top down. +public sealed class VarScopes +{ + private readonly List _stack; + + public VarScopes(OutputLevel level) => _stack = [new ScopeState(new Dictionary(StringComparer.Ordinal), false, level)]; + + public int Depth => _stack.Count; + + public OutputLevel Level + { + get => _stack[^1].Level; + set => _stack[^1] = _stack[^1] with { Level = value }; + } + + public void Push(bool local, OutputLevel level, IEnumerable> bindings) => + _stack.Add(new ScopeState(new Dictionary(bindings, StringComparer.Ordinal), local, level)); + + public void Pop() + { + if (_stack.Count > 1) + _stack.RemoveAt(_stack.Count - 1); + } + + public void PopTo(int depth) + { + while (_stack.Count > Math.Max(1, depth)) + _stack.RemoveAt(_stack.Count - 1); + } + + public string? Get(string name) + { + for (var i = _stack.Count - 1; i >= 0; i--) + { + if (_stack[i].Vars.TryGetValue(name, out var v)) + return v; + } + return null; + } + + public void Set(string name, string value) + { + for (var i = _stack.Count - 1; i >= 1; i--) + { + if (_stack[i].Local) + { + _stack[i].Vars[name] = value; + return; + } + } + _stack[0].Vars[name] = value; + } + + public Dictionary Visible() + { + var all = new Dictionary(StringComparer.Ordinal); + foreach (var scope in _stack) + foreach (var (k, v) in scope.Vars) + all[k] = v; + return all; + } + + public List Snapshot() => + [.. _stack.Select(s => s with { Vars = new Dictionary(s.Vars, StringComparer.Ordinal) })]; + + public void Restore(IReadOnlyList scopes) + { + _stack.Clear(); + _stack.AddRange(scopes.Select(s => s with { Vars = new Dictionary(s.Vars, StringComparer.Ordinal) })); + if (_stack.Count == 0) + _stack.Add(new ScopeState(new Dictionary(StringComparer.Ordinal), false, OutputLevel.Labels)); + } +} -- cgit