diff --git a/package.json b/package.json index 3a01375f1..99ee65aa7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ ], "scripts": { "build": "tsc -b", - "flint": "node packages/flint/bin/index.js", + "flint": "flint", "lint": "eslint . --max-warnings 0", "lint:knip": "knip", "lint:md": "markdownlint \"**/*.md\" \".github/**/*.md\" --rules sentences-per-line", diff --git a/packages/cli/src/runCli.ts b/packages/cli/src/runCli.ts index ef46ac954..2d989f5ed 100644 --- a/packages/cli/src/runCli.ts +++ b/packages/cli/src/runCli.ts @@ -7,8 +7,9 @@ import { createRendererFactory } from "./renderers/createRendererFactory.js"; import { runCliOnce } from "./runCliOnce.js"; import { runCliWatch } from "./runCliWatch.js"; -export async function runCli() { +export async function runCli(args: string[]) { const { values } = parseArgs({ + args, options, strict: true, }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db11ef5a8..0a7a565e3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,12 +10,19 @@ export { globs } from "./globs/index.js"; export { createLanguage } from "./languages/createLanguage.js"; export { createPlugin } from "./plugins/createPlugin.js"; export { formatReportPrimary } from "./reporting/formatReportPrimary.js"; +export { computeRulesWithOptions } from "./running/computeRulesWithOptions.js"; export { lintFixing } from "./running/lintFixing.js"; export { lintOnce } from "./running/lintOnce.js"; +export { + setTSExtraSupportedExtensions, + setTSProgramCreationProxy, +} from "./ts-patch/proxy-program.js"; export * from "./types/about.js"; +export * from "./types/arrays.js"; export * from "./types/cache.js"; export * from "./types/changes.js"; export * from "./types/configs.js"; +export * from "./types/context.js"; export * from "./types/directives.js"; export * from "./types/formatting.js"; export * from "./types/languages.js"; @@ -26,6 +33,6 @@ export * from "./types/ranges.js"; export * from "./types/reports.js"; export * from "./types/rules.js"; export * from "./types/shapes.js"; -export { binarySearch } from "./utils/arrays.js"; +export * from "./utils/arrays.js"; export * from "./utils/getColumnAndLineOfPosition.js"; export * from "./utils/predicates.js"; diff --git a/packages/core/src/ts-patch/install-patch-hooks.ts b/packages/core/src/ts-patch/install-patch-hooks.ts new file mode 100644 index 000000000..a12adfdf3 --- /dev/null +++ b/packages/core/src/ts-patch/install-patch-hooks.ts @@ -0,0 +1,20 @@ +import { registerHooks } from "node:module"; + +import { transformTscContent } from "./shared.js"; + +const typescriptUrl = import.meta.resolve("typescript"); + +registerHooks({ + load(url, context, nextLoad) { + const next = nextLoad(url, context); + + if (url !== typescriptUrl || next.source == null) { + return next; + } + + return { + ...next, + source: transformTscContent(next.source.toString()), + }; + }, +}); diff --git a/packages/core/src/ts-patch/install-patch.ts b/packages/core/src/ts-patch/install-patch.ts new file mode 100644 index 000000000..7ce8c34c9 --- /dev/null +++ b/packages/core/src/ts-patch/install-patch.ts @@ -0,0 +1,19 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; + +import { transformTscContent } from "./shared.js"; + +const require = createRequire(import.meta.url); +const typescriptPath = require.resolve("typescript"); + +const origReadFileSync = fs.readFileSync; +// @ts-expect-error +fs.readFileSync = (...args) => { + const res = origReadFileSync(...args); + if (args[0] === typescriptPath) { + return transformTscContent(res.toString()); + } + return res; +}; +require("typescript"); +fs.readFileSync = origReadFileSync; diff --git a/packages/core/src/ts-patch/proxy-program.ts b/packages/core/src/ts-patch/proxy-program.ts new file mode 100644 index 000000000..677cf2aef --- /dev/null +++ b/packages/core/src/ts-patch/proxy-program.ts @@ -0,0 +1,54 @@ +import type { createProgram } from "typescript"; + +const globalTyped = globalThis as unknown as { + createProgramProxies: Set< + ( + ts: typeof import("typescript"), + create: typeof createProgram, + ) => typeof createProgram + >; + extraSupportedExtensions: Set; +}; +// Since it's not possible to change the module graph evaluation order, +// we store proxies unordered +globalTyped.createProgramProxies ??= new Set(); + +globalTyped.extraSupportedExtensions ??= new Set(); + +export function getExtraSupportedExtensions() { + return Array.from(globalTyped.extraSupportedExtensions); +} + +export function setTSExtraSupportedExtensions(extensions: string[]) { + for (const ext of extensions) { + globalTyped.extraSupportedExtensions.add(ext); + } + return () => { + for (const ext of extensions) { + globalTyped.extraSupportedExtensions.delete(ext); + } + }; +} + +export function setTSProgramCreationProxy( + proxy: ( + ts: typeof import("typescript"), + create: typeof createProgram, + ) => typeof createProgram, +) { + globalTyped.createProgramProxies.add(proxy); + + return () => globalTyped.createProgramProxies.delete(proxy); +} + +// TODO: explanation +export function proxyCreateProgram( + ts: typeof import("typescript"), + original: typeof createProgram, +) { + let proxied = original; + for (const proxy of globalTyped.createProgramProxies) { + proxied = proxy(ts, proxied); + } + return proxied; +} diff --git a/packages/core/src/ts-patch/shared.ts b/packages/core/src/ts-patch/shared.ts new file mode 100644 index 000000000..481085eba --- /dev/null +++ b/packages/core/src/ts-patch/shared.ts @@ -0,0 +1,89 @@ +import { fileURLToPath } from "node:url"; + +function replaceOrThrow( + source: string, + search: RegExp | string, + replace: (substring: string, ...args: any[]) => string, +): string { + const before = source; + source = source.replace(search, replace); + const after = source; + if (after === before) { + throw new Error("Flint bug: failed to replace: " + search.toString()); + } + return after; +} + +const coreCreateProxyProgramPath = fileURLToPath( + import.meta.resolve("./proxy-program.js"), +); + +// https://github.com/volarjs/volar.js/blob/e08f2f449641e1c59686d3454d931a3c29ddd99c/packages/typescript/lib/quickstart/runTsc.ts +export function transformTscContent(source: string): string { + source += ` +function _flintDynamicProxy(getter) { + return new Proxy(function () {}, new Proxy({}, { + get(_, property) { + return (_, ...args) => Reflect[property](getter(), ...args) + } + })) +} + +const _flintTsPatch = require(${JSON.stringify(coreCreateProxyProgramPath)}) + `; + injectExtraSupportedExtensions("supportedTSExtensions"); + injectExtraSupportedExtensions("supportedJSExtensions"); + injectExtraSupportedExtensions("allSupportedExtensions"); + + injectDynamicProxy("supportedTSExtensionsFlat"); + injectDynamicProxy("supportedTSExtensionsWithJson"); + injectDynamicProxy("supportedJSExtensionsFlat"); + injectDynamicProxy("allSupportedExtensionsWithJson"); + + source = replaceOrThrow( + source, + "function changeExtension(path, newExtension)", + (s) => `${s} { +return _flintTsPatch.getExtraSupportedExtensions().some(ext => path.endsWith(ext)) + ? path + newExtension + : _changeExtension(path, newExtension) +} + +function _changeExtension(path, newExtension)`, + ); + + source = replaceOrThrow( + source, + /function createProgram\(/, + (match, args: string) => `function createProgram(...args) { + return _flintTsPatch.proxyCreateProgram( + new Proxy({}, { get(_target, p, _receiver) { return eval(p); } } ), + _createProgram, + )(...args) +} + +function _createProgram(`, + ); + + return source; + + function injectExtraSupportedExtensions(variable: string) { + injectDynamicProxy( + variable, + (initializer) => + `${initializer}.map((group, i) => (i === 0 && group.push(..._flintTsPatch.getExtraSupportedExtensions()), group))`, + ); + } + + function injectDynamicProxy( + variable: string, + transformInitializer?: (initializer: string) => string, + ) { + source = replaceOrThrow( + source, + new RegExp(`(${variable}) = (.*)(?=;)`), + (match, decl: string, initializer: string) => + `${decl} = _flintDynamicProxy(() => ${transformInitializer?.(initializer) ?? initializer})`, + ); + } +} diff --git a/packages/core/src/types/ranges.ts b/packages/core/src/types/ranges.ts index bfb61852c..1684e237b 100644 --- a/packages/core/src/types/ranges.ts +++ b/packages/core/src/types/ranges.ts @@ -1,7 +1,7 @@ /** * The column and line of a character in a source file, as visualized to users. */ -export interface ColumnAndLine { +export interface ColumnAndLineWithoutRaw { /** * Column in a source file (0-indexed integer). */ @@ -11,7 +11,12 @@ export interface ColumnAndLine { * Line in a source file (0-indexed integer). */ line: number; +} +/** + * The column and line of a character in a source file, as visualized to users. + */ +export interface ColumnAndLine extends ColumnAndLineWithoutRaw { /** * The original raw character position in the source file (0-indexed integer). */ diff --git a/packages/core/src/utils/getColumnAndLineOfPosition.test.ts b/packages/core/src/utils/getColumnAndLineOfPosition.test.ts index b11d52830..2ed9d10bd 100644 --- a/packages/core/src/utils/getColumnAndLineOfPosition.test.ts +++ b/packages/core/src/utils/getColumnAndLineOfPosition.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test, vi } from "vitest"; -import { getColumnAndLineOfPosition } from "./getColumnAndLineOfPosition.js"; +import { + getColumnAndLineOfPosition, + getPositionOfColumnAndLine, +} from "./getColumnAndLineOfPosition.js"; describe("getColumnAndLineOfPosition", () => { test("negative position", () => { @@ -260,3 +263,41 @@ describe("getColumnAndLineOfPosition", () => { }); }); }); + +describe("getPositionOfColumnAndLine", () => { + test("clamps negative line", () => { + const res = getPositionOfColumnAndLine("012", { line: -1, column: 1 }); + + expect(res).toBe(1); + }); + + test("clamps line after EOF", () => { + const res = getPositionOfColumnAndLine("012", { line: 1, column: 1 }); + + expect(res).toBe(1); + }); + + test("clamps column", () => { + const res = getPositionOfColumnAndLine("012\n45", { line: 0, column: 4 }); + + expect(res).toBe(3); + }); + + test("clamps column with empty line before it", () => { + const res = getPositionOfColumnAndLine("012\n\n56", { line: 1, column: 5 }); + + expect(res).toBe(4); + }); + + test("clamps column on the last line to EOF", () => { + const res = getPositionOfColumnAndLine("012", { line: 1, column: 5 }); + + expect(res).toBe(3); + }); + + test("column on EOF", () => { + const res = getPositionOfColumnAndLine("012", { line: 1, column: 3 }); + + expect(res).toBe(3); + }); +}); diff --git a/packages/core/src/utils/getColumnAndLineOfPosition.ts b/packages/core/src/utils/getColumnAndLineOfPosition.ts index 32a297773..fff21dcae 100644 --- a/packages/core/src/utils/getColumnAndLineOfPosition.ts +++ b/packages/core/src/utils/getColumnAndLineOfPosition.ts @@ -1,4 +1,4 @@ -import { ColumnAndLine } from "../types/ranges.js"; +import { ColumnAndLine, ColumnAndLineWithoutRaw } from "../types/ranges.js"; import { binarySearch } from "./arrays.js"; /** Subset of ts.SourceFileLike */ @@ -122,3 +122,40 @@ function computeLineStarts(source: string): readonly number[] { res.push(lineStart); return res; } + +/** + * Prefer passing a `source` of type `HasGetLineAndCharacterOfPosition` or `SourceFileWithLineMap`. + * This way, the expensive computation of the `lineMap` will be cached across multiple calls. + */ +export function getPositionOfColumnAndLine( + source: SourceFileWithLineMap | string, + columnAndLine: ColumnAndLineWithoutRaw, +): number { + if (typeof source === "string") { + return computePositionOfColumnAndLine( + source, + computeLineStarts(source), + columnAndLine, + ); + } + source.lineMap ??= computeLineStarts(source.text); + return computePositionOfColumnAndLine( + source.text, + source.lineMap, + columnAndLine, + ); +} + +function computePositionOfColumnAndLine( + sourceText: string, + lineStarts: readonly number[], + { column, line }: ColumnAndLineWithoutRaw, +): number { + line = Math.min(Math.max(line, 0), lineStarts.length - 1); + + const res = lineStarts[line] + column; + if (line === lineStarts.length - 1) { + return Math.min(res, sourceText.length); + } + return Math.min(res, lineStarts[line + 1] - 1); +} diff --git a/packages/flint/bin/index.js b/packages/flint/bin/index.js index 523823d05..cded6e6e0 100755 --- a/packages/flint/bin/index.js +++ b/packages/flint/bin/index.js @@ -1,7 +1,9 @@ #!/usr/bin/env node -import { runCli } from "@flint.fyi/cli"; +// @ts-check import { enableCompileCache } from "node:module"; enableCompileCache(); +await import("@flint.fyi/core/lib/ts-patch/install-patch.js"); +const { runCli } = await import("@flint.fyi/cli"); process.exitCode = await runCli(process.argv.slice(2)); diff --git a/packages/plugin-browser/src/rules/keyboardEventKeys.test.ts b/packages/plugin-browser/src/rules/keyboardEventKeys.test.ts index 96dc331f8..c7a401bb0 100644 --- a/packages/plugin-browser/src/rules/keyboardEventKeys.test.ts +++ b/packages/plugin-browser/src/rules/keyboardEventKeys.test.ts @@ -71,7 +71,6 @@ document.addEventListener("keyup", (event: KeyboardEvent) => { console.log(event.which); }); `, - // only: true, snapshot: ` document.addEventListener("keyup", (event: KeyboardEvent) => { console.log(event.which); @@ -86,7 +85,6 @@ function handleKeyDown(event: KeyboardEvent) { return event.keyCode === 8; } `, - // only: true, snapshot: ` function handleKeyDown(event: KeyboardEvent) { return event.keyCode === 8; diff --git a/packages/ts/src/createTypeScriptFileFromProgram.ts b/packages/ts/src/createTypeScriptFileFromProgram.ts index bb31c4a3d..e27ac3e07 100644 --- a/packages/ts/src/createTypeScriptFileFromProgram.ts +++ b/packages/ts/src/createTypeScriptFileFromProgram.ts @@ -1,86 +1,127 @@ import { + AnyOptionalSchema, + AnyRuleDefinition, + InferredObject, + LanguageFileCacheImpacts, LanguageFileDefinition, + LanguageFileDiagnostic, NormalizedReport, RuleReport, } from "@flint.fyi/core"; import * as ts from "typescript"; import { collectReferencedFilePaths } from "./collectReferencedFilePaths.js"; -import { formatDiagnostic } from "./formatDiagnostic.js"; +import { + formatDiagnostic, + RawDiagnostic, + SourceFileWithLineMapAndFileName, +} 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 interface TSDiagnosticLoose + extends Omit, + TSDiagnosticRelatedInformationLoose {} + +export interface TSDiagnosticRelatedInformationLoose + extends Omit { + file: SourceFileWithLineMapAndFileName | undefined; +} + +export function collectTypeScriptFileCacheImpacts( + program: ts.Program, + sourceFile: ts.SourceFile, +): LanguageFileCacheImpacts { + return { + dependencies: [ + // TODO: Add support for multi-TSConfig workspaces. + // https://github.com/JoshuaKGoldberg/flint/issues/64 & more. + "tsconfig.json", + + ...collectReferencedFilePaths(program, sourceFile), + ], + }; +} + +export function convertTypeScriptDiagnosticToLanguageFileDiagnostic( + diagnostic: TSDiagnosticLoose, +): 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 function createTypeScriptFileFromProgram( program: ts.Program, sourceFile: ts.SourceFile, ): LanguageFileDefinition { return { - cache: { - dependencies: [ - // TODO: Add support for multi-TSConfig workspaces. - // https://github.com/JoshuaKGoldberg/flint/issues/64 & more. - "tsconfig.json", - - ...collectReferencedFilePaths(program, sourceFile), - ], - }, + cache: collectTypeScriptFileCacheImpacts(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!, - }), - })); + .map(convertTypeScriptDiagnosticToLanguageFileDiagnostic); }, async runRule(rule, options) { - const reports: NormalizedReport[] = []; - - const context = { - program, - report: (report: RuleReport) => { - reports.push({ - ...report, - fix: - report.fix && !Array.isArray(report.fix) - ? [report.fix] - : report.fix, - message: rule.messages[report.message], - range: normalizeRange(report.range, sourceFile), - }); - }, - sourceFile, - typeChecker: program.getTypeChecker(), - }; - - const runtime = await rule.setup(context, options); - - if (!runtime?.visitors) { - return reports; - } - - const { visitors } = runtime; - const visit = (node: ts.Node) => { - visitors[NodeSyntaxKinds[node.kind]]?.(node); - - node.forEachChild(visit); - }; - - visit(sourceFile); - - return reports; + return runTypeScriptBasedLanguageRule(program, sourceFile, rule, options); + }, + }; +} + +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, + fix: + report.fix && !Array.isArray(report.fix) ? [report.fix] : report.fix, + message: rule.messages[report.message], + range: normalizeRange(report.range, sourceFile), + }); }, + sourceFile, + typeChecker: program.getTypeChecker(), + ...extraContext, }; + + const runtime = await rule.setup(context, options); + + if (!runtime?.visitors) { + return reports; + } + + const { visitors } = runtime; + const visit = (node: ts.Node) => { + visitors[NodeSyntaxKinds[node.kind]]?.(node); + + node.forEachChild(visit); + }; + + visit(sourceFile); + + return reports; } 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/directives/parseDirectivesFromTypeScriptFile.ts b/packages/ts/src/directives/parseDirectivesFromTypeScriptFile.ts index 8f41ee55b..1330dfa36 100644 --- a/packages/ts/src/directives/parseDirectivesFromTypeScriptFile.ts +++ b/packages/ts/src/directives/parseDirectivesFromTypeScriptFile.ts @@ -1,13 +1,18 @@ -import { DirectivesCollector } from "@flint.fyi/core"; +import { + DirectivesCollector, + NormalizedReportRangeObject, +} from "@flint.fyi/core"; import * as tsutils from "ts-api-utils"; import ts from "typescript"; import { normalizeRange } from "../normalizeRange.js"; -export function parseDirectivesFromTypeScriptFile(sourceFile: ts.SourceFile) { - const collector = new DirectivesCollector( - sourceFile.statements.at(0)?.getStart(sourceFile) ?? sourceFile.text.length, - ); +export function extractDirectivesFromTypeScriptFile(sourceFile: ts.SourceFile) { + const directives: { + range: NormalizedReportRangeObject; + selection: string; + type: string; + }[] = []; tsutils.forEachComment(sourceFile, (fullText, sourceRange) => { const commentText = fullText.slice(sourceRange.pos, sourceRange.end); @@ -24,8 +29,22 @@ export function parseDirectivesFromTypeScriptFile(sourceFile: ts.SourceFile) { const range = normalizeRange(commentRange, sourceFile); const [type, selection] = match.slice(1); - collector.add(range, selection, type); + directives.push({ range, selection, type }); }); + return directives; +} + +export function parseDirectivesFromTypeScriptFile(sourceFile: ts.SourceFile) { + const collector = new DirectivesCollector( + sourceFile.statements.at(0)?.getStart(sourceFile) ?? sourceFile.text.length, + ); + + for (const { range, selection, type } of extractDirectivesFromTypeScriptFile( + sourceFile, + )) { + collector.add(range, selection, type); + } + return collector.collect(); } diff --git a/packages/ts/src/formatDiagnostic.ts b/packages/ts/src/formatDiagnostic.ts index 0e87c836f..d9138754e 100644 --- a/packages/ts/src/formatDiagnostic.ts +++ b/packages/ts/src/formatDiagnostic.ts @@ -5,22 +5,35 @@ // eslint-disable-next-line @eslint-community/eslint-comments/disable-enable-pair /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import ts, { - flattenDiagnosticMessageText, - getLineAndCharacterOfPosition, - getPositionOfLineAndCharacter, - type SourceFile, -} from "typescript"; +import { + getColumnAndLineOfPosition, + getPositionOfColumnAndLine, + SourceFileWithLineMap, +} from "@flint.fyi/core"; +import ts, { flattenDiagnosticMessageText } from "typescript"; export interface RawDiagnostic { - file?: ts.SourceFile; + file?: SourceFileWithLineMapAndFileName; length: number; message: string; name: string; - relatedInformation?: ts.DiagnosticRelatedInformation[]; + relatedInformation?: RawDiagnosticRelatedInformation[]; start: number; } +export interface RawDiagnosticRelatedInformation { + category: ts.DiagnosticCategory; + code: number; + file: SourceFileWithLineMapAndFileName | undefined; + length: number | undefined; + messageText: string | ts.DiagnosticMessageChain; + start: number | undefined; +} +export interface SourceFileWithLineMapAndFileName + extends SourceFileWithLineMap { + fileName: string; +} + export function formatDiagnostic(diagnostic: RawDiagnostic) { let output = ""; @@ -87,17 +100,21 @@ function displayFilename(name: string) { } function formatCodeSpan( - file: SourceFile, + file: SourceFileWithLineMapAndFileName, start: number, length: number, indent: string, squiggleColor: string, ) { - const { character: firstLineChar, line: firstLine } = - getLineAndCharacterOfPosition(file, start); - const { character: lastLineChar, line: lastLine } = - getLineAndCharacterOfPosition(file, start + length); - const lastLineInFile = getLineAndCharacterOfPosition( + const { column: firstLineChar, line: firstLine } = getColumnAndLineOfPosition( + file, + start, + ); + const { column: lastLineChar, line: lastLine } = getColumnAndLineOfPosition( + file, + start + length, + ); + const lastLineInFile = getColumnAndLineOfPosition( file, file.text.length, ).line; @@ -118,10 +135,10 @@ function formatCodeSpan( "\n"; i = lastLine - 1; } - const lineStart = getPositionOfLineAndCharacter(file, i, 0); + const lineStart = getPositionOfColumnAndLine(file, { line: i, column: 0 }); const lineEnd = i < lastLineInFile - ? getPositionOfLineAndCharacter(file, i + 1, 0) + ? getPositionOfColumnAndLine(file, { line: i + 1, column: 0 }) : file.text.length; let lineContent = file.text.slice(lineStart, lineEnd); lineContent = lineContent.trimEnd(); @@ -153,14 +170,17 @@ function formatCodeSpan( return context; } -function formatLocation(file: SourceFile, start: number): string { - const { character, line } = getLineAndCharacterOfPosition(file, start); +function formatLocation( + file: SourceFileWithLineMapAndFileName, + start: number, +): string { + const { column, line } = getColumnAndLineOfPosition(file, start); const relativeFileName = displayFilename(file.fileName); let output = ""; output += color(relativeFileName, COLOR.Cyan); output += ":"; output += color(`${line + 1}`, COLOR.Yellow); output += ":"; - output += color(`${character + 1}`, COLOR.Yellow); + output += color(`${column + 1}`, COLOR.Yellow); return output; } diff --git a/packages/ts/src/index.ts b/packages/ts/src/index.ts index 55fe02e5f..1d0a61ea6 100644 --- a/packages/ts/src/index.ts +++ b/packages/ts/src/index.ts @@ -1,6 +1,13 @@ export { getTSNodeRange } from "./getTSNodeRange.js"; export * from "./language.js"; +// TODO: maybe it worth adding another export entry? +// For example '@flint.fyi/ts/utils' +export * from "./createTypeScriptFileFromProgram.js"; +export { extractDirectivesFromTypeScriptFile } from "./directives/parseDirectivesFromTypeScriptFile.js"; +export type { TSNodesByName } from "./nodes.js"; +export * from "./normalizeRange.js"; export { ts } from "./plugin.js"; +export * from "./prepareTypeScriptFile.js"; export { getDeclarationsIfGlobal } from "./utils/getDeclarationsIfGlobal.js"; export { isGlobalDeclaration } from "./utils/isGlobalDeclaration.js"; export { isGlobalDeclarationOfName } from "./utils/isGlobalDeclarationOfName.js"; diff --git a/packages/ts/src/language.ts b/packages/ts/src/language.ts index 1ce2082c3..6ce2998ed 100644 --- a/packages/ts/src/language.ts +++ b/packages/ts/src/language.ts @@ -9,7 +9,6 @@ import { debugForFile } from "debug-for-file"; import path from "node:path"; import * as ts from "typescript"; -import { createTypeScriptFileFromProgram } from "./createTypeScriptFileFromProgram.js"; import { createTypeScriptFileFromProjectService } from "./createTypeScriptFileFromProjectService.js"; import { TSNodesByName } from "./nodes.js"; import { prepareTypeScriptFile } from "./prepareTypeScriptFile.js"; @@ -18,104 +17,121 @@ const log = debugForFile(import.meta.filename); const projectRoot = path.join(import.meta.dirname, "../.."); +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 interface TypeScriptServices { program: ts.Program; sourceFile: ts.SourceFile; 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 function prepareTypeScriptBasedLanguage(): TypeScriptBasedLanguageFileFactoryDefinition { + const { service } = createProjectService(); + const seenPrograms = new Set(); - return createVirtualTypeScriptEnvironment( - system, - [filePathAbsolute], - ts, - { - skipLibCheck: true, - target: ts.ScriptTarget.ESNext, - }, - ); + 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, + }; + }, + }; +} - 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 676441703..5c2d3939c 100644 --- a/packages/ts/src/normalizeRange.ts +++ b/packages/ts/src/normalizeRange.ts @@ -1,14 +1,14 @@ -import type * as ts from "typescript"; - import { CharacterReportRange, getColumnAndLineOfPosition, NormalizedReportRangeObject, + SourceFileWithLineMap, } from "@flint.fyi/core"; +import * as ts from "typescript"; export function normalizeRange( original: CharacterReportRange, - sourceFile: ts.SourceFile, + sourceFile: SourceFileWithLineMap, ): NormalizedReportRangeObject { const onCharacters = isNode(original) ? { begin: original.getStart(), end: original.getEnd() } diff --git a/packages/ts/src/prepareTypeScriptFile.ts b/packages/ts/src/prepareTypeScriptFile.ts index fb21beae5..3e840ce89 100644 --- a/packages/ts/src/prepareTypeScriptFile.ts +++ b/packages/ts/src/prepareTypeScriptFile.ts @@ -1,14 +1,18 @@ -import { LanguageFileDefinition } from "@flint.fyi/core"; -import ts from "typescript"; +import { LanguagePreparedDefinition } from "@flint.fyi/core"; +import { createTypeScriptFileFromProgram } from "./createTypeScriptFileFromProgram.js"; import { parseDirectivesFromTypeScriptFile } from "./directives/parseDirectivesFromTypeScriptFile.js"; +import { TypeScriptBasedLanguageFile } from "./language.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..5e18aecbb --- /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 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; +} + +export function isBuiltinTypeAliasLike( + program: ts.Program, + type: ts.Type, + predicate: ( + subType: ts.Type & { + aliasSymbol: ts.Symbol; + aliasTypeArguments: readonly 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 ts.Type & { + aliasSymbol: ts.Symbol; + aliasTypeArguments: readonly ts.Type[]; + }, + ) + ) { + return true; + } + + return null; + }); +} + +export function isReadonlyTypeLike( + program: ts.Program, + type: ts.Type, + predicate?: ( + subType: ts.Type & { + aliasSymbol: ts.Symbol; + aliasTypeArguments: readonly ts.Type[]; + }, + ) => boolean, +): boolean { + return isBuiltinTypeAliasLike(program, type, (subtype) => { + return ( + subtype.aliasSymbol.getName() === "Readonly" && !!predicate?.(subtype) + ); + }); +} 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..0de172eca --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,22 @@ +{ + "name": "@flint.fyi/vue", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./lib/index.js", + "dependencies": { + "@flint.fyi/core": "workspace:", + "@flint.fyi/ts": "workspace:", + "@volar/language-core": "~2.4.27", + "@volar/typescript": "~2.4.27", + "@vue/language-core": "3.2.1", + "ts-api-utils": "^2.1.0" + }, + "devDependencies": { + "@flint.fyi/rule-tester": "workspace:", + "vue": "~3.5.26" + }, + "peerDependencies": { + "@vue/compiler-dom": "~3.5.0" + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 000000000..87a1724fa --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,2 @@ +export { vueLanguage, vueWrapRules } from "./language.js"; +export * from "./plugin.js"; diff --git a/packages/vue/src/language.ts b/packages/vue/src/language.ts new file mode 100644 index 000000000..f91c9db8a --- /dev/null +++ b/packages/vue/src/language.ts @@ -0,0 +1,501 @@ +import { + AnyLevelDeep, + AnyRule, + CharacterReportRange, + computeRulesWithOptions, + ConfigRuleDefinition, + createLanguage, + DirectivesCollector, + flatten, + getColumnAndLineOfPosition, + isSuggestionForFiles, + LanguagePreparedDefinition, + NormalizedReport, + NormalizedReportRangeObject, + RuleContext, + RuleReport, + RuleReporter, + setTSExtraSupportedExtensions, + setTSProgramCreationProxy, + SourceFileWithLineMap, +} from "@flint.fyi/core"; +import { + Language as VolarLanguage, + Mapper as VolarMapper, +} from "@volar/language-core"; +import { + parse as vueParse, + NodeTypes, + RootNode, + TemplateChildNode, +} from "@vue/compiler-dom"; +import { + createVueLanguagePlugin, + createParsedCommandLine as createVueParsedCommandLine, + createParsedCommandLineByJson as createVueParsedCommandLineByJson, + tsCodegen, + VueCompilerOptions, + VueVirtualCode, +} from "@vue/language-core"; +// for LanguagePlugin interface augmentation +import "@volar/typescript"; +import { + collectTypeScriptFileCacheImpacts, + convertTypeScriptDiagnosticToLanguageFileDiagnostic, + extractDirectivesFromTypeScriptFile, + NodeSyntaxKinds, + prepareTypeScriptBasedLanguage, + prepareTypeScriptFile, + TSNodesByName, + TypeScriptBasedLanguageFile, + TypeScriptServices, +} from "@flint.fyi/ts"; +import { proxyCreateProgram } from "@volar/typescript/lib/node/proxyCreateProgram.js"; +import ts from "typescript"; + +type ProxiedTSProgram = ts.Program & { + __flintVolarLanguage?: undefined | VolarLanguage; +}; + +// TODO: css + +setTSExtraSupportedExtensions([".vue"]); +setTSProgramCreationProxy( + (ts, createProgram) => + new Proxy(function () {} as unknown as typeof createProgram, { + apply(target, thisArg, args) { + let volarLanguage = null as null | VolarLanguage; + let vueCompilerOptions = null as null | VueCompilerOptions; + const proxied = proxyCreateProgram(ts, createProgram, (ts, options) => { + const { configFilePath } = options.options; + vueCompilerOptions = ( + typeof configFilePath === "string" + ? createVueParsedCommandLine( + ts, + ts.sys, + configFilePath.replaceAll("\\", "/"), + ) + : createVueParsedCommandLineByJson( + ts, + ts.sys, + (options.host ?? ts.sys).getCurrentDirectory(), + {}, + ) + ).vueOptions; + const vueLanguagePlugin = createVueLanguagePlugin( + ts, + options.options, + vueCompilerOptions, + (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), + }; + }); + + const program: ProxiedTSProgram = Reflect.apply(proxied, thisArg, args); + + if (volarLanguage == null) { + throw new Error("Flint bug: volarLanguage is not defined"); + } + if (vueCompilerOptions == null) { + throw new Error("Flint bug: vueCompilerOptions is not defined"); + } + + if (program.__flintVolarLanguage != null) { + return program; + } + + program.__flintVolarLanguage = volarLanguage; + return program; + }, + }), +); + +export interface VueServices extends TypeScriptServices { + vueServices?: { + codegen: VueCodegen; + map: VolarMapper; + sfc: RootNode; + virtualCode: VueVirtualCode; + // TODO: can we type MessageId? + reportSfc: RuleReporter; + }; +} + +type VueCodegen = typeof tsCodegen extends WeakMap ? V : never; + +export const vueLanguage = createLanguage({ + about: { + name: "Vue.js", + }, + prepare: () => { + const tsLang = prepareTypeScriptBasedLanguage(); + + return { + prepareFromDisk: (filePathAbsolute) => { + return prepareVueFile( + filePathAbsolute, + tsLang.createFromDisk(filePathAbsolute), + ); + }, + prepareFromVirtual: (filePathAbsolute, sourceText) => { + return prepareVueFile( + filePathAbsolute, + tsLang.createFromVirtual(filePathAbsolute, sourceText), + ); + }, + }; + }, +}); + +export function translateRange( + map: VolarMapper, + serviceBegin: number, + serviceEnd: number, +): null | { begin: number; end: number } { + for (const [begin, end] of map.toSourceRange( + serviceBegin, + serviceEnd, + true, + )) { + if (begin === end) { + continue; + } + return { begin, end }; + } + return null; +} + +export function vueWrapRules( + ...rules: AnyLevelDeep[] +): AnyRule[] { + return Array.from( + computeRulesWithOptions(flatten(rules)) + .keys() + .map((rule) => vueLanguage.createRule(rule)), + ); +} + +function prepareVueFile( + filePathAbsolute: string, + tsFile: TypeScriptBasedLanguageFile, +): LanguagePreparedDefinition { + const { program, sourceFile, [Symbol.dispose]: onDispose } = tsFile; + + // @ts-expect-error + const volarLanguage: VolarLanguage = program.__flintVolarLanguage; + + 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.languageId !== "vue") { + return prepareTypeScriptFile({ + program, + sourceFile, + [Symbol.dispose]: onDispose, + }); + } + 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 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, + ); + if (serviceScript == null) { + throw new Error("Expected serviceScript to exist"); + } + + const serviceText = serviceScript.code.snapshot.getText( + 0, + serviceScript.code.snapshot.getLength(), + ); + + const virtualCode = sourceScript.generated.root as VueVirtualCode; + const codegen = tsCodegen.get(virtualCode.sfc); + if (codegen == null) { + throw new Error("Expected codegen to exist"); + } + + const map = volarLanguage.maps.get(serviceScript.code, sourceScript); + const sortedMappings = map.mappings.toSorted( + (a, b) => a.generatedOffsets[0] - b.generatedOffsets[0], + ); + + const sfcAst = vueParse(sourceText, { + comments: true, + expressionPlugins: ["typescript"], + parseMode: "html", + // We ignore errors because virtual code already provides them, + // and it also provides them with sourceText-based locations, + // so we don't have to remap them. Oh, and it also contains errors from + // other blocks rather than only