Spelling
How a file spells what its definitions refer to, learned from the definitions already in it: ts.parse through
a namespace import, parse through a named one, fs for a default import. A target nothing there refers to yet
is spelled like a sibling from the same module (ts. again), or gets an import.
import type { CodebaseCore } from "#codebase/tcmts/cli/codebase"; import { extKey, joinName, specifierFor, specifierOf } from "#codebase/tcmts/cli/mirror"; import type { DefRecord, Span } from "#codebase/tcmts/core/types"; export class Spelling { private exact = new Map<string, string>(); /** by name too: a definition re-hashed under the same name (updated with others it's in a cycle with) reads the same */ private byName = new Map<string, string>(); /** a module (codebase file or package entry) → the namespace prefix it's reached through, `ts.` */ private prefix = new Map<string, string>(); /** imports the code written here needs: what each brings in, and whether any use of it is a value's */ private wanted: { key: string; spec: string; name: string; local: string; value: boolean; file?: string; }[] = []; /** what the file's code already uses as a value (the rest it may import as types only) */ private values = new Set<string>(); /** and by name (for a definition re-hashed since) */ private valueNames = new Set<string>(); /** names the file imports as types only that the code written here uses as values: their imports become value imports */ readonly promoted = new Set<string>(); private taken: Set<string>; private cb: CodebaseCore; private file: string; private ns: string; private fileOf: (hash: string) => { file: string; ns: string; leaf: string } | undefined; constructor( cb: CodebaseCore, file: string, ns: string, existing: DefRecord[], fileOf: (hash: string) => { file: string; ns: string; leaf: string } | undefined, taken: Iterable<string>, nameWas: (hash: string) => string | undefined, ) { this.cb = cb; this.file = file; this.ns = ns; this.fileOf = fileOf; this.taken = new Set(taken); for (const rec of existing) { for (const s of rec.spans) { const key = this.keyOf(s); if (!key || s.literal) continue; const text = rec.sourceText.slice(s.start, s.end); const was = s.hash && nameWas(s.hash); if (s.pos !== "type") { this.values.add(key); if (was) this.valueNames.add(was); } if (s.shorthand) continue; if (!this.exact.has(key)) this.exact.set(key, text); if (was && !this.byName.has(was)) this.byName.set(was, text); const dot = text.lastIndexOf("."); const mod = this.moduleOf(s); if (dot > 0 && mod && !this.prefix.has(mod)) this.prefix.set(mod, text.slice(0, dot + 1)); } } } private keyOf(s: Span): string | undefined { return s.hash ? `#${s.hash}` : s.ext ? extKey(s.ext) : undefined; } private moduleOf(s: Span): string | undefined { if (s.hash) return this.fileOf(s.hash)?.file; return s.ext ? `${s.ext.module}|${s.ext.subpath}` : undefined; } private local(want: string): string { let name = want; for (let i = 1; this.taken.has(name); i++) name = `${want}_${i}`; this.taken.add(name); return name; } /** How `s` reads here; `self` is the definition's own name. */ spell(s: Span, written: string, selfHash: string, selfLeaf: string): string { if (s.hash === selfHash) return selfLeaf; const key = this.keyOf(s); if (!key) return written; // a file of the codebase (a page, an image) isn't in the mirror's code: it's imported from the codebase, as a // scratch file does, by its name in quotes when that isn't an identifier if (s.hash && this.cb.store.getDef(s.hash)?.kind === "file") { const known = this.exact.get(key); if (known) return known; const name = this.cb.store.pathsOf(s.hash)[0]; if (!name) throw new Error(`refers to the file #${s.hash.slice(0, 8)}, which has no name`); const ns = name.slice(0, Math.max(0, name.lastIndexOf("."))); const leaf = name.slice(name.lastIndexOf(".") + 1); const local = this.local( /^[A-Za-z_$][\w$]*$/.test(leaf) ? leaf : leaf.replace(/[^\w$]/g, "_"), ); this.wanted.push({ key, spec: `#codebase${ns ? `/${ns.replaceAll(".", "/")}` : ""}`, name: leaf, local, value: true, }); return this.remember(key, local); } // a definition this file's namespace has a name for reads as that name, here: an identical one elsewhere (one // hash, two names) mustn't bring in its other name if (s.hash) { const prefix = this.ns ? `${this.ns}.` : ""; const own = this.cb.store .pathsOf(s.hash) .find((p) => p.startsWith(prefix) && !p.slice(prefix.length).includes(".")); if (own) return this.remember(key, own.slice(prefix.length)); } const known = this.exact.get(key); if (known) { const w = this.wanted.find((x) => x.key === key); if (w && s.pos !== "type") w.value = true; // imported here for its type only, until now if (!w && s.pos !== "type" && !this.values.has(key) && /^[A-Za-z_$][\w$]*$/.test(known)) { this.values.add(key); this.promoted.add(known); } return known; } if (s.hash) { const t = this.fileOf(s.hash); if (!t) throw new Error(`refers to #${s.hash.slice(0, 8)}, which isn't in the mirror`); const name = joinName(t.ns, t.leaf); const named = this.byName.get(name); if (named) { // imported here for its type only, until now if (s.pos !== "type" && !this.valueNames.has(name) && /^[A-Za-z_$][\w$]*$/.test(named)) { this.valueNames.add(name); this.promoted.add(named); } return this.remember(key, named); } if (t.ns === this.ns) return this.remember(key, t.leaf); const pre = this.prefix.get(t.file); if (pre) return this.remember(key, pre + t.leaf); const local = this.local(t.leaf); const value = s.pos !== "type" && this.cb.store.getDef(s.hash)?.kind !== "type"; this.wanted.push({ key, spec: specifierFor(this.file, t.file, t.leaf), name: t.leaf, local, value, file: t.file, }); return this.remember(key, local); } const ext = s.ext!; const pre = this.prefix.get(`${ext.module}|${ext.subpath}`); if (pre && ext.export !== "default" && ext.export !== "*") return this.remember(key, pre + ext.export); const wanted = /^[A-Za-z_$][\w$]*$/.test(written) ? written : ext.export === "default" || ext.export === "*" ? "imported" : ext.export; const local = this.local(wanted); this.wanted.push({ key, spec: specifierOf(ext), name: ext.export, local, value: s.pos !== "type", }); return this.remember(key, local); } /** * The imports the code written here needs: a statement per module (another for what's only used as a type), * Node's builtins first, then packages, then the project's own files. */ /** The project's files the imports come from, and what from each: their declarations must be exported. */ get importedFrom(): { file: string; leaf: string }[] { return this.wanted.flatMap((w) => (w.file ? [{ file: w.file, leaf: w.name }] : [])); } get imports(): string[] { const group = (spec: string) => (spec.startsWith("node:") ? 0 : spec.startsWith(".") ? 2 : 1); const out: string[] = []; for (const spec of [...new Set(this.wanted.map((w) => w.spec))].sort( (a, b) => group(a) - group(b), )) { const from = JSON.stringify(spec); for (const value of [true, false]) { const ws = this.wanted.filter((w) => w.spec === spec && w.value === value); const type = value ? "" : "type "; const star = ws.find((w) => w.name === "*"); const dflt = ws.find((w) => w.name === "default"); const named = ws .filter((w) => w.name !== "*" && w.name !== "default") .map((w) => w.local === w.name ? w.local : `${/^[A-Za-z_$][\w$]*$/.test(w.name) ? w.name : JSON.stringify(w.name)} as ${w.local}`, ); if (star) out.push(`import ${type}* as ${star.local} from ${from};`); // (a type-only import has a default or named bindings, not both) if (dflt && (value || !named.length)) out.push( `import ${type}${dflt.local}${named.length ? `, { ${named.join(", ")} }` : ""} from ${from};`, ); else { if (dflt) out.push(`import type ${dflt.local} from ${from};`); if (named.length) out.push(`import ${type}{ ${named.join(", ")} } from ${from};`); } } } return out; } private remember(key: string, text: string): string { this.exact.set(key, text); return text; } }
uses
- tcmts.cli.codebase.CodebaseCore
- tcmts.cli.mirror.extKey
- tcmts.cli.mirror.joinName
- tcmts.cli.mirror.specifierFor
- tcmts.cli.mirror.specifierOf
- tcmts.core.types.DefRecord
- tcmts.core.types.Span