diff --git a/.changeset/quick-peas-join.md b/.changeset/quick-peas-join.md new file mode 100644 index 000000000..b693c68c4 --- /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 +--- + +feat: introduce Volar.js meta-language diff --git a/packages/core/src/running/runLintRule.ts b/packages/core/src/running/runLintRule.ts index 69fed05ab..15b53a981 100644 --- a/packages/core/src/running/runLintRule.ts +++ b/packages/core/src/running/runLintRule.ts @@ -26,6 +26,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.", @@ -36,13 +37,53 @@ export async function runLintRule( log("Adding %s report for file path %s", ruleReport.message, filePath); + let range = ruleReport.range; + let fixes = + ruleReport.fix && !Array.isArray(ruleReport.fix) + ? [ruleReport.fix] + : ruleReport.fix; + let suggestions = ruleReport.suggestions; + + const { adjustReportRange } = currentFile; + if (adjustReportRange != null) { + const r = adjustReportRange(ruleReport.range); + if (r == null) { + return; + } + range = r; + fixes &&= fixes + .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); + } + reportsByFilePath.get(filePath).push({ ...ruleReport, about: rule.about, - fix: - ruleReport.fix && !Array.isArray(ruleReport.fix) - ? [ruleReport.fix] - : ruleReport.fix, + fix: fixes, message: nullThrows( rule.messages[ruleReport.message], `Rule "${rule.about.id}" reported message "${ruleReport.message}" which is not defined in its messages.`, @@ -50,13 +91,14 @@ export async function runLintRule( range: { begin: getColumnAndLineOfPosition( currentFile.about.sourceText, - ruleReport.range.begin, + range.begin, ), end: getColumnAndLineOfPosition( currentFile.about.sourceText, - ruleReport.range.end, + range.end, ), }, + suggestions, }); }, }); 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 be45b6334..a4e5baff9 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, getColumnAndLineOfPosition, type InferredOutputObject, type NormalizedReport, @@ -61,30 +62,65 @@ export async function runTestCaseRule< sourceText: code, }); - const reports: NormalizedReport[] = []; + const reports: FileReport[] = []; const ruleRuntime = await rule.setup({ report(ruleReport) { + let range = ruleReport.range; + let fixes = + ruleReport.fix && !Array.isArray(ruleReport.fix) + ? [ruleReport.fix] + : ruleReport.fix; + let suggestions = ruleReport.suggestions; + + const { adjustReportRange } = file; + if (adjustReportRange != null) { + const r = adjustReportRange(ruleReport.range); + if (r == null) { + return; + } + range = r; + fixes &&= fixes + .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); + } + reports.push({ ...ruleReport, - fix: - ruleReport.fix && !Array.isArray(ruleReport.fix) - ? [ruleReport.fix] - : ruleReport.fix, + about: rule.about, + fix: fixes, message: nullThrows( rule.messages[ruleReport.message], `Message should be defined (${ruleReport.message}) when reporting for rule "${rule.about.id}"`, ), range: { - begin: getColumnAndLineOfPosition( - file.about.sourceText, - ruleReport.range.begin, - ), - end: getColumnAndLineOfPosition( - file.about.sourceText, - ruleReport.range.end, - ), + begin: getColumnAndLineOfPosition(file.about.sourceText, range.begin), + end: getColumnAndLineOfPosition(file.about.sourceText, range.end), }, + suggestions, }); }, }); 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..5c1bd2e65 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,50 @@ 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 globalTyped = globalThis as typeof globalThis & { + _flintTypeScriptLanguageState?: GlobalLanguageState; +}; + +assert( + globalTyped._flintTypeScriptLanguageState == null, + `Two different versions of ${packageJson.name} are imported: ${packageJson.version} and ${globalTyped._flintTypeScriptLanguageState?.packageVersion}`, +); + +const languageState: GlobalLanguageState = + (globalTyped._flintTypeScriptLanguageState = { + 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 +121,46 @@ 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) { + let message = "Unknown extension."; + switch (fileExtension) { + case ".astro": + message = "Did you install & import @flint.fyi/astro?"; + break; + case ".mdx": + message = "Did you install & import @flint.fyi/mdx?"; + break; + case ".vue": + message = "Did you install & import @flint.fyi/vue?"; + break; + } + + throw new Error(`Cannot process ${sourceFile.fileName}. ${message}`); + } + 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 +171,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 +213,18 @@ 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", +]); diff --git a/packages/volar-language/package.json b/packages/volar-language/package.json new file mode 100644 index 000000000..9e244a652 --- /dev/null +++ b/packages/volar-language/package.json @@ -0,0 +1,52 @@ +{ + "name": "@flint.fyi/volar-language", + "version": "0.16.0", + "description": "[Experimental] Volar.js language for Flint.", + "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": { + ".": { + "@flint.fyi/source": "./src/index.ts", + "default": "./lib/index.js" + } + }, + "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.20.1", + "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..6475ae2ac --- /dev/null +++ b/packages/volar-language/src/language.ts @@ -0,0 +1,486 @@ +import { + type AnyRuleDefinition, + type CharacterReportRange, + createLanguage, + DirectivesCollector, + type FileAboutData, + type FileReport, + getColumnAndLineOfPosition, + 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, + type TypeScriptFileServices, + typescriptLanguage, + type TypeScriptNodesByName, +} from "@flint.fyi/typescript-language"; +import { assert, FlintAssertionError } 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 path from "node:path"; +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 globalTyped = globalThis as typeof globalThis & { + _flintVolarLanguageState?: { + packageVersion: string; + pluginInitializers: Set>; + }; +}; +assert( + globalTyped._flintVolarLanguageState == null, + `Two different versions of ${packageJson.name} are imported: ${packageJson.version} and ${globalTyped._flintVolarLanguageState?.packageVersion}`, +); +const { pluginInitializers } = (globalTyped._flintVolarLanguageState = { + 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 & { + __flintVolarLanguage?: undefined | VolarLanguage; +}; + +type VolarBasedLanguageCreateFile = ( + ctx: VolarBasedLanguageCreateFileContext, +) => { + cache?: LanguageFileCacheImpacts; + directives?: ExtractedDirective[]; + extraContext?: FileServices; + firstStatementPosition: number; + getDiagnostics?: () => LanguageDiagnostics; + reports?: FileReport[]; +}; + +type VolarLanguagePluginWithCreateFile = VolarLanguagePlugin & { + __flintCreateFile?: undefined | 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" + ) { + const fileExtension = path.extname(args[0]); + let message = "Unknown extension."; + switch (fileExtension) { + case ".astro": + message = "Did you install & import @flint.fyi/astro?"; + break; + case ".mdx": + message = "Did you install & import @flint.fyi/mdx?"; + break; + case ".vue": + message = "Did you install & import @flint.fyi/vue?"; + break; + } + + throw new Error(`Cannot process ${args[0]}. ${message}`); + } + 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 + ).__flintCreateFile = 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.__flintVolarLanguage ??= volarLanguage; + + return program; + }, + }, + ), +); + +setVolarCreateFile((data, program, sourceFile) => { + const volarLanguage = (program as ProxiedTSProgram).__flintVolarLanguage; + assert(volarLanguage != null, "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 = ( + sourceScript.generated.languagePlugin as VolarLanguagePluginWithCreateFile + ).__flintCreateFile; + assert( + createFile != null, + `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 = + sourceScript.generated.languagePlugin.typescript.getServiceScript( + sourceScript.generated.root, + ); + assert( + serviceScript != null, + `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 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 { + __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 = currentMapping.generatedOffsets[0]; + const currentMappingLength = + currentMapping.generatedLengths?.[0] ?? currentMapping.lengths[0]; + assert( + currentMappingOffset != null, + "Expected mapping to have at least one generated offset", + ); + assert( + currentMappingLength != null, + "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 anyReturns + return { + ...ruleDefinition, + language: typescriptLanguage, + } as UnsafeAnyRule; + }, + }; +} + +export function reportSourceCode( + context: RuleContext, + report: RuleReport, +) { + // TODO: suggestions, fixes + context.report({ + ...report, + range: { + begin: -report.range.begin, + end: report.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 c22907f85..592137e9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,6 +804,37 @@ importers: specifier: 4.0.15 version: 4.0.15(@types/node@24.10.1)(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.20.1 + version: 0.20.1(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.15.0)(synckit@0.11.11)(typescript@5.9.3) + vitest: + specifier: 4.0.15 + version: 4.0.15(@types/node@24.10.1)(jiti@2.6.1)(yaml@2.8.2) + packages/yaml: dependencies: '@flint.fyi/core': @@ -2602,6 +2633,9 @@ packages: '@volar/language-core@2.4.23': resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==} + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + '@volar/language-server@2.4.23': resolution: {integrity: sha512-k0iO+tybMGMMyrNdWOxgFkP0XJTdbH0w+WZlM54RzJU3WZSjHEupwL30klpM7ep4FO6qyQa03h+VcGHD4Q8gEg==} @@ -2611,9 +2645,15 @@ packages: '@volar/source-map@2.4.23': resolution: {integrity: sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==} + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + '@volar/typescript@2.4.23': resolution: {integrity: sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==} + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vscode/emmet-helper@2.11.0': resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} @@ -5746,7 +5786,7 @@ snapshots: '@astrojs/yaml2ts': 0.2.2 '@jridgewell/sourcemap-codec': 1.5.5 '@volar/kit': 2.4.23(typescript@5.9.3) - '@volar/language-core': 2.4.23 + '@volar/language-core': 2.4.28 '@volar/language-server': 2.4.23 '@volar/language-service': 2.4.23 fast-glob: 3.3.3 @@ -7544,6 +7584,10 @@ snapshots: dependencies: '@volar/source-map': 2.4.23 + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + '@volar/language-server@2.4.23': dependencies: '@volar/language-core': 2.4.23 @@ -7565,12 +7609,20 @@ snapshots: '@volar/source-map@2.4.23': {} + '@volar/source-map@2.4.28': {} + '@volar/typescript@2.4.23': dependencies: '@volar/language-core': 2.4.23 path-browserify: 1.0.1 vscode-uri: 3.1.0 + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + '@vscode/emmet-helper@2.11.0': dependencies: emmet: 2.4.11