escapedMatch
Where old, not in text as written, is with its escapes taken the other way, as JSON makes easy to get wrong: a
line break or \n (a backslash and an n), a bare backtick or \`, ${ or \${. Undefined if it isn't (or
has none of them). Else where it is, replacement written as the source writes them (what old needed changed,
changed in it too), and what was changed, said; or why replacement can't be written so (old needed one of them
changed in some places and not in others, and the replacement has it).
export function escapedMatch( text: string, old: string, replacement: string, ): | { matches: { index: number; length: number }[]; new: string; changed: string[] } | { problem: string } | undefined { type Kind = "line" | "tick" | "subst"; // each kind as written bare, and escaped const forms: Record<Kind, [string, string]> = { line: ["\n", "\\n"], tick: ["`", "\\`"], subst: ["${", "\\${"], }; // a text's pieces: one of those (its kind, and whether it's escaped), or other text (an escaped backslash is that) const pieces = (s: string) => { const out: { text: string; kind?: Kind; escaped?: boolean }[] = []; for (let i = 0; i < s.length;) { let piece: { text: string; kind?: Kind; escaped?: boolean } = { text: s.startsWith("\\\\", i) ? "\\\\" : s[i]!, }; for (const [kind, [bare, escaped]] of Object.entries(forms) as [Kind, [string, string]][]) if (s.startsWith(escaped, i)) piece = { text: escaped, kind, escaped: true }; else if (s.startsWith(bare, i) && piece.text.length === 1 && !piece.kind) piece = { text: bare, kind, escaped: false }; out.push(piece); i += piece.text.length; } return out; }; const wrote = pieces(old); const asked = wrote.filter((p) => p.kind); if (!asked.length) return undefined; // (each either way; the group says which the source has) const either: Record<Kind, string> = { line: "(\\\\n|\\n)", tick: "(\\\\?)`", subst: "(\\\\?)\\$\\{", }; const pattern = wrote .map((p) => (p.kind ? either[p.kind] : p.text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"))) .join(""); const found = [...text.matchAll(new RegExp(pattern, "g"))]; if (!found.length) return undefined; const how = new Map<Kind, { same: number; escaped: number; bare: number }>(); for (const m of found) asked.forEach((p, i) => { const g = m[i + 1]!; const escaped = p.kind === "line" ? g === "\\n" : g === "\\"; const h = how.get(p.kind!) ?? { same: 0, escaped: 0, bare: 0 }; if (escaped === p.escaped) h.same++; else if (escaped) h.escaped++; else h.bare++; how.set(p.kind!, h); }); const said: Record<Kind, [string, string]> = { line: [ 'a line break as the source has it, where old has \\n (in JSON, "\\n", not "\\\\n")', '\\n as the source has it, the two characters backslash and n, where old has a line break (in JSON, "\\\\n")', ], tick: [ "a bare backtick as the source has it, where old has \\`", '\\` as the source has it, where old has a bare backtick (in JSON, "\\\\`")', ], subst: [ "${ as the source has it, where old has \\${", '\\${ as the source has it, where old has ${ (in JSON, "\\\\${")', ], }; const named: Record<Kind, string> = { line: "line breaks", tick: "backticks", subst: "${" }; const changed: string[] = []; for (const [kind, h] of how) { if (h.escaped) changed.push(said[kind][1]); if (h.bare) changed.push(said[kind][0]); } let problem: string | undefined; const written = pieces(replacement) .map((p) => { const h = p.kind && how.get(p.kind); if (!h || (!h.escaped && !h.bare)) return p.text; if (h.same || (h.escaped && h.bare)) { problem ??= `not found as written: ${JSON.stringify(old.length > 80 ? `${old.slice(0, 79)}…` : old)}; found with its ${named[p.kind!]} as the source writes them in some places and not others (${changed.join("; ")}), so how to write new's isn't known: write old and new as \`tcm view\` shows the code`; return p.text; } return forms[p.kind!][h.escaped ? 1 : 0]; }) .join(""); if (problem) return { problem }; return { matches: found.map((m) => ({ index: m.index, length: m[0].length })), new: written, changed, }; }