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