diff --git a/.changeset/seven-hornets-jump.md b/.changeset/seven-hornets-jump.md new file mode 100644 index 000000000..9e0ca0c20 --- /dev/null +++ b/.changeset/seven-hornets-jump.md @@ -0,0 +1,6 @@ +--- +"@flint.fyi/vue-language": minor +"@flint.fyi/vue": minor +--- + +Introduce Vue language. diff --git a/cspell.json b/cspell.json index fcf9e48e3..bf70eaf4d 100644 --- a/cspell.json +++ b/cspell.json @@ -43,6 +43,7 @@ "bday", "BDFL", "bradzacher", + "codegen", "codemod", "contentinfo", "deoptimizations", diff --git a/knip.json b/knip.json index 52292d8c1..ef5122ff9 100644 --- a/knip.json +++ b/knip.json @@ -19,6 +19,9 @@ "sharp", "zod" ] + }, + "packages/vue": { + "ignoreDependencies": ["vue"] } } } diff --git a/packages/typescript-language/package.json b/packages/typescript-language/package.json index e418b5fec..1e14231fe 100644 --- a/packages/typescript-language/package.json +++ b/packages/typescript-language/package.json @@ -13,7 +13,7 @@ "name": "JoshuaKGoldberg", "email": "npm@joshuakgoldberg.com" }, - "sideEffects": false, + "sideEffects": true, "type": "module", "exports": { ".": "./src/index.ts" diff --git a/packages/typescript-language/src/convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts b/packages/typescript-language/src/convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts index fb9d7c243..a29d66467 100644 --- a/packages/typescript-language/src/convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts +++ b/packages/typescript-language/src/convertTypeScriptDiagnosticToLanguageFileDiagnostic.ts @@ -94,6 +94,7 @@ function displayFilename(name: string) { if (name.startsWith("./")) { return name.slice(2); } + // TODO: use LinterHost.getCurrentDirectory() return name.slice(process.cwd().length + 1); } diff --git a/packages/volar-language/package.json b/packages/volar-language/package.json index 35b10e7e9..c4291d82a 100644 --- a/packages/volar-language/package.json +++ b/packages/volar-language/package.json @@ -13,7 +13,7 @@ "name": "JoshuaKGoldberg", "email": "npm@joshuakgoldberg.com" }, - "sideEffects": false, + "sideEffects": true, "type": "module", "exports": { ".": "./src/index.ts" diff --git a/packages/vue-language/README.md b/packages/vue-language/README.md new file mode 100644 index 000000000..dd9cc8227 --- /dev/null +++ b/packages/vue-language/README.md @@ -0,0 +1,9 @@ +

@flint.fyi/vue-language

+ +

+ [Experimental] Vue.js language for Flint. + ❤️‍🔥 +

+ +This is an internal package for Flint. +Documentation will eventually be added. diff --git a/packages/vue-language/package.json b/packages/vue-language/package.json new file mode 100644 index 000000000..4c93d036c --- /dev/null +++ b/packages/vue-language/package.json @@ -0,0 +1,52 @@ +{ + "name": "@flint.fyi/vue-language", + "version": "0.0.1", + "description": "[Experimental] Vue.js language for Flint.", + "homepage": "https://flint.fyi", + "repository": { + "type": "git", + "url": "git+https://github.com/flint-fyi/flint.git", + "directory": "packages/vue-language" + }, + "license": "MIT", + "author": { + "name": "JoshuaKGoldberg", + "email": "npm@joshuakgoldberg.com" + }, + "sideEffects": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "lib/", + "!lib/**/*.map" + ], + "scripts": { + "test": "vitest --project vue-language" + }, + "dependencies": { + "@flint.fyi/ts-patch": "workspace:^", + "@flint.fyi/typescript-language": "workspace:^", + "@flint.fyi/utils": "workspace:^", + "@flint.fyi/volar-language": "workspace:^", + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/language-core": "3.2.1", + "typescript": "^5.9.0 || ^6.0.0" + }, + "devDependencies": { + "@flint.fyi/core": "workspace:^", + "tsdown": "catalog:dev", + "vitest": "catalog:dev" + }, + "engines": { + "node": ">=24.0.0" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./lib/index.js" + } + } +} diff --git a/packages/vue-language/src/extractTemplateDirectives.ts b/packages/vue-language/src/extractTemplateDirectives.ts new file mode 100644 index 000000000..082d74f5d --- /dev/null +++ b/packages/vue-language/src/extractTemplateDirectives.ts @@ -0,0 +1,56 @@ +import type { ExtractedDirective } from "@flint.fyi/typescript-language"; +import { nullThrows } from "@flint.fyi/utils"; +import { + NodeTypes, + type RootNode, + type TemplateChildNode, +} from "@vue/compiler-dom"; + +export function extractTemplateDirectives(ast: RootNode) { + const directives: ExtractedDirective[] = []; + + function visitTemplate(elem: TemplateChildNode) { + if (elem.type === NodeTypes.ELEMENT) { + for (const child of elem.children) { + visitTemplate(child); + } + return; + } + if (elem.type !== NodeTypes.COMMENT) { + return; + } + const match = /\s*flint-(\S+)(?:\s+(.+))?/.exec(elem.content); + if (match == null) { + return; + } + const [, type, selection] = match; + directives.push({ + range: { + begin: { + column: elem.loc.start.column - 1, + line: elem.loc.start.line - 1, + raw: elem.loc.start.offset, + }, + end: { + column: elem.loc.end.column - 1, + line: elem.loc.end.line - 1, + raw: elem.loc.end.offset, + }, + }, + selection: nullThrows( + selection, + "Expected RegExp to provide second capturing group", + ), + type: nullThrows( + type, + "Expected RegExp to provide first capturing group", + ), + }); + } + + for (const child of ast.children) { + visitTemplate(child); + } + + return directives; +} diff --git a/packages/vue-language/src/index.ts b/packages/vue-language/src/index.ts new file mode 100644 index 000000000..f7ff6e353 --- /dev/null +++ b/packages/vue-language/src/index.ts @@ -0,0 +1 @@ +export { vueLanguage } from "./language.ts"; diff --git a/packages/vue-language/src/language.ts b/packages/vue-language/src/language.ts new file mode 100644 index 000000000..4dbdcef3e --- /dev/null +++ b/packages/vue-language/src/language.ts @@ -0,0 +1,128 @@ +import { setTSExtraSupportedExtensions } from "@flint.fyi/ts-patch"; +import { assert, nullThrows } from "@flint.fyi/utils"; +import { createVolarBasedLanguage } from "@flint.fyi/volar-language"; +import type { Mapper as VolarMapper } from "@volar/language-core"; +import { NodeTypes, type RootNode, parse as vueParse } from "@vue/compiler-dom"; +import { + createVueLanguagePlugin, + createParsedCommandLine as createVueParsedCommandLine, + createParsedCommandLineByJson as createVueParsedCommandLineByJson, + tsCodegen, + VueVirtualCode, +} from "@vue/language-core"; + +import { extractTemplateDirectives } from "./extractTemplateDirectives.ts"; +import { vueParsingErrorsToLanguageDiagnostics } from "./vueParsingErrorsToLanguageDiagnostics.ts"; + +setTSExtraSupportedExtensions([".vue"]); + +export interface VueServices { + vue: { + codegen: VueCodegen; + map: VolarMapper; + sfc: RootNode; + virtualCode: VueVirtualCode; + }; +} + +type VueCodegen = + typeof tsCodegen extends WeakMap ? V : never; + +export const vueLanguage = createVolarBasedLanguage( + (ts, options) => { + const { configFilePath } = options.options; + const host = options.host + ? { + ...options.host, + useCaseSensitiveFileNames: options.host.useCaseSensitiveFileNames(), + } + : ts.sys; + const vueCompilerOptions = ( + typeof configFilePath === "string" + ? createVueParsedCommandLine( + ts, + host, + configFilePath.replaceAll("\\", "/"), + ) + : createVueParsedCommandLineByJson( + ts, + host, + host.getCurrentDirectory(), + {}, + ) + ).vueOptions; + + return { + createFile({ + data, + serviceScript, + sourceFile, + sourceScript, + volarLanguage, + }) { + const sourceText = sourceScript.snapshot.getText( + 0, + sourceScript.snapshot.getLength(), + ); + const virtualCode = sourceScript.generated.root; + assert( + virtualCode instanceof VueVirtualCode, + "Expected sourceScript.generated.root to be VueServiceCode", + ); + + const codegen = nullThrows( + tsCodegen.get(virtualCode.sfc), + `tsCodegen for ${data.filePathAbsolute} is undefined`, + ); + + const map = volarLanguage.maps.get(serviceScript.code, sourceScript); + + const sfcAst = vueParse(sourceText, { + comments: true, + expressionPlugins: ["typescript"], + onError: () => { + // 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