Spelling

tcmts.cli.mirror.Spelling · #f364y74e · class

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

used by