aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/modules/sbbuilder/Toml.js152
-rw-r--r--desktop/modules/sbbuilder/test_toml.js95
2 files changed, 247 insertions, 0 deletions
diff --git a/desktop/modules/sbbuilder/Toml.js b/desktop/modules/sbbuilder/Toml.js
new file mode 100644
index 0000000..7fd940a
--- /dev/null
+++ b/desktop/modules/sbbuilder/Toml.js
@@ -0,0 +1,152 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+.pragma library
+
+// Pure helpers for sb-builder's two config.toml files, tested by
+// test_toml.js under node. Walked a line at a time like Ini.js, so comments
+// and order survive: a key unset is commented out with #, a commented key
+// set again is activated. Top-level keys are section "".
+//
+// ponytail: the TOML these files use, one key per line: strings, integers,
+// booleans, one-line arrays. A value spanning lines makes set() throw rather
+// than write a duplicate key; such a file is edited by hand.
+
+const HEADER = /^\s*\[([A-Za-z0-9_.-]+)\]\s*(#.*)?$/;
+const KEY = /^(\s*)(#\s*)?("(?:[^"\\]|\\.)*"|[A-Za-z0-9_-]+)\s*=\s*(.*)$/;
+
+function unkey(k) {
+ if (!k.startsWith('"')) return k;
+ try { return JSON.parse(k); } catch (e) { return k; }
+}
+function enkey(k) { return /^[A-Za-z0-9_-]+$/.test(k) ? k : enc(k); }
+
+// JSON's escapes are a subset of TOML basic strings', bar a raw DEL.
+function enc(v) {
+ if (Array.isArray(v)) return "[" + v.map(enc).join(", ") + "]";
+ if (typeof v === "string") return JSON.stringify(v).replace(/\x7f/g, "\\u007f");
+ return String(v);
+}
+
+function skip(s, i) { while (s[i] === " " || s[i] === "\t") i++; return i; }
+
+// {v, i}: the value at s[i] and the index after it, or null.
+function val(s, i) {
+ const c = s[i];
+ if (c === '"') {
+ let j = i + 1;
+ while (j < s.length && s[j] !== '"') j += s[j] === "\\" ? 2 : 1;
+ if (j >= s.length) return null;
+ try { return { v: JSON.parse(s.slice(i, j + 1)), i: j + 1 }; } catch (e) { return null; }
+ }
+ if (c === "'") {
+ const j = s.indexOf("'", i + 1);
+ return j < 0 ? null : { v: s.slice(i + 1, j), i: j + 1 };
+ }
+ if (c === "[") {
+ const out = [];
+ i = skip(s, i + 1);
+ while (s[i] !== "]") {
+ const e = val(s, i);
+ if (!e) return null;
+ out.push(e.v);
+ i = skip(s, e.i);
+ if (s[i] === ",") i = skip(s, i + 1);
+ else if (s[i] !== "]") return null;
+ }
+ return { v: out, i: i + 1 };
+ }
+ const m = /^(true|false|[+-]?[0-9][0-9_]*)(?=[\s#,\]]|$)/.exec(s.slice(i));
+ if (!m) return null;
+ const v = m[1] === "true" ? true : m[1] === "false" ? false : Number(m[1].replace(/_/g, ""));
+ return { v: v, i: i + m[1].length };
+}
+
+// [{name, h, end}]: the header's line and the line after the section. The
+// top-level keys are a section "" with its header at -1.
+function ranges(lines) {
+ const out = [{ name: "", h: -1, end: lines.length }];
+ lines.forEach((l, i) => {
+ const m = HEADER.exec(l);
+ if (!m) return;
+ out[out.length - 1].end = i;
+ out.push({ name: m[1], h: i, end: lines.length });
+ });
+ return out;
+}
+
+// [{key, value}] for the active keys of a section, in file order.
+function entries(text, section) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) return [];
+ const out = [];
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ const p = m && !m[2] ? val(m[4], 0) : null;
+ if (p) out.push({ key: unkey(m[3]), value: p.v });
+ }
+ return out;
+}
+
+function get(text, section, key) {
+ const e = entries(text, section).find(x => x.key === key);
+ return e ? e.value : undefined;
+}
+
+// v null unsets: the key is commented out where it stands.
+function set(text, section, key, v) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ const line = enkey(key) + " = " + (v === null ? "" : enc(v));
+ if (!r) {
+ if (v === null) return text;
+ return text.replace(/\n*$/, "") + (text.trim() ? "\n\n" : "") + "[" + section + "]\n" + line + "\n";
+ }
+ let commented = -1;
+ let last = r.h;
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (!m) continue;
+ if (!m[2]) last = i;
+ if (unkey(m[3]) !== key) continue;
+ const p = val(m[4], 0);
+ if (!m[2]) {
+ if (!p) throw new Error(key + ": a value this page cannot edit; change it in the file");
+ lines[i] = v === null ? m[1] + "# " + lines[i].slice(m[1].length)
+ : m[1] + line + m[4].slice(p.i);
+ return lines.join("\n");
+ }
+ if (p && commented < 0) commented = i;
+ }
+ if (v === null) return text;
+ if (commented >= 0) {
+ const m = KEY.exec(lines[commented]);
+ lines[commented] = m[1] + line + m[4].slice(val(m[4], 0).i);
+ return lines.join("\n");
+ }
+ // No key yet at the top: after the leading comments, not above them.
+ if (last < 0) while (last + 1 < r.end && lines[last + 1].trim().startsWith("#")) last++;
+ lines.splice(last + 1, 0, line);
+ return lines.join("\n");
+}
+
+// Deletes an active key's line outright, for [manifests] rows.
+function remove(text, section, key) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) return text;
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (m && !m[2] && unkey(m[3]) === key) { lines.splice(i, 1); return lines.join("\n"); }
+ }
+ return text;
+}
diff --git a/desktop/modules/sbbuilder/test_toml.js b/desktop/modules/sbbuilder/test_toml.js
new file mode 100644
index 0000000..bb230e9
--- /dev/null
+++ b/desktop/modules/sbbuilder/test_toml.js
@@ -0,0 +1,95 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Run: node desktop/modules/sbbuilder/test_toml.js
+// Toml.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Toml.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const T = new Function(src + "\nreturn { entries, get, set, remove };")();
+
+const file = [
+ "# helper config",
+ "# Every key is optional.",
+ "",
+ 'state_dir = "/var/lib/x"',
+ 'overlays = ["one", "two"] # searched in order',
+ "# upstream_branch = 'current'",
+ "",
+ "# bundled deps: package = its deps list URL",
+ "[manifests]",
+ 'neovim = "https://example.org/v{VERSION}/deps.txt"',
+ '"gtk+3" = "https://example.org/gtk"',
+ "",
+ "[refresh]",
+ "jobs = 5",
+ "on = true",
+ "",
+].join("\n");
+
+// Top-level keys are section "".
+assert.strictEqual(T.get(file, "", "state_dir"), "/var/lib/x");
+assert.deepStrictEqual(T.get(file, "", "overlays"), ["one", "two"]);
+assert.strictEqual(T.get(file, "", "upstream_branch"), undefined);
+assert.strictEqual(T.get(file, "refresh", "jobs"), 5);
+assert.strictEqual(T.get(file, "refresh", "on"), true);
+// A quoted key reads unquoted; a commented line with = is not a key.
+assert.deepStrictEqual(T.entries(file, "manifests").map(e => e.key), ["neovim", "gtk+3"]);
+assert.deepStrictEqual(T.entries(file, "").map(e => e.key), ["state_dir", "overlays"]);
+
+// Replacing keeps the padding-free form, the inline comment and the line count.
+let t = T.set(file, "", "overlays", ["a"]);
+assert.strictEqual(t.split("\n")[4], 'overlays = ["a"] # searched in order');
+assert.strictEqual(t.split("\n").length, file.split("\n").length);
+// Setting a commented key activates its line, single-quoted or not.
+t = T.set(file, "", "upstream_branch", "main");
+assert.strictEqual(t.split("\n")[5], 'upstream_branch = "main"');
+// Unsetting comments a key out in place, and it reads back as unset.
+t = T.set(file, "refresh", "jobs", null);
+assert.strictEqual(t.split("\n")[13], "# jobs = 5");
+assert.strictEqual(T.get(t, "refresh", "jobs"), undefined);
+// ...and setting it again reactivates that line.
+assert.strictEqual(T.set(t, "refresh", "jobs", 9).split("\n")[13], "jobs = 9");
+// A new top-level key lands after the last top-level key, not after the
+// [manifests] comment, which belongs to its section.
+t = T.set(file, "", "genreprc", "/root/.genreprc");
+assert.strictEqual(t.split("\n")[5], 'genreprc = "/root/.genreprc"');
+assert.strictEqual(T.get(t, "", "genreprc"), "/root/.genreprc");
+// A new key in a file with only comments goes after the leading comments.
+t = T.set("# head\n# more\n", "", "x", 1);
+assert.strictEqual(t, "# head\n# more\nx = 1\n");
+// A new key in a section lands after that section's last key.
+t = T.set(file, "manifests", "foo.bar", "u");
+assert.strictEqual(t.split("\n")[11], '"foo.bar" = "u"');
+assert.strictEqual(T.get(t, "manifests", "foo.bar"), "u");
+// A missing section is appended, and an empty file gets no blank lead.
+t = T.set(file, "vm", "domain", "x");
+assert.ok(t.endsWith('\n\n[vm]\ndomain = "x"\n'), JSON.stringify(t));
+assert.strictEqual(T.set("", "vm", "autostart", false), "[vm]\nautostart = false\n");
+assert.strictEqual(T.set("", "", "target", "h"), 'target = "h"\n');
+// Strings are escaped and read back the same.
+t = T.set(file, "", "state_dir", 'a "b" \\ c');
+assert.strictEqual(T.get(t, "", "state_dir"), 'a "b" \\ c');
+// The same key in another section is untouched.
+assert.strictEqual(T.set("[a]\nx = 1\n\n[b]\nx = 2\n", "b", "x", 3), "[a]\nx = 1\n\n[b]\nx = 3\n");
+// A value spanning lines is refused, never duplicated.
+assert.throws(() => T.set("o = [\n \"a\",\n]\n", "", "o", ["b"]), /cannot edit/);
+
+// remove deletes the line outright.
+t = T.remove(file, "manifests", "gtk+3");
+assert.deepStrictEqual(T.entries(t, "manifests").map(e => e.key), ["neovim"]);
+assert.strictEqual(T.remove(file, "manifests", "nope"), file);
+
+console.log("test_toml: ok");