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