diff --git a/.changeset/curvy-socks-buy.md b/.changeset/curvy-socks-buy.md new file mode 100644 index 000000000..f9577063f --- /dev/null +++ b/.changeset/curvy-socks-buy.md @@ -0,0 +1,8 @@ +--- +"@flint.fyi/rule-tester": minor +"@flint.fyi/core": minor +"@flint.fyi/cli": minor +"@flint.fyi/typescript-language": minor +--- + +feat: use LinterHost for linting diff --git a/packages/browser/src/rules/ruleTester.ts b/packages/browser/src/rules/ruleTester.ts index 14a239398..f1409a07f 100644 --- a/packages/browser/src/rules/ruleTester.ts +++ b/packages/browser/src/rules/ruleTester.ts @@ -1,8 +1,15 @@ import { RuleTester } from "@flint.fyi/rule-tester"; +import { createRuleTesterTSConfig } from "@flint.fyi/typescript-language"; import { describe, it } from "vitest"; export const ruleTester = new RuleTester({ - defaults: { fileName: "file.ts" }, + defaults: { + fileName: "file.ts", + files: createRuleTesterTSConfig({ + lib: ["esnext", "DOM"], + }), + }, describe, + diskBackedFSRoot: import.meta.dirname, it, }); diff --git a/packages/browser/tsconfig.test.json b/packages/browser/tsconfig.test.json index 0197a85c6..3b5325cc8 100644 --- a/packages/browser/tsconfig.test.json +++ b/packages/browser/tsconfig.test.json @@ -3,7 +3,7 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.test.json", "rootDir": "src/", "outDir": "node_modules/.cache/tsbuild/test", - "types": [] + "types": ["node"] }, "extends": "../../tsconfig.base.json", "include": ["src/**/*.test.ts", "src/rules/ruleTester.ts"], diff --git a/packages/cli/src/runCliOnce.ts b/packages/cli/src/runCliOnce.ts index 3b7dfb2ed..f1db4e08c 100644 --- a/packages/cli/src/runCliOnce.ts +++ b/packages/cli/src/runCliOnce.ts @@ -1,4 +1,10 @@ -import { isConfig, runConfig, runConfigFixing } from "@flint.fyi/core"; +import { + createDiskBackedLinterHost, + createEphemeralLinterHost, + isConfig, + runConfig, + runConfigFixing, +} from "@flint.fyi/core"; import { debugForFile } from "debug-for-file"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -37,13 +43,18 @@ export async function runCliOnce( const ignoreCache = !!values["cache-ignore"]; const skipDiagnostics = !!values["skip-diagnostics"]; + + const host = createEphemeralLinterHost( + createDiskBackedLinterHost(process.cwd()), + ); + const lintResults = await (values.fix - ? runConfigFixing(configDefinition, { + ? runConfigFixing(configDefinition, host, { ignoreCache, requestedSuggestions: new Set(values["fix-suggestions"]), skipDiagnostics, }) - : runConfig(configDefinition, { ignoreCache, skipDiagnostics })); + : runConfig(configDefinition, host, { ignoreCache, skipDiagnostics })); // TODO: Eventually, it'd be nice to move everything fully in-memory. // This would be better for performance to avoid excess file system I/O. diff --git a/packages/core/src/host/createEphemeralLinterHost.ts b/packages/core/src/host/createEphemeralLinterHost.ts new file mode 100644 index 000000000..64cc7de46 --- /dev/null +++ b/packages/core/src/host/createEphemeralLinterHost.ts @@ -0,0 +1,27 @@ +import type { LinterHost } from "../types/host.ts"; + +/** + * Creates a one-shot `LinterHost` that disables file/directory watching. + * + * Useful for single-run linting (e.g. CLI execution, rule tester), + * where persistent watchers are unnecessary and can affect performance. + */ +export function createEphemeralLinterHost(baseHost: LinterHost): LinterHost { + return { + ...baseHost, + watchDirectory() { + return { + [Symbol.dispose]() { + // Intentionally empty to satisfy the Disposable interface. + }, + }; + }, + watchFile() { + return { + [Symbol.dispose]() { + // Intentionally empty to satisfy the Disposable interface. + }, + }; + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a8f67eae..45c87762e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export { DirectivesCollector } from "./directives/DirectivesCollector.ts"; export { directiveReports } from "./directives/reports/directiveReports.ts"; export { globs } from "./globs/index.ts"; export { createDiskBackedLinterHost } from "./host/createDiskBackedLinterHost.ts"; +export { createEphemeralLinterHost } from "./host/createEphemeralLinterHost.ts"; export { createVFSLinterHost, type CreateVFSLinterHostOpts, diff --git a/packages/core/src/languages/createLanguage.ts b/packages/core/src/languages/createLanguage.ts index e412dbc80..a58e2e11b 100644 --- a/packages/core/src/languages/createLanguage.ts +++ b/packages/core/src/languages/createLanguage.ts @@ -18,13 +18,13 @@ export function createLanguage( const language: Language = { ...languageDefinition, - createFileFactory() { + createFileFactory(host) { log( "Creating file factory for language: %s", languageDefinition.about.name, ); - const fileFactoryDefinition = languageDefinition.createFileFactory(); + const fileFactoryDefinition = languageDefinition.createFileFactory(host); log("Created file factory."); diff --git a/packages/core/src/languages/makeDisposable.ts b/packages/core/src/languages/makeDisposable.ts index 41ffac313..1eb0aeb66 100644 --- a/packages/core/src/languages/makeDisposable.ts +++ b/packages/core/src/languages/makeDisposable.ts @@ -1,8 +1,8 @@ export function makeDisposable(obj: T): Disposable & T { return { - ...obj, [Symbol.dispose]: () => () => { // Intentionally empty to satisfy the Disposable interface. }, + ...obj, }; } diff --git a/packages/core/src/running/collectFilesAndMetadata.ts b/packages/core/src/running/collectFilesAndMetadata.ts index c46bb638a..73265d7cf 100644 --- a/packages/core/src/running/collectFilesAndMetadata.ts +++ b/packages/core/src/running/collectFilesAndMetadata.ts @@ -3,6 +3,7 @@ import { nullThrows } from "@flint.fyi/utils"; import { readFromCache } from "../cache/readFromCache.ts"; import type { FileCacheStorage } from "../types/cache.ts"; import type { ProcessedConfigDefinition } from "../types/configs.ts"; +import type { LinterHost } from "../types/host.ts"; import type { AnyRule } from "../types/rules.ts"; import { collectLanguageMetadataByFilePath } from "./collectLanguageMetadataByFilePath.ts"; import { collectRulesOptionsByFile } from "./collectRulesOptionsByFile.ts"; @@ -45,6 +46,7 @@ export interface CollectedFilesAndMetadata { // Also, what if we removed the concept of a virtual file...? export async function collectFilesAndMetadata( configDefinition: ProcessedConfigDefinition, + host: LinterHost, ignoreCache: boolean | undefined, ): Promise { // 1. Collect all file paths to lint and the 'use' rule configuration groups @@ -63,6 +65,7 @@ export async function collectFilesAndMetadata( const languageFileMetadataByFilePath = collectLanguageMetadataByFilePath( cached, rulesOptionsByFile, + host, ); // 5. Join language metadata files into the corresponding options by file path diff --git a/packages/core/src/running/collectLanguageMetadataByFilePath.ts b/packages/core/src/running/collectLanguageMetadataByFilePath.ts index ba4bbb087..0c37a69fd 100644 --- a/packages/core/src/running/collectLanguageMetadataByFilePath.ts +++ b/packages/core/src/running/collectLanguageMetadataByFilePath.ts @@ -2,6 +2,7 @@ import { makeAbsolute } from "@flint.fyi/utils"; import { CachedFactory } from "cached-factory"; import type { FileCacheStorage } from "../types/cache.ts"; +import type { LinterHost } from "../types/host.ts"; import type { AnyLanguage, AnyLanguageFileMetadata, @@ -11,6 +12,7 @@ import type { AnyRule } from "../types/rules.ts"; export function collectLanguageMetadataByFilePath( cached: Map | undefined, rulesOptionsByFile: Map>, + host: LinterHost, ) { const languageFileMetadataByFilePath = new CachedFactory< string, @@ -19,7 +21,7 @@ export function collectLanguageMetadataByFilePath( const languageFilesMetadataByLanguage = new CachedFactory( (language: AnyLanguage) => { - const fileFactory = language.createFileFactory(); + const fileFactory = language.createFileFactory(host); return new CachedFactory((filePath: string) => fileFactory.prepareFromDisk({ diff --git a/packages/core/src/running/runConfig.ts b/packages/core/src/running/runConfig.ts index 6b109e565..346c47992 100644 --- a/packages/core/src/running/runConfig.ts +++ b/packages/core/src/running/runConfig.ts @@ -2,6 +2,7 @@ import { CachedFactory } from "cached-factory"; import { writeToCache } from "../cache/writeToCache.ts"; import type { ProcessedConfigDefinition } from "../types/configs.ts"; +import type { LinterHost } from "../types/host.ts"; import type { LintResults } from "../types/linting.ts"; import type { FileReport } from "../types/reports.ts"; import type { AnyRule } from "../types/rules.ts"; @@ -17,6 +18,7 @@ export interface RunConfigOptions { export async function runConfig( configDefinition: ProcessedConfigDefinition, + host: LinterHost, { ignoreCache, skipDiagnostics }: RunConfigOptions, ): Promise { // 1. Based on the original config definition, collect: @@ -29,7 +31,7 @@ export async function runConfig( cached, languageFileMetadataByFilePath, rulesFilesAndOptionsByRule, - } = await collectFilesAndMetadata(configDefinition, ignoreCache); + } = await collectFilesAndMetadata(configDefinition, host, ignoreCache); // 2. For each lint rule, run it on all files and store each file's results const reportsByFilePath = await runRules(rulesFilesAndOptionsByRule); diff --git a/packages/core/src/running/runConfigFixing.ts b/packages/core/src/running/runConfigFixing.ts index d685e28b1..180845042 100644 --- a/packages/core/src/running/runConfigFixing.ts +++ b/packages/core/src/running/runConfigFixing.ts @@ -2,6 +2,7 @@ import { debugForFile } from "debug-for-file"; import { applyChangesToFiles } from "../changing/applyChangesToFiles.ts"; import type { ProcessedConfigDefinition } from "../types/configs.ts"; +import type { LinterHost } from "../types/host.ts"; import type { LintResultsWithChanges } from "../types/linting.ts"; import { runConfig } from "./runConfig.ts"; @@ -17,6 +18,7 @@ export interface RunConfigFixingOptions { export async function runConfigFixing( configDefinition: ProcessedConfigDefinition, + host: LinterHost, { ignoreCache, requestedSuggestions, @@ -38,7 +40,7 @@ export async function runConfigFixing( // Why read file many time when few do trick? // Or, at least it should all be virtual... // https://github.com/flint-fyi/flint/issues/73 - const lintResults = await runConfig(configDefinition, { + const lintResults = await runConfig(configDefinition, host, { ignoreCache, skipDiagnostics, }); diff --git a/packages/core/src/types/languages.ts b/packages/core/src/types/languages.ts index 6c04489c2..b35ef93ac 100644 --- a/packages/core/src/types/languages.ts +++ b/packages/core/src/types/languages.ts @@ -1,4 +1,5 @@ import type { CommentDirective } from "./directives.ts"; +import type { LinterHost } from "./host.ts"; import type { FileReport } from "./reports.ts"; import type { Rule, RuleAbout, RuleDefinition, RuleRuntime } from "./rules.ts"; import type { AnyOptionalSchema, InferredOutputObject } from "./shapes.ts"; @@ -68,7 +69,9 @@ export interface Language< AstNodesByName, FileServices extends object, > extends LanguageDefinition { - createFileFactory(): LanguageFileFactory; + createFileFactory( + host: LinterHost, + ): LanguageFileFactory; createRule: LanguageCreateRule; } @@ -91,10 +94,9 @@ export interface LanguageDefinition< FileServices extends object, > { about: LanguageAbout; - createFileFactory(): LanguageFileFactoryDefinition< - AstNodesByName, - FileServices - >; + createFileFactory( + host: LinterHost, + ): LanguageFileFactoryDefinition; } export interface LanguageFileCacheImpacts { diff --git a/packages/jsx/src/rules/ruleTester.ts b/packages/jsx/src/rules/ruleTester.ts index d0b00d491..53f374c5b 100644 --- a/packages/jsx/src/rules/ruleTester.ts +++ b/packages/jsx/src/rules/ruleTester.ts @@ -1,8 +1,9 @@ import { RuleTester } from "@flint.fyi/rule-tester"; +import { createRuleTesterTSConfig } from "@flint.fyi/typescript-language"; import { describe, it } from "vitest"; export const ruleTester = new RuleTester({ - defaults: { fileName: "file.tsx" }, + defaults: { fileName: "file.tsx", files: createRuleTesterTSConfig() }, describe, it, }); diff --git a/packages/next/tsconfig.test.json b/packages/next/tsconfig.test.json index 0197a85c6..3b5325cc8 100644 --- a/packages/next/tsconfig.test.json +++ b/packages/next/tsconfig.test.json @@ -3,7 +3,7 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.test.json", "rootDir": "src/", "outDir": "node_modules/.cache/tsbuild/test", - "types": [] + "types": ["node"] }, "extends": "../../tsconfig.base.json", "include": ["src/**/*.test.ts", "src/rules/ruleTester.ts"], diff --git a/packages/node/src/rules/ruleTester.ts b/packages/node/src/rules/ruleTester.ts index 14a239398..76eab48ba 100644 --- a/packages/node/src/rules/ruleTester.ts +++ b/packages/node/src/rules/ruleTester.ts @@ -1,8 +1,17 @@ import { RuleTester } from "@flint.fyi/rule-tester"; +import { createRuleTesterTSConfig } from "@flint.fyi/typescript-language"; import { describe, it } from "vitest"; export const ruleTester = new RuleTester({ - defaults: { fileName: "file.ts" }, + defaults: { + fileName: "file.ts", + files: createRuleTesterTSConfig({ + types: ["node"], + // TODO: remove this; there is a bug in blobReadingMethods - it doesn't respect type from @types/node + lib: ["dom"], + }), + }, describe, + diskBackedFSRoot: import.meta.dirname, it, }); diff --git a/packages/node/tsconfig.test.json b/packages/node/tsconfig.test.json index 0197a85c6..3b5325cc8 100644 --- a/packages/node/tsconfig.test.json +++ b/packages/node/tsconfig.test.json @@ -3,7 +3,7 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.test.json", "rootDir": "src/", "outDir": "node_modules/.cache/tsbuild/test", - "types": [] + "types": ["node"] }, "extends": "../../tsconfig.base.json", "include": ["src/**/*.test.ts", "src/rules/ruleTester.ts"], diff --git a/packages/performance/src/rules/ruleTester.ts b/packages/performance/src/rules/ruleTester.ts index 14a239398..4748414d2 100644 --- a/packages/performance/src/rules/ruleTester.ts +++ b/packages/performance/src/rules/ruleTester.ts @@ -1,8 +1,12 @@ import { RuleTester } from "@flint.fyi/rule-tester"; +import { createRuleTesterTSConfig } from "@flint.fyi/typescript-language"; import { describe, it } from "vitest"; export const ruleTester = new RuleTester({ - defaults: { fileName: "file.ts" }, + defaults: { + fileName: "file.ts", + files: createRuleTesterTSConfig(), + }, describe, it, }); diff --git a/packages/plugin-flint/src/rules/ruleTester.ts b/packages/plugin-flint/src/rules/ruleTester.ts index c2bc67205..b2f9dadc1 100644 --- a/packages/plugin-flint/src/rules/ruleTester.ts +++ b/packages/plugin-flint/src/rules/ruleTester.ts @@ -1,8 +1,13 @@ import { RuleTester } from "@flint.fyi/rule-tester"; +import { createRuleTesterTSConfig } from "@flint.fyi/typescript-language"; import { describe, it } from "vitest"; export const ruleTester = new RuleTester({ - defaults: { fileName: "file.test.ts" }, + defaults: { + fileName: "file.test.ts", + files: createRuleTesterTSConfig(), + }, describe, + diskBackedFSRoot: import.meta.dirname, it, }); diff --git a/packages/plugin-flint/tsconfig.test.json b/packages/plugin-flint/tsconfig.test.json index 0197a85c6..3b5325cc8 100644 --- a/packages/plugin-flint/tsconfig.test.json +++ b/packages/plugin-flint/tsconfig.test.json @@ -3,7 +3,7 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.test.json", "rootDir": "src/", "outDir": "node_modules/.cache/tsbuild/test", - "types": [] + "types": ["node"] }, "extends": "../../tsconfig.base.json", "include": ["src/**/*.test.ts", "src/rules/ruleTester.ts"], diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index dd1c3220f..8d3499ada 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -3,12 +3,17 @@ import { type AnyLanguageFileFactory, type AnyOptionalSchema, type AnyRule, + createDiskBackedLinterHost, + createEphemeralLinterHost, + createVFSLinterHost, type InferredInputObject, parseOptions, type RuleAbout, + type VFSLinterHost, } from "@flint.fyi/core"; import { CachedFactory } from "cached-factory"; import assert from "node:assert/strict"; +import path from "node:path"; import { createReportSnapshot } from "./createReportSnapshot.ts"; import { normalizeTestCase } from "./normalizeTestCase.ts"; @@ -16,11 +21,14 @@ import { resolveReportedSuggestions } from "./resolveReportedSuggestions.ts"; import { runTestCaseRule } from "./runTestCaseRule.ts"; import type { InvalidTestCase, TestCase, ValidTestCase } from "./types.ts"; +export interface RuleTesterDefaults { + fileName?: string; + files?: Record; +} export interface RuleTesterOptions { - defaults?: { - fileName?: string; - }; + defaults?: RuleTesterDefaults; describe?: TesterSetupDescribe; + diskBackedFSRoot?: string; it?: TesterSetupIt; only?: TesterSetupIt; scope?: Record; @@ -44,18 +52,48 @@ export type TesterSetupIt = ( export class RuleTester { #fileFactories: CachedFactory; - #testerOptions: Required; + #linterHost: VFSLinterHost; + #testerOptions: Required>; constructor({ - defaults, + defaults = {}, describe, + diskBackedFSRoot, it, only, scope = globalThis, skip, }: RuleTesterOptions = {}) { + let baseHost = + diskBackedFSRoot != null + ? createEphemeralLinterHost( + createDiskBackedLinterHost( + path.resolve( + process.cwd(), + diskBackedFSRoot, + "_flint-rule-tester-virtual", + ), + ), + ) + : undefined; + const { files: defaultFiles = {} } = defaults; + if (Object.keys(defaultFiles).length > 0) { + const vfs = createVFSLinterHost( + baseHost == null ? { cwd: process.cwd() } : { baseHost }, + ); + for (const [name, content] of Object.entries(defaultFiles)) { + const filePath = path.resolve(vfs.getCurrentDirectory(), name); + vfs.vfsUpsertFile(filePath, content); + } + baseHost = vfs; + } + // another overlay to prevent `defaultFiles` from being overwritten + // by per-test-case `files` + this.#linterHost = createVFSLinterHost( + baseHost == null ? { cwd: process.cwd() } : { baseHost }, + ); this.#fileFactories = new CachedFactory((language: AnyLanguage) => - language.createFileFactory(), + language.createFileFactory(this.#linterHost), ); it = defaultTo(it, scope, "it"); @@ -74,7 +112,7 @@ export class RuleTester { } this.#testerOptions = { - defaults: defaults ?? {}, + defaults, describe: defaultTo(describe, scope, "describe"), it, only, @@ -114,6 +152,7 @@ export class RuleTester { this.#itTestCase(testCaseNormalized, async () => { const reports = await runTestCaseRule( this.#fileFactories, + this.#linterHost, { options: parseOptions(rule.options, testCase.options), rule }, testCaseNormalized, ); @@ -159,6 +198,7 @@ export class RuleTester { this.#itTestCase(testCaseNormalized, async () => { const reports = await runTestCaseRule( this.#fileFactories, + this.#linterHost, { options: parseOptions(rule.options, testCase.options), rule }, testCaseNormalized, ); diff --git a/packages/rule-tester/src/runTestCaseRule.ts b/packages/rule-tester/src/runTestCaseRule.ts index 069581668..8d6a1a087 100644 --- a/packages/rule-tester/src/runTestCaseRule.ts +++ b/packages/rule-tester/src/runTestCaseRule.ts @@ -6,10 +6,13 @@ import { getColumnAndLineOfPosition, type InferredOutputObject, type NormalizedReport, + normalizePath, type RuleAbout, + type VFSLinterHost, } from "@flint.fyi/core"; import { nullThrows } from "@flint.fyi/utils"; import type { CachedFactory } from "cached-factory"; +import path from "node:path"; import type { TestCaseNormalized } from "./normalizeTestCase.ts"; @@ -24,12 +27,24 @@ export async function runTestCaseRule< OptionsSchema extends AnyOptionalSchema | undefined, >( fileFactories: CachedFactory, + linterHost: VFSLinterHost, { options, rule }: Required>, { code, fileName }: TestCaseNormalized, ): Promise { + const filePathAbsolute = normalizePath( + path.resolve(linterHost.getCurrentDirectory(), fileName), + linterHost.isCaseSensitiveFS(), + ); + for (const oldFile of linterHost.vfsListFiles().keys()) { + if (oldFile !== filePathAbsolute) { + linterHost.vfsDeleteFile(oldFile); + } + } + linterHost.vfsUpsertFile(filePathAbsolute, code); + using file = fileFactories.get(rule.language).prepareFromVirtual({ filePath: fileName, - filePathAbsolute: fileName, + filePathAbsolute, sourceText: code, }).file; diff --git a/packages/ts/src/rules/ruleTester.ts b/packages/ts/src/rules/ruleTester.ts index 14a239398..cfc9f97f2 100644 --- a/packages/ts/src/rules/ruleTester.ts +++ b/packages/ts/src/rules/ruleTester.ts @@ -1,8 +1,16 @@ import { RuleTester } from "@flint.fyi/rule-tester"; +import { createRuleTesterTSConfig } from "@flint.fyi/typescript-language"; import { describe, it } from "vitest"; export const ruleTester = new RuleTester({ - defaults: { fileName: "file.ts" }, + defaults: { + fileName: "file.ts", + files: createRuleTesterTSConfig({ + // TODO: use per-test-case tsconfig.json instead -- https://github.com/flint-fyi/flint/issues/621 + lib: ["esnext", "dom"], + }), + }, describe, + diskBackedFSRoot: import.meta.dirname, it, }); diff --git a/packages/ts/src/rules/undefinedVariables.test.ts b/packages/ts/src/rules/undefinedVariables.test.ts index e5bfcedd2..dd1a1a009 100644 --- a/packages/ts/src/rules/undefinedVariables.test.ts +++ b/packages/ts/src/rules/undefinedVariables.test.ts @@ -5,12 +5,12 @@ ruleTester.describe(rule, { invalid: [ { code: ` -console.log(undefinedVar); +undefinedVar; `, snapshot: ` -console.log(undefinedVar); - ~~~~~~~~~~~~ - Variable 'undefinedVar' is used but was never defined. +undefinedVar; +~~~~~~~~~~~~ +Variable 'undefinedVar' is used but was never defined. `, }, { @@ -67,15 +67,15 @@ const result = first + second; }, ], valid: [ - `const value = 5; console.log(value);`, + `const value = 5; value;`, `function test(parameter: number) { return parameter; }`, `let count = 0; count++;`, - `const obj = { prop: 1 }; console.log(obj.prop);`, + `const obj = { prop: 1 }; obj.prop;`, `typeof undefinedVar === "undefined"`, `const obj = {}; const { prop } = obj;`, `function fn() { return 1; } fn();`, `class MyClass {} const instance = new MyClass();`, - `import { value } from "module"; console.log(value);`, - `const array = [1, 2, 3]; array.forEach(item => console.log(item));`, + `import { value } from "module"; value;`, + `const array = [1, 2, 3]; array.forEach(item => item);`, ], }); diff --git a/packages/typescript-language/package.json b/packages/typescript-language/package.json index 938adac60..d39993633 100644 --- a/packages/typescript-language/package.json +++ b/packages/typescript-language/package.json @@ -30,9 +30,7 @@ "dependencies": { "@flint.fyi/core": "workspace:^", "@flint.fyi/utils": "workspace:^", - "@typescript-eslint/project-service": "^8.46.2", - "@typescript/vfs": "^1.6.1", - "cached-factory": "^0.1.0", + "@typescript-eslint/project-service": "^8.53.0", "debug-for-file": "^0.2.0", "ts-api-utils": "^2.1.0", "typescript": "^5.9.0 || ^6.0.0" diff --git a/packages/typescript-language/src/createTypeScriptFileFromProjectService.ts b/packages/typescript-language/src/createTypeScriptFileFromProjectService.ts deleted file mode 100644 index c77816b8b..000000000 --- a/packages/typescript-language/src/createTypeScriptFileFromProjectService.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type * as ts from "typescript"; - -import type * as AST from "./types/ast.ts"; - -export function createTypeScriptFileFromProjectService( - filePathAbsolute: string, - program: ts.Program, - service: ts.server.ProjectService, -) { - const sourceFile = program.getSourceFile(filePathAbsolute); - if (!sourceFile) { - throw new Error(`Could not retrieve source file for: ${filePathAbsolute}`); - } - - return { - program, - sourceFile: sourceFile as AST.SourceFile, - [Symbol.dispose]() { - service.closeClientFile(filePathAbsolute); - }, - }; -} diff --git a/packages/typescript-language/src/createTypeScriptServerHost.ts b/packages/typescript-language/src/createTypeScriptServerHost.ts new file mode 100644 index 000000000..500b8e7ac --- /dev/null +++ b/packages/typescript-language/src/createTypeScriptServerHost.ts @@ -0,0 +1,152 @@ +import type { LinterHost } from "@flint.fyi/core"; +import { assert, FlintAssertionError } from "@flint.fyi/utils"; +import fs from "node:fs"; +import path from "node:path"; +import timers from "node:timers"; +import ts from "typescript"; + +function serverHostMethodNotImplemented(methodName: string): never { + throw new FlintAssertionError( + `ts.ServerHost's method '${methodName}' is not implemented.`, + ); +} + +// Internal API: https://github.com/nodejs/node/blob/7b7f693a98da060e19f2ec12fb99997d5d5524f9/deps/uv/include/uv.h#L1260-L1269 +const UV_DIRENT_TYPE = { + UV_DIRENT_DIR: 2, + UV_DIRENT_FILE: 1, +}; + +// Internal API: https://github.com/nodejs/node/blob/7b7f693a98da060e19f2ec12fb99997d5d5524f9/lib/internal/fs/utils.js#L160 +const DirentCtor = fs.Dirent as new ( + name: string, + type: number, + parentPath: string, +) => fs.Dirent; + +export function createTypeScriptServerHost( + host: LinterHost, +): ts.server.ServerHost { + return { + ...ts.sys, + args: [], + clearImmediate: timers.clearImmediate, + clearTimeout: timers.clearTimeout, + createDirectory() { + serverHostMethodNotImplemented("createDirectory"); + }, + directoryExists(directoryPath) { + return ( + host.stat(path.resolve(host.getCurrentDirectory(), directoryPath)) === + "directory" + ); + }, + exit() { + serverHostMethodNotImplemented("exit"); + }, + fileExists(filePath) { + return ( + host.stat(path.resolve(host.getCurrentDirectory(), filePath)) === "file" + ); + }, + readDirectory(directoryPath, extensions, exclude, include, depth) { + const originalCwd = process.cwd.bind(process); + process.cwd = () => host.getCurrentDirectory(); + const originalReadDirSync = fs.readdirSync; + // @ts-expect-error - TypeScript doesn't understand that the overloads do match up. + const patchedReaddirSync: typeof fs.readdirSync = (readPath, options) => { + assert( + typeof options === "object" && + options != null && + Object.keys(options).length === 1 && + options.withFileTypes === true, + `ts.sys.readDirectory passed unexpected options to fs.readdirSync: ${JSON.stringify(options)}`, + ); + assert( + typeof readPath === "string", + "ts.sys.readDirectory passed unexpected path to fs.readdirSync", + ); + try { + fs.readdirSync = originalReadDirSync; + return host + .readDirectory(path.resolve(host.getCurrentDirectory(), readPath)) + .map( + (dirent) => + new DirentCtor( + dirent.name, + dirent.type === "file" + ? UV_DIRENT_TYPE.UV_DIRENT_FILE + : UV_DIRENT_TYPE.UV_DIRENT_DIR, + readPath, + ), + ); + } finally { + fs.readdirSync = patchedReaddirSync; + } + }; + fs.readdirSync = patchedReaddirSync; + try { + return ts.sys.readDirectory( + directoryPath, + extensions, + exclude, + include, + depth, + ); + } finally { + process.cwd = originalCwd; + fs.readdirSync = originalReadDirSync; + } + }, + readFile(filePath) { + return host.readFile(path.resolve(host.getCurrentDirectory(), filePath)); + }, + setImmediate: timers.setImmediate, + setTimeout: timers.setTimeout, + watchDirectory(directoryPath, callback, recursive = false) { + const watcher = host.watchDirectory( + path.resolve(host.getCurrentDirectory(), directoryPath), + recursive, + (filePathAbsolute) => { + callback(filePathAbsolute); + }, + ); + return { + close() { + watcher[Symbol.dispose](); + }, + }; + }, + watchFile(filePath, callback) { + const watcher = host.watchFile( + path.resolve(host.getCurrentDirectory(), filePath), + (event) => { + let eventKind: ts.FileWatcherEventKind; + switch (event) { + case "changed": + eventKind = ts.FileWatcherEventKind.Changed; + break; + case "created": + eventKind = ts.FileWatcherEventKind.Created; + break; + case "deleted": + eventKind = ts.FileWatcherEventKind.Deleted; + break; + } + callback(filePath, eventKind); + }, + ); + return { + close() { + watcher[Symbol.dispose](); + }, + }; + }, + write() { + serverHostMethodNotImplemented("write"); + }, + writeFile() { + serverHostMethodNotImplemented("writeFile"); + }, + }; +} diff --git a/packages/typescript-language/src/index.ts b/packages/typescript-language/src/index.ts index 48846eef5..c458a78fd 100644 --- a/packages/typescript-language/src/index.ts +++ b/packages/typescript-language/src/index.ts @@ -11,13 +11,9 @@ export { getTSNodeRange } from "./getTSNodeRange.ts"; export * from "./language.ts"; export type * from "./nodes.ts"; export type { TypeScriptNodesByName } from "./nodes.ts"; -export { - prepareTypeScriptBasedLanguage, - type TypeScriptBasedLanguageFile, - type TypeScriptBasedLanguageFileFactoryDefinition, -} from "./prepareTypeScriptBasedLanguage.ts"; export type * as AST from "./types/ast.ts"; export type { Checker } from "./types/checker.ts"; +export { createRuleTesterTSConfig } from "./utils/createRuleTesterTSConfig.ts"; export { declarationIncludesGlobal } from "./utils/declarationIncludesGlobal.ts"; export { getDeclarationsIfGlobal } from "./utils/getDeclarationsIfGlobal.ts"; export { getModifyingReferences } from "./utils/getModifyingReferences.ts"; diff --git a/packages/typescript-language/src/language.ts b/packages/typescript-language/src/language.ts index 649734ed6..e0a6feb5a 100644 --- a/packages/typescript-language/src/language.ts +++ b/packages/typescript-language/src/language.ts @@ -1,9 +1,13 @@ -import { createLanguage } from "@flint.fyi/core"; +import { createLanguage, type FileAboutData } from "@flint.fyi/core"; +import { assert } from "@flint.fyi/utils"; +import { createProjectService } from "@typescript-eslint/project-service"; +import { debugForFile } from "debug-for-file"; import type * as ts from "typescript"; +import { createTypeScriptFileFromProgram } from "./createTypeScriptFileFromProgram.ts"; +import { createTypeScriptServerHost } from "./createTypeScriptServerHost.ts"; +import { parseDirectivesFromTypeScriptFile } from "./directives/parseDirectivesFromTypeScriptFile.ts"; import type { TypeScriptNodesByName } from "./nodes.ts"; -import { prepareTypeScriptBasedLanguage } from "./prepareTypeScriptBasedLanguage.ts"; -import { prepareTypeScriptFile } from "./prepareTypeScriptFile.ts"; import type * as AST from "./types/ast.ts"; import type { Checker } from "./types/checker.ts"; @@ -13,6 +17,8 @@ export interface TypeScriptFileServices { typeChecker: Checker; } +const log = debugForFile(import.meta.filename); + export const typescriptLanguage = createLanguage< TypeScriptNodesByName, TypeScriptFileServices @@ -20,21 +26,64 @@ export const typescriptLanguage = createLanguage< about: { name: "TypeScript", }, - createFileFactory: () => { - const language = prepareTypeScriptBasedLanguage(); + createFileFactory: (host) => { + const { service } = createProjectService({ + host: createTypeScriptServerHost(host), + }); + + function prepareFile(data: FileAboutData) { + log("Opening client file:", data.filePathAbsolute); + service.openClientFile(data.filePathAbsolute); + + log("Retrieving client services:", data.filePathAbsolute); + const scriptInfo = service.getScriptInfo(data.filePathAbsolute); + assert( + scriptInfo != null, + `Could not find script info for file: ${data.filePathAbsolute}`, + ); + + const defaultProject = service.getDefaultProjectForFile( + scriptInfo.fileName, + true, + ); + assert( + defaultProject != null, + `Could not find default project for file: ${data.filePathAbsolute}`, + ); + + const program = defaultProject.getLanguageService(true).getProgram(); + assert( + program != null, + `Could not retrieve program for file: ${data.filePathAbsolute}`, + ); + + const sourceFile = program.getSourceFile(data.filePathAbsolute); + assert( + sourceFile != null, + `Could not retrieve source file for: ${data.filePathAbsolute}`, + ); + + return { + ...parseDirectivesFromTypeScriptFile(sourceFile as AST.SourceFile), + file: { + [Symbol.dispose]() { + service.closeClientFile(data.filePathAbsolute); + }, + ...createTypeScriptFileFromProgram( + data, + program, + sourceFile as AST.SourceFile, + ), + }, + }; + } return { prepareFromDisk(data) { - return prepareTypeScriptFile( - data, - language.createFromDisk(data.filePathAbsolute), - ); + return prepareFile(data); }, prepareFromVirtual(data) { - return prepareTypeScriptFile( - data, - language.createFromVirtual(data.filePathAbsolute, data.sourceText), - ); + return prepareFile(data); }, }; }, diff --git a/packages/typescript-language/src/prepareTypeScriptBasedLanguage.ts b/packages/typescript-language/src/prepareTypeScriptBasedLanguage.ts deleted file mode 100644 index 07c15e8c4..000000000 --- a/packages/typescript-language/src/prepareTypeScriptBasedLanguage.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - createFSBackedSystem, - createVirtualTypeScriptEnvironment, -} from "@typescript/vfs"; -import { createProjectService } from "@typescript-eslint/project-service"; -import { CachedFactory } from "cached-factory"; -import { debugForFile } from "debug-for-file"; -import path from "node:path"; -import ts from "typescript"; - -import { createTypeScriptFileFromProjectService } from "./createTypeScriptFileFromProjectService.ts"; -import type * as AST from "./types/ast.ts"; - -const projectRoot = path.join(import.meta.dirname, "../.."); -const log = debugForFile(import.meta.filename); - -export interface TypeScriptBasedLanguageFile extends Partial { - program: ts.Program; - sourceFile: AST.SourceFile; -} - -export interface TypeScriptBasedLanguageFileFactoryDefinition { - createFromDisk(filePathAbsolute: string): TypeScriptBasedLanguageFile; - createFromVirtual( - filePathAbsolute: string, - sourceText: string, - ): TypeScriptBasedLanguageFile; -} - -export function prepareTypeScriptBasedLanguage(): TypeScriptBasedLanguageFileFactoryDefinition { - const { service } = createProjectService(); - const seenPrograms = new Set(); - - const environments = new CachedFactory((filePathAbsolute: string) => { - const system = createFSBackedSystem( - new Map([[filePathAbsolute, "// ..."]]), - projectRoot, - ts, - ); - - return createVirtualTypeScriptEnvironment(system, [filePathAbsolute], ts, { - skipLibCheck: true, - target: ts.ScriptTarget.ESNext, - }); - }); - - const servicePrograms = new CachedFactory((filePathAbsolute: string) => { - log("Opening client file:", filePathAbsolute); - service.openClientFile(filePathAbsolute); - - log("Retrieving client services:", filePathAbsolute); - const scriptInfo = service.getScriptInfo(filePathAbsolute); - if (!scriptInfo) { - throw new Error( - `Could not find script info for file: ${filePathAbsolute}`, - ); - } - - const defaultProject = service.getDefaultProjectForFile( - scriptInfo.fileName, - true, - ); - if (!defaultProject) { - throw new Error( - `Could not find default project for file: ${filePathAbsolute}`, - ); - } - - const program = defaultProject.getLanguageService(true).getProgram(); - if (!program) { - throw new Error( - `Could not retrieve program for file: ${filePathAbsolute}`, - ); - } - - return program; - }); - - return { - createFromDisk: (filePathAbsolute) => { - const program = servicePrograms.get(filePathAbsolute); - - seenPrograms.add(program); - - return createTypeScriptFileFromProjectService( - filePathAbsolute, - program, - service, - ); - }, - createFromVirtual: (filePathAbsolute, sourceText) => { - const environment = environments.get(filePathAbsolute); - environment.updateFile(filePathAbsolute, sourceText); - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - const sourceFile = environment.getSourceFile( - filePathAbsolute, - )! as AST.SourceFile; - const program = environment.languageService.getProgram()!; - /* eslint-enable @typescript-eslint/no-non-null-assertion */ - - seenPrograms.add(program); - - return { - program, - sourceFile, - }; - }, - }; -} diff --git a/packages/typescript-language/src/prepareTypeScriptFile.ts b/packages/typescript-language/src/prepareTypeScriptFile.ts deleted file mode 100644 index 4c561cceb..000000000 --- a/packages/typescript-language/src/prepareTypeScriptFile.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { FileAboutData } from "@flint.fyi/core"; - -import { createTypeScriptFileFromProgram } from "./createTypeScriptFileFromProgram.ts"; -import { parseDirectivesFromTypeScriptFile } from "./directives/parseDirectivesFromTypeScriptFile.ts"; -import type { TypeScriptBasedLanguageFile } from "./prepareTypeScriptBasedLanguage.ts"; - -export function prepareTypeScriptFile( - data: FileAboutData, - file: TypeScriptBasedLanguageFile, -) { - const { program, sourceFile, [Symbol.dispose]: onDispose } = file; - return { - ...parseDirectivesFromTypeScriptFile(sourceFile), - file: { - ...(onDispose != null && { [Symbol.dispose]: onDispose }), - ...createTypeScriptFileFromProgram(data, program, sourceFile), - }, - }; -} diff --git a/packages/typescript-language/src/utils/createRuleTesterTSConfig.ts b/packages/typescript-language/src/utils/createRuleTesterTSConfig.ts new file mode 100644 index 000000000..8d16218d6 --- /dev/null +++ b/packages/typescript-language/src/utils/createRuleTesterTSConfig.ts @@ -0,0 +1,25 @@ +export function createRuleTesterTSConfig( + defaultCompilerOptions?: Record, +) { + return { + "tsconfig.base.json": JSON.stringify( + { + compilerOptions: { + lib: ["esnext"], + moduleResolution: "bundler", + strict: true, + target: "esnext", + types: [], + ...defaultCompilerOptions, + }, + }, + null, + 2, + ), + "tsconfig.json": JSON.stringify( + { extends: "./tsconfig.base.json" }, + null, + 2, + ), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44308739e..2a33a17a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -755,14 +755,8 @@ importers: specifier: workspace:^ version: link:../utils '@typescript-eslint/project-service': - specifier: ^8.46.2 - version: 8.50.1(typescript@5.9.3) - '@typescript/vfs': - specifier: ^1.6.1 - version: 1.6.1(typescript@5.9.3) - cached-factory: - specifier: ^0.1.0 - version: 0.1.0 + specifier: ^8.53.0 + version: 8.53.0(typescript@5.9.3) debug-for-file: specifier: ^0.2.0 version: 0.2.0 @@ -2283,6 +2277,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.53.0': + resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/scope-manager@8.50.0': resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2303,6 +2303,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.53.0': + resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.50.0': resolution: {integrity: sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2318,6 +2324,10 @@ packages: resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.53.0': + resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.50.0': resolution: {integrity: sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6117,7 +6127,7 @@ snapshots: '@es-joy/jsdoccomment@0.76.0': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/types': 8.53.0 comment-parser: 1.4.1 esquery: 1.6.0 jsdoc-type-pratt-parser: 6.10.0 @@ -6934,8 +6944,8 @@ snapshots: '@typescript-eslint/project-service@8.50.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) + '@typescript-eslint/types': 8.53.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -6943,8 +6953,17 @@ snapshots: '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) + '@typescript-eslint/types': 8.53.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.53.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) + '@typescript-eslint/types': 8.53.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -6968,6 +6987,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@typescript-eslint/type-utils@8.50.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.50.0 @@ -6984,6 +7007,8 @@ snapshots: '@typescript-eslint/types@8.50.1': {} + '@typescript-eslint/types@8.53.0': {} + '@typescript-eslint/typescript-estree@8.50.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.50.0(typescript@5.9.3)