// JsSerializer.cs // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors // https://github.com/gnh1201/welsonjs // using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Text; namespace WelsonJS.Launcher { public sealed class JsSerializer : IDisposable { private readonly JsCore _core; private readonly bool _ownsCore; // In-engine parsed document store management private bool _storeReady; private int _nextId = 1; // 0 is reserved (unused) public JsSerializer() : this(new JsCore(), true) { } public JsSerializer(JsCore core, bool ownsCore) { if (core == null) throw new ArgumentNullException("core"); _core = core; _ownsCore = ownsCore; } // ---------------- Engine-backed document store ---------------- /// /// Parses JSON once and stores it in the engine under a numeric id. /// Returns the id which can be used for fast repeated extraction. /// public int Load(string json) { if (json == null) throw new ArgumentNullException("json"); EnsureStore(); int id = _nextId++; // Create slot and parse // Using Object.create(null) for a clean dictionary without prototype. var sb = new StringBuilder(); sb.Append("(function(){var S=globalThis.__WJ_STORE;"); sb.Append("S[").Append(id.ToString(CultureInfo.InvariantCulture)).Append("]=JSON.parse(").Append(Q(json)).Append(");"); sb.Append("return '1';})()"); string r = _core.EvaluateToString(sb.ToString()); if (r != "1") throw new InvalidOperationException("Failed to load JSON into the engine store."); return id; } /// /// Removes a previously loaded document from the engine store. /// After this, the id becomes invalid. /// public void Unload(int id) { EnsureStore(); string script = "(function(){var S=globalThis.__WJ_STORE; delete S[" + id.ToString(CultureInfo.InvariantCulture) + "]; return '1';})()"; _core.EvaluateToString(script); } /// /// Replaces the stored JSON at a given id (parse once, reuse later). /// public void Reload(int id, string json) { if (json == null) throw new ArgumentNullException("json"); EnsureStore(); string script = "(function(){var S=globalThis.__WJ_STORE; S[" + id.ToString(CultureInfo.InvariantCulture) + "]=JSON.parse(" + Q(json) + "); return '1';})()"; _core.EvaluateToString(script); } /// /// Stringifies the stored value identified by id (no reparse). /// public string ToJson(int id, int space) { EnsureStore(); space = Clamp(space, 0, 10); string script = "(function(){var v=globalThis.__WJ_STORE[" + id.ToString(CultureInfo.InvariantCulture) + "]; return JSON.stringify(v,null," + space.ToString(CultureInfo.InvariantCulture) + ");})()"; return _core.EvaluateToString(script); } /// /// Extracts from a stored document identified by id (no reparse). /// Returns JSON of the selected value. /// public string ExtractFrom(int id, params object[] path) { EnsureStore(); if (path == null) path = new object[0]; string jsPath = BuildJsPath(path); var sb = new StringBuilder(); sb.Append("(function(){var v=globalThis.__WJ_STORE[") .Append(id.ToString(CultureInfo.InvariantCulture)) .Append("];var p=").Append(jsPath).Append(";"); sb.Append("for(var i=0;i /// Extracts by string-only path (kept for backward compatibility). /// Internally forwards to the mixed-path overload. /// public string Extract(string json, params string[] path) { if (path == null) path = new string[0]; object[] mixed = new object[path.Length]; for (int i = 0; i < path.Length; i++) mixed[i] = path[i]; return Extract(json, mixed); } /// /// Extracts by a mixed path directly from a JSON string (parses every call). /// Prefer Load(...) + ExtractFrom(...) to avoid repeated parsing. /// public string Extract(string json, params object[] path) { if (json == null) throw new ArgumentNullException("json"); if (path == null) path = new object[0]; string jsPath = BuildJsPath(path); var sb = new StringBuilder(); sb.Append("(function(){var v=JSON.parse(").Append(Q(json)).Append(");"); sb.Append("var p=").Append(jsPath).Append(";"); sb.Append("for(var i=0;i(ReferenceEqualityComparer.Instance), 0); string script = "JSON.stringify((" + expr + "),null," + space.ToString(CultureInfo.InvariantCulture) + ")"; return _core.EvaluateToString(script); } // ---------------- Helpers ---------------- private static int Clamp(int v, int min, int max) { if (v < min) return min; if (v > max) return max; return v; } /// /// Encode a .NET string as a JS double-quoted string literal. /// private static string Q(string s) { if (s == null) return "null"; var sb = new StringBuilder(s.Length + 16); sb.Append('"'); for (int i = 0; i < s.Length; i++) { char ch = s[i]; switch (ch) { case '\\': sb.Append(@"\\"); break; case '"': sb.Append("\\\""); break; case '\b': sb.Append(@"\b"); break; case '\f': sb.Append(@"\f"); break; case '\n': sb.Append(@"\n"); break; case '\r': sb.Append(@"\r"); break; case '\t': sb.Append(@"\t"); break; case '\u2028': sb.Append("\\u2028"); break; case '\u2029': sb.Append("\\u2029"); break; default: if (char.IsControl(ch)) { sb.Append("\\u"); sb.Append(((int)ch).ToString("X4")); } else { sb.Append(ch); } break; } } sb.Append('"'); return sb.ToString(); } /// /// Builds a JS array literal representing the path. /// Numeric segments are emitted as numbers; others as strings. /// private static string BuildJsPath(object[] segments) { if (segments == null || segments.Length == 0) return "[]"; var sb = new StringBuilder(); sb.Append('['); for (int i = 0; i < segments.Length; i++) { if (i > 0) sb.Append(','); object seg = segments[i]; // Treat integral types as numbers for array indexing if (seg is sbyte || seg is byte || seg is short || seg is ushort || seg is int || seg is uint || seg is long || seg is ulong) { sb.Append(Convert.ToString(seg, CultureInfo.InvariantCulture)); } else { string str = (seg == null) ? string.Empty : Convert.ToString(seg, CultureInfo.InvariantCulture); sb.Append(Q(str)); } } sb.Append(']'); return sb.ToString(); } private static bool IsNumeric(object v) { if (v == null) return false; Type t = v.GetType(); t = Nullable.GetUnderlyingType(t) ?? t; return t == typeof(byte) || t == typeof(sbyte) || t == typeof(short) || t == typeof(ushort) || t == typeof(int) || t == typeof(uint) || t == typeof(long) || t == typeof(ulong) || t == typeof(float) || t == typeof(double) || t == typeof(decimal); } private static bool IsImmutableLike(object v) { return v is string || v is bool || v is byte || v is sbyte || v is short || v is ushort || v is int || v is uint || v is long || v is ulong || v is float || v is double || v is decimal || v is Guid || v is DateTime || v is DateTimeOffset; } /// /// Builds a safe JS expression for a .NET value (no engine calls here). /// Engine will stringify the produced expression via JSON.stringify. /// private static string BuildJsExpression(object value, HashSet seen, int depth) { if (depth > 64) return "null"; // depth guard if (value == null) return "null"; // Primitives if (value is string) return Q((string)value); if (value is bool) return ((bool)value) ? "true" : "false"; if (IsNumeric(value)) return Convert.ToString(value, CultureInfo.InvariantCulture); // Common value-like types → stringify as JS strings if (value is Guid) return Q(((Guid)value).ToString()); if (value is DateTime) return Q(((DateTime)value).ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)); if (value is DateTimeOffset) return Q(((DateTimeOffset)value).ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)); if (value is byte[]) return Q(Convert.ToBase64String((byte[])value)); // Prevent circular refs for reference types if (!IsImmutableLike(value) && !seen.Add(value)) return "null"; // IDictionary (string keys only) if (value is IDictionary) { var map = (IDictionary)value; var sb = new StringBuilder(); sb.Append('{'); bool first = true; foreach (DictionaryEntry kv in map) { string key = kv.Key as string; if (key == null) continue; // JSON keys must be strings if (!first) sb.Append(','); first = false; sb.Append(Q(key)).Append(':') .Append(BuildJsExpression(kv.Value, seen, depth + 1)); } sb.Append('}'); return sb.ToString(); } // IEnumerable → array if (value is IEnumerable) { var seq = (IEnumerable)value; var sb = new StringBuilder(); sb.Append('['); bool first = true; foreach (object item in seq) { if (!first) sb.Append(','); first = false; sb.Append(BuildJsExpression(item, seen, depth + 1)); } sb.Append(']'); return sb.ToString(); } // Fallback → ToString() as JS string string s = value.ToString(); return Q(s ?? string.Empty); } public void Dispose() { if (_ownsCore) _core.Dispose(); } /// /// Reference equality comparer for cycle detection (works on .NET Framework). /// private sealed class ReferenceEqualityComparer : IEqualityComparer { private ReferenceEqualityComparer() { } public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer(); bool IEqualityComparer.Equals(object x, object y) { return object.ReferenceEquals(x, y); } int IEqualityComparer.GetHashCode(object obj) { return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); } } } }