addToScratch
uses tcmts.cli.mirror.declarations, which uses tcmts.core.ts.parse, which uses tcmts.core.ts.compiler, which uses process
A scratch file's text with more definitions in it (text, rendered as tcm edit writes them): their imports
after the file's own, without the names it imports already, and their code before the first of the file's that
uses them (a constant's value, say: declared before it, as TypeScript wants), unless they use what's declared after
that, else at the end. A name from a module the file imports from already joins that import, if it's the same kind
(values, or types: import type stays apart); the file's imports from the codebase of what they declare go (it
has them now: the names mean the same, as tcm edit writes them).
import { importedLocals, importLines } from "#codebase/tcmts/cli/main"; import { declarations } from "#codebase/tcmts/cli/mirror"; import { isTypeOnlyImport, parse } from "#codebase/tcmts/core/ts"; import { groupImports } from "#codebase/tcmts/loader"; import { isImportDeclaration as ts_isImportDeclaration, isNamedImports as ts_isNamedImports } from "typescript/ast"; export function addToScratch(file: string, existing: string, text: string): string { const declaredNow = new Set(declarations(file, text).keys()); for (const st of parse(file, existing).statements.filter(ts_isImportDeclaration).reverse()) { const spec = (st.moduleSpecifier as { text?: string }).text ?? ""; if (!/^#codebase(\/|$)/.test(spec)) continue; const ls = importLines(st, existing); if (!ls.some((l) => declaredNow.has(l.local))) continue; const keep = ls.filter((l) => !declaredNow.has(l.local)).map((l) => l.line); const to = st.end + (!keep.length && existing[st.end] === "\n" ? 1 : 0); existing = `${existing.slice(0, st.getStart())}${keep.length ? groupImports(keep).join("\n") : ""}${existing.slice(to)}`; } const sf = parse(file, existing); const had = sf.statements.filter(ts_isImportDeclaration); // (names it imports, or declares itself) const bound = new Set([ ...had.flatMap((st) => importedLocals(st)), ...declarations(file, existing).keys(), ]); const added = parse(file, text).statements.filter(ts_isImportDeclaration); const lines = added.flatMap((st) => importLines(st, text) .filter((l) => !bound.has(l.local)) .map((l) => l.line), ); // named imports from a module it has a named import from: into that one (a type into a value import as `type T`) const joins = new Map<number, string[]>(); const apart: string[] = []; for (const line of lines) { const m = /^import (type )?\{ (.+) \} from ("[^"]+");$/.exec(line); const into = m ? had.findIndex((st) => { const nb = st.importClause?.namedBindings; const typeOnly = !!st.importClause && isTypeOnlyImport(st.importClause); return ( (st.moduleSpecifier as { text?: string }).text === JSON.parse(m[3]!) && !!nb && ts_isNamedImports(nb) && typeOnly === !!m[1] ); }) : -1; if (m && into >= 0) { joins.set(into, [...(joins.get(into) ?? []), ...m[2]!.split(", ")]); } else apart.push(line); } for (const [i, names] of [...joins].sort((a, b) => b[0] - a[0])) { const nb = had[i]!.importClause!.namedBindings!; const all = [...(ts_isNamedImports(nb) ? nb.elements.map((e) => e.getText(sf)) : []), ...names]; existing = `${existing.slice(0, nb.getStart(sf))}{ ${all.join(", ")} }${existing.slice(nb.end)}`; } const have = joins.size ? parse(file, existing).statements.filter(ts_isImportDeclaration) : had; const imports = apart.length ? groupImports(apart) : []; const code = text.slice(added.at(-1)?.end ?? 0).trim(); const last = have.at(-1); // after the last import, or else the namespace line, or else at the top const pragma = /^\/\/ tcm: namespace \S+\n/.exec(existing); const at = last ? last.end + (existing[last.end] === "\n" ? 1 : 0) : pragma ? pragma[0].length : 0; const head = existing.slice(0, at) + (imports.length ? imports.join("\n") + "\n" : ""); let rest = existing.slice(at); // before the first statement using what's added, if what's added uses nothing the file declares from there on const esc = (n: string) => n.replace(/[$]/g, "\\$&"); const words = (ns: string[]) => ns.length ? new RegExp(`(?<![\\w$.])(?:${ns.map(esc).join("|")})(?![\\w$])`) : undefined; const now = parse(file, existing); const statements = now.statements.filter((st) => !ts_isImportDeclaration(st)); const uses = words([...declaredNow]); const first = uses ? statements.findIndex((st) => uses.test(existing.slice(st.getStart(now), st.end))) : -1; const needs = words([...declarations(file, existing).keys()].filter((n) => !declaredNow.has(n))); const needed = needs ? statements.findLastIndex((st) => { const m = /(?:const|let|var|function\*?|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)/.exec( existing.slice(st.getStart(now), st.end), ); return !!m && new RegExp(`(?<![\\w$.])${esc(m[1]!)}(?![\\w$])`).test(code); }) : -1; const user = statements[first]; let placed = false; if (user && needed < first) { const trivia = existing.slice(user.pos, user.getStart(now)); const from = user.pos + trivia.length - trivia.trimStart().length - at; if (from >= 0) { rest = `${rest.slice(0, from)}${code}\n\n${rest.slice(from)}`; placed = true; } } return `${head}${!last && imports.length && rest.trim() ? "\n" : ""}${rest.trimEnd()}${placed ? "" : `\n\n${code}`}\n`.replace( /\n{3,}/g, "\n\n", ); }
uses
- tcmts.cli.main.importLines
- tcmts.cli.main.importedLocals
- tcmts.cli.mirror.declarations
- tcmts.core.ts.isTypeOnlyImport
- tcmts.core.ts.parse
- tcmts.loader.groupImports