298 lines
14 KiB
TypeScript
298 lines
14 KiB
TypeScript
// One value of storebound.config.ts / .js / .json set by its path, as a text edit: the rest of the file (comments,
|
|
// order, quotes, formatting) stays byte for byte. Used by `storebound edit` (the desktop's editors). Pure.
|
|
//
|
|
// A path is dot-separated keys with array selectors: `app.supportUrl`, `apple.info.categories.primary`,
|
|
// `products[id=com.example.no_ads].listings.en.title`, `subscriptions[0].listings.tr.name`,
|
|
// `apple.testflight.groups[name=Friends].internal`. A selector `[key=value]` picks the array element whose `key` is that
|
|
// string; `[n]` the n-th element. Keys may be quoted in the file ('pt-BR': ...) or not.
|
|
// Setting `undefined` removes the property. Missing objects on the way are created; a missing array element is an
|
|
// error (adding a product is a config change for the owner's editor, not a path edit). A value on the way that is
|
|
// not a plain literal (a variable, a function call, a spread) is an error: the owner edits that in the file.
|
|
import { matchBrace, toTs } from './scan/config-edit';
|
|
|
|
export type PathPart = { key: string } | { index: number } | { match: string; eq: string };
|
|
|
|
/** `a.b[id=x].c[0]` -> parts. Throws on a malformed path. */
|
|
export function parsePath(path: string): PathPart[] {
|
|
const out: PathPart[] = [];
|
|
let i = 0;
|
|
while (i < path.length) {
|
|
if (path[i] === '.') { i++; continue; }
|
|
if (path[i] === '[') {
|
|
const end = path.indexOf(']', i);
|
|
if (end < 0) throw new Error(`path ${path}: unclosed [`);
|
|
const inner = path.slice(i + 1, end);
|
|
if (/^\d+$/.test(inner)) out.push({ index: Number(inner) });
|
|
else {
|
|
const m = /^([A-Za-z_$][\w$]*)=(.+)$/.exec(inner);
|
|
if (!m) throw new Error(`path ${path}: [${inner}] is neither [n] nor [key=value]`);
|
|
out.push({ match: m[1]!, eq: m[2]! });
|
|
}
|
|
i = end + 1;
|
|
continue;
|
|
}
|
|
const m = /^[A-Za-z0-9_$-]+/.exec(path.slice(i));
|
|
if (!m) throw new Error(`path ${path}: unexpected "${path[i]}"`);
|
|
out.push({ key: m[0] });
|
|
i += m[0].length;
|
|
}
|
|
if (!out.length) throw new Error('empty path');
|
|
return out;
|
|
}
|
|
|
|
export const pathText = (parts: PathPart[]): string =>
|
|
parts.map((p, i) => ('key' in p ? `${i ? '.' : ''}${p.key}` : 'index' in p ? `[${p.index}]` : `[${p.match}=${p.eq}]`)).join('');
|
|
|
|
// ---- a small scanner for object and array literals ----
|
|
|
|
const isWs = (c: string | undefined): boolean => c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
|
|
|
/** Index after whitespace and comments from `i`. */
|
|
function skip(t: string, i: number): number {
|
|
for (;;) {
|
|
while (i < t.length && isWs(t[i])) i++;
|
|
if (t[i] === '/' && t[i + 1] === '/') { const n = t.indexOf('\n', i); i = n < 0 ? t.length : n + 1; continue; }
|
|
if (t[i] === '/' && t[i + 1] === '*') { const n = t.indexOf('*/', i + 2); i = n < 0 ? t.length : n + 2; continue; }
|
|
return i;
|
|
}
|
|
}
|
|
|
|
/** End (exclusive) of the string literal starting at `i`. */
|
|
function stringEnd(t: string, i: number): number {
|
|
const q = t[i]!;
|
|
for (let j = i + 1; j < t.length; j++) {
|
|
if (t[j] === '\\') { j++; continue; }
|
|
if (t[j] === q) return j + 1;
|
|
}
|
|
throw new Error('unterminated string in the config');
|
|
}
|
|
|
|
/** End (exclusive, trailing whitespace and comments not included) of the expression starting at `i`: up to the next
|
|
* `,` or closing bracket at depth 0. */
|
|
function exprEnd(t: string, i: number): number {
|
|
let last = i;
|
|
let j = i;
|
|
while (j < t.length) {
|
|
const c = t[j]!;
|
|
if (c === '/' && (t[j + 1] === '/' || t[j + 1] === '*')) { j = skip(t, j); continue; }
|
|
if (isWs(c)) { j++; continue; }
|
|
if (c === ',' || c === '}' || c === ']' || c === ')') return last;
|
|
if (c === '"' || c === '\'' || c === '`') { j = stringEnd(t, j); last = j; continue; }
|
|
if (c === '{' || c === '[' || c === '(') {
|
|
const close = matchBrace(t, j);
|
|
if (close < 0) throw new Error('unbalanced brackets in the config');
|
|
j = close + 1; last = j; continue;
|
|
}
|
|
j++; last = j;
|
|
}
|
|
return last;
|
|
}
|
|
|
|
interface Member { key: string | null; start: number; valueStart: number; valueEnd: number; end: number }
|
|
|
|
/** Members of the object (or elements of the array) whose opening bracket is at `open`. `end`: after the member's
|
|
* trailing comma, if any. key null: a spread, a computed key or an array element. */
|
|
function members(t: string, open: number): { list: Member[]; close: number } {
|
|
const close = matchBrace(t, open);
|
|
if (close < 0) throw new Error('unbalanced brackets in the config');
|
|
const isArray = t[open] === '[';
|
|
const list: Member[] = [];
|
|
let i = skip(t, open + 1);
|
|
while (i < close) {
|
|
const start = i;
|
|
let key: string | null = null;
|
|
let valueStart = i;
|
|
if (!isArray) {
|
|
if (t.startsWith('...', i)) { key = null; valueStart = i; }
|
|
else {
|
|
let k: string;
|
|
let after: number;
|
|
if (t[i] === '"' || t[i] === '\'') { after = stringEnd(t, i); k = JSON.parse(t[i] === '"' ? t.slice(i, after) : `"${t.slice(i + 1, after - 1).replace(/\\'/g, '\'').replace(/"/g, '\\"')}"`); }
|
|
else if (t[i] === '[') { after = matchBrace(t, i) + 1; k = ''; }
|
|
else { const m = /^[A-Za-z0-9_$]+/.exec(t.slice(i)); if (!m) throw new Error(`unexpected "${t[i]}" in the config`); k = m[0]; after = i + k.length; }
|
|
key = t[i] === '[' ? null : k;
|
|
const colon = skip(t, after);
|
|
if (t[colon] === ':') valueStart = skip(t, colon + 1);
|
|
else if (t[colon] === '(') { key = null; valueStart = i; } // a method
|
|
else valueStart = i; // shorthand `{ name }`: the value is the identifier
|
|
}
|
|
}
|
|
const valueEnd = exprEnd(t, valueStart);
|
|
let end = skip(t, valueEnd);
|
|
if (t[end] === ',') end++;
|
|
list.push({ key, start, valueStart, valueEnd, end });
|
|
i = skip(t, end);
|
|
if (end === valueEnd && i < close) throw new Error('could not read the config (a member without a comma)');
|
|
}
|
|
return { list, close };
|
|
}
|
|
|
|
/** The opening brace of the object the config exports: `export default {`, `export default defineConfig({`,
|
|
* `module.exports = {`, or the `const x = {` that `export default x` names. */
|
|
export function rootObject(t: string): number {
|
|
const direct = /(?:export\s+default|module\.exports\s*=)\s*(?:[A-Za-z_$][\w$]*\s*\(\s*)?\{/.exec(t);
|
|
if (direct) return direct.index + direct[0].length - 1;
|
|
const named = /export\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(t) ?? /module\.exports\s*=\s*([A-Za-z_$][\w$]*)/.exec(t);
|
|
if (named) {
|
|
const decl = new RegExp(`(?:const|let|var)\\s+${named[1]}\\b[^=]*=\\s*(?:[A-Za-z_$][\\w$]*\\s*\\(\\s*)?\\{`).exec(t);
|
|
if (decl) return decl.index + decl[0].length - 1;
|
|
}
|
|
throw new Error('could not find the exported config object (export default { ... })');
|
|
}
|
|
|
|
/** Indentation of the line that `at` is on. */
|
|
function lineIndent(t: string, at: number): string {
|
|
const ls = t.lastIndexOf('\n', at - 1) + 1;
|
|
return /^[ \t]*/.exec(t.slice(ls))![0];
|
|
}
|
|
|
|
/** One indentation step of the file (2 spaces by default). */
|
|
function step(t: string): string {
|
|
const m = /\n([ \t]+)\S/.exec(t);
|
|
return m ? (m[1]!.startsWith('\t') ? '\t' : m[1]!.length >= 4 && m[1]!.length % 4 === 0 && !/\n {2}\S/.test(t) ? ' ' : ' ') : ' ';
|
|
}
|
|
|
|
/** JSON files are edited as text too (their own formatting, e.g. one scene per line in the shots copy file, stays):
|
|
* values as JSON, keys in double quotes, no trailing commas. Set for the duration of one setByPath call. */
|
|
let JSON_MODE = false;
|
|
|
|
/** A JSON literal: one line when short, else indented like the line it sits on. */
|
|
function jsonLit(v: unknown, ind: string): string {
|
|
const one = JSON.stringify(v);
|
|
if (one.length <= 100 || typeof v !== 'object' || v === null) return one;
|
|
return JSON.stringify(v, null, 2).split('\n').map((l, i) => (i ? ind + l : l)).join('\n');
|
|
}
|
|
const lit = (v: unknown, ind: string): string => (JSON_MODE ? jsonLit(v, ind) : toTs(v, ind));
|
|
|
|
/** A literal for `v` in the style of the value it replaces (the quote character of an old string). */
|
|
function literal(v: unknown, ind: string, old?: string): string {
|
|
if (JSON_MODE) return jsonLit(v, ind);
|
|
if (typeof v === 'string' && old && (old[0] === '"' || old[0] === '`') && !v.includes('\n')) {
|
|
if (old[0] === '"') return JSON.stringify(v);
|
|
if (!/[`$\\]/.test(v)) return `\`${v}\``;
|
|
}
|
|
return toTs(v, ind);
|
|
}
|
|
|
|
/** Is the text at [s, e) a literal we may read through or replace (no identifiers, calls or spreads)? */
|
|
const plain = (src: string): boolean => /^(['"`]|-?\d|true\b|false\b|null\b|undefined\b|\{|\[)/.test(src.trim());
|
|
|
|
/** The config text with the value at `path` set (undefined: removed). */
|
|
export function setByPath(text: string, file: string, path: string | PathPart[], value: unknown): string {
|
|
const parts = typeof path === 'string' ? parsePath(path) : path;
|
|
JSON_MODE = file.endsWith('.json');
|
|
try { return setInText(text, parts, value); } finally { JSON_MODE = false; }
|
|
}
|
|
|
|
function setInText(text: string, parts: PathPart[], value: unknown): string {
|
|
let open = JSON_MODE ? text.search(/\S/) : rootObject(text);
|
|
if (JSON_MODE && text[open] !== '{') throw new Error('the JSON file is not an object');
|
|
for (let n = 0; n < parts.length; n++) {
|
|
const part = parts[n]!;
|
|
const last = n === parts.length - 1;
|
|
const { list, close } = members(text, open);
|
|
const isArray = text[open] === '[';
|
|
let hit: Member | undefined;
|
|
if ('key' in part) {
|
|
if (isArray) throw new Error(`${pathText(parts.slice(0, n))} is a list; use [n] or [key=value]`);
|
|
hit = [...list].reverse().find(m => m.key === part.key);
|
|
} else {
|
|
if (!isArray) throw new Error(`${pathText(parts.slice(0, n))} is not a list`);
|
|
if ('index' in part) hit = list[part.index];
|
|
else hit = list.find(m => {
|
|
const v = text.slice(m.valueStart, m.valueEnd);
|
|
if (!v.startsWith('{')) return false;
|
|
const inner = members(text, m.valueStart).list.find(x => x.key === part.match);
|
|
if (!inner) return false;
|
|
const s = text.slice(inner.valueStart, inner.valueEnd);
|
|
return plain(s) && /^['"`]/.test(s) && evalString(s) === part.eq;
|
|
});
|
|
if (!hit) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`);
|
|
}
|
|
if (last) {
|
|
if (value === undefined) {
|
|
if (!hit) return text;
|
|
return removeMember(text, hit, list, open, close);
|
|
}
|
|
if (hit) {
|
|
const old = text.slice(hit.valueStart, hit.valueEnd);
|
|
if (hit.valueStart === hit.start && !isArray) throw new Error(`${pathText(parts)} is written as a shorthand property; change it in the file`);
|
|
if (!plain(old)) throw new Error(`${pathText(parts)} is not a plain value in the config (${old.slice(0, 40)}); change it in the file`);
|
|
return text.slice(0, hit.valueStart) + literal(value, lineIndent(text, hit.start), old) + text.slice(hit.valueEnd);
|
|
}
|
|
return insertMember(text, open, close, list, (part as { key: string }).key, value);
|
|
}
|
|
if (!hit) {
|
|
if (value === undefined) return text;
|
|
// build the rest of the path as nested objects (only keys can be created)
|
|
let v: unknown = value;
|
|
for (let k = parts.length - 1; k > n; k--) {
|
|
const p = parts[k]!;
|
|
if (!('key' in p)) throw new Error(`${pathText(parts.slice(0, k + 1))}: no such element in the config`);
|
|
v = { [p.key]: v };
|
|
}
|
|
return insertMember(text, open, close, list, (part as { key: string }).key, v);
|
|
}
|
|
const src = text.slice(hit.valueStart, hit.valueEnd);
|
|
if (!src.startsWith('{') && !src.startsWith('[')) throw new Error(`${pathText(parts.slice(0, n + 1))} is not a plain object in the config (${src.slice(0, 40)}); change it in the file`);
|
|
open = hit.valueStart;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/** The value of a string literal (single, double or backtick quotes without ${}). */
|
|
function evalString(s: string): string {
|
|
const q = s[0];
|
|
if (q === '"') return JSON.parse(s) as string;
|
|
const body = s.slice(1, -1);
|
|
return body.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (_m, e: string) => {
|
|
if (e[0] === 'u') return String.fromCodePoint(parseInt(e[1] === '{' ? e.slice(2, -1) : e.slice(1), 16));
|
|
if (e[0] === 'x') return String.fromCharCode(parseInt(e.slice(1), 16));
|
|
return ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', '0': '\0' } as Record<string, string>)[e] ?? e;
|
|
});
|
|
}
|
|
|
|
function insertMember(t: string, open: number, close: number, list: Member[], key: string, value: unknown): string {
|
|
const k = JSON_MODE ? JSON.stringify(key) : /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`;
|
|
const end = JSON_MODE ? '' : ',';
|
|
const body = t.slice(open + 1, close);
|
|
if (!body.includes('\n')) {
|
|
const l1 = lit(value, lineIndent(t, open));
|
|
const inner = body.trim().replace(/,$/, '');
|
|
return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${l1} ${t.slice(close)}`;
|
|
}
|
|
const lastM = list.at(-1);
|
|
const ind = lastM ? lineIndent(t, lastM.start) : lineIndent(t, open) + step(t);
|
|
const l2 = lit(value, ind);
|
|
if (lastM) {
|
|
const needComma = t.slice(lastM.valueEnd, lastM.end).indexOf(',') < 0;
|
|
// after the last member's comma and any comment on the same line
|
|
let at = needComma ? lastM.valueEnd : lastM.end;
|
|
const nl = t.indexOf('\n', at);
|
|
if (nl >= 0 && nl < close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(at, nl))) at = nl;
|
|
const pre = needComma ? t.slice(0, lastM.valueEnd) + ',' + t.slice(lastM.valueEnd, at) : t.slice(0, at);
|
|
return `${pre}\n${ind}${k}: ${l2}${end}${t.slice(at)}`;
|
|
}
|
|
return `${t.slice(0, open + 1)}\n${ind}${k}: ${l2}${end}${t.slice(open + 1)}`;
|
|
}
|
|
|
|
function removeMember(t: string, m: Member, list: Member[], open: number, close: number): string {
|
|
const ls = t.lastIndexOf('\n', m.start - 1) + 1;
|
|
const ownLine = /^[ \t]*$/.test(t.slice(ls, m.start));
|
|
let end = m.end;
|
|
const nl = t.indexOf('\n', end);
|
|
if (ownLine && nl >= 0 && nl <= close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(end, nl))) end = nl + 1;
|
|
const start = ownLine ? ls : m.start;
|
|
let out = t.slice(0, start) + t.slice(end);
|
|
if (list.length === 1 && !t.slice(open, close).includes('\n')) out = t.slice(0, open + 1) + t.slice(close);
|
|
else if (JSON_MODE && m === list.at(-1) && list.length > 1) {
|
|
// JSON has no trailing comma: drop the one after the member that is now the last
|
|
const prev = list[list.length - 2]!;
|
|
const comma = t.indexOf(',', prev.valueEnd);
|
|
if (comma >= 0 && comma < prev.end) out = out.slice(0, comma) + out.slice(comma + 1);
|
|
}
|
|
else if (m === list.at(-1) && t.slice(m.valueEnd, m.end).indexOf(',') < 0 && list.length > 1) {
|
|
// the previous member keeps a trailing comma: valid in TS and JS
|
|
}
|
|
return out;
|
|
}
|