diff --git a/.vscode/launch.json b/.vscode/launch.json index 57134c35e..70d958ac1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "request": "launch", "name": "Run Current File Tests", "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", - "args": ["${fileBasenameNoExtension}"], + "args": ["${fileDirname}/${fileBasenameNoExtension}"], "sourceMaps": true, "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" diff --git a/packages/astro-language/package.json b/packages/astro-language/package.json new file mode 100644 index 000000000..985c72fea --- /dev/null +++ b/packages/astro-language/package.json @@ -0,0 +1,39 @@ +{ + "name": "@flint.fyi/astro-language", + "version": "0.0.1", + "description": "[Experimental] TypeScript language plugin for Flint.", + "repository": { + "type": "git", + "url": "https://github.com/JoshuaKGoldberg/flint", + "directory": "packages/ts" + }, + "license": "MIT", + "author": { + "name": "JoshuaKGoldberg", + "email": "npm@joshuakgoldberg.com" + }, + "type": "module", + "exports": { + ".": "./lib/index.js" + }, + "dependencies": { + "@astrojs/compiler": "^2.13.0", + "@astrojs/ts-plugin": "^1.10.6", + "@flint.fyi/core": "workspace:", + "@flint.fyi/ts": "workspace:", + "@flint.fyi/ts-patch": "workspace:", + "@flint.fyi/volar-language": "workspace:", + "ts-api-utils": "^2.1.0", + "typescript": ">=5.9.3" + }, + "devDependencies": { + "@flint.fyi/rule-tester": "workspace:" + }, + "engines": { + "node": ">=24.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/astro-language/src/language.ts b/packages/astro-language/src/language.ts new file mode 100644 index 000000000..c600ca06e --- /dev/null +++ b/packages/astro-language/src/language.ts @@ -0,0 +1,50 @@ +import { getLanguagePlugin } from "@astrojs/ts-plugin/dist/language.js"; +import { parse } from "@astrojs/compiler/sync"; +import { RootNode } from "@astrojs/compiler/types"; + +import { RuleReporter } from "@flint.fyi/core"; + +import { setTSExtraSupportedExtensions } from "@flint.fyi/ts-patch"; +import { createVolarBasedLanguage } from "@flint.fyi/volar-language"; + +setTSExtraSupportedExtensions([".astro"]); + +export interface AstroServices { + astroServices?: { + ast: RootNode; + reportComponent: RuleReporter; + }; +} + +export const astroLanguage = createVolarBasedLanguage( + (ts, options) => { + return { + languagePlugins: getLanguagePlugin(), + prepareFile( + filePathAbsolute, + { program, sourceFile }, + volarLanguage, + sourceScript, + ) { + const sourceText = sourceScript.snapshot.getText( + 0, + sourceScript.snapshot.getLength(), + ); + // TODO: report parsing errors? + const { ast } = parse(sourceText, { position: true }); + return { + // TODO: first statement + firstStatementPosition: sourceText.length, + extraContext(reportTranslated) { + return { + astroServices: { + ast, + reportComponent: reportTranslated, + }, + }; + }, + }; + }, + }; + }, +); diff --git a/packages/astro-language/src/rules/anyReturns.test.ts b/packages/astro-language/src/rules/anyReturns.test.ts new file mode 100644 index 000000000..a3d9afa8b --- /dev/null +++ b/packages/astro-language/src/rules/anyReturns.test.ts @@ -0,0 +1,53 @@ +import rule from "../../../ts/lib/rules/anyReturns.js"; +import { astroLanguage } from "../language.js"; +import { ruleTester } from "./ruleTester.js"; + +ruleTester.describe(astroLanguage.createRule(rule), { + invalid: [ + { + code: ` +--- +function foo() { + return 1 as any +} +--- + `, + snapshot: ` +--- +function foo() { + return 1 as any + ~~~~~~~~~~~~~~~ + Unsafe return of a value of type \`any\`. +} +--- + `, + }, + { + code: ` +{ +function foo() { + return 1 as any +} +} + `, + snapshot: ` +{ +function foo() { + return 1 as any + ~~~~~~~~~~~~~~~ + Unsafe return of a value of type \`any\`. +} +} + `, + }, + ], + valid: [ + ` +--- +function foo() { + return 1 +} +--- + `, + ], +}); diff --git a/packages/astro-language/src/rules/ruleTester.ts b/packages/astro-language/src/rules/ruleTester.ts new file mode 100644 index 000000000..c5d374870 --- /dev/null +++ b/packages/astro-language/src/rules/ruleTester.ts @@ -0,0 +1,11 @@ +import { createRuleTesterTSHost, RuleTester } from "@flint.fyi/rule-tester"; +import { describe, it } from "vitest"; + +export const ruleTester = new RuleTester({ + describe, + it, + defaults: { + fileName: "file.astro", + }, + host: createRuleTesterTSHost(import.meta.dirname), +}); diff --git a/packages/astro-language/src/rules/setHtmlDirectives.test.ts b/packages/astro-language/src/rules/setHtmlDirectives.test.ts new file mode 100644 index 000000000..41adb5d00 --- /dev/null +++ b/packages/astro-language/src/rules/setHtmlDirectives.test.ts @@ -0,0 +1,57 @@ +import rule from "./setHtmlDirectives.js"; +import { ruleTester } from "./ruleTester.js"; + +ruleTester.describe(rule, { + invalid: [ + { + code: ` +--- +let string = 'this string contains some HTML!!!' +--- + +

+ `, + snapshot: ` +--- +let string = 'this string contains some HTML!!!' +--- + +

+ ~~~~~~~~ + TODO: don't use set:html to reduce XSS risk + `, + }, + { + code: ` +
+

HTML!!!\`>

+
+ `, + snapshot: ` +
+

HTML!!!\`>

+ ~~~~~~~~ + TODO: don't use set:html to reduce XSS risk +
+ `, + }, + ], + valid: [ + ` +--- +let string = 'this string contains some HTML!!!' +--- + +

+ `, + "

HTML!!!`>

", + ` +--- +let string = 'this string contains some HTML!!!' +--- + +

{string}

+ `, + "

{`this string contains some HTML!!!`}

", + ], +}); diff --git a/packages/astro-language/src/rules/setHtmlDirectives.ts b/packages/astro-language/src/rules/setHtmlDirectives.ts new file mode 100644 index 000000000..10dfe634e --- /dev/null +++ b/packages/astro-language/src/rules/setHtmlDirectives.ts @@ -0,0 +1,42 @@ +import { astroLanguage } from "../language.js"; + +export default astroLanguage.createRule({ + about: { + description: "TODO", + id: "setHtmlDirectives", + preset: "logical", + }, + messages: { + setHtml: { + primary: `TODO: don't use set:html to reduce XSS risk`, + secondary: ["TODO"], + suggestions: ["TODO"], + }, + }, + setup(context) { + const { astroServices } = context; + if (astroServices == null) { + return undefined; + } + astroServices.ast.children.forEach(function visit(node) { + if ("attributes" in node) { + for (const attr of node.attributes) { + if (attr.name === "set:html") { + const begin = attr.position!.start.offset; + astroServices.reportComponent({ + message: "setHtml", + range: { + begin, + end: begin + attr.name.length, + }, + }); + } + } + } + if ("children" in node) { + node.children.forEach(visit); + } + }); + return undefined; + }, +}); diff --git a/packages/astro-language/tsconfig.json b/packages/astro-language/tsconfig.json new file mode 100644 index 000000000..16522fb6a --- /dev/null +++ b/packages/astro-language/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "extends": "../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../core" }, + { "path": "../ts" }, + { "path": "../rule-tester" }, + { "path": "../ts-patch" }, + { "path": "../volar-language" } + ] +} diff --git a/packages/core/src/host/createFSBackedLinterHost.ts b/packages/core/src/host/createFSBackedLinterHost.ts new file mode 100644 index 000000000..4662cffd5 --- /dev/null +++ b/packages/core/src/host/createFSBackedLinterHost.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { LinterHost, LinterHostDirectoryEntry } from "../types/host.js"; +import { normalizePath } from "./normalizePath.js"; + +export function createFSBackedLinterHost(cwd: string): LinterHost { + cwd = normalizePath(cwd); + + return { + getCurrentDirectory() { + return cwd; + }, + stat(pathAbsolute) { + try { + const stat = fs.statSync(pathAbsolute); + if (stat.isDirectory()) { + return "directory"; + } + if (stat.isFile()) { + return "file"; + } + } catch {} + return undefined; + }, + readDirectory(directoryPathAbsolute) { + const result: LinterHostDirectoryEntry[] = []; + const dirents = fs.readdirSync(directoryPathAbsolute, { + withFileTypes: true, + }); + + for (let entry of dirents) { + let stat = entry as Pick; + if (entry.isSymbolicLink()) { + try { + stat = fs.statSync(path.join(directoryPathAbsolute, entry.name)); + } catch { + continue; + } + } + if (stat.isDirectory()) { + result.push({ type: "directory", name: entry.name }); + } + if (stat.isFile()) { + result.push({ type: "file", name: entry.name }); + } + } + + return result; + }, + readFile(filePathAbsolute) { + return fs.readFileSync(filePathAbsolute, "utf8"); + }, + // TODO + watchFile(filePathAbsolute, callback) { + return { + [Symbol.dispose]() {}, + }; + }, + watchDirectory(directoryPathAbsolute, recursive, callback) { + return { + [Symbol.dispose]() {}, + }; + }, + }; +} diff --git a/packages/core/src/host/createVFSLinterHost.ts b/packages/core/src/host/createVFSLinterHost.ts new file mode 100644 index 000000000..1f980ef25 --- /dev/null +++ b/packages/core/src/host/createVFSLinterHost.ts @@ -0,0 +1,169 @@ +import path from "node:path"; +import { + LinterHost, + LinterHostDirectoryEntry, + LinterHostDirectoryWatcher, + LinterHostFileWatcher, + LinterHostFileWatcherEvent, + VFSLinterHost, +} from "../types/host.js"; +import { normalizePath } from "./normalizePath.js"; + +export function createVFSLinterHost( + cwd: string, + baseHost?: LinterHost, +): VFSLinterHost { + cwd = normalizePath(cwd); + const fileMap = new Map(); + const fileWatchers = new Map>(); + const directoryWatchers = new Map>(); + const recursiveDirectoryWatchers = new Map< + string, + Set + >(); + function watchEvent( + normalizedFilePathAbsolute: string, + fileEvent: LinterHostFileWatcherEvent, + ) { + for (const watcher of fileWatchers.get(normalizedFilePathAbsolute) ?? []) { + watcher(fileEvent); + } + let slashIdx = normalizedFilePathAbsolute.lastIndexOf("/"); + if (slashIdx < 0) { + return; + } + let directoryPathAbsolute = normalizedFilePathAbsolute.slice(0, slashIdx); + for (const watcher of directoryWatchers.get(directoryPathAbsolute) ?? []) { + watcher(normalizedFilePathAbsolute); + } + do { + directoryPathAbsolute = directoryPathAbsolute.slice(0, slashIdx); + for (const watcher of recursiveDirectoryWatchers.get( + directoryPathAbsolute, + ) ?? []) { + watcher(normalizedFilePathAbsolute); + } + } while ((slashIdx = directoryPathAbsolute.lastIndexOf("/")) >= 0); + } + return { + getCurrentDirectory() { + return cwd; + }, + stat(pathAbsolute) { + pathAbsolute = normalizePath(pathAbsolute); + for (const filePath of fileMap.keys()) { + if (pathAbsolute === filePath) { + return "file"; + } + if (filePath.startsWith(pathAbsolute + "/")) { + return "directory"; + } + } + return baseHost?.stat(pathAbsolute); + }, + readFile(filePathAbsolute) { + filePathAbsolute = normalizePath(filePathAbsolute); + const file = fileMap.get(filePathAbsolute); + if (file != null) { + return file; + } + if (baseHost?.stat(filePathAbsolute) === "file") { + return baseHost.readFile(filePathAbsolute); + } + return undefined; + }, + readDirectory(directoryPathAbsolute) { + directoryPathAbsolute = normalizePath(directoryPathAbsolute) + "/"; + const result = new Map(); + + for (let filePath of fileMap.keys()) { + if (!filePath.startsWith(directoryPathAbsolute)) { + continue; + } + filePath = filePath.slice(directoryPathAbsolute.length); + const slashIndex = filePath.indexOf("/"); + let dirent: LinterHostDirectoryEntry = { + type: "file", + name: filePath, + }; + if (slashIndex >= 0) { + dirent = { + type: "directory", + name: filePath.slice(0, slashIndex), + }; + } + if (!result.get(dirent.name)) { + result.set(dirent.name, dirent); + } + } + + return [ + ...result.values(), + ...((baseHost?.stat(directoryPathAbsolute) === "directory" && + baseHost.readDirectory(directoryPathAbsolute)) || + []), + ]; + }, + watchFile(filePathAbsolute, callback) { + filePathAbsolute = normalizePath(filePathAbsolute); + let watchers = fileWatchers.get(filePathAbsolute); + if (watchers == null) { + watchers = new Set(); + fileWatchers.set(filePathAbsolute, watchers); + } + watchers.add(callback); + const baseWatcher = baseHost?.watchFile(filePathAbsolute, callback); + return { + [Symbol.dispose]() { + watchers.delete(callback); + if (watchers.size === 0) { + fileWatchers.delete(filePathAbsolute); + } + baseWatcher?.[Symbol.dispose](); + }, + }; + }, + watchDirectory(directoryPathAbsolute, recursive, callback) { + directoryPathAbsolute = normalizePath(directoryPathAbsolute); + const collection = recursive + ? recursiveDirectoryWatchers + : directoryWatchers; + let watchers = collection.get(directoryPathAbsolute); + if (watchers == null) { + watchers = new Set(); + collection.set(directoryPathAbsolute, watchers); + } + watchers.add(callback); + const baseWatcher = baseHost?.watchDirectory( + directoryPathAbsolute, + recursive, + callback, + ); + return { + [Symbol.dispose]() { + watchers.delete(callback); + if (watchers.size === 0) { + collection.delete(directoryPathAbsolute); + } + baseWatcher?.[Symbol.dispose](); + }, + }; + }, + vfsUpsertFile(filePathAbsolute, content) { + filePathAbsolute = normalizePath(filePathAbsolute); + const fileEvent = fileMap.has(filePathAbsolute) ? "changed" : "created"; + fileMap.set(filePathAbsolute, content); + watchEvent(filePathAbsolute, fileEvent); + }, + vfsDeleteFile(filePathAbsolute) { + filePathAbsolute = normalizePath(filePathAbsolute); + if (!fileMap.delete(filePathAbsolute)) { + return; + } + watchEvent(filePathAbsolute, "deleted"); + }, + vfsListFiles() { + return fileMap; + }, + }; +} diff --git a/packages/core/src/host/normalizePath.ts b/packages/core/src/host/normalizePath.ts new file mode 100644 index 000000000..7fe823659 --- /dev/null +++ b/packages/core/src/host/normalizePath.ts @@ -0,0 +1,5 @@ +import { normalize } from "node:path"; + +export function normalizePath(path: string): string { + return normalize(path).replaceAll("\\", "/"); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db11ef5a8..180955882 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,12 +12,18 @@ export { createPlugin } from "./plugins/createPlugin.js"; export { formatReportPrimary } from "./reporting/formatReportPrimary.js"; export { lintFixing } from "./running/lintFixing.js"; export { lintOnce } from "./running/lintOnce.js"; +export { createVFSLinterHost } from "./host/createVFSLinterHost.js"; +export { createFSBackedLinterHost } from "./host/createFSBackedLinterHost.js"; +export { normalizePath } from "./host/normalizePath.js"; export * from "./types/about.js"; +export * from "./types/arrays.js"; export * from "./types/cache.js"; export * from "./types/changes.js"; export * from "./types/configs.js"; +export * from "./types/context.js"; export * from "./types/directives.js"; export * from "./types/formatting.js"; +export * from "./types/host.js"; export * from "./types/languages.js"; export * from "./types/linting.js"; export * from "./types/modes.js"; @@ -26,6 +32,6 @@ export * from "./types/ranges.js"; export * from "./types/reports.js"; export * from "./types/rules.js"; export * from "./types/shapes.js"; -export { binarySearch } from "./utils/arrays.js"; +export { binarySearch, flatten } from "./utils/arrays.js"; export * from "./utils/getColumnAndLineOfPosition.js"; export * from "./utils/predicates.js"; diff --git a/packages/core/src/languages/createLanguage.ts b/packages/core/src/languages/createLanguage.ts index fdeffb113..f9616c71e 100644 --- a/packages/core/src/languages/createLanguage.ts +++ b/packages/core/src/languages/createLanguage.ts @@ -23,29 +23,20 @@ export function createLanguage( }; }) as CreateRule, - prepare() { + prepare(host) { log( "Preparing file factory for language: %s", languageDefinition.about.name, ); - const fileFactoryDefinition = languageDefinition.prepare(); + const fileFactoryDefinition = languageDefinition.prepare(host); log("Prepared file factory."); const fileFactory = makeDisposable({ ...fileFactoryDefinition, - prepareFromDisk: (filePathAbsolute: string) => { - const { file, ...rest } = - fileFactoryDefinition.prepareFromDisk(filePathAbsolute); - - return { - file: makeDisposable(file), - ...rest, - }; - }, - prepareFromVirtual: (filePathAbsolute: string, sourceText: string) => { - const { file, ...rest } = fileFactoryDefinition.prepareFromVirtual( + prepareFile: (filePathAbsolute: string, sourceText: string) => { + const { file, ...rest } = fileFactoryDefinition.prepareFile( filePathAbsolute, sourceText, ); diff --git a/packages/core/src/languages/makeDisposable.ts b/packages/core/src/languages/makeDisposable.ts index 52a06686b..5ef2d7a31 100644 --- a/packages/core/src/languages/makeDisposable.ts +++ b/packages/core/src/languages/makeDisposable.ts @@ -1,10 +1,10 @@ export function makeDisposable(obj: T): Disposable & T { return { - ...obj, [Symbol.dispose]: () => // Intentionally empty to satisfy the Disposable interface. // eslint-disable-next-line @typescript-eslint/no-empty-function () => {}, + ...obj, }; } diff --git a/packages/core/src/running/lintFile.ts b/packages/core/src/running/lintFile.ts index 9841e7d41..49c960f2d 100644 --- a/packages/core/src/running/lintFile.ts +++ b/packages/core/src/running/lintFile.ts @@ -28,7 +28,7 @@ export async function lintFile( const reports: FileReport[] = []; const languageFiles = new CachedFactory((language: AnyLanguage) => - languageFactories.get(language).prepareFromDisk(filePathAbsolute), + languageFactories.get(language).prepareFile(filePathAbsolute), ); const rulesWithOptions = computeRulesWithOptions(filePath, useDefinitions); diff --git a/packages/core/src/types/host.ts b/packages/core/src/types/host.ts new file mode 100644 index 000000000..5ef7f0374 --- /dev/null +++ b/packages/core/src/types/host.ts @@ -0,0 +1,31 @@ +export interface LinterHostDirectoryEntry { + type: "file" | "directory"; + name: string; +} + +export type LinterHostFileWatcherEvent = "created" | "changed" | "deleted"; +export type LinterHostFileWatcher = (event: LinterHostFileWatcherEvent) => void; + +export type LinterHostDirectoryWatcher = (filePathAbsolute: string) => void; + +export interface LinterHost { + getCurrentDirectory(): string; + stat(pathAbsolute: string): "file" | "directory" | undefined; + readFile(filePathAbsolute: string): string | undefined; + readDirectory(directoryPathAbsolute: string): LinterHostDirectoryEntry[]; + watchFile( + filePathAbsolute: string, + callback: LinterHostFileWatcher, + ): Disposable; + watchDirectory( + directoryPathAbsolute: string, + recursive: boolean, + callback: LinterHostDirectoryWatcher, + ): Disposable; +} + +export interface VFSLinterHost extends LinterHost { + vfsUpsertFile(filePathAbsolute: string, content: string): void; + vfsDeleteFile(filePathAbsolute: string): void; + vfsListFiles(): ReadonlyMap; +} diff --git a/packages/core/src/types/languages.ts b/packages/core/src/types/languages.ts index 031101287..b93d6ad30 100644 --- a/packages/core/src/types/languages.ts +++ b/packages/core/src/types/languages.ts @@ -1,6 +1,7 @@ import type { PromiseOrSync } from "@flint.fyi/utils"; import { CommentDirective } from "./directives.js"; +import { LinterHost } from "./host.js"; import { FileReport, NormalizedReport } from "./reports.js"; import { AnyRule, @@ -44,7 +45,6 @@ export interface Language< ContextServices extends object, > extends LanguageDefinition { createRule: CreateRule; - prepare(): LanguageFileFactory; } export interface LanguageAbout { @@ -63,7 +63,7 @@ export interface LanguageFileDiagnostic { */ export interface LanguageDefinition { about: LanguageAbout; - prepare(): LanguageFileFactoryDefinition; + prepare(host: LinterHost): LanguageFileFactoryDefinition; } export interface LanguageFileCacheImpacts { @@ -106,11 +106,7 @@ export interface LanguageFileDefinition extends Partial { * Creates wrappers around files to be linted. */ export interface LanguageFileFactory extends Disposable { - prepareFromDisk(filePathAbsolute: string): LanguagePrepared; - prepareFromVirtual( - filePathAbsolute: string, - sourceText: string, - ): LanguagePrepared; + prepareFile(filePathAbsolute: string, sourceText: string): LanguagePrepared; } /** @@ -126,8 +122,7 @@ export interface LanguagePrepared { * Internal definition of how to create wrappers around files to be linted. */ export interface LanguageFileFactoryDefinition extends Partial { - prepareFromDisk(filePathAbsolute: string): LanguagePreparedDefinition; - prepareFromVirtual( + prepareFile( filePathAbsolute: string, sourceText: string, ): LanguagePreparedDefinition; diff --git a/packages/json/src/language.ts b/packages/json/src/language.ts index 6b0284a0c..0b9db62b9 100644 --- a/packages/json/src/language.ts +++ b/packages/json/src/language.ts @@ -1,5 +1,4 @@ import { createLanguage } from "@flint.fyi/core"; -import fsSync from "node:fs"; import * as ts from "typescript"; import { createTypeScriptJsonFile } from "./createJsonFile.js"; @@ -16,15 +15,7 @@ export const jsonLanguage = createLanguage({ }, prepare: () => { return { - prepareFromDisk: (filePathAbsolute) => { - return { - file: createTypeScriptJsonFile( - filePathAbsolute, - fsSync.readFileSync(filePathAbsolute, "utf8"), - ), - }; - }, - prepareFromVirtual: (filePathAbsolute, sourceText) => { + prepareFile: (filePathAbsolute, sourceText) => { return { file: createTypeScriptJsonFile(filePathAbsolute, sourceText), }; diff --git a/packages/md/src/language.ts b/packages/md/src/language.ts index cee52cc71..d3dc2fc36 100644 --- a/packages/md/src/language.ts +++ b/packages/md/src/language.ts @@ -1,7 +1,6 @@ import type * as mdast from "mdast"; import { createLanguage } from "@flint.fyi/core"; -import fsSync from "node:fs"; import { createMarkdownFile } from "./createMarkdownFile.js"; import { MarkdownNodesByName, WithPosition } from "./nodes.js"; @@ -20,13 +19,7 @@ export const markdownLanguage = createLanguage< }, prepare: () => { return { - prepareFromDisk: (filePathAbsolute) => { - const sourceText = fsSync.readFileSync(filePathAbsolute, "utf8"); - const { languageFile, root } = createMarkdownFile(sourceText); - - return prepareMarkdownFile(languageFile, root, sourceText); - }, - prepareFromVirtual: (filePathAbsolute, sourceText) => { + prepareFile: (filePathAbsolute, sourceText) => { const { languageFile, root } = createMarkdownFile(sourceText); return prepareMarkdownFile(languageFile, root, sourceText); diff --git a/packages/plugin-browser/src/rules/ruleTester.ts b/packages/plugin-browser/src/rules/ruleTester.ts index 14a239398..1096e1c4d 100644 --- a/packages/plugin-browser/src/rules/ruleTester.ts +++ b/packages/plugin-browser/src/rules/ruleTester.ts @@ -1,8 +1,13 @@ -import { RuleTester } from "@flint.fyi/rule-tester"; +import { createRuleTesterTSHost, RuleTester } from "@flint.fyi/rule-tester"; import { describe, it } from "vitest"; export const ruleTester = new RuleTester({ defaults: { fileName: "file.ts" }, describe, it, + host: createRuleTesterTSHost(import.meta.dirname, { + defaultCompilerOptions: { + lib: ["esnext", "DOM"], + }, + }), }); diff --git a/packages/plugin-node/src/rules/blobReadingMethods.ts b/packages/plugin-node/src/rules/blobReadingMethods.ts index 68eccc8c7..13302fcd4 100644 --- a/packages/plugin-node/src/rules/blobReadingMethods.ts +++ b/packages/plugin-node/src/rules/blobReadingMethods.ts @@ -52,7 +52,7 @@ export default typescriptLanguage.createRule({ const methodName = node.expression.name.text; if ( !blobReadingMethods.has(methodName) || - !isGlobalDeclaration(node.expression.name, typeChecker) + !isGlobalDeclaration(receiver.expression, typeChecker) ) { return; } diff --git a/packages/plugin-node/src/rules/ruleTester.ts b/packages/plugin-node/src/rules/ruleTester.ts index 14a239398..461a9c4ce 100644 --- a/packages/plugin-node/src/rules/ruleTester.ts +++ b/packages/plugin-node/src/rules/ruleTester.ts @@ -1,8 +1,13 @@ -import { RuleTester } from "@flint.fyi/rule-tester"; +import { createRuleTesterTSHost, RuleTester } from "@flint.fyi/rule-tester"; import { describe, it } from "vitest"; export const ruleTester = new RuleTester({ defaults: { fileName: "file.ts" }, describe, it, + host: createRuleTesterTSHost(import.meta.dirname, { + defaultCompilerOptions: { + types: ["node"], + }, + }), }); diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index d6e03eff0..af4232a33 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -1,19 +1,22 @@ +import path from "node:path"; import { AnyLanguage, AnyOptionalSchema, AnyRule, + createVFSLinterHost, InferredObject, LanguageFileFactory, + LinterHost, RuleAbout, + VFSLinterHost, } from "@flint.fyi/core"; import { CachedFactory } from "cached-factory"; import assert from "node:assert/strict"; import { createReportSnapshot } from "./createReportSnapshot.js"; -import { normalizeTestCase } from "./normalizeTestCase.js"; +import { normalizeTestCase, TestCaseNormalized } from "./normalizeTestCase.js"; import { resolveReportedSuggestions } from "./resolveReportedSuggestions.js"; -import { runTestCaseRule } from "./runTestCaseRule.js"; -import { InvalidTestCase, TestCase, ValidTestCase } from "./types.js"; +import { InvalidTestCase, ValidTestCase } from "./types.js"; export interface RuleTesterOptions { defaults?: { @@ -24,6 +27,7 @@ export interface RuleTesterOptions { only?: TesterSetupIt; scope?: Record; skip?: TesterSetupIt; + host?: LinterHost | undefined; } export interface TestCases { @@ -41,9 +45,17 @@ export type TesterSetupIt = ( setup: () => Promise, ) => void; +interface TestCaseRuleConfiguration< + OptionsSchema extends AnyOptionalSchema | undefined, +> { + options?: InferredObject; + rule: AnyRule; +} + export class RuleTester { #fileFactories: CachedFactory; - #testerOptions: Required; + #linterHost: VFSLinterHost; + #testerOptions: Required>; constructor({ defaults, @@ -52,9 +64,15 @@ export class RuleTester { only, scope = globalThis, skip, - }: RuleTesterOptions = {}) { + host, + }: RuleTesterOptions) { + this.#linterHost = createVFSLinterHost( + host?.getCurrentDirectory() ?? process.cwd(), + host, + ); + this.#fileFactories = new CachedFactory((language: AnyLanguage) => - language.prepare(), + language.prepare(this.#linterHost), ); it = defaultTo(it, scope, "it"); @@ -101,6 +119,37 @@ export class RuleTester { }); } + #runTestCaseRule( + { options, rule }: Required>, + { code, fileName, files }: TestCaseNormalized, + ) { + const preparedLanguage = this.#fileFactories + // TODO: How to make types more permissive around assignability? + // See AnyRule's any + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + .get(rule.language); + for (const oldFile of this.#linterHost.vfsListFiles().keys()) { + this.#linterHost.vfsDeleteFile(oldFile); + } + for (const [name, content] of Object.entries({ + ...files, + [fileName]: code, + })) { + const filePath = path.resolve( + this.#linterHost.getCurrentDirectory(), + name, + ); + this.#linterHost.vfsUpsertFile(filePath, content); + } + const filePath = path.resolve( + this.#linterHost.getCurrentDirectory(), + fileName, + ); + using file = preparedLanguage.prepareFile(filePath, code).file; + + return file.runRule(rule, options as InferredObject); + } + #itInvalidCase( rule: AnyRule, testCase: InvalidTestCase>, @@ -111,8 +160,7 @@ export class RuleTester { ); this.#itTestCase(testCaseNormalized, async () => { - const reports = await runTestCaseRule( - this.#fileFactories, + const reports = await this.#runTestCaseRule( { // TODO: Figure out a way around the type assertion... options: testCase.options ?? ({} as InferredObject), @@ -132,7 +180,7 @@ export class RuleTester { }); } - #itTestCase(testCase: TestCase, setup: () => Promise) { + #itTestCase(testCase: TestCaseNormalized, setup: () => Promise) { let test = testCase.only ? this.#testerOptions.only : this.#testerOptions.it; @@ -145,7 +193,25 @@ export class RuleTester { } } - test(testCase.code, setup); + test( + "files" in testCase + ? JSON.stringify( + { [testCase.fileName]: testCase.code, ...testCase.files }, + null, + 2, + ) + : testCase.code, + () => { + if (testCase.files != null) { + assert.notEqual( + Object.keys(testCase.files), + 0, + '"files" must have at least one file', + ); + } + return setup(); + }, + ); } #itValidCase( @@ -160,8 +226,7 @@ export class RuleTester { ); this.#itTestCase(testCaseNormalized, async () => { - const reports = await runTestCaseRule( - this.#fileFactories, + const reports = await this.#runTestCaseRule( { // TODO: Figure out a way around the type assertion... options: (testCase.options ?? {}) as InferredObject, diff --git a/packages/rule-tester/src/host.ts b/packages/rule-tester/src/host.ts new file mode 100644 index 000000000..99b2c1534 --- /dev/null +++ b/packages/rule-tester/src/host.ts @@ -0,0 +1,56 @@ +import path from "node:path"; +import { createFSBackedLinterHost, createVFSLinterHost } from "@flint.fyi/core"; + +export interface RuleTesterTSHostOptions { + defaultCompilerOptions?: Record; +} + +export function createRuleTesterTSHost( + dirname: string, + opts: RuleTesterTSHostOptions = {}, +) { + // At the time of writing this, packages/ts/src/rules contains ~120 .ts files. + // If we set the VFS cwd to dirname ('packages/ts/src/rules'), all 120 .ts files + // will be included into TS programs. However, if we create a virtual directory + // that contains only test case fixtures, we will avoid doing extra work. + // On my machine, this speeds up `pnpm vitest --run packages/ts` from ~22.6s to ~12.5s (1.8x). + dirname += "/_flint-rule-tester-virtual"; + const fsHost = createFSBackedLinterHost(dirname); + const overlay = createVFSLinterHost(dirname, { + ...fsHost, + stat(pathAbsolute) { + if (pathAbsolute.endsWith("tsconfig.json")) { + return undefined; + } + return fsHost.stat(pathAbsolute); + }, + }); + overlay.vfsUpsertFile( + path.join(dirname, "tsconfig.base.json"), + JSON.stringify( + { + compilerOptions: { + strict: true, + lib: ["esnext"], + target: "esnext", + types: [], + moduleResolution: "bundler", + ...opts.defaultCompilerOptions, + }, + }, + null, + 2, + ), + ); + overlay.vfsUpsertFile( + path.join(dirname, "tsconfig.json"), + JSON.stringify( + { + extends: "./tsconfig.base.json", + }, + null, + 2, + ), + ); + return overlay; +} diff --git a/packages/rule-tester/src/index.ts b/packages/rule-tester/src/index.ts index 619891ce8..5777c5152 100644 --- a/packages/rule-tester/src/index.ts +++ b/packages/rule-tester/src/index.ts @@ -1,2 +1,3 @@ export { RuleTester } from "./RuleTester.js"; export type * from "./types.js"; +export * from "./host.js"; diff --git a/packages/rule-tester/src/runTestCaseRule.ts b/packages/rule-tester/src/runTestCaseRule.ts deleted file mode 100644 index 10305f1e7..000000000 --- a/packages/rule-tester/src/runTestCaseRule.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { PromiseOrSync } from "@flint.fyi/utils"; - -import { - AnyLanguage, - AnyOptionalSchema, - AnyRule, - InferredObject, - LanguageFileFactory, - type NormalizedReport, - RuleAbout, -} from "@flint.fyi/core"; -import { CachedFactory } from "cached-factory"; - -import { TestCaseNormalized } from "./normalizeTestCase.js"; - -export interface TestCaseRuleConfiguration< - OptionsSchema extends AnyOptionalSchema | undefined, -> { - options?: InferredObject; - rule: AnyRule; -} - -export function runTestCaseRule< - OptionsSchema extends AnyOptionalSchema | undefined, ->( - fileFactories: CachedFactory, - { options, rule }: Required>, - { code, fileName }: TestCaseNormalized, -): PromiseOrSync { - using file = fileFactories - // TODO: How to make types more permissive around assignability? - // See AnyRule's any - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - .get(rule.language) - .prepareFromVirtual(fileName, code).file; - - return file.runRule(rule, options as InferredObject); -} diff --git a/packages/rule-tester/src/types.ts b/packages/rule-tester/src/types.ts index 824714756..a93b2cc2f 100644 --- a/packages/rule-tester/src/types.ts +++ b/packages/rule-tester/src/types.ts @@ -11,6 +11,7 @@ export interface TestCase< > { code: string; fileName?: string; + files?: Record; /** * Run only this test case. Useful for debugging. diff --git a/packages/svelte-language/package.json b/packages/svelte-language/package.json new file mode 100644 index 000000000..2b09e6eb0 --- /dev/null +++ b/packages/svelte-language/package.json @@ -0,0 +1,41 @@ +{ + "name": "@flint.fyi/svelte-language", + "version": "0.0.1", + "description": "[Experimental] TypeScript language plugin for Flint.", + "repository": { + "type": "git", + "url": "https://github.com/JoshuaKGoldberg/flint", + "directory": "packages/ts" + }, + "license": "MIT", + "author": { + "name": "JoshuaKGoldberg", + "email": "npm@joshuakgoldberg.com" + }, + "type": "module", + "exports": { + ".": "./lib/index.js" + }, + "dependencies": { + "@flint.fyi/core": "workspace:", + "@flint.fyi/ts": "workspace:", + "@flint.fyi/ts-patch": "workspace:", + "@flint.fyi/volar-language": "workspace:", + "@jridgewell/sourcemap-codec": "^1.5.5", + "@volar/language-core": "2.4.27", + "svelte2tsx": "^0.7.46", + "ts-api-utils": "^2.1.0", + "typescript": ">=5.9.3" + }, + "devDependencies": { + "@flint.fyi/rule-tester": "workspace:", + "svelte": "^5.46.1" + }, + "engines": { + "node": ">=24.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/svelte-language/src/language.ts b/packages/svelte-language/src/language.ts new file mode 100644 index 000000000..40f293611 --- /dev/null +++ b/packages/svelte-language/src/language.ts @@ -0,0 +1,53 @@ +import { RuleReporter } from "@flint.fyi/core"; + +import { setTSExtraSupportedExtensions } from "@flint.fyi/ts-patch"; +import { createVolarBasedLanguage } from "@flint.fyi/volar-language"; +import { svelteVolarLanguagePlugin } from "./volarLanguagePlugin.js"; +import { AST, parse } from "svelte/compiler"; + +setTSExtraSupportedExtensions([".svelte"]); + +export interface SvelteServices { + svelteServices?: { + ast: AST.Root; + reportComponent: RuleReporter; + sourceText: string; + }; +} + +export const svelteLanguage = createVolarBasedLanguage( + (ts, options) => { + return { + languagePlugins: svelteVolarLanguagePlugin, + prepareFile( + filePathAbsolute, + { program, sourceFile }, + volarLanguage, + sourceScript, + serviceScript, + ) { + const sourceText = sourceScript.snapshot.getText( + 0, + sourceScript.snapshot.getLength(), + ); + // TODO: report parsing errors? + const ast = parse(sourceText, { + modern: true, + }); + return { + // TODO: first statement + firstStatementPosition: sourceText.length, + extraContext(reportTranslated) { + return { + svelteServices: { + ast, + reportComponent: reportTranslated, + sourceText, + }, + }; + }, + }; + }, + }; + }, +); diff --git a/packages/svelte-language/src/rules/anyReturns.test.ts b/packages/svelte-language/src/rules/anyReturns.test.ts new file mode 100644 index 000000000..f3e9fbc43 --- /dev/null +++ b/packages/svelte-language/src/rules/anyReturns.test.ts @@ -0,0 +1,55 @@ +import rule from "../../../ts/lib/rules/anyReturns.js"; +import { svelteLanguage } from "../language.js"; +import { ruleTester } from "./ruleTester.js"; + +ruleTester.describe(svelteLanguage.createRule(rule), { + invalid: [ + { + code: ` + + `, + snapshot: ` + + `, + }, + { + code: ` + + + + `, + snapshot: ` + + + + ~~~ + Unsafe return of a value of type \`any\`. + `, + }, + ], + valid: [ + { + code: ` + + + + `, + }, + ], +}); diff --git a/packages/svelte-language/src/rules/rawSpecialElements.test.ts b/packages/svelte-language/src/rules/rawSpecialElements.test.ts new file mode 100644 index 000000000..78dd9014a --- /dev/null +++ b/packages/svelte-language/src/rules/rawSpecialElements.test.ts @@ -0,0 +1,28 @@ +import rule from "./rawSpecialElements.js"; +import { ruleTester } from "./ruleTester.js"; + +ruleTester.describe(rule, { + invalid: [ + { + code: ` + + Title + + `, + snapshot: ` + + ~~~~ + TODO: don't use \`head\` tag, use \`svelte:head\` instead + Title + + `, + }, + ], + valid: [ + ` + + Title + + `, + ], +}); diff --git a/packages/svelte-language/src/rules/rawSpecialElements.ts b/packages/svelte-language/src/rules/rawSpecialElements.ts new file mode 100644 index 000000000..48c8e0d54 --- /dev/null +++ b/packages/svelte-language/src/rules/rawSpecialElements.ts @@ -0,0 +1,62 @@ +import { + getPositionOfColumnAndLine, + SourceFileWithLineMap, +} from "@flint.fyi/core"; +import { svelteLanguage } from "../language.js"; + +export default svelteLanguage.createRule({ + about: { + description: "TODO", + id: "rawSpecialElements", + preset: "logical", + }, + messages: { + rawSpecialElement: { + primary: + "TODO: don't use `{{ element }}` tag, use `svelte:{{ element }}` instead", + secondary: ["TODO"], + suggestions: ["TODO"], + }, + }, + setup(context) { + const { svelteServices } = context; + if (svelteServices == null) { + return undefined; + } + const sourceText: SourceFileWithLineMap = { + text: svelteServices.sourceText, + }; + svelteServices.ast.fragment.nodes.forEach(function visit(node) { + if (node.type === "RegularElement") { + switch (node.name) { + case "head": + case "body": + case "window": + case "document": + case "element": + case "options": + svelteServices.reportComponent({ + message: "rawSpecialElement", + range: { + begin: getPositionOfColumnAndLine(sourceText, { + line: node.name_loc.start.line - 1, + column: node.name_loc.start.column, + }), + end: getPositionOfColumnAndLine(sourceText, { + line: node.name_loc.end.line - 1, + column: node.name_loc.end.column, + }), + }, + data: { + element: node.name, + }, + }); + } + } + if ("fragment" in node) { + node.fragment.nodes.forEach(visit); + } + }); + return undefined; + }, +}); diff --git a/packages/svelte-language/src/rules/ruleTester.ts b/packages/svelte-language/src/rules/ruleTester.ts new file mode 100644 index 000000000..171f59880 --- /dev/null +++ b/packages/svelte-language/src/rules/ruleTester.ts @@ -0,0 +1,11 @@ +import { createRuleTesterTSHost, RuleTester } from "@flint.fyi/rule-tester"; +import { describe, it } from "vitest"; + +export const ruleTester = new RuleTester({ + describe, + it, + defaults: { + fileName: "file.svelte", + }, + host: createRuleTesterTSHost(import.meta.dirname), +}); diff --git a/packages/svelte-language/src/volarLanguagePlugin.ts b/packages/svelte-language/src/volarLanguagePlugin.ts new file mode 100644 index 000000000..17e2cc1fa --- /dev/null +++ b/packages/svelte-language/src/volarLanguagePlugin.ts @@ -0,0 +1,175 @@ +import { + getPositionOfColumnAndLine, + SourceFileWithLineMap, +} from "@flint.fyi/core"; +import { decode } from "@jridgewell/sourcemap-codec"; +import { + forEachEmbeddedCode, + type CodeMapping, + type LanguagePlugin, + type VirtualCode, +} from "@volar/language-core"; +import { svelte2tsx } from "svelte2tsx"; +import type * as ts from "typescript"; + +// adapted from https://github.com/withastro/astro/blob/a19140fd11efbc635a391d176da54b0dc5e4a99c/packages/language-tools/ts-plugin/src/astro2tsx.ts + +export const svelteVolarLanguagePlugin: LanguagePlugin = { + getLanguageId(fileName) { + if (fileName.endsWith(".svelte")) { + return "svelte"; + } + }, + createVirtualCode(fileName, languageId, snapshot) { + if (languageId === "svelte") { + return { + id: "root", + languageId, + snapshot, + embeddedCodes: [ + getEmbeddedTsCode(snapshot.getText(0, snapshot.getLength())), + ].filter((v): v is VirtualCode => !!v), + mappings: [], + codegenStacks: [], + }; + } + }, + updateVirtualCode(_fileNameOrUri, virtualCode, snapshot) { + virtualCode.snapshot = snapshot; + virtualCode.embeddedCodes = [ + getEmbeddedTsCode(snapshot.getText(0, snapshot.getLength())), + ].filter((v): v is VirtualCode => !!v); + return virtualCode; + }, + typescript: { + extraFileExtensions: [ + { + extension: "svelte", + isMixedContent: true, + scriptKind: 7 satisfies ts.ScriptKind.Deferred, + }, + ], + getServiceScript(root) { + for (const code of forEachEmbeddedCode(root)) { + if (code.id === "tsx") { + return { + code, + scriptKind: 4, + extension: ".tsx", + }; + } + } + }, + }, +}; + +function getEmbeddedTsCode(text: string): VirtualCode | undefined { + // TODO: handle parsing errors + const tsx = svelte2tsx(text, { + isTsFile: true, + mode: "ts", + }); + const v3Mappings = decode(tsx.map.mappings); + const sourceTextWithLineMap: SourceFileWithLineMap = { + text, + }; + const serviceTextWithLineMap: SourceFileWithLineMap = { + text: tsx.code, + }; + const mappings: CodeMapping[] = []; + + let current: + | { + genOffset: number; + sourceOffset: number; + } + | undefined; + + for (let genLine = 0; genLine < v3Mappings.length; genLine++) { + for (const segment of v3Mappings[genLine]) { + const genCharacter = segment[0]; + const genOffset = getPositionOfColumnAndLine(serviceTextWithLineMap, { + line: genLine, + column: genCharacter, + }); + if (current) { + let length = genOffset - current.genOffset; + const sourceText = text.substring( + current.sourceOffset, + current.sourceOffset + length, + ); + const genText = tsx.code.substring( + current.genOffset, + current.genOffset + length, + ); + if (sourceText !== genText) { + length = 0; + for (let i = 0; i < genOffset - current.genOffset; i++) { + if (sourceText[i] === genText[i]) { + length = i + 1; + } else { + break; + } + } + } + if (length > 0) { + const lastMapping = mappings.length + ? mappings[mappings.length - 1] + : undefined; + if ( + lastMapping && + lastMapping.generatedOffsets[0] + lastMapping.lengths[0] === + current.genOffset && + lastMapping.sourceOffsets[0] + lastMapping.lengths[0] === + current.sourceOffset + ) { + lastMapping.lengths[0] += length; + } else { + mappings.push({ + sourceOffsets: [current.sourceOffset], + generatedOffsets: [current.genOffset], + lengths: [length], + data: { + verification: true, + completion: true, + semantic: true, + navigation: true, + structure: false, + format: false, + }, + }); + } + } + current = undefined; + } + if (segment[2] !== undefined && segment[3] !== undefined) { + const sourceOffset = getPositionOfColumnAndLine(sourceTextWithLineMap, { + line: segment[2], + column: segment[3], + }); + current = { + genOffset, + sourceOffset, + }; + } + } + } + + return { + id: "tsx", + languageId: "typescriptreact", + snapshot: { + getText(start, end) { + return tsx.code.substring(start, end); + }, + getLength() { + return tsx.code.length; + }, + getChangeRange() { + return undefined; + }, + }, + mappings: mappings, + embeddedCodes: [], + }; +} diff --git a/packages/svelte-language/tsconfig.json b/packages/svelte-language/tsconfig.json new file mode 100644 index 000000000..16522fb6a --- /dev/null +++ b/packages/svelte-language/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "extends": "../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../core" }, + { "path": "../ts" }, + { "path": "../rule-tester" }, + { "path": "../ts-patch" }, + { "path": "../volar-language" } + ] +} diff --git a/packages/text/src/language.ts b/packages/text/src/language.ts index 222cf488b..6143fa8a3 100644 --- a/packages/text/src/language.ts +++ b/packages/text/src/language.ts @@ -1,5 +1,4 @@ import { createLanguage } from "@flint.fyi/core"; -import fsSync from "node:fs"; import { createTextFile } from "./createTextFile.js"; import { TextNodes, TextServices } from "./types.js"; @@ -10,15 +9,7 @@ export const textLanguage = createLanguage({ }, prepare: () => { return { - prepareFromDisk: (filePathAbsolute) => { - return { - file: createTextFile( - filePathAbsolute, - fsSync.readFileSync(filePathAbsolute, "utf8"), - ), - }; - }, - prepareFromVirtual: (filePathAbsolute, sourceText) => { + prepareFile: (filePathAbsolute, sourceText) => { return { file: createTextFile(filePathAbsolute, sourceText), }; diff --git a/packages/ts/package.json b/packages/ts/package.json index 0e367bb78..99a0782a6 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -23,8 +23,8 @@ ], "dependencies": { "@flint.fyi/core": "workspace:", + "@flint.fyi/ts-patch": "workspace:", "@typescript-eslint/project-service": "^8.46.2", - "@typescript/vfs": "^1.6.1", "cached-factory": "^0.1.0", "debug-for-file": "^0.2.0", "ts-api-utils": "^2.1.0", diff --git a/packages/ts/src/createTypeScriptFileFromProjectService.ts b/packages/ts/src/createTypeScriptFileFromProjectService.ts deleted file mode 100644 index 940cf4ac6..000000000 --- a/packages/ts/src/createTypeScriptFileFromProjectService.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as ts from "typescript"; - -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, - [Symbol.dispose]() { - service.closeClientFile(filePathAbsolute); - }, - }; -} diff --git a/packages/ts/src/createTypeScriptServerHost.ts b/packages/ts/src/createTypeScriptServerHost.ts new file mode 100644 index 000000000..bc5468e7d --- /dev/null +++ b/packages/ts/src/createTypeScriptServerHost.ts @@ -0,0 +1,148 @@ +import { LinterHost } from "@flint.fyi/core"; +import ts from "typescript"; +import fs from "node:fs"; +import path from "node:path"; +import assert from "node:assert/strict"; + +function notImplemented(methodName: string): any { + throw new Error( + `Flint bug: ts.System's method '${methodName}' is not implemented.`, + ); +} + +// https://github.com/nodejs/node/blob/7b7f693a98da060e19f2ec12fb99997d5d5524f9/deps/uv/include/uv.h#L1260-L1269 +const UV_DIRENT_TYPE = { + UV_DIRENT_UNKNOWN: 0, + UV_DIRENT_FILE: 1, + UV_DIRENT_DIR: 1, +}; + +const DirentCtor = fs.Dirent as { + // https://github.com/nodejs/node/blob/7b7f693a98da060e19f2ec12fb99997d5d5524f9/lib/internal/fs/utils.js#L160 + new (name: string, type: number, parentPath: string): fs.Dirent; +}; + +export function createTypeScriptServerHost( + host: LinterHost, +): ts.server.ServerHost { + return { + ...ts.sys, + args: [], + write() { + notImplemented("write"); + }, + writeFile() { + notImplemented("writeFile"); + }, + createDirectory() { + notImplemented("createDirectory"); + }, + exit() { + notImplemented("exit"); + }, + readFile(filePath) { + return host.readFile(path.resolve(host.getCurrentDirectory(), filePath)); + }, + directoryExists(directoryPath) { + return ( + host.stat(path.resolve(host.getCurrentDirectory(), directoryPath)) === + "directory" + ); + }, + fileExists(filePath) { + return ( + host.stat(path.resolve(host.getCurrentDirectory(), filePath)) === "file" + ); + }, + readDirectory(directoryPath, extensions, exclude, include, depth) { + const originalCwd = process.cwd; + 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.deepEqual( + options, + { withFileTypes: true }, + "Flint bug: ts.sys.readDirectory passed unexpected options to fs.readdirSync", + ); + assert.ok( + typeof readPath === "string", + "Flint bug: 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; + } + }, + setImmediate, + setTimeout, + clearImmediate, + clearTimeout, + watchFile(filePath, callback) { + const watcher = host.watchFile( + path.resolve(host.getCurrentDirectory(), filePath), + (event) => { + let eventKind: ts.FileWatcherEventKind; + switch (event) { + case "created": + eventKind = ts.FileWatcherEventKind.Created; + break; + case "changed": + eventKind = ts.FileWatcherEventKind.Changed; + break; + case "deleted": + eventKind = ts.FileWatcherEventKind.Deleted; + break; + } + callback(filePath, eventKind); + }, + ); + return { + close() { + watcher[Symbol.dispose](); + }, + }; + }, + watchDirectory(directoryPath, callback, recursive = false) { + const watcher = host.watchDirectory( + path.resolve(host.getCurrentDirectory(), directoryPath), + recursive, + (filePathAbsolute) => { + callback(filePathAbsolute); + }, + ); + return { + close() { + watcher[Symbol.dispose](); + }, + }; + }, + }; +} diff --git a/packages/ts/src/index.ts b/packages/ts/src/index.ts index d801bbae1..e55dd03cc 100644 --- a/packages/ts/src/index.ts +++ b/packages/ts/src/index.ts @@ -11,11 +11,7 @@ export { getTSNodeRange } from "./getTSNodeRange.js"; export * from "./language.js"; export { TSNodesByName } from "./nodes.js"; export { ts } from "./plugin.js"; -export { - prepareTypeScriptBasedLanguage, - TypeScriptBasedLanguageFile, - TypeScriptBasedLanguageFileFactoryDefinition, -} from "./prepareTypeScriptBasedLanguage.js"; +export { DisposableTypeScriptFile } from "./openTypeScriptFileInProjectService.js"; export { getDeclarationsIfGlobal } from "./utils/getDeclarationsIfGlobal.js"; export { isGlobalDeclaration } from "./utils/isGlobalDeclaration.js"; export { isGlobalDeclarationOfName } from "./utils/isGlobalDeclarationOfName.js"; diff --git a/packages/ts/src/language.ts b/packages/ts/src/language.ts index c753aa2fa..1613a2b14 100644 --- a/packages/ts/src/language.ts +++ b/packages/ts/src/language.ts @@ -1,9 +1,17 @@ -import { createLanguage } from "@flint.fyi/core"; +import { createLanguage, LanguagePreparedDefinition } from "@flint.fyi/core"; import * as ts from "typescript"; +import path from "node:path"; +import assert from "node:assert/strict"; import { TSNodesByName } from "./nodes.js"; -import { prepareTypeScriptBasedLanguage } from "./prepareTypeScriptBasedLanguage.js"; -import { prepareTypeScriptFile } from "./prepareTypeScriptFile.js"; +import { createProjectService } from "@typescript-eslint/project-service"; +import { createTypeScriptServerHost } from "./createTypeScriptServerHost.js"; +import { parseDirectivesFromTypeScriptFile } from "./directives/parseDirectivesFromTypeScriptFile.js"; +import { + DisposableTypeScriptFile, + openTypeScriptFileInProjectService, +} from "./openTypeScriptFileInProjectService.js"; +import { createTypeScriptFileFromProgram } from "./createTypeScriptFileFromProgram.js"; export interface TypeScriptFileServices { program: ts.Program; @@ -11,6 +19,41 @@ export interface TypeScriptFileServices { typeChecker: ts.TypeChecker; } +let volarPrepareFile: + | (( + filePathAbsolute: string, + tsFile: DisposableTypeScriptFile, + ) => LanguagePreparedDefinition) + | null; +export function setVolarPrepareFile( + prepare: NonNullable, +) { + assert.ok( + volarPrepareFile == null, + `setVolarPrepareFile was called twice. Please ensure that you don't have two copies of @flint.fyi/volar-language in you dependency tree.`, + ); + volarPrepareFile = prepare; +} + +function isTypeScriptCoreSupportedExtension(extname: string) { + switch (extname) { + case ".ts": + case ".tsx": + case ".d.ts": + case ".js": + case ".jsx": + case ".cts": + case ".d.cts": + case ".cjs": + case ".mts": + case ".d.mts": + case ".mjs": + return true; + default: + return false; + } +} + export const typescriptLanguage = createLanguage< TSNodesByName, TypeScriptFileServices @@ -18,17 +61,53 @@ export const typescriptLanguage = createLanguage< about: { name: "TypeScript", }, - prepare: () => { - const lang = prepareTypeScriptBasedLanguage(); + prepare: (host) => { + const { service } = createProjectService({ + host: createTypeScriptServerHost(host), + }); return { - prepareFromDisk(filePathAbsolute) { - return prepareTypeScriptFile(lang.createFromDisk(filePathAbsolute)); - }, - prepareFromVirtual(filePathAbsolute, sourceText) { - return prepareTypeScriptFile( - lang.createFromVirtual(filePathAbsolute, sourceText), + prepareFile(filePathAbsolute) { + const tsFile = openTypeScriptFileInProjectService( + service, + filePathAbsolute, ); + + const fileExtension = path.extname(tsFile.sourceFile.fileName); + + if (isTypeScriptCoreSupportedExtension(fileExtension)) { + return { + ...parseDirectivesFromTypeScriptFile(tsFile.sourceFile), + file: { + [Symbol.dispose]: tsFile[Symbol.dispose], + ...createTypeScriptFileFromProgram( + tsFile.program, + tsFile.sourceFile, + ), + }, + }; + } + + if (volarPrepareFile == null) { + let message = "Unknown extension."; + switch (fileExtension) { + case ".astro": + message = "Did you install @flint.fyi/astro?"; + break; + case ".mdx": + message = "Did you install @flint.fyi/mdx?"; + break; + case ".vue": + message = "Did you install @flint.fyi/vue?"; + break; + } + + throw new Error( + `Cannot process ${tsFile.sourceFile.fileName}. ${message}`, + ); + } + + return volarPrepareFile(filePathAbsolute, tsFile); }, }; }, diff --git a/packages/ts/src/openTypeScriptFileInProjectService.ts b/packages/ts/src/openTypeScriptFileInProjectService.ts new file mode 100644 index 000000000..909cd1ead --- /dev/null +++ b/packages/ts/src/openTypeScriptFileInProjectService.ts @@ -0,0 +1,51 @@ +import { debugForFile } from "debug-for-file"; +import ts from "typescript"; + +const log = debugForFile(import.meta.filename); + +export interface DisposableTypeScriptFile extends Disposable { + program: ts.Program; + sourceFile: ts.SourceFile; +} + +export function openTypeScriptFileInProjectService( + service: ts.server.ProjectService, + filePathAbsolute: string, +): DisposableTypeScriptFile { + 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}`); + } + + const sourceFile = program.getSourceFile(filePathAbsolute); + if (!sourceFile) { + throw new Error(`Could not retrieve source file for: ${filePathAbsolute}`); + } + + return { + program, + sourceFile, + [Symbol.dispose]() { + service.closeClientFile(filePathAbsolute); + }, + }; +} diff --git a/packages/ts/src/prepareTypeScriptBasedLanguage.ts b/packages/ts/src/prepareTypeScriptBasedLanguage.ts deleted file mode 100644 index 60885691a..000000000 --- a/packages/ts/src/prepareTypeScriptBasedLanguage.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createProjectService } from "@typescript-eslint/project-service"; -import { - createFSBackedSystem, - createVirtualTypeScriptEnvironment, -} from "@typescript/vfs"; -import { CachedFactory } from "cached-factory"; -import { debugForFile } from "debug-for-file"; -import path from "node:path"; -import * as ts from "typescript"; - -import { createTypeScriptFileFromProjectService } from "./createTypeScriptFileFromProjectService.js"; - -const projectRoot = path.join(import.meta.dirname, "../.."); -const log = debugForFile(import.meta.filename); - -export interface TypeScriptBasedLanguageFile extends Partial { - program: ts.Program; - sourceFile: ts.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)!; - const program = environment.languageService.getProgram()!; - /* eslint-enable @typescript-eslint/no-non-null-assertion */ - - seenPrograms.add(program); - - return { - program, - sourceFile, - }; - }, - }; -} diff --git a/packages/ts/src/prepareTypeScriptFile.ts b/packages/ts/src/prepareTypeScriptFile.ts deleted file mode 100644 index e2a294ad9..000000000 --- a/packages/ts/src/prepareTypeScriptFile.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createTypeScriptFileFromProgram } from "./createTypeScriptFileFromProgram.js"; -import { parseDirectivesFromTypeScriptFile } from "./directives/parseDirectivesFromTypeScriptFile.js"; -import { TypeScriptBasedLanguageFile } from "./prepareTypeScriptBasedLanguage.js"; - -export function prepareTypeScriptFile(file: TypeScriptBasedLanguageFile) { - const { program, sourceFile, [Symbol.dispose]: onDispose } = file; - return { - ...parseDirectivesFromTypeScriptFile(sourceFile), - file: { - ...(onDispose != null && { [Symbol.dispose]: onDispose }), - ...createTypeScriptFileFromProgram(program, sourceFile), - }, - }; -} diff --git a/packages/ts/src/rules/functionNewCalls.test.ts b/packages/ts/src/rules/functionNewCalls.test.ts index 712131d78..6aba948ee 100644 --- a/packages/ts/src/rules/functionNewCalls.test.ts +++ b/packages/ts/src/rules/functionNewCalls.test.ts @@ -1,6 +1,14 @@ import rule from "./functionNewCalls.js"; import { ruleTester } from "./ruleTester.js"; +const tsconfigWithDomLib = { + "tsconfig.json": `{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "DOM"] + } +}`, +}; ruleTester.describe(rule, { invalid: [ { @@ -67,6 +75,7 @@ const fn = globalThis.Function("return 1"); code: ` const fn = new window.Function("return 1"); `, + files: tsconfigWithDomLib, snapshot: ` const fn = new window.Function("return 1"); ~~~~~~~~~~~~~~~ @@ -77,6 +86,7 @@ const fn = new window.Function("return 1"); code: ` const fn = window.Function("return 1"); `, + files: tsconfigWithDomLib, snapshot: ` const fn = window.Function("return 1"); ~~~~~~~~~~~~~~~ diff --git a/packages/ts/src/rules/globalAssignments.test.ts b/packages/ts/src/rules/globalAssignments.test.ts index 967d00c15..c5413b254 100644 --- a/packages/ts/src/rules/globalAssignments.test.ts +++ b/packages/ts/src/rules/globalAssignments.test.ts @@ -107,6 +107,14 @@ Read-only global variables should not be reassigned or modified. code: ` window = {}; `, + files: { + "tsconfig.json": `{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "DOM"] + } +}`, + }, snapshot: ` window = {}; ~~~~~~ diff --git a/packages/ts/src/rules/ruleTester.ts b/packages/ts/src/rules/ruleTester.ts index 14a239398..6cc8becb7 100644 --- a/packages/ts/src/rules/ruleTester.ts +++ b/packages/ts/src/rules/ruleTester.ts @@ -1,8 +1,9 @@ -import { RuleTester } from "@flint.fyi/rule-tester"; +import { createRuleTesterTSHost, RuleTester } from "@flint.fyi/rule-tester"; import { describe, it } from "vitest"; export const ruleTester = new RuleTester({ defaults: { fileName: "file.ts" }, describe, it, + host: createRuleTesterTSHost(import.meta.dirname), }); diff --git a/packages/ts/src/rules/undefinedVariables.test.ts b/packages/ts/src/rules/undefinedVariables.test.ts index b447caeba..e529f5998 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/ts/tsconfig.json b/packages/ts/tsconfig.json index 1c093e614..f3c1c93ae 100644 --- a/packages/ts/tsconfig.json +++ b/packages/ts/tsconfig.json @@ -5,5 +5,9 @@ }, "extends": "../../tsconfig.base.json", "include": ["src"], - "references": [{ "path": "../core" }, { "path": "../rule-tester" }] + "references": [ + { "path": "../core" }, + { "path": "../rule-tester" }, + { "path": "../ts-patch" } + ] } diff --git a/packages/volar-language/package.json b/packages/volar-language/package.json new file mode 100644 index 000000000..3f124bb5c --- /dev/null +++ b/packages/volar-language/package.json @@ -0,0 +1,34 @@ +{ + "name": "@flint.fyi/volar-language", + "version": "0.0.1", + "description": "[Experimental] TypeScript language plugin for Flint.", + "repository": { + "type": "git", + "url": "https://github.com/JoshuaKGoldberg/flint", + "directory": "packages/ts" + }, + "license": "MIT", + "author": { + "name": "JoshuaKGoldberg", + "email": "npm@joshuakgoldberg.com" + }, + "type": "module", + "exports": { + ".": "./lib/index.js" + }, + "dependencies": { + "@flint.fyi/core": "workspace:", + "@flint.fyi/ts": "workspace:", + "@flint.fyi/ts-patch": "workspace:", + "@volar/language-core": "2.4.27", + "@volar/typescript": "2.4.27", + "typescript": ">=5.9.3" + }, + "engines": { + "node": ">=24.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/volar-language/src/index.ts b/packages/volar-language/src/index.ts new file mode 100644 index 000000000..f44e0cf28 --- /dev/null +++ b/packages/volar-language/src/index.ts @@ -0,0 +1,485 @@ +import { + AnyLevelDeep, + flatten, + RuleReporter, + LanguageFileCacheImpacts, + LanguageDiagnostics, + FileReport, + SourceFileWithLineMap, + CharacterReportRange, + NormalizedReportRangeObject, + getColumnAndLineOfPosition, + NormalizedReport, + RuleContext, + isSuggestionForFiles, + Language, + AnyRuleDefinition, + CreateRule, + DirectivesCollector, +} from "@flint.fyi/core"; +import assert from "node:assert/strict"; +import { setTSProgramCreationProxy } from "@flint.fyi/ts-patch"; +import { proxyCreateProgram } from "@volar/typescript/lib/node/proxyCreateProgram.js"; +// for LanguagePlugin interface augmentation +import "@volar/typescript"; +import ts from "typescript"; + +import { + convertTypeScriptDiagnosticToLanguageFileDiagnostic, + DisposableTypeScriptFile, + extractDirectivesFromTypeScriptFile, + ExtractedDirective, + NodeSyntaxKinds, + setVolarPrepareFile, + TSNodesByName, + TypeScriptFileServices, + typescriptLanguage, +} from "@flint.fyi/ts"; + +import { + LanguagePlugin as VolarLanguagePlugin, + Language as VolarLanguage, + Mapper as VolarMapper, + SourceScript as VolarSourceScript, +} from "@volar/language-core"; +import { TypeScriptServiceScript as VolarTypeScriptServiceScript } from "@volar/typescript"; +import { AsyncLocalStorage } from "node:async_hooks"; + +type VolarLanguagePluginInitializer = ( + ts: typeof import("typescript"), + options: ts.CreateProgramOptions, +) => { + languagePlugins: AnyLevelDeep>; + prepareFile: VolarBasedLanguagePrepareFile; +}; +const volarLanguagePluginInitializers = new Set< + VolarLanguagePluginInitializer +>(); + +type ProxiedTSProgram = ts.Program & { + __flintVolarLanguage?: undefined | VolarLanguage; +}; + +export type VolarBasedLanguagePrepareFile = ( + filePathAbsolute: string, + tsFile: DisposableTypeScriptFile, + volarLanguage: VolarLanguage, + sourceScript: VolarSourceScript & { + generated: NonNullable["generated"]>; + }, + serviceScript: VolarTypeScriptServiceScript, +) => { + directives?: ExtractedDirective[]; + firstStatementPosition: number; + reports?: FileReport[]; + cache?: LanguageFileCacheImpacts; + getDiagnostics?(): LanguageDiagnostics; + extraContext: (reportTranslated: RuleReporter) => ContextServices; +}; + +type VolarLanguagePluginWithPrepareFile = VolarLanguagePlugin & { + __flintPrepareFile?: VolarBasedLanguagePrepareFile | undefined; +}; + +type CompilerHostSourceFileGetterStorage = { sourceFile: ts.SourceFile | null }; +const compilerHostSourceFileGetterStorage = + new AsyncLocalStorage(); + +setTSProgramCreationProxy( + (ts, createProgram) => + new Proxy(function () {} as unknown as typeof createProgram, { + apply(target, thisArg, args) { + let volarLanguage = null as null | VolarLanguage; + const createProgramProxy = new Proxy(createProgram, { + apply(target, thisArg, [options]: [ts.CreateProgramOptions]) { + assert.ok(options.host != null, `Flint bug: options.host is null`); + const patchedGetSourceFile = options.host.getSourceFile; + options.host.getSourceFile = (...args) => { + const store: CompilerHostSourceFileGetterStorage = { + sourceFile: null, + }; + const result = compilerHostSourceFileGetterStorage.run( + store, + () => patchedGetSourceFile(...args), + ); + if (result != null) { + assert.ok( + store.sourceFile, + `Flint bug: sourceFile in compilerHostSourceFileGetterStorage expected to be set`, + ); + assert.ok( + "scriptKind" in store.sourceFile && + typeof store.sourceFile.scriptKind === "number", + `Flint bug: ts.SourceFile doesn't have scriptKind property`, + ); + assert.ok( + "scriptKind" in result && + typeof result.scriptKind === "number", + `Flint bug: ts.SourceFile doesn't have scriptKind property`, + ); + store.sourceFile.scriptKind = result.scriptKind; + } + return result; + }; + return Reflect.apply(target, thisArg, args); + }, + }); + const proxied = proxyCreateProgram( + ts, + createProgramProxy, + (ts, options) => { + assert.ok( + options.host != null, + `Flint bug: createProgram was called without compiler host`, + ); + const originalGetSourceFile = options.host.getSourceFile; + options.host.getSourceFile = (...args) => { + const file = originalGetSourceFile(...args); + const store = compilerHostSourceFileGetterStorage.getStore(); + if (store != null && file != null) { + store.sourceFile = file; + } + return file; + }; + + const languagePlugins = Array.from(volarLanguagePluginInitializers) + .map((initializer) => initializer(ts, options)) + .map(({ languagePlugins, prepareFile }) => + flatten(languagePlugins).map((plugin) => { + ( + plugin as VolarLanguagePluginWithPrepareFile + ).__flintPrepareFile = prepareFile; + if (plugin.typescript == null) { + return plugin; + } + + const { getServiceScript } = plugin.typescript; + plugin.typescript.getServiceScript = (root) => { + const script = getServiceScript(root); + if (script == null) { + return script; + } + return { + ...script, + // Leading offset is useful for LanguageService [1], but we don't use it. + // The Vue language plugin doesn't provide preventLeadingOffset [2], so we + // have to provide it ourselves. + // + // [1] https://github.com/volarjs/volar.js/discussions/188 + // [2] https://github.com/vuejs/language-tools/blob/fd05a1c92c9af63e6af1eab926084efddf7c46c3/packages/language-core/lib/languagePlugin.ts#L113-L130 + preventLeadingOffset: true, + }; + }; + + return plugin; + }), + ); + return { + languagePlugins: flatten(languagePlugins), + setup: (lang) => (volarLanguage = lang), + }; + }, + ); + + const program: ProxiedTSProgram = Reflect.apply(proxied, thisArg, args); + + assert.ok(volarLanguage != null, `Flint bug: volarLanguage is null`); + + if (program.__flintVolarLanguage == null) { + program.__flintVolarLanguage = volarLanguage; + } + + return program; + }, + }), +); + +setVolarPrepareFile( + ( + filePathAbsolute, + { program, sourceFile, [Symbol.dispose]: disposeFile }, + ) => { + const volarLanguage = (program as ProxiedTSProgram).__flintVolarLanguage; + assert.ok( + volarLanguage != null, + `Flint bug: TypeScript wasn't proxied with Volar.js`, + ); + + const sourceScript = volarLanguage.scripts.get(sourceFile.fileName); + + assert.ok( + sourceScript != null, + `Flint bug: Volar.js source script for ${sourceFile.fileName} is undefined`, + ); + assert.ok( + sourceScript.generated != null, + `Flint bug: Volar.js sourceScript.generated for ${sourceFile.fileName} is undefined`, + ); + assert.ok( + sourceScript.generated.languagePlugin.typescript != null, + `Flint bug: Volar.js sourceScript.generated.languagePlugin.typescript for ${sourceFile.fileName} is undefined`, + ); + + const prepareFile = ( + sourceScript.generated + .languagePlugin as VolarLanguagePluginWithPrepareFile + ).__flintPrepareFile; + assert.ok( + prepareFile != null, + `Flint bug: Volar.js language plugin for script (${sourceFile.fileName}) with language id ${sourceScript.generated.root.languageId} doesn't have __flintPrepareFile function`, + ); + + const sourceText = sourceScript.snapshot.getText( + 0, + sourceScript.snapshot.getLength(), + ); + const sourceTextWithLineMap: SourceFileWithLineMap = { + text: sourceText, + }; + function normalizeSourceRange( + range: CharacterReportRange, + ): NormalizedReportRangeObject { + return { + begin: getColumnAndLineOfPosition(sourceTextWithLineMap, range.begin), + end: getColumnAndLineOfPosition(sourceTextWithLineMap, range.end), + }; + } + + const serviceScript = + sourceScript.generated.languagePlugin.typescript.getServiceScript( + sourceScript.generated.root, + ); + assert.ok( + serviceScript != null, + `Flint bug: Volar.js service script for ${sourceFile.fileName} is undefined`, + ); + + const map = volarLanguage.maps.get(serviceScript.code, sourceScript); + const sortedMappings = map.mappings.toSorted( + (a, b) => a.generatedOffsets[0] - b.generatedOffsets[0], + ); + const { + directives, + firstStatementPosition, + reports, + extraContext, + getDiagnostics, + cache, + } = prepareFile( + filePathAbsolute, + { + program, + sourceFile, + [Symbol.dispose]: disposeFile, + }, + volarLanguage, + sourceScript as VolarSourceScript & { + generated: NonNullable["generated"]>; + }, + serviceScript, + ); + + const translatedDirectives = [...(directives ?? [])]; + + for (const d of extractDirectivesFromTypeScriptFile(sourceFile)) { + const range = translateRange(map, d.range.begin.raw, d.range.end.raw); + if (range != null) { + translatedDirectives.push({ + ...d, + range: normalizeSourceRange(range), + }); + } + } + + const directivesCollector = new DirectivesCollector(firstStatementPosition); + translatedDirectives.sort((a, b) => a.range.begin.raw - b.range.begin.raw); + for (const { range, selection, type } of translatedDirectives) { + directivesCollector.add(range, selection, type); + } + + const collected = directivesCollector.collect(); + + return { + directives: collected.directives, + reports: [...collected.reports, ...(reports ?? [])], + file: { + cache, + getDiagnostics() { + return [ + ...ts.getPreEmitDiagnostics(program, sourceFile).map((diagnostic) => + convertTypeScriptDiagnosticToLanguageFileDiagnostic({ + ...diagnostic, + // For some unknown reason, Volar doesn't set file.text to sourceText + // when preventLeadingOffset is true, so we have to do it ourselves + // https://github.com/volarjs/volar.js/blob/4a9d25d797d08d9c149bebf0f52ac5e172f4757d/packages/typescript/lib/node/transform.ts#L102 + file: diagnostic.file + ? { + fileName: diagnostic.file.fileName, + text: sourceText, + } + : diagnostic.file, + }), + ), + ...(getDiagnostics?.() ?? []), + ]; + }, + async runRule(rule, options) { + const reports: NormalizedReport[] = []; + const context = { + program, + sourceFile, + typeChecker: program.getTypeChecker(), + report: (report) => { + const reportRange = translateRange( + map, + report.range.begin, + report.range.end, + ); + if (reportRange == null) { + return; + } + + const translatedReport: NormalizedReport = { + ...report, + fix: + report.fix && !Array.isArray(report.fix) + ? [report.fix] + : report.fix, + message: rule.messages[report.message], + range: normalizeSourceRange(reportRange), + }; + + for (const suggestion of translatedReport.suggestions ?? []) { + assert.ok( + !isSuggestionForFiles(suggestion), + `Flint bug: suggestions for multiple files in Volar.js based languages are not yet supported`, + ); + const range = translateRange( + map, + suggestion.range.begin, + suggestion.range.end, + ); + // TODO: maybe we should filter out these suggestions intead of erroring? + assert.ok( + range != null, + `Flint rule bug: suggestion ranges must not overlap with virtual code`, + ); + suggestion.range = range; + } + + reports.push(translatedReport); + }, + ...extraContext?.((report) => { + reports.push({ + ...report, + fix: + report.fix && !Array.isArray(report.fix) + ? [report.fix] + : report.fix, + message: rule.messages[report.message], + range: normalizeSourceRange(report.range), + }); + }), + } satisfies TypeScriptFileServices & RuleContext; + + const runtime = await rule.setup(context, options); + + if (runtime?.visitors) { + const { visitors } = runtime; + let lastMappingIdx = 0; + const visit = (node: ts.Node) => { + visitors[NodeSyntaxKinds[node.kind]]?.(node, context); + + node.forEachChild(visit); + }; + // Visit only statements that have a mapping to the source code + // to avoid doing extra work + Statements: for (const statement of sourceFile.statements) { + while (true) { + const currentMapping = sortedMappings[lastMappingIdx]; + if (currentMapping == null) { + break Statements; + } + const currentMappingLength = + currentMapping.generatedLengths?.[0] ?? + currentMapping.lengths[0]; + if ( + currentMappingLength === 0 || + statement.pos >= + currentMapping.generatedOffsets[0] + currentMappingLength + ) { + lastMappingIdx++; + continue; + } + if (statement.end <= currentMapping.generatedOffsets[0]) { + continue Statements; + } + break; + } + + visit(statement); + } + visit(sourceFile.endOfFileToken); + } + + await runtime?.teardown?.(); + + return reports; + }, + [Symbol.dispose]() { + disposeFile?.(); + }, + }, + }; + }, +); + +export function translateRange( + map: VolarMapper, + serviceBegin: number, + serviceEnd: number, +): null | { begin: number; end: number } { + for (const [begin, end] of map.toSourceRange( + serviceBegin, + serviceEnd, + true, + )) { + if (begin === end) { + continue; + } + return { begin, end }; + } + return null; +} + +export function createVolarBasedLanguage( + initializer: VolarLanguagePluginInitializer, +) { + volarLanguagePluginInitializers.add(initializer); + const language: Language< + TSNodesByName, + Partial & TypeScriptFileServices + > = { + about: { + name: "Volar.js-based language", + }, + createRule: (ruleDefinition: AnyRuleDefinition) => { + return { + ...ruleDefinition, + // @ts-expect-error - ContextServices type is not satisfied, but we pass correct + // services in runRule + language: typescriptLanguage as Language< + TSNodesByName, + Partial & TypeScriptFileServices + >, + }; + }, + prepare() { + throw new Error( + "Flint bug: Volar.js based language should never be prepared directly", + ); + }, + }; + + return language; +} diff --git a/packages/volar-language/tsconfig.json b/packages/volar-language/tsconfig.json new file mode 100644 index 000000000..e996a4e06 --- /dev/null +++ b/packages/volar-language/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "extends": "../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../core" }, + { "path": "../ts" }, + { "path": "../ts-patch" } + ] +} diff --git a/packages/vue-language/package.json b/packages/vue-language/package.json new file mode 100644 index 000000000..1cde20a51 --- /dev/null +++ b/packages/vue-language/package.json @@ -0,0 +1,41 @@ +{ + "name": "@flint.fyi/vue-language", + "version": "0.0.1", + "description": "[Experimental] TypeScript language plugin for Flint.", + "repository": { + "type": "git", + "url": "https://github.com/JoshuaKGoldberg/flint", + "directory": "packages/ts" + }, + "license": "MIT", + "author": { + "name": "JoshuaKGoldberg", + "email": "npm@joshuakgoldberg.com" + }, + "type": "module", + "exports": { + ".": "./lib/index.js" + }, + "dependencies": { + "@flint.fyi/core": "workspace:", + "@flint.fyi/ts": "workspace:", + "@flint.fyi/ts-patch": "workspace:", + "@flint.fyi/volar-language": "workspace:", + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/language-core": "3.2.1", + "ts-api-utils": "^2.1.0", + "typescript": ">=5.9.3" + }, + "devDependencies": { + "@flint.fyi/rule-tester": "workspace:", + "vue": "~3.5.26" + }, + "engines": { + "node": ">=24.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/vue-language/src/index.ts b/packages/vue-language/src/index.ts new file mode 100644 index 000000000..87a1724fa --- /dev/null +++ b/packages/vue-language/src/index.ts @@ -0,0 +1,2 @@ +export { vueLanguage, vueWrapRules } from "./language.js"; +export * from "./plugin.js"; diff --git a/packages/vue-language/src/language.ts b/packages/vue-language/src/language.ts new file mode 100644 index 000000000..eb72be27b --- /dev/null +++ b/packages/vue-language/src/language.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { RuleReporter } from "@flint.fyi/core"; +import { setTSExtraSupportedExtensions } from "@flint.fyi/ts-patch"; +import { createVolarBasedLanguage } from "@flint.fyi/volar-language"; +import { Mapper as VolarMapper } from "@volar/language-core"; +import { + parse as vueParse, + NodeTypes, + RootNode, + TemplateChildNode, +} from "@vue/compiler-dom"; +import { + createVueLanguagePlugin, + createParsedCommandLine as createVueParsedCommandLine, + createParsedCommandLineByJson as createVueParsedCommandLineByJson, + tsCodegen, + VueVirtualCode, +} from "@vue/language-core"; +import { + collectTypeScriptFileCacheImpacts, + ExtractedDirective, +} from "@flint.fyi/ts"; + +setTSExtraSupportedExtensions([".vue"]); + +export interface VueServices { + vueServices: { + codegen: VueCodegen; + map: VolarMapper; + sfc: RootNode; + virtualCode: VueVirtualCode; + // TODO: can we type MessageId? + reportSfc: RuleReporter; + }; +} + +type VueCodegen = typeof tsCodegen extends WeakMap ? V : never; + +export const vueLanguage = createVolarBasedLanguage( + (ts, options) => { + const { configFilePath } = options.options; + const vueCompilerOptions = ( + typeof configFilePath === "string" + ? createVueParsedCommandLine( + ts, + ts.sys, + configFilePath.replaceAll("\\", "/"), + ) + : createVueParsedCommandLineByJson( + ts, + ts.sys, + (options.host ?? ts.sys).getCurrentDirectory(), + {}, + ) + ).vueOptions; + return { + languagePlugins: createVueLanguagePlugin( + ts, + options.options, + vueCompilerOptions, + (id) => id, + ), + prepareFile( + filePathAbsolute, + { program, sourceFile }, + volarLanguage, + sourceScript, + serviceScript, + ) { + const sourceText = sourceScript.snapshot.getText( + 0, + sourceScript.snapshot.getLength(), + ); + const virtualCode = sourceScript.generated.root as VueVirtualCode; + const codegen = tsCodegen.get(virtualCode.sfc); + assert.ok( + codegen != null, + `Flint bug: tsCodegen for ${filePathAbsolute} is undefined`, + ); + + const map = volarLanguage.maps.get(serviceScript.code, sourceScript); + + const sfcAst = vueParse(sourceText, { + comments: true, + expressionPlugins: ["typescript"], + parseMode: "html", + // We ignore errors because virtual code already provides them, + // and it also provides them with sourceText-based locations, + // so we don't have to remap them. Oh, and it also contains errors from + // other blocks rather than only