diff --git a/.changeset/brown-cheetahs-greet.md b/.changeset/brown-cheetahs-greet.md new file mode 100644 index 00000000..e32592a5 --- /dev/null +++ b/.changeset/brown-cheetahs-greet.md @@ -0,0 +1,5 @@ +--- +"svelte-eslint-parser": minor +--- + +feat: apply runes to `*.svelte.js` and `*.svelte.ts`. diff --git a/README.md b/README.md index 2c493c04..717578b2 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,44 @@ module.exports = { } ``` +### Runes support + +***This is an experimental feature. It may be changed or removed in minor versions without notice.*** + +If you install Svelte v5 the parser will be able to parse runes, and will also be able to parse `*.js` and `*.ts` files. + +When using this mode in an ESLint configuration, it is recommended to set it per file pattern as below. + +```json +{ + "overrides": [ + { + "files": ["*.svelte"], + "parser": "svelte-eslint-parser", + "parserOptions": { + "parser": "...", + ... + } + }, + { + "files": ["*.svelte.js"], + "parser": "svelte-eslint-parser", + "parserOptions": { + ... + } + }, + { + "files": ["*.svelte.ts"], + "parser": "svelte-eslint-parser", + "parserOptions": { + "parser": "...(ts parser)...", + ... + } + } + ] +} +``` + ## :computer: Editor Integrations ### Visual Studio Code diff --git a/package.json b/package.json index 954e77d7..c15536a4 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "release": "changeset publish", "test": "pnpm run mocha \"tests/src/**/*.ts\" --reporter dot --timeout 60000", "ts": "node -r esbuild-register", - "update-fixtures": "pnpm i -D svelte@4 && pnpm run run-update-fixtures && git checkout package.json && pnpm i && pnpm run run-update-fixtures", + "update-fixtures": "git add package.json && pnpm i -D svelte@4 && git checkout package.json && pnpm run run-update-fixtures && pnpm i && pnpm run run-update-fixtures", "run-update-fixtures": "pnpm run ts ./tools/update-fixtures.ts", "version:ci": "env-cmd -e version-ci pnpm run build:meta && changeset version" }, diff --git a/src/context/index.ts b/src/context/index.ts index 66168e1c..4b061b5e 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -1,5 +1,3 @@ -import fs from "fs"; -import path from "path"; import type { Comment, Locations, @@ -15,19 +13,13 @@ import type ESTree from "estree"; import type * as SvAST from "../parser/svelte-ast-types"; import { ScriptLetContext } from "./script-let"; import { LetDirectiveCollections } from "./let-directive-collection"; -import { getParserForLang } from "../parser/resolve-parser"; import type { AttributeToken } from "../parser/html"; import { parseAttributes } from "../parser/html"; -import { - isTSESLintParserObject, - maybeTSESLintParserObject, -} from "../parser/parser-object"; import { sortedLastIndex } from "../utils"; - -const TS_PARSER_NAMES = [ - "@typescript-eslint/parser", - "typescript-eslint-parser-for-extra-files", -]; +import { + isTypeScript, + type NormalizedParserOptions, +} from "../parser/parser-options"; export class ScriptsSourceCode { private raw: string; @@ -116,7 +108,7 @@ export type ContextSourceCode = { export class Context { public readonly code: string; - public readonly parserOptions: any; + public readonly parserOptions: NormalizedParserOptions; // ----- Source Code ------ public readonly sourceCode: ContextSourceCode; @@ -155,7 +147,7 @@ export class Context { private readonly blocks: Block[] = []; - public constructor(code: string, parserOptions: any) { + public constructor(code: string, parserOptions: NormalizedParserOptions) { this.code = code; this.parserOptions = parserOptions; this.locs = new LinesAndColumns(code); @@ -287,44 +279,7 @@ export class Context { return this.state.isTypeScript; } const lang = this.sourceCode.scripts.attrs.lang; - if (!lang) { - return (this.state.isTypeScript = false); - } - const parserValue = getParserForLang( - this.sourceCode.scripts.attrs, - this.parserOptions?.parser, - ); - if (typeof parserValue !== "string") { - return (this.state.isTypeScript = - maybeTSESLintParserObject(parserValue) || - isTSESLintParserObject(parserValue)); - } - const parserName = parserValue; - if (TS_PARSER_NAMES.includes(parserName)) { - return (this.state.isTypeScript = true); - } - if (TS_PARSER_NAMES.some((nm) => parserName.includes(nm))) { - let targetPath = parserName; - while (targetPath) { - const pkgPath = path.join(targetPath, "package.json"); - if (fs.existsSync(pkgPath)) { - try { - return (this.state.isTypeScript = TS_PARSER_NAMES.includes( - JSON.parse(fs.readFileSync(pkgPath, "utf-8"))?.name, - )); - } catch { - return (this.state.isTypeScript = false); - } - } - const parent = path.dirname(targetPath); - if (targetPath === parent) { - break; - } - targetPath = parent; - } - } - - return (this.state.isTypeScript = false); + return (this.state.isTypeScript = isTypeScript(this.parserOptions, lang)); } public stripScriptCode(start: number, end: number): void { diff --git a/src/parser/analyze-scope.ts b/src/parser/analyze-scope.ts index d42b4fca..b6f35935 100644 --- a/src/parser/analyze-scope.ts +++ b/src/parser/analyze-scope.ts @@ -5,12 +5,13 @@ import { getFallbackKeys } from "../traverse"; import type { SvelteReactiveStatement, SvelteScriptElement } from "../ast"; import { addReference, addVariable } from "../scope"; import { addElementToSortedArray } from "../utils"; +import type { NormalizedParserOptions } from "./parser-options"; /** * Analyze scope */ export function analyzeScope( node: ESTree.Node, - parserOptions: any = {}, + parserOptions: NormalizedParserOptions, ): ScopeManager { const ecmaVersion = parserOptions.ecmaVersion || 2020; const ecmaFeatures = parserOptions.ecmaFeatures || {}; diff --git a/src/parser/globals.ts b/src/parser/globals.ts index 9c3824dd..aa3a2ea6 100644 --- a/src/parser/globals.ts +++ b/src/parser/globals.ts @@ -1,16 +1,16 @@ -import { VERSION as SVELTE_VERSION } from "svelte/compiler"; +import { svelteVersion } from "./svelte-version"; -const globalsForSvelte4: Readonly = [ - "$$slots", - "$$props", - "$$restProps", -] as const; -export const globalsForSvelte5 = [ +const globalsForSvelte4 = ["$$slots", "$$props", "$$restProps"] as const; +export const globalsForRunes = [ "$state", "$derived", "$effect", "$props", ] as const; -export const globals = SVELTE_VERSION.startsWith("5") - ? [...globalsForSvelte4, ...globalsForSvelte5] +const globalsForSvelte5 = [...globalsForSvelte4, ...globalsForRunes]; +export const globals = svelteVersion.gte(5) + ? globalsForSvelte5 : globalsForSvelte4; +export const globalsForSvelteScript = svelteVersion.gte(5) + ? globalsForRunes + : []; diff --git a/src/parser/index.ts b/src/parser/index.ts index 5898b13b..8fc7df35 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -10,7 +10,7 @@ import type { import type { Program } from "estree"; import type { ScopeManager } from "eslint-scope"; import { Variable } from "eslint-scope"; -import { parseScript } from "./script"; +import { parseScript, parseScriptInSvelte } from "./script"; import type * as SvAST from "./svelte-ast-types"; import { sortNodes } from "./sort"; import { parseTemplate } from "./template"; @@ -20,7 +20,7 @@ import { analyzeStoreScope, } from "./analyze-scope"; import { ParseError } from "../errors"; -import { parseTypeScript } from "./typescript"; +import { parseTypeScript, parseTypeScriptInSvelte } from "./typescript"; import { addReference } from "../scope"; import { parseStyleContext, @@ -32,7 +32,10 @@ import { styleNodeLoc, styleNodeRange, } from "./style-context"; -import { globals } from "./globals"; +import { globals, globalsForSvelteScript } from "./globals"; +import { svelteVersion } from "./svelte-version"; +import type { NormalizedParserOptions } from "./parser-options"; +import { isTypeScript, normalizeParserOptions } from "./parser-options"; export { StyleContext, @@ -59,39 +62,48 @@ export interface ESLintExtendedProgram { // The code used to parse the script. _virtualScriptCode?: string; } -/** - * Parse source code - */ -export function parseForESLint( - code: string, - options?: any, -): { +type ParseResult = { ast: SvelteProgram; - services: Record & { - isSvelte: true; - getSvelteHtmlAst: () => SvAST.Fragment; - getStyleContext: () => StyleContext; - }; + services: Record & + ( + | { + isSvelte: true; + isSvelteScript: false; + getSvelteHtmlAst: () => SvAST.Fragment; + getStyleContext: () => StyleContext; + } + | { isSvelte: false; isSvelteScript: true } + ); visitorKeys: { [type: string]: string[] }; scopeManager: ScopeManager; -} { - const parserOptions = { - ecmaVersion: 2020, - sourceType: "module", - loc: true, - range: true, - raw: true, - tokens: true, - comment: true, - eslintVisitorKeys: true, - eslintScopeManager: true, - ...(options || {}), - }; - parserOptions.sourceType = "module"; - if (parserOptions.ecmaVersion <= 5 || parserOptions.ecmaVersion == null) { - parserOptions.ecmaVersion = 2015; +}; +/** + * Parse source code + */ +export function parseForESLint(code: string, options?: any): ParseResult { + const parserOptions = normalizeParserOptions(options); + + if ( + svelteVersion.hasRunes && + parserOptions.filePath && + !parserOptions.filePath.endsWith(".svelte") + ) { + const trimmed = code.trim(); + if (!trimmed.startsWith("<") && !trimmed.endsWith(">")) { + return parseAsScript(code, parserOptions); + } } + return parseAsSvelte(code, parserOptions); +} + +/** + * Parse source code as svelte component + */ +function parseAsSvelte( + code: string, + parserOptions: NormalizedParserOptions, +): ParseResult { const ctx = new Context(code, parserOptions); const resultTemplate = parseTemplate( ctx.sourceCode.template, @@ -101,13 +113,13 @@ export function parseForESLint( const scripts = ctx.sourceCode.scripts; const resultScript = ctx.isTypeScript() - ? parseTypeScript( + ? parseTypeScriptInSvelte( scripts.getCurrentVirtualCodeInfo(), scripts.attrs, parserOptions, { slots: ctx.slots }, ) - : parseScript( + : parseScriptInSvelte( scripts.getCurrentVirtualCode(), scripts.attrs, parserOptions, @@ -123,24 +135,7 @@ export function parseForESLint( analyzeStoreScope(resultScript.scopeManager!); // for reactive vars // Add $$xxx variable - for (const $$name of globals) { - const globalScope = resultScript.scopeManager!.globalScope; - const variable = new Variable(); - variable.name = $$name; - (variable as any).scope = globalScope; - globalScope.variables.push(variable); - globalScope.set.set($$name, variable); - globalScope.through = globalScope.through.filter((reference) => { - if (reference.identifier.name === $$name) { - // Links the variable and the reference. - // And this reference is removed from `Scope#through`. - reference.resolved = variable; - addReference(variable.references, reference); - return false; - } - return true; - }); - } + addGlobalVariables(resultScript.scopeManager!, globals); const ast = resultTemplate.ast; @@ -195,6 +190,7 @@ export function parseForESLint( resultScript.ast = ast as any; resultScript.services = Object.assign(resultScript.services || {}, { isSvelte: true, + isSvelteScript: false, getSvelteHtmlAst() { return resultTemplate.svelteAst.html; }, @@ -212,6 +208,54 @@ export function parseForESLint( return resultScript as any; } +/** + * Parse source code as script + */ +function parseAsScript( + code: string, + parserOptions: NormalizedParserOptions, +): ParseResult { + const lang = parserOptions.filePath?.split(".").pop(); + const resultScript = isTypeScript(parserOptions, lang) + ? parseTypeScript(code, { lang }, parserOptions) + : parseScript(code, { lang }, parserOptions); + + // Add $$xxx variable + addGlobalVariables(resultScript.scopeManager!, globalsForSvelteScript); + + resultScript.services = Object.assign(resultScript.services || {}, { + isSvelte: false, + isSvelteScript: true, + }); + resultScript.visitorKeys = Object.assign({}, KEYS, resultScript.visitorKeys); + return resultScript as any; +} + +function addGlobalVariables( + scopeManager: ScopeManager, + globals: readonly string[], +) { + const globalScope = scopeManager.globalScope; + for (const globalName of globals) { + if (globalScope.set.has(globalName)) continue; + const variable = new Variable(); + variable.name = globalName; + (variable as any).scope = globalScope; + globalScope.variables.push(variable); + globalScope.set.set(globalName, variable); + globalScope.through = globalScope.through.filter((reference) => { + if (reference.identifier.name === globalName) { + // Links the variable and the reference. + // And this reference is removed from `Scope#through`. + reference.resolved = variable; + addReference(variable.references, reference); + return false; + } + return true; + }); + } +} + /** Extract tokens */ function extractTokens(ctx: Context) { const useRanges = sortNodes([...ctx.tokens, ...ctx.comments]).map( diff --git a/src/parser/parser-options.ts b/src/parser/parser-options.ts new file mode 100644 index 00000000..6f134ced --- /dev/null +++ b/src/parser/parser-options.ts @@ -0,0 +1,99 @@ +import fs from "fs"; +import path from "path"; +import { + isTSESLintParserObject, + maybeTSESLintParserObject, +} from "./parser-object"; +import { getParserForLang, type UserOptionParser } from "./resolve-parser"; + +export type NormalizedParserOptions = { + parser?: UserOptionParser; + project?: string | string[] | null; + + ecmaVersion: number | "latest"; + sourceType: "module" | "script"; + ecmaFeatures?: { + globalReturn?: boolean | undefined; + impliedStrict?: boolean | undefined; + jsx?: boolean | undefined; + experimentalObjectRestSpread?: boolean | undefined; + [key: string]: any; + }; + loc: boolean; + range: boolean; + raw: boolean; + tokens: boolean; + comment: boolean; + eslintVisitorKeys: boolean; + eslintScopeManager: boolean; + filePath?: string; +}; + +/** Normalize parserOptions */ +export function normalizeParserOptions(options: any): NormalizedParserOptions { + const parserOptions = { + ecmaVersion: 2020, + sourceType: "module", + loc: true, + range: true, + raw: true, + tokens: true, + comment: true, + eslintVisitorKeys: true, + eslintScopeManager: true, + ...(options || {}), + }; + parserOptions.sourceType = "module"; + if (parserOptions.ecmaVersion <= 5 || parserOptions.ecmaVersion == null) { + parserOptions.ecmaVersion = 2015; + } + + return parserOptions; +} + +const TS_PARSER_NAMES = [ + "@typescript-eslint/parser", + "typescript-eslint-parser-for-extra-files", +]; + +export function isTypeScript( + parserOptions: NormalizedParserOptions, + lang: string | undefined, +): boolean { + if (!lang) { + return false; + } + const parserValue = getParserForLang(lang, parserOptions?.parser); + if (typeof parserValue !== "string") { + return ( + maybeTSESLintParserObject(parserValue) || + isTSESLintParserObject(parserValue) + ); + } + const parserName = parserValue; + if (TS_PARSER_NAMES.includes(parserName)) { + return true; + } + if (TS_PARSER_NAMES.some((nm) => parserName.includes(nm))) { + let targetPath = parserName; + while (targetPath) { + const pkgPath = path.join(targetPath, "package.json"); + if (fs.existsSync(pkgPath)) { + try { + return TS_PARSER_NAMES.includes( + JSON.parse(fs.readFileSync(pkgPath, "utf-8"))?.name, + ); + } catch { + return false; + } + } + const parent = path.dirname(targetPath); + if (targetPath === parent) { + break; + } + targetPath = parent; + } + } + + return false; +} diff --git a/src/parser/resolve-parser.ts b/src/parser/resolve-parser.ts index 3cc2c1dd..9dab4de1 100644 --- a/src/parser/resolve-parser.ts +++ b/src/parser/resolve-parser.ts @@ -2,7 +2,7 @@ import { getEspree } from "./espree"; import type { ParserObject } from "./parser-object"; import { isParserObject } from "./parser-object"; -type UserOptionParser = +export type UserOptionParser = | string | ParserObject | Record @@ -10,7 +10,7 @@ type UserOptionParser = /** Get parser for script lang */ export function getParserForLang( - attrs: Record, + lang: string | undefined | null, parser: UserOptionParser, ): string | ParserObject { if (parser) { @@ -18,7 +18,7 @@ export function getParserForLang( return parser; } if (typeof parser === "object") { - const value = parser[attrs.lang || "js"]; + const value = parser[lang || "js"]; if (typeof value === "string" || isParserObject(value)) { return value; } @@ -32,7 +32,7 @@ export function getParser( attrs: Record, parser: UserOptionParser, ): ParserObject { - const parserValue = getParserForLang(attrs, parser); + const parserValue = getParserForLang(attrs.lang, parser); if (isParserObject(parserValue)) { return parserValue; } diff --git a/src/parser/script.ts b/src/parser/script.ts index 0fedd339..80de7662 100644 --- a/src/parser/script.ts +++ b/src/parser/script.ts @@ -3,25 +3,17 @@ import { analyzeScope } from "./analyze-scope"; import { traverseNodes } from "../traverse"; import { getParser } from "./resolve-parser"; import { isEnhancedParserObject } from "./parser-object"; +import type { NormalizedParserOptions } from "./parser-options"; /** - * Parse for script + * Parse for