Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/quick-peas-join.md
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

@michaelfaith michaelfaith Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From #2298 -> #2322

Suggested change
feat: introduce Volar.js meta-language
Introduce Volar.js meta-language.

54 changes: 48 additions & 6 deletions packages/core/src/running/runLintRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -36,27 +37,68 @@ 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.`,
),
range: {
begin: getColumnAndLineOfPosition(
currentFile.about.sourceText,
ruleReport.range.begin,
range.begin,
),
end: getColumnAndLineOfPosition(
currentFile.about.sourceText,
ruleReport.range.end,
range.end,
),
},
suggestions,
});
},
});
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/types/languages.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -138,6 +139,9 @@ export type LanguageFile<FileServices extends object> = Disposable &
*/
export interface LanguageFileBase<FileServices extends object> {
about: FileAboutData;
adjustReportRange?: (
range: CharacterReportRange,
) => CharacterReportRange | null;
directives?: CommentDirective[];
reports?: FileReport[];
services: FileServices;
Expand Down
62 changes: 49 additions & 13 deletions packages/rule-tester/src/runTestCaseRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type AnyLanguageFileFactory,
type AnyOptionalSchema,
type AnyRule,
type FileReport,
getColumnAndLineOfPosition,
type InferredOutputObject,
type NormalizedReport,
Expand Down Expand Up @@ -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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, this is copy-pasted from core/src/running/runLintRule.ts. We can refactor it later. I just tried to minimize the overall diff.

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,
});
},
});
Expand Down
13 changes: 0 additions & 13 deletions packages/typescript-language/src/getTypeScriptFileDiagnostics.ts

This file was deleted.

140 changes: 129 additions & 11 deletions packages/typescript-language/src/language.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<object> & {
__volarServices: {
getDiagnostics(): LanguageDiagnostics;
runVisitors(
file: LanguageFile<TypeScriptFileServices>,
options: InferredOutputObject<AnyOptionalSchema | undefined>,
runtime: RuleRuntime<TypeScriptNodeVisitors, TypeScriptFileServices>,
): 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,
Expand Down Expand Up @@ -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);
},
Expand All @@ -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 };

Expand All @@ -110,3 +213,18 @@ export const typescriptLanguage = createLanguage<
visit(file.services.sourceFile);
},
});

const typeScriptCoreSupportedExtensions: ReadonlySet<string> = new Set([
".cjs",
".cts",
".d.cts",
".d.mts",
".d.ts",
".js",
".json",
".jsx",
".mjs",
".mts",
".ts",
".tsx",
]);
Loading