index

tcmts.vscode.files.plugin.index · #qxwmiqrr · js file

// A TypeScript server plugin, loaded into VS Code's TypeScript server by the tcm-ts extension. It does two things:
//
// - Packages. A scratch file can import a package the project hasn't installed: tcm provides it, at the version
//   the code around it uses, from its cache. tcm records where for each scratch file in .tcm/packages.json; this
//   resolves the file's imports the same way, so the editor sees the same package tcm checks against. A file tcm
//   hasn't seen yet gets the newest copy in the cache.
// - Go to Definition. Codebase definitions reach scratch files through generated modules (.tcm/codebase
//   re-exports .tcm/cache/<hash>.ts), so TypeScript's answers land in generated code. The tcm language server
//   answers the same request with the definition's source (a tcm: document); this drops TypeScript's answers
//   that point into generated modules, so Cmd+click goes straight to the source instead of offering both.
const fs = require("node:fs");
const path = require("node:path");
const { builtinModules } = require("node:module");

const GENERATED = /[\\/]\.tcm[\\/](cache|codebase)[\\/]/;

/** `react/jsx-runtime` → `react`, `@scope/pkg/sub` → `@scope/pkg`; undefined for relative, `#`, and builtins */
function packageNameOf(specifier) {
  if (/^[./#]/.test(specifier) || specifier.startsWith("node:") || builtinModules.includes(specifier.split("/")[0])) return undefined;
  const parts = specifier.split("/");
  return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
}

/** The codebase a file belongs to: the nearest directory above it with a .tcm. */
function tcmDirOf(file) {
  for (let dir = path.dirname(file); ; dir = path.dirname(dir)) {
    if (fs.existsSync(path.join(dir, ".tcm", "codebase.sqlite"))) return path.join(dir, ".tcm");
    if (path.dirname(dir) === dir) return undefined;
  }
}

/** .tcm/packages.json, re-read when it changes */
const mappings = new Map();
function mappingOf(tcmDir) {
  const file = path.join(tcmDir, "packages.json");
  let mtime = 0;
  try {
    mtime = fs.statSync(file).mtimeMs;
  } catch {
    return undefined;
  }
  const hit = mappings.get(file);
  if (hit && hit.mtime === mtime) return hit.value;
  let value;
  try {
    value = JSON.parse(fs.readFileSync(file, "utf8"));
  } catch {
    value = undefined;
  }
  mappings.set(file, { mtime, value });
  return value;
}

/** The newest range of `name` in the cache (`npm__react__19` over `npm__react__18`). */
function newestAlias(cacheDir, name) {
  const prefix = `npm__${name.replace(/^@/, "").replace("/", "__").toLowerCase()}__`;
  let names = [];
  try {
    names = fs.readdirSync(path.join(cacheDir, "node_modules")).filter((n) => n.startsWith(prefix));
  } catch {
    return undefined;
  }
  const num = (n) => n.slice(prefix.length).split(".").map(Number);
  names.sort((a, b) => {
    const x = num(a);
    const y = num(b);
    for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (y[i] ?? 0) - (x[i] ?? 0);
    return 0;
  });
  return names[0];
}

function init(modules) {
  const ts = modules && modules.typescript;
  return {
    create(info) {
      const ls = info.languageService;
      const proxy = Object.create(null);
      for (const k of Object.keys(ls)) proxy[k] = (...args) => ls[k](...args);
      // inside generated code (if someone opens it), leave navigation alone
      const keep = (file, defs) => (!defs || GENERATED.test(file) ? defs : defs.filter((d) => !GENERATED.test(d.fileName)));
      proxy.getDefinitionAtPosition = (file, pos) => keep(file, ls.getDefinitionAtPosition(file, pos));
      proxy.getDefinitionAndBoundSpan = (file, pos) => {
        const r = ls.getDefinitionAndBoundSpan(file, pos);
        return r && r.definitions ? { ...r, definitions: keep(file, r.definitions) } : r;
      };
      const host = info.languageServiceHost;
      if (ts && host) providePackages(ts, info, host);
      return proxy;
    },
  };
}

/** Resolve a scratch file's imports of packages tcm provides into tcm's cache, as tcm itself does. */
function providePackages(ts, info, host) {
  const original = host.resolveModuleNameLiterals ? host.resolveModuleNameLiterals.bind(host) : undefined;
  const sys = { fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, directoryExists: ts.sys.directoryExists, realpath: ts.sys.realpath, getDirectories: ts.sys.getDirectories };
  const plain = (lit, containingFile, redirected, options, sf) =>
    ts.resolveModuleName(lit.text, containingFile, options, sys, undefined, redirected, ts.getModeForUsageLocation(sf, lit, options));
  host.resolveModuleNameLiterals = (literals, containingFile, redirected, options, sf, reused) => {
    const base = original ? original(literals, containingFile, redirected, options, sf, reused) : literals.map((lit) => plain(lit, containingFile, redirected, options, sf));
    if (GENERATED.test(containingFile)) return base;
    const tcmDir = tcmDirOf(containingFile);
    const mapping = tcmDir && mappingOf(tcmDir);
    if (!tcmDir) return base;
    const cacheDir = (mapping && mapping.cacheDir) || path.join(tcmDir, "cache");
    const forFile = (mapping && mapping.files && mapping.files[containingFile]) || {};
    return literals.map((lit, i) => {
      const name = packageNameOf(lit.text);
      if (!name) return base[i];
      // what tcm chose for this file; for a file it hasn't seen, the newest copy it has, if the project has none
      const alias = forFile[name] || (base[i] && base[i].resolvedModule ? undefined : newestAlias(cacheDir, name));
      if (!alias) return base[i];
      const mode = ts.getModeForUsageLocation(sf, lit, options);
      return ts.resolveModuleName(alias + lit.text.slice(name.length), path.join(cacheDir, "index.ts"), options, sys, undefined, redirected, mode);
    });
  };
  // when tcm records new choices, resolve again
  const project = info.project;
  const watched = new Set();
  const watch = () => {
    for (const root of host.getScriptFileNames ? host.getScriptFileNames() : []) {
      const tcmDir = tcmDirOf(root);
      if (!tcmDir || watched.has(tcmDir)) continue;
      watched.add(tcmDir);
      const watcher = fs.watchFile(path.join(tcmDir, "packages.json"), { interval: 500 }, () => {
        if (!project) return;
        try {
          if (project.resolutionCache && project.resolutionCache.clear) project.resolutionCache.clear();
          project.markAsDirty();
          if (project.refreshDiagnostics) project.refreshDiagnostics();
        } catch {
          // an older or newer tsserver without these: the next edit picks the change up
        }
      });
      // the server's own lifetime decides how long this lasts, not this watch
      if (watcher && watcher.unref) watcher.unref();
    }
  };
  try {
    watch();
  } catch {
    // no files yet
  }
}

module.exports = init;

used by