storebound/cli/lib/config-path.ts

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' : '');
}