From c90a970c0cf78806cc8549fc312003ef3c5595ad Mon Sep 17 00:00:00 2001 From: auvred Date: Sun, 26 Oct 2025 16:28:01 +0300 Subject: [PATCH 01/19] --- .vscode/launch.json | 2 +- MyComponent.vue | 7 + foo.ts | 1 + .../ts/src/createTypeScriptFileFromProgram.ts | 138 +- .../createTypeScriptFileFromProjectService.ts | 16 +- packages/ts/src/formatDiagnostic.ts | 33 +- packages/ts/src/language.ts | 185 ++- packages/ts/src/normalizeRange.ts | 45 +- packages/ts/src/prepareTypeScriptFile.ts | 17 +- .../ts/src/rules/utils/builtinSymbolLikes.ts | 190 +++ .../rules/utils/isSymbolFromDefaultLibrary.ts | 20 + packages/vue/package.json | 22 + packages/vue/src/index.test.ts | 115 ++ packages/vue/src/index.ts | 357 +++++ packages/vue/src/patch-typescript.ts | 128 ++ packages/vue/src/rules/AnotherComponent.vue | 3 + packages/vue/src/rules/MyComponent.vue | 5 + packages/vue/src/rules/anyReturns.test.ts | 107 ++ packages/vue/src/rules/asyncComputed.test.ts | 390 +++++ packages/vue/src/rules/asyncComputed.ts | 92 ++ .../vue/src/rules/debuggerStatements.test.ts | 84 ++ packages/vue/src/rules/ruleTester.ts | 4 + packages/vue/src/rules/vForKey.test.ts | 526 +++++++ packages/vue/src/rules/vForKey.ts | 261 ++++ packages/vue/src/setup-tests.ts | 54 + packages/vue/tsconfig.json | 9 + pnpm-lock.yaml | 1268 +++++++++++------ tsconfig.json | 2 +- vitest.config.ts | 5 +- 29 files changed, 3449 insertions(+), 637 deletions(-) create mode 100644 MyComponent.vue create mode 100644 foo.ts create mode 100644 packages/ts/src/rules/utils/builtinSymbolLikes.ts create mode 100644 packages/ts/src/rules/utils/isSymbolFromDefaultLibrary.ts create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/index.test.ts create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/src/patch-typescript.ts create mode 100644 packages/vue/src/rules/AnotherComponent.vue create mode 100644 packages/vue/src/rules/MyComponent.vue create mode 100644 packages/vue/src/rules/anyReturns.test.ts create mode 100644 packages/vue/src/rules/asyncComputed.test.ts create mode 100644 packages/vue/src/rules/asyncComputed.ts create mode 100644 packages/vue/src/rules/debuggerStatements.test.ts create mode 100644 packages/vue/src/rules/ruleTester.ts create mode 100644 packages/vue/src/rules/vForKey.test.ts create mode 100644 packages/vue/src/rules/vForKey.ts create mode 100644 packages/vue/src/setup-tests.ts create mode 100644 packages/vue/tsconfig.json diff --git a/.vscode/launch.json b/.vscode/launch.json index eacb5420a..c18271b4e 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": ["${relativeFile}"], "sourceMaps": true, "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" diff --git a/MyComponent.vue b/MyComponent.vue new file mode 100644 index 000000000..554496ed8 --- /dev/null +++ b/MyComponent.vue @@ -0,0 +1,7 @@ + + + diff --git a/foo.ts b/foo.ts new file mode 100644 index 000000000..45dd9d249 --- /dev/null +++ b/foo.ts @@ -0,0 +1 @@ +export const bar = 123; diff --git a/packages/ts/src/createTypeScriptFileFromProgram.ts b/packages/ts/src/createTypeScriptFileFromProgram.ts index 01a29e858..f676fc9b1 100644 --- a/packages/ts/src/createTypeScriptFileFromProgram.ts +++ b/packages/ts/src/createTypeScriptFileFromProgram.ts @@ -1,5 +1,10 @@ import { + AnyOptionalSchema, + AnyRuleDefinition, + InferredObject, + LanguageFileCacheImpacts, LanguageFileDefinition, + LanguageFileDiagnostic, NormalizedReport, RuleReport, } from "@flint.fyi/core"; @@ -10,73 +15,98 @@ import { formatDiagnostic } from "./formatDiagnostic.js"; import { getFirstEnumValues } from "./getFirstEnumValues.js"; import { normalizeRange } from "./normalizeRange.js"; -const NodeSyntaxKinds = getFirstEnumValues(ts.SyntaxKind); +export const NodeSyntaxKinds = getFirstEnumValues(ts.SyntaxKind); -export function createTypeScriptFileFromProgram( +export function collectTypeScriptFileCacheImpacts( program: ts.Program, sourceFile: ts.SourceFile, -): LanguageFileDefinition { +): LanguageFileCacheImpacts { return { - cache: { - dependencies: [ - // TODO: Add support for multi-TSConfig workspaces. - // https://github.com/JoshuaKGoldberg/flint/issues/64 & more. - "tsconfig.json", + dependencies: [ + // TODO: Add support for multi-TSConfig workspaces. + // https://github.com/JoshuaKGoldberg/flint/issues/64 & more. + "tsconfig.json", - ...collectReferencedFilePaths(program, sourceFile), - ], - }, - getDiagnostics() { - return ts - .getPreEmitDiagnostics(program, sourceFile) - .map((diagnostic) => ({ - code: `TS${diagnostic.code}`, - text: formatDiagnostic({ - ...diagnostic, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - length: diagnostic.length!, - message: ts.flattenDiagnosticMessageText( - diagnostic.messageText, - "\n", - ), - name: `TS${diagnostic.code}`, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - start: diagnostic.start!, - }), - })); + ...collectReferencedFilePaths(program, sourceFile), + ], + }; +} + +export function convertTypeScriptDiagnosticToLanguageFileDiagnostic( + diagnostic: ts.Diagnostic, +): LanguageFileDiagnostic { + return { + code: `TS${diagnostic.code}`, + text: formatDiagnostic({ + ...diagnostic, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + length: diagnostic.length!, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + name: `TS${diagnostic.code}`, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + start: diagnostic.start!, + }), + }; +} + +export async function runTypeScriptBasedLanguageRule< + OptionsSchema extends AnyOptionalSchema | undefined = + | AnyOptionalSchema + | undefined, +>( + program: ts.Program, + sourceFile: ts.SourceFile, + rule: AnyRuleDefinition, + options: InferredObject, + extraContext?: Record, +): Promise { + const reports: NormalizedReport[] = []; + + const context = { + program, + report: (report: RuleReport) => { + reports.push({ + ...report, + message: rule.messages[report.message], + range: normalizeRange(report.range, sourceFile), + }); }, - async runRule(rule, options) { - const reports: NormalizedReport[] = []; + sourceFile, + typeChecker: program.getTypeChecker(), + ...extraContext, + }; - const context = { - program, - report: (report: RuleReport) => { - reports.push({ - ...report, - message: rule.messages[report.message], - range: normalizeRange(report.range, sourceFile), - }); - }, - sourceFile, - typeChecker: program.getTypeChecker(), - }; + const runtime = await rule.setup(context, options); - const runtime = await rule.setup(context, options); + if (!runtime?.visitors) { + return reports; + } - if (!runtime?.visitors) { - return reports; - } + const { visitors } = runtime; + const visit = (node: ts.Node) => { + visitors[NodeSyntaxKinds[node.kind]]?.(node); - const { visitors } = runtime; - const visit = (node: ts.Node) => { - visitors[NodeSyntaxKinds[node.kind]]?.(node); + node.forEachChild(visit); + }; - node.forEachChild(visit); - }; + sourceFile.forEachChild(visit); - sourceFile.forEachChild(visit); + return reports; +} - return reports; +export function createTypeScriptFileFromProgram( + program: ts.Program, + sourceFile: ts.SourceFile, +): LanguageFileDefinition { + return { + cache: collectTypeScriptFileCacheImpacts(program, sourceFile), + getDiagnostics() { + return ts + .getPreEmitDiagnostics(program, sourceFile) + .map(convertTypeScriptDiagnosticToLanguageFileDiagnostic); + }, + async runRule(rule, options) { + return runTypeScriptBasedLanguageRule(program, sourceFile, rule, options); }, }; } diff --git a/packages/ts/src/createTypeScriptFileFromProjectService.ts b/packages/ts/src/createTypeScriptFileFromProjectService.ts index 4ee740c88..44cfaf074 100644 --- a/packages/ts/src/createTypeScriptFileFromProjectService.ts +++ b/packages/ts/src/createTypeScriptFileFromProjectService.ts @@ -1,7 +1,7 @@ import { debugForFile } from "debug-for-file"; import * as ts from "typescript"; -import { createTypeScriptFileFromProgram } from "./createTypeScriptFileFromProgram.js"; +import { TypeScriptBasedLanguageFile } from "./language.js"; const log = debugForFile(import.meta.filename); @@ -9,7 +9,7 @@ export function createTypeScriptFileFromProjectService( filePathAbsolute: string, program: ts.Program, service: ts.server.ProjectService, -) { +): TypeScriptBasedLanguageFile { const sourceFile = program.getSourceFile(filePathAbsolute); if (!sourceFile) { throw new Error(`Could not retrieve source file for: ${filePathAbsolute}`); @@ -17,15 +17,11 @@ export function createTypeScriptFileFromProjectService( log("Retrieved source file and type checker for file %s:", filePathAbsolute); - const file = createTypeScriptFileFromProgram(program, sourceFile); - return { - languageFile: { - ...file, - [Symbol.dispose]() { - service.closeClientFile(filePathAbsolute); - }, - }, + program, sourceFile, + [Symbol.dispose]() { + service.closeClientFile(filePathAbsolute); + }, }; } diff --git a/packages/ts/src/formatDiagnostic.ts b/packages/ts/src/formatDiagnostic.ts index 9049485ae..00439e270 100644 --- a/packages/ts/src/formatDiagnostic.ts +++ b/packages/ts/src/formatDiagnostic.ts @@ -5,19 +5,31 @@ // eslint-disable-next-line @eslint-community/eslint-comments/disable-enable-pair /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import ts, { - flattenDiagnosticMessageText, +import ts, { flattenDiagnosticMessageText } from "typescript"; +import { getLineAndCharacterOfPosition, getPositionOfLineAndCharacter, - type SourceFile, -} from "typescript"; + SourceFileLikeLoose, +} from "./normalizeRange.js"; +export interface SourceFileLikeLooseWithName extends SourceFileLikeLoose { + fileName: string; +} + +export interface RawDiagnosticRelatedInformation { + category: ts.DiagnosticCategory; + code: number; + file: SourceFileLikeLooseWithName | undefined; + start: number | undefined; + length: number | undefined; + messageText: string | ts.DiagnosticMessageChain; +} export interface RawDiagnostic { - file?: ts.SourceFile; + file?: SourceFileLikeLooseWithName; length: number; message: string; name: string; - relatedInformation?: ts.DiagnosticRelatedInformation[]; + relatedInformation?: RawDiagnosticRelatedInformation[]; start: number; } @@ -49,7 +61,7 @@ export function formatDiagnostic(diagnostic: RawDiagnostic) { messageText, start, } of diagnostic.relatedInformation) { - const indent = " "; + const indent = " "; if (file) { output += "\n"; output += " " + formatLocation(file, start!); @@ -87,7 +99,7 @@ function displayFilename(name: string) { } function formatCodeSpan( - file: SourceFile, + file: SourceFileLikeLoose, start: number, length: number, indent: string, @@ -153,7 +165,10 @@ function formatCodeSpan( return context; } -function formatLocation(file: SourceFile, start: number): string { +function formatLocation( + file: SourceFileLikeLooseWithName, + start: number, +): string { const { character, line } = getLineAndCharacterOfPosition(file, start); const relativeFileName = displayFilename(file.fileName); let output = ""; diff --git a/packages/ts/src/language.ts b/packages/ts/src/language.ts index 1ce2082c3..5832c3123 100644 --- a/packages/ts/src/language.ts +++ b/packages/ts/src/language.ts @@ -1,4 +1,8 @@ -import { createLanguage } from "@flint.fyi/core"; +import { + createLanguage, + LanguageFileFactoryDefinition, + LanguagePreparedDefinition, +} from "@flint.fyi/core"; import { createProjectService } from "@typescript-eslint/project-service"; import { createFSBackedSystem, @@ -24,98 +28,121 @@ export interface TypeScriptServices { typeChecker: ts.TypeChecker; } -export const typescriptLanguage = createLanguage< - TSNodesByName, - TypeScriptServices ->({ - about: { - name: "TypeScript", - }, - prepare: () => { - const { service } = createProjectService(); - const seenPrograms = new Set(); - - const environments = new CachedFactory((filePathAbsolute: string) => { - const system = createFSBackedSystem( - new Map([[filePathAbsolute, "// ..."]]), - projectRoot, - ts, - ); +export type TypeScriptBasedLanguageFileFactory = ( + program: ts.Program, + sourceFile: ts.SourceFile, +) => LanguagePreparedDefinition; - return createVirtualTypeScriptEnvironment( - system, - [filePathAbsolute], - ts, - { - skipLibCheck: true, - target: ts.ScriptTarget.ESNext, - }, - ); +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); + 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}`, - ); - } + 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 defaultProject = service.getDefaultProjectForFile( - scriptInfo.fileName, - true, + const program = defaultProject.getLanguageService(true).getProgram(); + if (!program) { + throw new Error( + `Could not retrieve program for file: ${filePathAbsolute}`, ); - 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 program; - }); + return { + createFromDisk: (filePathAbsolute) => { + const program = servicePrograms.get(filePathAbsolute); - return { - prepareFromDisk: (filePathAbsolute) => { - const program = servicePrograms.get(filePathAbsolute); + seenPrograms.add(program); - 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, + }; + // return opts.createTypeScriptFile(program, sourceFile); + }, + }; +} - const { languageFile, sourceFile } = - createTypeScriptFileFromProjectService( - filePathAbsolute, - program, - service, - ); +export const typescriptLanguage = createLanguage< + TSNodesByName, + TypeScriptServices +>({ + about: { + name: "TypeScript", + }, + prepare: () => { + const lang = prepareTypeScriptBasedLanguage(); - return prepareTypeScriptFile(languageFile, sourceFile); + return { + prepareFromDisk(filePathAbsolute) { + return prepareTypeScriptFile(lang.createFromDisk(filePathAbsolute)); }, - prepareFromVirtual: (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); - - const languageFile = createTypeScriptFileFromProgram( - program, - sourceFile, + prepareFromVirtual(filePathAbsolute, sourceText) { + return prepareTypeScriptFile( + lang.createFromVirtual(filePathAbsolute, sourceText), ); - - return prepareTypeScriptFile(languageFile, sourceFile); }, }; }, diff --git a/packages/ts/src/normalizeRange.ts b/packages/ts/src/normalizeRange.ts index 05ed06c68..6050f1b6c 100644 --- a/packages/ts/src/normalizeRange.ts +++ b/packages/ts/src/normalizeRange.ts @@ -1,13 +1,16 @@ -import type * as ts from "typescript"; - import { CharacterReportRange, NormalizedReportRangeObject, } from "@flint.fyi/core"; +import * as ts from "typescript"; + +export interface SourceFileLikeLoose { + readonly text: string; +} export function normalizeRange( original: CharacterReportRange, - sourceFile: ts.SourceFile, + sourceFile: SourceFileLikeLoose, ): NormalizedReportRangeObject { const onCharacters = isNode(original) ? { begin: original.getStart(), end: original.getEnd() } @@ -23,8 +26,40 @@ function isNode(value: unknown): value is ts.Node { return typeof value === "object" && value !== null && "kind" in value; } -function normalizeRangePosition(raw: number, sourceFile: ts.SourceFile) { - const { character, line } = sourceFile.getLineAndCharacterOfPosition(raw); +// Internally, the SourceFileLike interface declares getLineAndCharacterOfPosition +// as an optional field [1]. However, it is later made required through module augmentation[2]. +// Despite that, ts.getLineAndCharacterOfPosition never accesses sourceFile.getLineAndCharacterOfPosition [3]. +// Therefore, it's safe to pass SourceFileLikeLoose here. +// +// We don't pass the raw text directly, because when sourceFile is a ts.SourceFile, +// it may contain a precomputed lineMap, which helps avoid recalculating it. +// +// [1] https://github.com/microsoft/TypeScript/blob/af3a3779de6bc27619c85077e1b4d1de8feddd35/src/compiler/types.ts#L4290 +// [2] https://github.com/microsoft/TypeScript/blob/af3a3779de6bc27619c85077e1b4d1de8feddd35/src/services/types.ts#L178-L183 +// [3] https://github.com/microsoft/TypeScript/blob/af3a3779de6bc27619c85077e1b4d1de8feddd35/src/compiler/scanner.ts#L503-L505 +export function getLineAndCharacterOfPosition( + sourceFile: SourceFileLikeLoose, + position: number, +) { + return ts.getLineAndCharacterOfPosition( + sourceFile as ts.SourceFileLike, + position, + ); +} +export function getPositionOfLineAndCharacter( + sourceFile: SourceFileLikeLoose, + line: number, + character: number, +) { + return ts.getPositionOfLineAndCharacter( + sourceFile as ts.SourceFileLike, + line, + character, + ); +} + +function normalizeRangePosition(raw: number, sourceFile: SourceFileLikeLoose) { + const { character, line } = getLineAndCharacterOfPosition(sourceFile, raw); return { column: character, line, raw }; } diff --git a/packages/ts/src/prepareTypeScriptFile.ts b/packages/ts/src/prepareTypeScriptFile.ts index fb21beae5..74583c057 100644 --- a/packages/ts/src/prepareTypeScriptFile.ts +++ b/packages/ts/src/prepareTypeScriptFile.ts @@ -1,14 +1,17 @@ -import { LanguageFileDefinition } from "@flint.fyi/core"; -import ts from "typescript"; - import { parseDirectivesFromTypeScriptFile } from "./directives/parseDirectivesFromTypeScriptFile.js"; +import { TypeScriptBasedLanguageFile } from "./language.js"; +import { LanguagePreparedDefinition } from "@flint.fyi/core"; +import { createTypeScriptFileFromProgram } from "./createTypeScriptFileFromProgram.js"; export function prepareTypeScriptFile( - languageFile: LanguageFileDefinition, - sourceFile: ts.SourceFile, -) { + file: TypeScriptBasedLanguageFile, +): LanguagePreparedDefinition { + const { program, sourceFile, [Symbol.dispose]: onDispose } = file; return { ...parseDirectivesFromTypeScriptFile(sourceFile), - file: languageFile, + file: { + ...(onDispose != null && { [Symbol.dispose]: onDispose }), + ...createTypeScriptFileFromProgram(program, sourceFile), + }, }; } diff --git a/packages/ts/src/rules/utils/builtinSymbolLikes.ts b/packages/ts/src/rules/utils/builtinSymbolLikes.ts new file mode 100644 index 000000000..7c444ce13 --- /dev/null +++ b/packages/ts/src/rules/utils/builtinSymbolLikes.ts @@ -0,0 +1,190 @@ +import * as tsutils from "ts-api-utils"; +import ts from "typescript"; + +import { isSymbolFromDefaultLibrary } from "./isSymbolFromDefaultLibrary.js"; + +/** + * @example + * ```ts + * class DerivedClass extends Promise {} + * DerivedClass.reject + * // ^ PromiseLike + * ``` + */ +export function isPromiseLike(program: ts.Program, type: ts.Type): boolean { + return isBuiltinSymbolLike(program, type, "Promise"); +} + +/** + * @example + * ```ts + * const value = Promise + * value.reject + * // ^ PromiseConstructorLike + * ``` + */ +export function isPromiseConstructorLike( + program: ts.Program, + type: ts.Type, +): boolean { + return isBuiltinSymbolLike(program, type, "PromiseConstructor"); +} + +/** + * @example + * ```ts + * class Foo extends Error {} + * new Foo() + * // ^ ErrorLike + * ``` + */ +export function isErrorLike(program: ts.Program, type: ts.Type): boolean { + return isBuiltinSymbolLike(program, type, "Error"); +} + +/** + * @example + * ```ts + * type T = Readonly + * // ^ ReadonlyErrorLike + * ``` + */ +export function isReadonlyErrorLike( + program: ts.Program, + type: ts.Type, +): boolean { + return isReadonlyTypeLike(program, type, (subtype) => { + const [typeArgument] = subtype.aliasTypeArguments; + return ( + isErrorLike(program, typeArgument) || + isReadonlyErrorLike(program, typeArgument) + ); + }); +} + +/** + * @example + * ```ts + * type T = Readonly<{ foo: 'bar' }> + * // ^ ReadonlyTypeLike + * ``` + */ +export function isReadonlyTypeLike( + program: ts.Program, + type: ts.Type, + predicate?: ( + subType: { + aliasSymbol: ts.Symbol; + aliasTypeArguments: readonly ts.Type[]; + } & ts.Type, + ) => boolean, +): boolean { + return isBuiltinTypeAliasLike(program, type, (subtype) => { + return ( + subtype.aliasSymbol.getName() === "Readonly" && !!predicate?.(subtype) + ); + }); +} +export function isBuiltinTypeAliasLike( + program: ts.Program, + type: ts.Type, + predicate: ( + subType: { + aliasSymbol: ts.Symbol; + aliasTypeArguments: readonly ts.Type[]; + } & ts.Type, + ) => boolean, +): boolean { + return isBuiltinSymbolLikeRecurser(program, type, (subtype) => { + const { aliasSymbol, aliasTypeArguments } = subtype; + + if (!aliasSymbol || !aliasTypeArguments) { + return false; + } + + if ( + isSymbolFromDefaultLibrary(program, aliasSymbol) && + predicate( + subtype as { + aliasSymbol: ts.Symbol; + aliasTypeArguments: readonly ts.Type[]; + } & ts.Type, + ) + ) { + return true; + } + + return null; + }); +} + +export function isBuiltinSymbolLike( + program: ts.Program, + type: ts.Type, + symbolName: string | string[], +): boolean { + return isBuiltinSymbolLikeRecurser(program, type, (subType) => { + const symbol = subType.getSymbol(); + if (!symbol) { + return false; + } + + const actualSymbolName = symbol.getName(); + + if ( + (Array.isArray(symbolName) + ? symbolName.some((name) => actualSymbolName === name) + : actualSymbolName === symbolName) && + isSymbolFromDefaultLibrary(program, symbol) + ) { + return true; + } + + return null; + }); +} + +export function isBuiltinSymbolLikeRecurser( + program: ts.Program, + type: ts.Type, + predicate: (subType: ts.Type) => boolean | null, +): boolean { + if (type.isIntersection()) { + return type.types.some((t) => + isBuiltinSymbolLikeRecurser(program, t, predicate), + ); + } + if (type.isUnion()) { + return type.types.every((t) => + isBuiltinSymbolLikeRecurser(program, t, predicate), + ); + } + if (tsutils.isTypeParameter(type)) { + const t = type.getConstraint(); + + if (t) { + return isBuiltinSymbolLikeRecurser(program, t, predicate); + } + + return false; + } + + const predicateResult = predicate(type); + if (typeof predicateResult === "boolean") { + return predicateResult; + } + + const symbol = type.getSymbol(); + if ( + symbol && + symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface) + ) { + const checker = program.getTypeChecker(); + for (const baseType of checker.getBaseTypes(type as ts.InterfaceType)) { + if (isBuiltinSymbolLikeRecurser(program, baseType, predicate)) { + return true; + } + } + } + return false; +} diff --git a/packages/ts/src/rules/utils/isSymbolFromDefaultLibrary.ts b/packages/ts/src/rules/utils/isSymbolFromDefaultLibrary.ts new file mode 100644 index 000000000..03ad9dcd4 --- /dev/null +++ b/packages/ts/src/rules/utils/isSymbolFromDefaultLibrary.ts @@ -0,0 +1,20 @@ +import type ts from "typescript"; + +export function isSymbolFromDefaultLibrary( + program: ts.Program, + symbol: ts.Symbol | undefined, +): boolean { + if (!symbol) { + return false; + } + + const declarations = symbol.getDeclarations() ?? []; + for (const declaration of declarations) { + const sourceFile = declaration.getSourceFile(); + if (program.isSourceFileDefaultLibrary(sourceFile)) { + return true; + } + } + + return false; +} diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 000000000..b5d40f512 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,22 @@ +{ + "name": "@flint.fyi/md", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@flint.fyi/core": "workspace:", + "@typescript/vfs": "^1.6.1", + "@volar/language-core": "^2.4.23", + "@volar/source-map": "^2.4.23", + "@volar/typescript": "^2.4.23", + "@vue/compiler-core": "^3.5.22", + "@vue/language-core": "^3.1.1", + "ts-api-utils": "^2.1.0", + "vfile": "^6.0.3", + "vfile-location": "^5.0.3", + "vue": "^3.5.22" + }, + "devDependencies": { + "@flint.fyi/rule-tester": "workspace:" + } +} diff --git a/packages/vue/src/index.test.ts b/packages/vue/src/index.test.ts new file mode 100644 index 000000000..fbe394db2 --- /dev/null +++ b/packages/vue/src/index.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { translateRange } from "./index.js"; +import { SourceMap } from "@volar/source-map"; + +describe("translateRange", () => { + const prefix = ' "; + const variable = "foo"; + const suffix = '"/>'; + const source = prefix + beforeVariable + variable + suffix; + + const generatedPrefix = "generated_bar: ("; + const generatedBeforeVariable = "__VLS_ctx."; + const generatedSuffix = ")"; + const generated = + generatedPrefix + + beforeVariable + + generatedBeforeVariable + + variable + + generatedSuffix; + + const mapping = new SourceMap([ + { + sourceOffsets: [prefix.length], + generatedOffsets: [generatedPrefix.length], + lengths: [beforeVariable.length], + data: {}, + }, + { + sourceOffsets: [(prefix + beforeVariable).length], + generatedOffsets: [ + (generatedPrefix + beforeVariable + generatedBeforeVariable).length, + ], + lengths: [variable.length], + data: {}, + }, + ]); + + it(` +generated_bar: (() => __VLS_ctx.foo) + ^^^^^^^^^^^^^ + + ^^^ +`, () => { + const range = translateRange( + generated, + mapping, + (generatedPrefix + beforeVariable).length, + (generatedPrefix + beforeVariable + generatedBeforeVariable + variable) + .length, + ); + + expect(range).toStrictEqual({ + begin: (prefix + beforeVariable).length, + end: (prefix + beforeVariable + variable).length, + }); + }); + + it(` +generated_bar: (() => __VLS_ctx.foo) + ^^^^^^^^^^^^^^^ + + ^^^^^ +`, () => { + const range = translateRange( + generated, + mapping, + (generatedPrefix + beforeVariable).length - 2, + (generatedPrefix + beforeVariable + generatedBeforeVariable + variable) + .length, + ); + + expect(range).toStrictEqual({ + begin: (prefix + beforeVariable).length - 2, + end: (prefix + beforeVariable + variable).length, + }); + }); + + it(` +generated_bar: (() => __VLS_ctx.foo) + ^^^^^^^^^^^^^^ + + +`, () => { + const range = translateRange( + generated, + mapping, + (generatedPrefix + beforeVariable).length, + (generatedPrefix + beforeVariable + generatedBeforeVariable + variable) + .length + 1, + ); + + expect(range).toBeNull(); + }); + + it(` +generated_bar: (() => __VLS_ctx.foo) + ^^^^^^^^^^^^^^^^^^^ + + ^^^^^^^^^ +`, () => { + const range = translateRange( + generated, + mapping, + generatedPrefix.length, + (generatedPrefix + beforeVariable + generatedBeforeVariable + variable) + .length, + ); + + expect(range).toStrictEqual({ + begin: prefix.length, + end: (prefix + beforeVariable + variable).length, + }); + }); +}); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 000000000..fa9f7cb06 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,357 @@ +import { + CharacterReportRange, + createLanguage, + isSuggestionForFiles, + NormalizedReport, + RuleReport, + Suggestion, +} from "@flint.fyi/core"; +import { + Mapper as VolarMapper, + Language as VolarLanguage, + LanguagePlugin as VolarLanguagePlugin, + Mapping, +} from "@volar/language-core"; +import { + createGlobalTypesWriter as createGlobalVueTypesWriter, + createVueLanguagePlugin, + createParsedCommandLine as createVueParsedCommandLine, + createParsedCommandLineByJson as createVueParsedCommandLineByJson, + Sfc, + VueVirtualCode, +} from "@vue/language-core"; +import { RootNode } from "@vue/compiler-core"; +// for LanguagePlugin interface augmentation +import "@volar/typescript"; +import ts from "typescript"; +import { VFile } from "vfile"; +import { location } from "vfile-location"; + +import { + TypeScriptServices, + prepareTypeScriptBasedLanguage, +} from "../../ts/lib/language.js"; +import { normalizeRange } from "../../ts/lib/normalizeRange.js"; +import { vueLanguageParseContext } from "./setup-tests.js"; +import { + collectTypeScriptFileCacheImpacts, + convertTypeScriptDiagnosticToLanguageFileDiagnostic, + createTypeScriptFileFromProgram, + runTypeScriptBasedLanguageRule, +} from "../../ts/lib/createTypeScriptFileFromProgram.js"; +import { RuleReporter } from "@flint.fyi/core/src/types/context.js"; + +export interface VueServices extends TypeScriptServices { + sfc: Sfc; + templateAst: RootNode | null; + // TODO: can we type MessageId? + reportSfc: RuleReporter; + map: VolarMapper; +} + +export const vueLanguage = createLanguage({ + about: { + name: "Vue.js", + }, + prepare: () => { + const tsLang = prepareTypeScriptBasedLanguage(); + + return { + prepareFromDisk: () => { + throw new Error("TODO: prepareFromDisk"); + }, + prepareFromVirtual: (filePathAbsolute, sourceText) => { + const fileLocation = location( + new VFile({ + path: filePathAbsolute, + value: sourceText, + }), + ); + let volarLanguage = null as null | VolarLanguage; + function getLanguagePlugins( + ts: typeof import("typescript"), + options: ts.CreateProgramOptions, + ): { + languagePlugins: VolarLanguagePlugin[]; + setup?(language: VolarLanguage): void; + } { + const { configFilePath } = options.options; + const { vueOptions } = + typeof configFilePath === "string" + ? createVueParsedCommandLine( + ts, + ts.sys, + configFilePath.replaceAll("\\", "/"), + ) + : createVueParsedCommandLineByJson( + ts, + ts.sys, + (options.host ?? ts.sys).getCurrentDirectory(), + {}, + ); + vueOptions.globalTypesPath = createGlobalVueTypesWriter( + vueOptions, + ts.sys.writeFile, + ); + const vueLanguagePlugin = createVueLanguagePlugin( + ts, + options.options, + vueOptions, + (id) => id, + ); + if (vueLanguagePlugin.typescript != null) { + const { getServiceScript } = vueLanguagePlugin.typescript; + vueLanguagePlugin.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 { + languagePlugins: [vueLanguagePlugin], + setup: (lang) => (volarLanguage = lang), + }; + } + + let templateAst = null as RootNode | null; + + const { + program, + sourceFile, + [Symbol.dispose]: onDispose, + } = vueLanguageParseContext.run( + { + getLanguagePlugins, + setVueAst(ast, options) { + // TODO: ts plugin creates VFS environment with this empty file + // and later updates it + if (ast.source === "// ...") { + return; + } + // we don't interested in top-level SFC blocks parsing + if (options.parseMode !== "html") { + return; + } + templateAst = structuredClone(ast); + }, + }, + () => { + return tsLang.createFromVirtual(filePathAbsolute, sourceText); + }, + ); + + if (volarLanguage == null) { + throw new Error( + "'typescript' package wasn't properly patched. Make sure you don't import 'typescript' before Flint.", + ); + } + + const sourceScript = volarLanguage.scripts.get(filePathAbsolute); + if (sourceScript == null) { + throw new Error("Expected sourceScript to be set"); + } + if (sourceScript.generated == null) { + throw new Error("Expected sourceScript.generated to be set"); + } + if (sourceScript.snapshot == null) { + throw new Error("Expected sourceScript.snapshot to be set"); + } + if (sourceScript.generated.languagePlugin.typescript == null) { + throw new Error( + "Expected sourceScript.generated.languagePlugin.typescript to be set", + ); + } + + const serviceScript = + sourceScript.generated.languagePlugin.typescript.getServiceScript( + sourceScript.generated.root, + ); + if (serviceScript == null) { + throw new Error("Expected serviceScript to exist"); + } + + const virtualCode = sourceScript.generated.root as VueVirtualCode; + + const map = volarLanguage.maps.get(serviceScript.code, sourceScript); + + // TODO: parsing errors + // TODO: directives + + return { + file: { + ...(onDispose != null && { [Symbol.dispose]: onDispose }), + cache: collectTypeScriptFileCacheImpacts(program, sourceFile), + getDiagnostics() { + // TODO: report parse errors + // TODO: transform ranges + return ts + .getPreEmitDiagnostics(program, sourceFile) + .map(convertTypeScriptDiagnosticToLanguageFileDiagnostic); + }, + async runRule(rule, options) { + const translatedReports: NormalizedReport[] = []; + const reports = await runTypeScriptBasedLanguageRule( + program, + sourceFile, + rule, + options, + { + sfc: virtualCode.sfc, + templateAst, + map, + reportSfc: (report: RuleReport) => { + const positionBegin = fileLocation.toPoint( + report.range.begin, + ); + if (positionBegin == null) { + throw new Error("Invalid report.range.begin"); + } + const positionEnd = fileLocation.toPoint(report.range.end); + if (positionEnd == null) { + throw new Error("Invalid report.range.begin"); + } + translatedReports.push({ + ...report, + message: rule.messages[report.message], + range: { + begin: { + line: positionBegin.line - 1, + column: positionBegin.column - 1, + raw: report.range.begin, + }, + end: { + line: positionEnd.line - 1, + column: positionEnd.column - 1, + raw: report.range.end, + }, + }, + }); + }, + }, + ); + + for (const report of reports) { + const reportRange = translateRange( + serviceScript.code.snapshot.getText( + 0, + serviceScript.code.snapshot.getLength(), + ), + map, + report.range.begin.raw, + report.range.end.raw, + ); + if (reportRange == null) { + continue; + } + + const translatedReport: NormalizedReport = { + ...report, + range: normalizeRange(reportRange, { + text: sourceScript.snapshot.getText( + 0, + sourceScript.snapshot.getLength(), + ), + }), + }; + if (report.suggestions != null) { + translatedReport.suggestions = + report.suggestions.map((suggestion) => { + if (isSuggestionForFiles(suggestion)) { + throw new Error( + "TODO: vue - suggestions for multiple files are not yet supported", + ); + } + const range = translateRange( + serviceScript.code.snapshot.getText( + 0, + serviceScript.code.snapshot.getLength(), + ), + map, + suggestion.range.begin, + suggestion.range.end, + ); + if (range == null) { + // TODO: maybe we should filter out these suggestions intead of erroring? + throw new Error( + "Suggestion range overlaps with virtual code", + ); + } + return { + ...suggestion, + range, + }; + }); + } + + translatedReports.push(translatedReport); + } + + return translatedReports; + }, + }, + }; + }, + }; + }, +}); + +export function translateRange( + generated: string, + map: VolarMapper, + begin: number, + end: number, +): { begin: number; end: number } | null { + if (end < begin) { + throw new Error("TODO"); + } + // TODO(perf): binary search? + + // we don't care about mappings with two positions (are we right?) + const mappings = map.mappings.filter( + (m) => m.sourceOffsets.length === 1 && m.lengths[0] > 0, + ); + + let sourceBegin: number | null = null; + + for (const mapping of mappings) { + const generatedLengths = mapping.generatedLengths ?? mapping.lengths; + + if (begin < mapping.generatedOffsets[0]) { + // TODO: __VLS_dollars + const a = "__VLS_ctx."; + if (generated.slice(begin, mapping.generatedOffsets[0]) !== a) { + return null; + } + if (end <= mapping.generatedOffsets[0]) { + return null; + } + sourceBegin ??= mapping.sourceOffsets[0]; + } else if (begin < mapping.generatedOffsets[0] + generatedLengths[0]) { + sourceBegin ??= + mapping.sourceOffsets[0] + (begin - mapping.generatedOffsets[0]); + } else { + continue; + } + + if (end <= mapping.generatedOffsets[0] + generatedLengths[0]) { + return { + begin: sourceBegin, + end: mapping.sourceOffsets[0] + (end - mapping.generatedOffsets[0]), + }; + } + begin = mapping.generatedOffsets[0] + generatedLengths[0]; + } + + return null; +} diff --git a/packages/vue/src/patch-typescript.ts b/packages/vue/src/patch-typescript.ts new file mode 100644 index 000000000..0108496f3 --- /dev/null +++ b/packages/vue/src/patch-typescript.ts @@ -0,0 +1,128 @@ +import type { Language, LanguagePlugin } from "@volar/language-core"; +import type * as ts from "typescript"; + +export type VolarLanguagePluginsGetter = ( + ts: typeof import("typescript"), + options: ts.CreateProgramOptions, +) => + | LanguagePlugin[] + | { + languagePlugins: LanguagePlugin[]; + setup?(language: Language): void; + }; + +// https://github.com/volarjs/volar.js/blob/e08f2f449641e1c59686d3454d931a3c29ddd99c/packages/typescript/lib/quickstart/runTsc.ts +export function transformTscContent( + tsc: string, + extraSupportedExtensions: string[], + extraExtensionsToRemove: string[] = [], + proxyApiPath: string = require.resolve( + "@volar/typescript/lib/node/proxyCreateProgram", + ), + typescriptObject = `new Proxy({}, { get(_target, p, _receiver) { return eval(p); } } )`, +) { + const neededPatchExtenstions = extraSupportedExtensions.filter( + (ext) => !extraExtensionsToRemove.includes(ext), + ); + + // Add allow extensions + if (extraSupportedExtensions.length) { + const extsText = extraSupportedExtensions + .map((ext) => `"${ext}"`) + .join(", "); + tsc = replace( + tsc, + /supportedTSExtensions = .*(?=;)/, + (s) => + s + + `.map((group, i) => i === 0 ? group.splice(0, 0, ${extsText}) && group : group)`, + ); + tsc = replace( + tsc, + /supportedJSExtensions = .*(?=;)/, + (s) => + s + + `.map((group, i) => i === 0 ? group.splice(0, 0, ${extsText}) && group : group)`, + ); + tsc = replace( + tsc, + /allSupportedExtensions = .*(?=;)/, + (s) => + s + + `.map((group, i) => i === 0 ? group.splice(0, 0, ${extsText}) && group : group)`, + ); + } + // Use to emit basename.xxx to basename.d.ts instead of basename.xxx.d.ts + if (extraExtensionsToRemove.length) { + const extsText = extraExtensionsToRemove + .map((ext) => `"${ext}"`) + .join(", "); + tsc = replace( + tsc, + /extensionsToRemove = .*(?=;)/, + (s) => s + `.concat([${extsText}])`, + ); + } + // Support for basename.xxx to basename.xxx.d.ts + if (neededPatchExtenstions.length) { + const extsText = neededPatchExtenstions.map((ext) => `"${ext}"`).join(", "); + tsc = replace( + tsc, + /function changeExtension\(/, + (s) => + `function changeExtension(path, newExtension) { + return [${extsText}].some(ext => path.endsWith(ext)) + ? path + newExtension + : _changeExtension(path, newExtension) + }\n` + s.replace("changeExtension", "_changeExtension"), + ); + } + + // proxy createProgram + tsc = replace( + tsc, + /function createProgram\(.+\) \{/, + (s) => + // proxyCreateProgram caches volar language setup, + // but we want it to create language on each call + `var createProgram = (...args) => require(${JSON.stringify(proxyApiPath)}).proxyCreateProgram(` + + [ + typescriptObject, + `_createProgram`, + `globalThis._vueLanguageParseContext.getStore().getLanguagePlugins`, + ].join(", ") + + `)(...args);\n` + + s.replace("createProgram", "_createProgram"), + ); + + return tsc; +} + +function replace( + text: string, + search: RegExp | string, + replace: (substring: string) => string, +) { + const before = text; + text = text.replace(search, replace); + const after = text; + if (after === before) { + throw new Error("Failed to replace: " + search); + } + return after; +} + +export function transformVueCompilerCore(content: string): string { + return replace( + content, + "function baseParse(input, options) {", + (s) => ` +${s} + const ast = _baseParse(input, options) + globalThis._vueLanguageParseContext.getStore().setVueAst(ast, options) + return ast +} + +function _baseParse(input, options) {`, + ); +} diff --git a/packages/vue/src/rules/AnotherComponent.vue b/packages/vue/src/rules/AnotherComponent.vue new file mode 100644 index 000000000..3c6a1721b --- /dev/null +++ b/packages/vue/src/rules/AnotherComponent.vue @@ -0,0 +1,3 @@ + diff --git a/packages/vue/src/rules/MyComponent.vue b/packages/vue/src/rules/MyComponent.vue new file mode 100644 index 000000000..a2abf7c27 --- /dev/null +++ b/packages/vue/src/rules/MyComponent.vue @@ -0,0 +1,5 @@ + + + diff --git a/packages/vue/src/rules/anyReturns.test.ts b/packages/vue/src/rules/anyReturns.test.ts new file mode 100644 index 000000000..8dcfc2e02 --- /dev/null +++ b/packages/vue/src/rules/anyReturns.test.ts @@ -0,0 +1,107 @@ +import path from "node:path"; +import rule from "../../../ts/lib/rules/anyReturns.js"; +import { vueLanguage } from "../index.js"; +import { ruleTester } from "./ruleTester.js"; + +const fileName = path.join(import.meta.dirname, "file.vue"); + +ruleTester.describe(vueLanguage.createRule(rule), { + invalid: [ + { + code: ` + + `, + fileName, + snapshot: ` + + `, + }, + { + code: ` + + + + `, + fileName, + snapshot: ` + + + + `, + }, + { + code: ` + + + + `, + fileName, + snapshot: ` + + + + `, + }, + ], + valid: [ + { + code: ` + + `, + fileName, + }, + ], +}); diff --git a/packages/vue/src/rules/asyncComputed.test.ts b/packages/vue/src/rules/asyncComputed.test.ts new file mode 100644 index 000000000..2c4dac3f4 --- /dev/null +++ b/packages/vue/src/rules/asyncComputed.test.ts @@ -0,0 +1,390 @@ +import path from "node:path"; +import rule from "./asyncComputed.js"; +import { ruleTester } from "./ruleTester.js"; + +const fileName = path.join(import.meta.dirname, "file.vue"); + +ruleTester.describe(rule, { + invalid: [ + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + // TODO: volar sets different ScriptKind depending on + + + `, + snapshot: ` + + + + `, + }, + ], + valid: [ + // invalid computed + { + fileName, + code: ` + + `, + }, + // invalid computed + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + ], +}); diff --git a/packages/vue/src/rules/asyncComputed.ts b/packages/vue/src/rules/asyncComputed.ts new file mode 100644 index 000000000..04f50c3ec --- /dev/null +++ b/packages/vue/src/rules/asyncComputed.ts @@ -0,0 +1,92 @@ +import ts from "typescript"; +import * as tsutils from "ts-api-utils"; +import { vueLanguage } from "../index.js"; +import { isPromiseLike } from "../../../ts/lib/rules/utils/builtinSymbolLikes.js"; +import { isTypeRecursive } from "../../../ts/lib/rules/utils/isTypeRecursive.js"; + +export default vueLanguage.createRule({ + about: { + id: "asyncComputed", + description: "Reports asynchronous functions in computed properties.", + preset: "logical", + }, + messages: { + async: { + primary: + "Asynchronous functions in computed properties can lead to loss of reactivity.", + secondary: [ + "Computed properties must be synchronous to maintain the reactive data flow.", + "Using async functions can cause computed properties to return promises, disrupting the reactivity system.", + ], + suggestions: [ + "Consider using watches for asynchronous operations instead of computed properties.", + ], + }, + }, + setup(context) { + return { + visitors: { + CallExpression(node: ts.CallExpression) { + if (!isComputed(context.typeChecker, node)) { + return; + } + + const type = context.typeChecker.getTypeAtLocation(node); + if (!tsutils.isTypeReference(type)) { + return; + } + + // declare function computed(...): ... + const [getterType] = context.typeChecker.getTypeArguments(type); + if (getterType == null) { + return; + } + + if ( + !isTypeRecursive(getterType, (type) => + isPromiseLike(context.program, type), + ) + ) { + return; + } + + const reportNode = node.arguments[0] ?? node.expression; + + context.report({ + range: { + begin: reportNode.getStart(context.sourceFile), + end: reportNode.getEnd(), + }, + message: "async", + }); + }, + }, + }; + }, +}); + +function isComputed( + typeChecker: ts.TypeChecker, + node: ts.CallExpression, +): boolean { + const callee = node.expression; + if (!ts.isIdentifier(callee) || callee.text !== "computed") { + return false; + } + + const type = typeChecker.getTypeAtLocation(callee); + + if (type.symbol?.declarations == null) { + return false; + } + + for (const decl of type.symbol.declarations) { + if ( + decl.getSourceFile().fileName.includes("/node_modules/@vue/reactivity/") + ) { + return true; + } + } + + return false; +} diff --git a/packages/vue/src/rules/debuggerStatements.test.ts b/packages/vue/src/rules/debuggerStatements.test.ts new file mode 100644 index 000000000..f8ccea3d2 --- /dev/null +++ b/packages/vue/src/rules/debuggerStatements.test.ts @@ -0,0 +1,84 @@ +import rule from "../../../ts/lib/rules/debuggerStatements.js"; +import { vueLanguage } from "../index.js"; +import { ruleTester } from "./ruleTester.js"; + +ruleTester.describe(vueLanguage.createRule(rule), { + invalid: [ + { + code: ` + + `, + fileName: "file.vue", + snapshot: ` + + `, + suggestions: [ + { + id: "removeDebugger", + updated: ` + + `, + }, + ], + }, + { + code: ` + + `, + fileName: "file.vue", + snapshot: ` + + `, + only: true, + suggestions: [ + { + id: "removeDebugger", + updated: ` + + `, + }, + ], + }, + ], + valid: [ + { + code: ` + + `, + fileName: "file.vue", + }, + ], +}); diff --git a/packages/vue/src/rules/ruleTester.ts b/packages/vue/src/rules/ruleTester.ts new file mode 100644 index 000000000..304c9ed12 --- /dev/null +++ b/packages/vue/src/rules/ruleTester.ts @@ -0,0 +1,4 @@ +import { RuleTester } from "@flint.fyi/rule-tester"; +import { describe, it } from "vitest"; + +export const ruleTester = new RuleTester({ describe, it }); diff --git a/packages/vue/src/rules/vForKey.test.ts b/packages/vue/src/rules/vForKey.test.ts new file mode 100644 index 000000000..301c25114 --- /dev/null +++ b/packages/vue/src/rules/vForKey.test.ts @@ -0,0 +1,526 @@ +import path from "node:path"; +import rule from "./vForKey.js"; +import { ruleTester } from "./ruleTester.js"; + +const fileName = path.join(import.meta.dirname, "file.vue"); + +ruleTester.describe(rule, { + invalid: [ + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + + + }, + `, + snapshot: ` + + + + }, + `, + }, + { + fileName, + code: ` + + }, + `, + snapshot: ` + + }, + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + `, + snapshot: ` + + `, + }, + { + fileName, + code: ` + + + + `, + snapshot: ` + + + + `, + }, + { + fileName, + code: ` + + + + `, + snapshot: ` + + + + `, + }, + ], + valid: [ + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + `, + }, + // TS error + { + fileName, + code: ` + + `, + }, + { + fileName, + code: ` + + + + `, + }, + { + fileName, + code: ` + + + + `, + }, + ], +}); diff --git a/packages/vue/src/rules/vForKey.ts b/packages/vue/src/rules/vForKey.ts new file mode 100644 index 000000000..e36c8bf7f --- /dev/null +++ b/packages/vue/src/rules/vForKey.ts @@ -0,0 +1,261 @@ +import { Mapper as VolarMapper } from "@volar/language-core"; +import * as vue from "@vue/compiler-core"; +import ts from "typescript"; +import { vueLanguage } from "../index.js"; +import { CharacterReportRange } from "@flint.fyi/core"; + +export default vueLanguage.createRule({ + about: { + id: "vForKey", + description: "Reports v-for directives without a valid key binding.", + preset: "logical", + }, + messages: { + missingKey: { + primary: + "Elements using v-for must include a unique :key to ensure correct reactivity and DOM stability.", + secondary: [ + "A missing :key can cause unpredictable updates during rendering optimizations.", + "Without a key, Vue may reuse or reorder elements incorrectly, which breaks expected behavior in transitions and stateful components.", + ], + suggestions: [ + "Always provide a unique :key based on the v-for item, such as an id.", + ], + }, + invalidKey: { + primary: + "The :key on this v-for element does not reference the iteration variable.", + secondary: [ + "Keys must uniquely identify each item in the v-for loop to maintain object constancy.", + "Using values unrelated to the loop can still lead to rendering issues during reordering.", + ], + suggestions: [ + "Bind the :key to something derived from the v-for item, like item.id or the index if no unique identifier exists.", + ], + }, + staticKey: { + primary: + "Static key values prevent Vue from tracking changes in v-for lists.", + secondary: [ + 'Using key="literal" means every item in the v-for shares the same key, which prevents Vue from tracking list updates correctly.', + "This blocks proper reactivity, leading to stale DOM content and skipped updates.", + ], + suggestions: [ + "Replace the static key with a dynamic and unique :key derived from the v-for item, such as item.id.", + ], + }, + }, + setup(context) { + const { + templateAst, + sfc: { template: templateTransformed }, + } = context; + if (templateAst == null || templateTransformed == null) { + return {}; + } + const { startTagEnd } = templateTransformed; + + const propValueRange = (propValue: vue.TextNode) => { + let strip = propValue.loc.source === propValue.content ? 0 : 1; + return { + begin: propValue.loc.start.offset + strip + startTagEnd, + end: propValue.loc.end.offset - strip + startTagEnd, + }; + }; + + const checkFor = ( + forDirective: vue.DirectiveNode, + forParseResult: vue.ForParseResult, + keyProp: vue.DirectiveNode | vue.AttributeNode | null, + ) => { + if (keyProp == null) { + context.reportSfc({ + range: { + begin: forDirective.loc.start.offset + startTagEnd, + end: forDirective.loc.start.offset + startTagEnd + "v-for".length, + }, + message: "missingKey", + }); + return; + } + if (keyProp.type === vue.NodeTypes.ATTRIBUTE) { + if (keyProp.value == null) { + return; // TS error + } + context.reportSfc({ + range: propValueRange(keyProp.value), + message: "staticKey", + }); + return; + } + + if (keyProp.arg == null) { + throw new Error("Expected keyProp.arg to be non-null"); + } + + let reportRange: CharacterReportRange; + let valueRange: CharacterReportRange; + + if (keyProp.exp == null) { + // :key + reportRange = { + begin: keyProp.loc.start.offset + startTagEnd, + end: keyProp.loc.end.offset + startTagEnd, + }; + const generatedLocations = Array.from( + context.map.toGeneratedLocation( + keyProp.arg.loc.start.offset + startTagEnd, + ), + ).filter(([, m]) => m.lengths[0] > 0); + + // |key|: |key| + // ^^^^^ + // |key|: __VLS_ctx.|key| + // ^^^^^ + const valueMapping = generatedLocations[1][1]; + + valueRange = { + begin: valueMapping.generatedOffsets[0], + end: valueMapping.generatedOffsets[0] + valueMapping.lengths[0], + }; + } else { + reportRange = { + begin: keyProp.exp.loc.start.offset + startTagEnd, + end: keyProp.exp.loc.end.offset + startTagEnd, + }; + + const valueBegin = toGeneratedLocation( + context.map, + keyProp.exp.loc.start.offset + startTagEnd, + ); + if (valueBegin == null) { + // Bug in vue/language-tools: virtual code is not generated for `, fileName: "file.vue", + only: true, snapshot: ` `, + fileName, snapshot: `