diff --git a/.changeset/quick-peas-join.md b/.changeset/quick-peas-join.md new file mode 100644 index 000000000..55f0bd2d3 --- /dev/null +++ b/.changeset/quick-peas-join.md @@ -0,0 +1,8 @@ +--- +"@flint.fyi/core": patch +"@flint.fyi/rule-tester": patch +"@flint.fyi/typescript-language": patch +"@flint.fyi/volar-language": minor +--- + +Introduce Volar.js meta-language. diff --git a/packages/core/src/running/processRuleReport.ts b/packages/core/src/running/processRuleReport.ts index 2287a716e..c0f343b39 100644 --- a/packages/core/src/running/processRuleReport.ts +++ b/packages/core/src/running/processRuleReport.ts @@ -13,6 +13,48 @@ export function processRuleReport( rule: AnyRule, ruleReport: RuleReport, ) { + let range = ruleReport.range; + let fix = + ruleReport.fix && !Array.isArray(ruleReport.fix) + ? [ruleReport.fix] + : ruleReport.fix; + let suggestions = ruleReport.suggestions; + const { adjustReportRange } = currentFile; + if (adjustReportRange != null) { + const adjustedRange = adjustReportRange(ruleReport.range); + if (adjustedRange == null) { + return null; + } + range = adjustedRange; + fix &&= fix + .map((fix) => { + const range = adjustReportRange(fix.range); + return ( + range && { + ...fix, + range, + } + ); + }) + .filter((f) => f != null); + + suggestions &&= suggestions + .map((s) => { + if ("files" in s) { + // TODO: support cross-file suggestions + return null; + } + const range = adjustReportRange(s.range); + return ( + range && { + ...s, + range, + } + ); + }) + .filter((s) => s != null); + } + return { ...ruleReport, about: { @@ -21,10 +63,7 @@ export function processRuleReport( ? `${rule.about.pluginId}/${rule.about.id}` : rule.about.id, }, - fix: - ruleReport.fix && !Array.isArray(ruleReport.fix) - ? [ruleReport.fix] - : ruleReport.fix, + fix, message: nullThrows( rule.messages[ruleReport.message], `Rule "${rule.about.id}" reported message "${ruleReport.message}" which is not defined in its messages.`, @@ -32,12 +71,10 @@ export function processRuleReport( range: { begin: getColumnAndLineOfPosition( currentFile.about.sourceText, - ruleReport.range.begin, - ), - end: getColumnAndLineOfPosition( - currentFile.about.sourceText, - ruleReport.range.end, + range.begin, ), + end: getColumnAndLineOfPosition(currentFile.about.sourceText, range.end), }, + suggestions, }; } diff --git a/packages/core/src/running/runLintRule.ts b/packages/core/src/running/runLintRule.ts index 8d17e5eec..cadf95823 100644 --- a/packages/core/src/running/runLintRule.ts +++ b/packages/core/src/running/runLintRule.ts @@ -25,6 +25,7 @@ export async function runLintRule( const ruleRuntime = await rule.setup({ report(ruleReport) { + // TODO: what if report is called asynchronously? maybe we can use AsyncLocalStorage? if (!currentFile) { throw new Error( "`filePath` not provided in a rule report() not called by a visitor.", @@ -35,9 +36,11 @@ export async function runLintRule( log("Adding %s report for file path %s", ruleReport.message, filePath); - reportsByFilePath - .get(filePath) - .push(processRuleReport(currentFile, rule, ruleReport)); + const processedReport = processRuleReport(currentFile, rule, ruleReport); + if (processedReport == null) { + return; + } + reportsByFilePath.get(filePath).push(processedReport); }, }); diff --git a/packages/core/src/types/languages.ts b/packages/core/src/types/languages.ts index abe85fcb7..43f3ec995 100644 --- a/packages/core/src/types/languages.ts +++ b/packages/core/src/types/languages.ts @@ -1,5 +1,6 @@ import type { CommentDirective } from "./directives.ts"; import type { LinterHost } from "./host.ts"; +import type { CharacterReportRange } from "./ranges.ts"; import type { FileReport } from "./reports.ts"; import type { Rule, RuleAbout, RuleDefinition, RuleRuntime } from "./rules.ts"; import type { AnyOptionalSchema, InferredOutputObject } from "./shapes.ts"; @@ -138,6 +139,9 @@ export type LanguageFile = Disposable & */ export interface LanguageFileBase { about: FileAboutData; + adjustReportRange?: ( + range: CharacterReportRange, + ) => CharacterReportRange | null; directives?: CommentDirective[]; reports?: FileReport[]; services: FileServices; diff --git a/packages/rule-tester/src/runTestCaseRule.ts b/packages/rule-tester/src/runTestCaseRule.ts index a1bcadb8c..52d9bbce8 100644 --- a/packages/rule-tester/src/runTestCaseRule.ts +++ b/packages/rule-tester/src/runTestCaseRule.ts @@ -3,6 +3,7 @@ import { type AnyLanguageFileFactory, type AnyOptionalSchema, type AnyRule, + type FileReport, type InferredOutputObject, type NormalizedReport, normalizePath, @@ -60,11 +61,15 @@ export async function runTestCaseRule< sourceText: code, }); - const reports: NormalizedReport[] = []; + const reports: FileReport[] = []; const ruleRuntime = await rule.setup({ report(ruleReport) { - reports.push(processRuleReport(file, rule, ruleReport)); + const processedReport = processRuleReport(file, rule, ruleReport); + if (processedReport == null) { + return; + } + reports.push(processedReport); }, }); diff --git a/packages/typescript-language/src/getTypeScriptFileDiagnostics.ts b/packages/typescript-language/src/getTypeScriptFileDiagnostics.ts deleted file mode 100644 index 93c62c440..000000000 --- a/packages/typescript-language/src/getTypeScriptFileDiagnostics.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { LanguageFile, LanguageFileDiagnostic } from "@flint.fyi/core"; -import ts from "typescript"; - -import { convertTypeScriptDiagnosticToLanguageFileDiagnostic } from "./convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts"; -import type { TypeScriptFileServices } from "./language.ts"; - -export function getTypeScriptFileDiagnostics( - file: LanguageFile, -): LanguageFileDiagnostic[] { - return ts - .getPreEmitDiagnostics(file.services.program, file.services.sourceFile) - .map(convertTypeScriptDiagnosticToLanguageFileDiagnostic); -} diff --git a/packages/typescript-language/src/language.ts b/packages/typescript-language/src/language.ts index a3179723f..5a90b9b25 100644 --- a/packages/typescript-language/src/language.ts +++ b/packages/typescript-language/src/language.ts @@ -1,14 +1,25 @@ -import { createLanguage, type FileAboutData } from "@flint.fyi/core"; +import { + type AnyOptionalSchema, + createLanguage, + type FileAboutData, + type InferredOutputObject, + type LanguageDiagnostics, + type LanguageFile, + type LanguageFileDefinition, + type RuleRuntime, +} from "@flint.fyi/core"; import { assert } from "@flint.fyi/utils"; import { createProjectService } from "@typescript-eslint/project-service"; import { debugForFile } from "debug-for-file"; +import path from "node:path"; import * as ts from "typescript"; +import packageJson from "../package.json" with { type: "json" }; +import { convertTypeScriptDiagnosticToLanguageFileDiagnostic } from "./convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts"; import { createTypeScriptServerHost } from "./createTypeScriptServerHost.ts"; import { parseDirectivesFromTypeScriptFile } from "./directives/parseDirectivesFromTypeScriptFile.ts"; import { getFirstEnumValues } from "./getFirstEnumValues.ts"; import { getTypeScriptFileCacheImpacts } from "./getTypeScriptFileCacheImpacts.ts"; -import { getTypeScriptFileDiagnostics } from "./getTypeScriptFileDiagnostics.ts"; import type { TypeScriptNodesByName, TypeScriptNodeVisitors } from "./nodes.ts"; import type * as AST from "./types/ast.ts"; import type { Checker } from "./types/checker.ts"; @@ -21,7 +32,52 @@ export interface TypeScriptFileServices { const log = debugForFile(import.meta.filename); -const NodeSyntaxKinds = getFirstEnumValues(ts.SyntaxKind); +export const NodeSyntaxKinds = getFirstEnumValues(ts.SyntaxKind); + +interface GlobalLanguageState { + packageVersion: string; + volarCreateFile: null | VolarCreateFile; +} +type VolarCreateFile = ( + data: FileAboutData, + program: ts.Program, + sourceFile: AST.SourceFile, +) => VolarLanguageFileDefinition; + +type VolarLanguageFileDefinition = LanguageFileDefinition & { + __volarServices: { + getDiagnostics(): LanguageDiagnostics; + runVisitors( + file: LanguageFile, + options: InferredOutputObject, + runtime: RuleRuntime, + ): void; + }; +}; + +const stateSymbol = Symbol.for("@flint.fyi/typescript-language/state"); + +const globalTyped = globalThis as typeof globalThis & { + [stateSymbol]?: GlobalLanguageState; +}; + +assert( + globalTyped[stateSymbol] == null, + `Two different versions of ${packageJson.name} are imported: ${packageJson.version} and ${globalTyped[stateSymbol]?.packageVersion}`, +); + +const languageState: GlobalLanguageState = (globalTyped[stateSymbol] = { + packageVersion: packageJson.version, + volarCreateFile: null, +}); + +export function setVolarCreateFile(create: VolarCreateFile) { + assert( + languageState.volarCreateFile == null, + "setVolarCreateFile is expected to be called only once", + ); + languageState.volarCreateFile = create; +} export const typescriptLanguage = createLanguage< TypeScriptNodeVisitors, @@ -67,15 +123,33 @@ export const typescriptLanguage = createLanguage< `Could not retrieve source file for: ${data.filePathAbsolute}`, ); + const fileExtension = path.extname(data.filePathAbsolute); + if (typeScriptCoreSupportedExtensions.has(fileExtension)) { + return { + ...parseDirectivesFromTypeScriptFile(sourceFile as AST.SourceFile), + about: data, + language: typescriptLanguage, + services: { + program, + sourceFile, + typeChecker: program.getTypeChecker(), + }, + [Symbol.dispose]() { + service.closeClientFile(data.filePathAbsolute); + }, + }; + } + + if (languageState.volarCreateFile == null) { + throwUnknownLanguageExtension(data.filePathAbsolute); + } + return { - ...parseDirectivesFromTypeScriptFile(sourceFile as AST.SourceFile), - about: data, - language: typescriptLanguage, - services: { + ...languageState.volarCreateFile( + data, program, - sourceFile, - typeChecker: program.getTypeChecker(), - }, + sourceFile as AST.SourceFile, + ), [Symbol.dispose]() { service.closeClientFile(data.filePathAbsolute); }, @@ -86,12 +160,30 @@ export const typescriptLanguage = createLanguage< }, getFileCacheImpacts: getTypeScriptFileCacheImpacts, - getFileDiagnostics: getTypeScriptFileDiagnostics, + getFileDiagnostics(file) { + if ("__volarServices" in file) { + return ( + file as VolarLanguageFileDefinition + ).__volarServices.getDiagnostics(); + } + return ts + .getPreEmitDiagnostics(file.services.program, file.services.sourceFile) + .map(convertTypeScriptDiagnosticToLanguageFileDiagnostic); + }, runFileVisitors(file, options, runtime) { if (!runtime.visitors) { return; } + if ("__volarServices" in file) { + (file as VolarLanguageFileDefinition).__volarServices.runVisitors( + file, + options, + runtime, + ); + return; + } + const { visitors } = runtime; const visitorServices = { options, ...file.services }; @@ -110,3 +202,35 @@ export const typescriptLanguage = createLanguage< visit(file.services.sourceFile); }, }); + +const typeScriptCoreSupportedExtensions: ReadonlySet = new Set([ + ".cjs", + ".cts", + ".d.cts", + ".d.mts", + ".d.ts", + ".js", + ".json", + ".jsx", + ".mjs", + ".mts", + ".ts", + ".tsx", +]); + +const fileExtToFlintPlugin: Record = { + ".astro": "@flint.fyi/astro", + ".gjs": "@flint.fyi/ember", + ".gts": "@flint.fyi/ember", + ".mdx": "@flint.fyi/mdx", + ".svelte": "@flint.fyi/svelte", + ".vue": "@flint.fyi/vue", +}; + +export function throwUnknownLanguageExtension(filename: string): never { + const pluginName = fileExtToFlintPlugin[path.extname(filename)]; + const message = pluginName + ? `Did you install & import ${pluginName}?` + : "Unknown extension."; + throw new Error(`Cannot process ${filename}. ${message}`); +} diff --git a/packages/volar-language/package.json b/packages/volar-language/package.json new file mode 100644 index 000000000..91d89917b --- /dev/null +++ b/packages/volar-language/package.json @@ -0,0 +1,50 @@ +{ + "name": "@flint.fyi/volar-language", + "version": "0.0.1", + "description": "[Experimental] Volar.js language for Flint.", + "homepage": "https://flint.fyi", + "repository": { + "type": "git", + "url": "git+https://github.com/flint-fyi/flint.git", + "directory": "packages/volar-language" + }, + "license": "MIT", + "author": { + "name": "JoshuaKGoldberg", + "email": "npm@joshuakgoldberg.com" + }, + "sideEffects": false, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "lib/", + "!lib/**/*.map" + ], + "scripts": { + "test": "vitest --typecheck --project volar-language" + }, + "dependencies": { + "@flint.fyi/core": "workspace:^", + "@flint.fyi/ts-patch": "workspace:^", + "@flint.fyi/typescript-language": "workspace:^", + "@flint.fyi/utils": "workspace:^", + "@volar/language-core": "2.4.28", + "@volar/typescript": "2.4.28", + "typescript": "^5.9.0 || ^6.0.0" + }, + "devDependencies": { + "tsdown": "0.21.0", + "vitest": "4.0.15" + }, + "engines": { + "node": ">=24.0.0" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./lib/index.js" + } + } +} diff --git a/packages/volar-language/src/index.ts b/packages/volar-language/src/index.ts new file mode 100644 index 000000000..241f26921 --- /dev/null +++ b/packages/volar-language/src/index.ts @@ -0,0 +1 @@ +export { createVolarBasedLanguage, reportSourceCode } from "./language.ts"; diff --git a/packages/volar-language/src/language.ts b/packages/volar-language/src/language.ts new file mode 100644 index 000000000..c8519472f --- /dev/null +++ b/packages/volar-language/src/language.ts @@ -0,0 +1,503 @@ +import { + type AnyRuleDefinition, + type CharacterReportRange, + createLanguage, + DirectivesCollector, + type FileAboutData, + type FileReport, + getColumnAndLineOfPosition, + isSuggestionForFiles, + type Language, + type LanguageDiagnostics, + type LanguageFileCacheImpacts, + type NormalizedReportRangeObject, + type RuleContext, + type RuleReport, + type SourceFileWithLineMap, +} from "@flint.fyi/core"; +import { setTSProgramCreationProxy } from "@flint.fyi/ts-patch"; +import { + type AST, + type Checker, + convertTypeScriptDiagnosticToLanguageFileDiagnostic, + extractDirectivesFromTypeScriptFile, + type ExtractedDirective, + NodeSyntaxKinds, + setVolarCreateFile, + throwUnknownLanguageExtension, + type TypeScriptFileServices, + typescriptLanguage, + type TypeScriptNodesByName, +} from "@flint.fyi/typescript-language"; +import { assert, FlintAssertionError, nullThrows } from "@flint.fyi/utils"; +import type { + Language as VolarLanguage, + LanguagePlugin as VolarLanguagePlugin, + Mapper as VolarMapper, + SourceScript as VolarSourceScript, +} from "@volar/language-core"; +import type { TypeScriptServiceScript as VolarTypeScriptServiceScript } from "@volar/typescript"; +// eslint-disable-next-line no-restricted-syntax +import { proxyCreateProgram } from "@volar/typescript/lib/node/proxyCreateProgram.js"; +import ts from "typescript"; + +import type { UnsafeAnyRule } from "../../core/src/plugins/createPlugin.ts"; +import packageJson from "../package.json" with { type: "json" }; + +type VolarLanguagePluginInitializer = ( + ts: typeof import("typescript"), + options: ts.CreateProgramOptions, +) => { + createFile: VolarBasedLanguageCreateFile; + languagePlugins: VolarLanguagePlugin[]; +}; + +const stateSymbol = Symbol.for("@flint.fyi/volar-language/state"); + +const globalTyped = globalThis as typeof globalThis & { + [stateSymbol]?: { + packageVersion: string; + pluginInitializers: Set>; + }; +}; +assert( + globalTyped[stateSymbol] == null, + `Two different versions of ${packageJson.name} are imported: ${packageJson.version} and ${globalTyped[stateSymbol]?.packageVersion}`, +); +const { pluginInitializers } = (globalTyped[stateSymbol] = { + packageVersion: packageJson.version, + pluginInitializers: new Set(), +}); + +export interface VolarBasedLanguageCreateFileContext { + data: FileAboutData; + program: ts.Program; + serviceScript: VolarTypeScriptServiceScript; + sourceFile: AST.SourceFile; + sourceScript: VolarSourceScript & { + generated: NonNullable["generated"]>; + }; + volarLanguage: VolarLanguage; +} + +type ProxiedTSProgram = ts.Program & { + [stateSymbol]?: + | undefined + | { + volarLanguage: VolarLanguage; + }; +}; + +type VolarBasedLanguageCreateFile = ( + ctx: VolarBasedLanguageCreateFileContext, +) => { + cache?: LanguageFileCacheImpacts; + directives?: ExtractedDirective[]; + extraContext?: FileServices; + firstStatementPosition: number; + getDiagnostics?: () => LanguageDiagnostics; + reports?: FileReport[]; +}; + +type VolarLanguagePluginWithCreateFile = VolarLanguagePlugin & { + [stateSymbol]?: + | undefined + | { + createFile: VolarBasedLanguageCreateFile; + }; +}; + +setTSProgramCreationProxy( + (ts, createProgram) => + new Proxy( + function () { + /* for apply */ + } as unknown as typeof createProgram, + { + apply(target, thisArg, args: unknown[]) { + let volarLanguage = null as null | VolarLanguage; + const createProgramProxy = new Proxy(createProgram, { + apply(target, thisArg, [options]: [ts.CreateProgramOptions]) { + assert( + options.host != null, + "Expected options.host to be defined", + ); + const patchedGetSourceFile = options.host.getSourceFile.bind( + options.host, + ); + options.host.getSourceFile = (...args) => { + try { + return patchedGetSourceFile(...args); + } catch (error) { + if ( + error instanceof Error && + error.message === "!!sourceScript" + ) { + throwUnknownLanguageExtension(args[0]); + } + throw error; + } + }; + return Reflect.apply(target, thisArg, args) as ts.Program; + }, + }); + const proxied = proxyCreateProgram( + ts, + createProgramProxy, + (ts, options) => { + const languagePlugins = Array.from(pluginInitializers) + .map((initializer) => initializer(ts, options)) + .flatMap(({ createFile, languagePlugins }) => + languagePlugins.map((plugin) => { + if (plugin.typescript == null) { + return plugin; + } + + (plugin as VolarLanguagePluginWithCreateFile)[stateSymbol] = + { createFile }; + + const getServiceScript = + plugin.typescript.getServiceScript.bind( + 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, + setup: (lang) => { + volarLanguage = lang; + }, + }; + }, + ); + + const program = Reflect.apply( + proxied, + thisArg, + args, + ) as ProxiedTSProgram; + + assert(volarLanguage != null, "Expected volarLanguage to be set"); + + program[stateSymbol] ??= { volarLanguage }; + + return program; + }, + }, + ), +); + +setVolarCreateFile((data, program, sourceFile) => { + const volarLanguage = nullThrows( + (program as ProxiedTSProgram)[stateSymbol]?.volarLanguage, + "TypeScript wasn't proxied with Volar.js", + ); + + const sourceScript = volarLanguage.scripts.get(sourceFile.fileName); + + assert( + sourceScript != null, + `Volar.js source script for ${sourceFile.fileName} is undefined`, + ); + assert( + sourceScript.generated != null, + `Volar.js sourceScript.generated for ${sourceFile.fileName} is undefined`, + ); + assert( + sourceScript.generated.languagePlugin.typescript != null, + `Volar.js sourceScript.generated.languagePlugin.typescript for ${sourceFile.fileName} is undefined`, + ); + + const createFile = nullThrows( + ( + sourceScript.generated.languagePlugin as VolarLanguagePluginWithCreateFile + )[stateSymbol]?.createFile, + `Volar.js language plugin for script (${sourceFile.fileName}) with language id ${sourceScript.generated.root.languageId} doesn't have __flintCreateFile property`, + ); + + 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 = nullThrows( + sourceScript.generated.languagePlugin.typescript.getServiceScript( + sourceScript.generated.root, + ), + `Volar.js service script for ${sourceFile.fileName} is undefined`, + ); + + const map = volarLanguage.maps.get(serviceScript.code, sourceScript); + const sortedMappings = map.mappings.toSorted( + ({ generatedOffsets: [a] }, { generatedOffsets: [b] }) => { + assert( + a != null, + "Expected generatedOffsets to have at least one element", + ); + assert( + b != null, + "Expected generatedOffsets to have at least one element", + ); + return a - b; + }, + ); + const { + directives, + extraContext, + firstStatementPosition, + getDiagnostics, + reports, + } = createFile({ + data, + program, + serviceScript, + sourceFile, + sourceScript: sourceScript as VolarSourceScript & { + generated: NonNullable["generated"]>; + }, + volarLanguage, + }); + + const translatedDirectives = [...(directives ?? [])]; + + for (const directive of extractDirectivesFromTypeScriptFile(sourceFile)) { + const range = translateRange( + map, + directive.range.begin.raw, + directive.range.end.raw, + ); + if (range != null) { + translatedDirectives.push({ + ...directive, + 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 { + __volarServices: { + runVisitors(file, options, runtime) { + const { visitors } = runtime; + if (!visitors) { + return; + } + + const visitorServices = { options, ...file.services }; + let lastMappingIdx = 0; + const visit = (node: ts.Node) => { + const key = NodeSyntaxKinds[node.kind] as keyof TypeScriptNodesByName; + + // @ts-expect-error -- The node parameter type shouldn't be `never`...? + visitors[key]?.(node, visitorServices); + + node.forEachChild(visit); + + // @ts-expect-error -- The node parameter type shouldn't be `never`...? + visitors[`${key}:exit`]?.(node, visitorServices); + }; + visitors.SourceFile?.(sourceFile, visitorServices); + // 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 currentMappingOffset = nullThrows( + currentMapping.generatedOffsets[0], + "Expected mapping to have at least one generated offset", + ); + const currentMappingLength = nullThrows( + currentMapping.generatedLengths?.[0] ?? currentMapping.lengths[0], + "Expected mapping to have at least one length", + ); + if ( + currentMappingLength === 0 || + statement.pos >= currentMappingOffset + currentMappingLength + ) { + lastMappingIdx++; + continue; + } + if (statement.end <= currentMappingOffset) { + continue Statements; + } + break; + } + + visit(statement); + } + visit(sourceFile.endOfFileToken); + }, + // TODO: 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?.() ?? []), + ]; + }, + }, + about: { + ...data, + sourceText, + }, + adjustReportRange(range) { + if (range.begin < 0) { + return { + begin: -range.begin, + end: range.end, + }; + } + return translateRange(map, range.begin, range.end); + }, + directives: collected.directives, + language: typescriptLanguage, + + reports: [...collected.reports, ...(reports ?? [])], + services: { + program, + sourceFile, + typeChecker: program.getTypeChecker() as Checker, + ...extraContext, + }, + }; +}); + +export function createVolarBasedLanguage( + initializer: VolarLanguagePluginInitializer, +): Language< + TypeScriptNodesByName, + Partial & TypeScriptFileServices +> { + pluginInitializers.add(initializer); + return { + ...createLanguage< + TypeScriptNodesByName, + Partial & TypeScriptFileServices + >({ + about: { + name: "Volar.js-based language", + }, + createFileFactory() { + throw new FlintAssertionError( + "Volar.js based language should never be called directly", + ); + }, + runFileVisitors() { + throw new FlintAssertionError( + "Volar.js based language should never be called directly", + ); + }, + }), + createRule: (ruleDefinition: AnyRuleDefinition) => { + // flint-disable-next-line ts/anyReturns + return { + ...ruleDefinition, + language: typescriptLanguage, + } as UnsafeAnyRule; + }, + }; +} + +export function reportSourceCode( + context: RuleContext, + report: RuleReport, +) { + context.report({ + ...report, + fix: (report.fix && !Array.isArray(report.fix) + ? [report.fix] + : report.fix + )?.map((change) => ({ + ...change, + range: sourceCodeRange(change.range), + })), + range: sourceCodeRange(report.range), + suggestions: report.suggestions + ?.map((suggestion) => { + if (isSuggestionForFiles(suggestion)) { + // TODO: support cross-file suggestions + return null; + } + return { + ...suggestion, + range: sourceCodeRange(suggestion.range), + }; + }) + .filter((s) => s != null), + }); +} +function sourceCodeRange(range: CharacterReportRange): CharacterReportRange { + return { + begin: -range.begin, + end: range.end, + }; +} + +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; +} diff --git a/packages/volar-language/tsconfig.json b/packages/volar-language/tsconfig.json new file mode 100644 index 000000000..c37e7bdb5 --- /dev/null +++ b/packages/volar-language/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.test.json" } + ] +} diff --git a/packages/volar-language/tsconfig.src.json b/packages/volar-language/tsconfig.src.json new file mode 100644 index 000000000..4542df8f2 --- /dev/null +++ b/packages/volar-language/tsconfig.src.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.src.json", + "rootDir": "src/", + "outDir": "lib/", + "types": ["node"] + }, + "extends": "../../tsconfig.base.json", + "include": ["src"], + "exclude": ["src/**/*.test.ts"], + "references": [ + { "path": "../core" }, + { "path": "../ts-patch" }, + { "path": "../typescript-language" }, + { "path": "../utils" } + ] +} diff --git a/packages/volar-language/tsconfig.test.json b/packages/volar-language/tsconfig.test.json new file mode 100644 index 000000000..bed5231ef --- /dev/null +++ b/packages/volar-language/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuild/info.test.json", + "rootDir": "src/", + "outDir": "node_modules/.cache/tsbuild/test", + "types": ["node"], + "erasableSyntaxOnly": false + }, + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.test.ts"], + "references": [{ "path": "./tsconfig.src.json" }] +} diff --git a/packages/volar-language/tsdown.config.ts b/packages/volar-language/tsdown.config.ts new file mode 100644 index 000000000..091100ce3 --- /dev/null +++ b/packages/volar-language/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + attw: { + enabled: "ci-only", + profile: "esm-only", + }, + clean: ["./node_modules/.cache/tsbuild/"], + dts: { build: true, incremental: true }, + entry: ["src/index.ts"], + exports: { + devExports: "@flint.fyi/source", + packageJson: false, + }, + failOnWarn: true, + fixedExtension: false, + outDir: "lib", + unbundle: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f077fbe44..c9c9069d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -840,6 +840,37 @@ importers: specifier: 4.0.15 version: 4.0.15(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.2) + packages/volar-language: + dependencies: + '@flint.fyi/core': + specifier: workspace:^ + version: link:../core + '@flint.fyi/ts-patch': + specifier: workspace:^ + version: link:../ts-patch + '@flint.fyi/typescript-language': + specifier: workspace:^ + version: link:../typescript-language + '@flint.fyi/utils': + specifier: workspace:^ + version: link:../utils + '@volar/language-core': + specifier: 2.4.28 + version: 2.4.28 + '@volar/typescript': + specifier: 2.4.28 + version: 2.4.28 + typescript: + specifier: ^5.9.0 || ^6.0.0 + version: 5.9.3 + devDependencies: + tsdown: + specifier: 0.21.0 + version: 0.21.0(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.1)(synckit@0.11.12)(typescript@5.9.3) + vitest: + specifier: 4.0.15 + version: 4.0.15(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.2) + packages/yaml: dependencies: '@flint.fyi/core':