297 lines
14 KiB
TypeScript
297 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) ? ' ' : ' ') : ' ';
|
|
}
|
|
|
|
/** 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 (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;
|
|
if (file.endsWith('.json')) return setJson(text, parts, value);
|
|
let open = rootObject(text);
|
|
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 = /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`;
|
|
const body = t.slice(open + 1, close);
|
|
if (!body.includes('\n')) {
|
|
const lit = toTs(value, lineIndent(t, open));
|
|
const inner = body.trim().replace(/,$/, '');
|
|
return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${lit} ${t.slice(close)}`;
|
|
}
|
|
const lastM = list.at(-1);
|
|
const ind = lastM ? lineIndent(t, lastM.start) : lineIndent(t, open) + step(t);
|
|
const lit = toTs(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 = 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}: ${lit},${t.slice(at)}`;
|
|
}
|
|
return `${t.slice(0, open + 1)}\n${ind}${k}: ${lit},${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 (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;
|
|
}
|
|
|
|
function setJson(text: string, parts: PathPart[], value: unknown): string {
|
|
const root = JSON.parse(text) as Record<string, unknown>;
|
|
let cur: unknown = root;
|
|
for (let n = 0; n < parts.length; n++) {
|
|
const p = parts[n]!;
|
|
const last = n === parts.length - 1;
|
|
if ('key' in p) {
|
|
if (typeof cur !== 'object' || cur === null || Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not an object`);
|
|
const o = cur as Record<string, unknown>;
|
|
if (last) { if (value === undefined) delete o[p.key]; else o[p.key] = value; break; }
|
|
if (o[p.key] === undefined) { if (value === undefined) return text; o[p.key] = {}; }
|
|
cur = o[p.key];
|
|
} else {
|
|
if (!Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not a list`);
|
|
const i = 'index' in p ? p.index : cur.findIndex(x => x && typeof x === 'object' && (x as Record<string, unknown>)[p.match] === p.eq);
|
|
if (i < 0 || i >= cur.length) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`);
|
|
if (last) { if (value === undefined) cur.splice(i, 1); else cur[i] = value; break; }
|
|
cur = cur[i];
|
|
}
|
|
}
|
|
const ind = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ';
|
|
return JSON.stringify(root, null, ind) + (text.endsWith('\n') ? '\n' : '');
|
|
}
|