UNPKG

svelte-eslint-parser

Version:
201 lines (200 loc) 8.13 kB
import fs from "fs"; import path from "path"; import { createRequire } from "module"; import { svelteToVirtualTypeScript } from "./parser/svelte-to-virtual-ts.js"; import { loadNewestModule } from "./utils/cjs-module.js"; /** * Experimental hook that intercepts `ts.sys.readFile` and returns virtual * TypeScript for `.svelte` paths. ESLint's own reads go through `fs` and * are unaffected. Activate with `SVELTE_ESLINT_PARSER_EXPERIMENTAL_TS_SYS_HOOK=1`. * * Several preconditions must hold for the hook to actually speed anything up * (extraFileExtensions includes '.svelte', type-aware lint is on, the TS * instance is reachable through CJS require.cache). When the hook is opted * in but a precondition fails, a warning is emitted so users can diagnose * why no speed-up materialised. */ const ENV_FLAG = "SVELTE_ESLINT_PARSER_EXPERIMENTAL_TS_SYS_HOOK"; const translations = new Map(); const patchedSysObjects = new WeakSet(); let activeParserOptions = null; let installed = false; let needsParseTimeRescan = false; let parserOptionsInspected = false; let patchAppliedCount = 0; let primeStoredCount = 0; let svelteReadFileCount = 0; function warn(msg) { process.stderr.write(`[svelte-eslint-parser:ts-sys-hook] WARNING: ${msg}\n`); } /** Called from `parseForESLint` so translation has the user's parser config. */ export function rememberParserOptions(options) { activeParserOptions = options; if (installed && !parserOptionsInspected) { parserOptionsInspected = true; inspectParserOptions(options); } // Catch a TypeScript instance that landed in `require.cache` between // `installTsSysHook` and the first parse (e.g. typescript-eslint loaded // after svelte-eslint-parser in `eslint.config.js`). Walking on every // subsequent parse would be wasteful and gains nothing — TS instances // are not added back later in a typical lint run. if (!needsParseTimeRescan) return; needsParseTimeRescan = false; patchAllLoadedTypeScripts(); } function inspectParserOptions(options) { // `extraFileExtensions` lives on the raw options object; normalize spreads // it through, even though it isn't part of the typed interface. const raw = options; const extra = raw.extraFileExtensions; const hasSvelteExt = Array.isArray(extra) && extra.includes(".svelte"); if (!hasSvelteExt) { warn("parserOptions.extraFileExtensions does not include '.svelte'. " + "ts.sys.readFile will not be called for .svelte paths, so the hook " + "cannot speed anything up."); } if (options.project == null && options.projectService == null && options.EXPERIMENTAL_useProjectService == null) { warn("neither `project` nor `projectService` is configured. The hook only " + "helps when typescript-eslint is doing type-aware lint."); } } /** Seed the cache so a later `ts.sys.readFile` skips re-translating. */ export function primeTranslationCache(filePath, virtualCode) { if (!installed) return; // Skip the stat + write if on-demand translation already cached a positive // entry — the prime is redundant when ts.sys read the file before ESLint did. const existing = translations.get(filePath); if (existing && existing.virtualCode !== null) return; const mtimeMs = statMtimeMs(filePath); if (mtimeMs === null) return; translations.set(filePath, { mtimeMs, virtualCode }); primeStoredCount++; } function statMtimeMs(filePath) { try { return fs.statSync(filePath).mtimeMs; } catch { return null; } } function readUtf8(filePath) { try { return fs.readFileSync(filePath, "utf-8"); } catch { return null; } } function translateOnDemand(filePath) { if (!activeParserOptions) return null; const mtimeMs = statMtimeMs(filePath); if (mtimeMs === null) return null; const cached = translations.get(filePath); if (cached && cached.mtimeMs === mtimeMs) return cached.virtualCode; const svelteSource = readUtf8(filePath); if (svelteSource === null) { translations.set(filePath, { mtimeMs, virtualCode: null }); return null; } const virtualCode = svelteToVirtualTypeScript(filePath, svelteSource, activeParserOptions); // Store the outcome — including `null` — to negative-cache failed translations. translations.set(filePath, { mtimeMs, virtualCode }); return virtualCode; } function patchTsSys(sys) { if (!sys || typeof sys.readFile !== "function") return; if (patchedSysObjects.has(sys)) return; patchedSysObjects.add(sys); patchAppliedCount++; const originalReadFile = sys.readFile.bind(sys); sys.readFile = (filePath, encoding) => { if (typeof filePath === "string" && filePath.endsWith(".svelte")) { svelteReadFileCount++; const virtual = translateOnDemand(filePath); if (virtual !== null) return virtual; } return originalReadFile(filePath, encoding); }; } /** * Force-load the project's TypeScript so it lands in `require.cache`. The * patch then happens through `patchAllLoadedTypeScripts`; no need to capture * the returned module ourselves. */ function ensureTypeScriptLoaded() { try { loadNewestModule("typescript"); } catch (e) { warn(`failed to load 'typescript' from the project: ${e instanceof Error ? e.message : String(e)}. The hook has nothing to patch.`); } } /** Patch every `typescript.js` already in `require.cache`. */ function patchAllLoadedTypeScripts() { const cache = createRequire(import.meta.url).cache; if (!cache) return; for (const [resolvedPath, mod] of Object.entries(cache)) { if (resolvedPath.includes(`${path.sep}typescript${path.sep}`) && resolvedPath.endsWith(`${path.sep}typescript.js`)) { patchTsSys(mod?.exports?.sys); } } } /** Idempotent. No-op unless `SVELTE_ESLINT_PARSER_EXPERIMENTAL_TS_SYS_HOOK=1`. */ export function installTsSysHook() { if (installed) return; // eslint-disable-next-line no-process-env -- intentional opt-in gate if (process.env[ENV_FLAG] !== "1") return; ensureTypeScriptLoaded(); patchAllLoadedTypeScripts(); installed = true; // Arm a one-shot rescan; see `rememberParserOptions`. needsParseTimeRescan = true; // End-of-run check: if .svelte files were linted but the patched readFile // was never hit for any of them, the hook silently did nothing. process.on("exit", () => { if (primeStoredCount > 0 && svelteReadFileCount === 0) { warn("ts.sys.readFile was never called for a .svelte path even though " + `${primeStoredCount} .svelte file(s) were parsed. Likely causes: ` + "parserOptions.extraFileExtensions missing '.svelte', " + "typescript-eslint not running type-aware lint, " + "or the TS instance it uses was not in CJS require.cache " + "(e.g. ESM-loaded)."); } else if (patchAppliedCount === 0) { warn("no `typescript.js` was found in CJS require.cache, so nothing was " + "patched. The TS instance used by typescript-eslint may be " + "ESM-loaded or otherwise outside the cache."); } }); // Visible on every lint run so users know they opted into an experiment. process.stderr.write(`[svelte-eslint-parser] ${ENV_FLAG}=1 enables an experimental ts.sys.readFile hook. ` + `Behavior and API may change or be removed in any release.\n`); } /** Test seam. The `ts.sys` patches themselves are intentionally permanent. */ export function _resetTranslationCacheForTesting() { translations.clear(); activeParserOptions = null; needsParseTimeRescan = false; } /** Test seam: patch a caller-supplied `sys` instead of the require.cache ones. */ export function _patchTsSysForTesting(sys) { patchTsSys(sys); }