// Copyright (C) 2026 Danilo M. // // 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 with anything but a comment after it // (multi-line strings and arrays included) is refused: entries() skips it and // set() throws rather than write a duplicate key; such a file is edited by // hand. Also not recognised: CRLF line endings, `[ spaced ]` and `[[array]]` // headers, and 'literal' keys. 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 }; } // A whole line's value: the rest of the line must be blank or a comment. function whole(s) { const p = val(s, 0); return p && /^\s*(#.*)?$/.test(s.slice(p.i)) ? p : null; } // [{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] ? whole(m[4]) : 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 = whole(m[4]); 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; }