diff --git a/.changeset/famous-camels-battle.md b/.changeset/famous-camels-battle.md new file mode 100644 index 00000000..8f722ba0 --- /dev/null +++ b/.changeset/famous-camels-battle.md @@ -0,0 +1,5 @@ +--- +"svelte-eslint-parser": minor +--- + +fix: change virtual code to provide correct type information for reactive statements diff --git a/docs/internal-mechanism.md b/docs/internal-mechanism.md index 377468ce..22fe7b5d 100644 --- a/docs/internal-mechanism.md +++ b/docs/internal-mechanism.md @@ -52,9 +52,10 @@ Parse the following virtual script code as a script: function inputHandler () { // process } -; +;function $_render1(){ -(inputHandler)as ((e:'input' extends keyof HTMLElementEventMap?HTMLElementEventMap['input']:CustomEvent)=>void); +(inputHandler) as ((e:'input' extends keyof HTMLElementEventMap ? HTMLElementEventMap['input'] : CustomEvent) => void ); +} ``` This gives the correct type information to the inputHandler when used with `on:input={inputHandler}`. @@ -64,6 +65,8 @@ The script AST for the HTML template is then remapped to the template AST. You can check what happens to virtual scripts in the Online Demo. https://ota-meshi.github.io/svelte-eslint-parser/virtual-script-code/ +See also [Scope Types](#scope-types) section. + ### `scopeManager` This parser returns a ScopeManager instance. @@ -88,13 +91,13 @@ Parse the following virtual script code as a script: ```ts const array = [1, 2, 3] -; +;function $_render1(){ - - - - -Array.from(array).forEach((e)=>{const ee = e * 2;(ee);}); +Array.from(array).forEach((e) => { + const ee = e * 2; + (ee); +}); +} ``` This ensures that the variable `e` defined by `{#each}` is correctly scoped only within `{#each}`. @@ -111,3 +114,58 @@ You can also check the results [Online DEMO](https://ota-meshi.github.io/svelte- ESLint custom parsers that provide their own AST require `visitorKeys` to properly traverse the node. See https://eslint.org/docs/latest/developer-guide/working-with-custom-parsers. + +## Scope Types + +TypeScript's type inference is pretty good, so parsing Svelte as-is gives some wrong type information. + +e.g. + +```ts +export let foo: { bar: number } | null = null + +$: console.log(foo && foo.bar); + // ^ never type +``` + +(You can see it on [TypeScript Online Playground](https://www.typescriptlang.org/play?#code/KYDwDg9gTgLgBAG2PAZhCAuOBvOAjAQyiwDsBXAWz2CjgF84AfOchBOAXhbLYFgAoAQBIsAYwgkAzhCQA6BBADmACjQQ4AMg1w1swlACUAbgFwz5i5YsB6a3AB6LYADcacGAE8wwAUA)) + +In the above code, foo in `$:` should be `object` or `null` in `*.svelte`, but TypeScript infers that it is `null` only. + +To avoid this problem, the parser generates virtual code and traps statements within `$:` to function scope. +Then restore it to have the correct AST and ScopeManager. + +For example: + +```svelte + + +{foo && foo.bar} +``` + +Parse the following virtual script code as a script: + +```ts + +export let foo: { bar: number } | null = null + +$: function $_reactiveStatementScopeFunction1(){console.log(foo && foo.bar);} + +$: let r = $_reactiveVariableScopeFunction2(); +function $_reactiveVariableScopeFunction2(){return foo && foo.bar;} + +$: let { bar: n } = $_reactiveVariableScopeFunction3(); +function $_reactiveVariableScopeFunction3(){return foo || { bar: 42 };} +;function $_render4(){ + +(foo && foo.bar); +} +``` diff --git a/explorer-v2/src/lib/AstExplorer.svelte b/explorer-v2/src/lib/AstExplorer.svelte index 872e8424..81c73042 100644 --- a/explorer-v2/src/lib/AstExplorer.svelte +++ b/explorer-v2/src/lib/AstExplorer.svelte @@ -24,15 +24,38 @@ let jsonEditor, sourceEditor; + $: hasLangTs = /lang\s*=\s*(?:"ts"|ts|'ts'|"typescript"|typescript|'typescript')/u.test( + svelteValue + ); + + let tsParser = undefined; + $: { + if (hasLangTs && !tsParser) { + import('@typescript-eslint/parser').then((parser) => { + if (typeof window !== 'undefined') { + if (!window.process) { + window.process = { + cwd: () => '', + env: {} + }; + } + } + tsParser = parser; + }); + } + } + $: { - refresh(options, svelteValue); + refresh(options, svelteValue, tsParser); } - function refresh(options, svelteValue) { + function refresh(options, svelteValue, tsParser) { let ast; const start = Date.now(); try { - ast = svelteEslintParser.parseForESLint(svelteValue).ast; + ast = svelteEslintParser.parseForESLint(svelteValue, { + parser: { ts: tsParser, typescript: tsParser } + }).ast; } catch (e) { ast = { message: e.message, diff --git a/explorer-v2/src/lib/ESLintPlayground.svelte b/explorer-v2/src/lib/ESLintPlayground.svelte index 7404fa86..b273bad8 100644 --- a/explorer-v2/src/lib/ESLintPlayground.svelte +++ b/explorer-v2/src/lib/ESLintPlayground.svelte @@ -34,6 +34,23 @@ let time = ''; let options = {}; + $: hasLangTs = /lang\s*=\s*(?:"ts"|ts|'ts'|"typescript"|typescript|'typescript')/u.test(code); + let tsParser = undefined; + $: { + if (hasLangTs && !tsParser) { + import('@typescript-eslint/parser').then((parser) => { + if (typeof window !== 'undefined') { + if (!window.process) { + window.process = { + cwd: () => '', + env: {} + }; + } + } + tsParser = parser; + }); + } + } $: { options = useEslintPluginSvelte3 ? getEslintPluginSvelte3Options() : {}; } @@ -124,7 +141,8 @@ parser: useEslintPluginSvelte3 ? undefined : 'svelte-eslint-parser', parserOptions: { ecmaVersion: 2020, - sourceType: 'module' + sourceType: 'module', + parser: { ts: tsParser, typescript: tsParser } }, rules, env: { diff --git a/explorer-v2/src/lib/Header.svelte b/explorer-v2/src/lib/Header.svelte index cf89b3cf..073f9b77 100644 --- a/explorer-v2/src/lib/Header.svelte +++ b/explorer-v2/src/lib/Header.svelte @@ -10,9 +10,6 @@ normalizedPathname === normalizedPath || normalizedPathname === `${baseUrl}${normalizedPath}` ); } - - // eslint-disable-next-line no-process-env -- ignore - const dev = process.env.NODE_ENV !== 'production';
@@ -35,13 +32,11 @@ sveltekit:prefetch href="{baseUrl}/scope">Scope - {#if dev || isActive($page.url.pathname, `/virtual-script-code`)} - Virtual Script Code - {/if} + Virtual Script Code
$page.url.pathname: {$page.url.pathname} baseUrl: {baseUrl} diff --git a/explorer-v2/src/lib/ScopeExplorer.svelte b/explorer-v2/src/lib/ScopeExplorer.svelte index 1ea0b560..ece4d8c7 100644 --- a/explorer-v2/src/lib/ScopeExplorer.svelte +++ b/explorer-v2/src/lib/ScopeExplorer.svelte @@ -23,14 +23,36 @@ let time = ''; let jsonEditor, sourceEditor; + + $: hasLangTs = /lang\s*=\s*(?:"ts"|ts|'ts'|"typescript"|typescript|'typescript')/u.test( + svelteValue + ); + let tsParser = undefined; + $: { + if (hasLangTs && !tsParser) { + import('@typescript-eslint/parser').then((parser) => { + if (typeof window !== 'undefined') { + if (!window.process) { + window.process = { + cwd: () => '', + env: {} + }; + } + } + tsParser = parser; + }); + } + } $: { - refresh(options, svelteValue); + refresh(options, svelteValue, tsParser); } - function refresh(options, svelteValue) { + function refresh(options, svelteValue, tsParser) { let scopeManager; const start = Date.now(); try { - scopeManager = svelteEslintParser.parseForESLint(svelteValue).scopeManager; + scopeManager = svelteEslintParser.parseForESLint(svelteValue, { + parser: { ts: tsParser, typescript: tsParser } + }).scopeManager; } catch (e) { scopeJson = { json: JSON.stringify({ diff --git a/src/context/index.ts b/src/context/index.ts index 4021ae63..44e86d29 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -14,7 +14,11 @@ import { LetDirectiveCollections } from "./let-directive-collection"; import { getParserForLang } from "../parser/resolve-parser"; import type { AttributeToken } from "../parser/html"; import { parseAttributes } from "../parser/html"; -import { maybeTSESLintParserObject } from "../parser/parser-object"; +import { + isTSESLintParserObject, + maybeTSESLintParserObject, +} from "../parser/parser-object"; +import { sortedLastIndex } from "../utils"; export class ScriptsSourceCode { private raw: string; @@ -23,6 +27,8 @@ export class ScriptsSourceCode { public readonly attrs: Record; + private _separate = ""; + private _appendScriptLets: string | null = null; public separateIndexes: number[] = []; @@ -37,26 +43,48 @@ export class ScriptsSourceCode { this.separateIndexes = [script.length]; } - public get vcode(): string { + public getCurrentVirtualCode(): string { if (this._appendScriptLets == null) { return this.raw; } - return this.trimmedRaw + this._appendScriptLets; + return this.trimmedRaw + this._separate + this._appendScriptLets; + } + + public getCurrentVirtualCodeInfo(): { script: string; render: string } { + if (this._appendScriptLets == null) { + return { script: this.raw, render: "" }; + } + return { + script: this.trimmedRaw + this._separate, + render: this._appendScriptLets, + }; + } + + public getCurrentVirtualCodeLength(): number { + if (this._appendScriptLets == null) { + return this.raw.length; + } + return ( + this.trimmedRaw.length + + this._separate.length + + this._appendScriptLets.length + ); } public addLet(letCode: string): { start: number; end: number } { if (this._appendScriptLets == null) { this._appendScriptLets = ""; - this.separateIndexes = [this.vcode.length, this.vcode.length + 1]; - this._appendScriptLets += "\n;"; - const after = this.raw.slice(this.vcode.length); + const currentLength = this.getCurrentVirtualCodeLength(); + this.separateIndexes = [currentLength, currentLength + 1]; + this._separate += "\n;"; + const after = this.raw.slice(this.getCurrentVirtualCodeLength()); this._appendScriptLets += after; } - const start = this.vcode.length; + const start = this.getCurrentVirtualCodeLength(); this._appendScriptLets += letCode; return { start, - end: this.vcode.length, + end: this.getCurrentVirtualCodeLength(), }; } @@ -207,16 +235,15 @@ export class Context { this.sourceCode.scripts.attrs, this.parserOptions?.parser ); - if ( - maybeTSESLintParserObject(parserValue) || - parserValue === "@typescript-eslint/parser" - ) { - return (this.state.isTypeScript = true); - } if (typeof parserValue !== "string") { - return (this.state.isTypeScript = false); + return (this.state.isTypeScript = + maybeTSESLintParserObject(parserValue) || + isTSESLintParserObject(parserValue)); } const parserName = parserValue; + if (parserName === "@typescript-eslint/parser") { + return (this.state.isTypeScript = true); + } if (parserName.includes("@typescript-eslint/parser")) { let targetPath = parserName; while (targetPath) { @@ -330,7 +357,10 @@ export class LinesAndColumns { } public getLocFromIndex(index: number): { line: number; column: number } { - const lineNumber = sortedLastIndex(this.lineStartIndices, index); + const lineNumber = sortedLastIndex( + this.lineStartIndices, + (target) => target - index + ); return { line: lineNumber, column: index - this.lineStartIndices[lineNumber - 1], @@ -343,26 +373,17 @@ export class LinesAndColumns { return positionIndex; } -} -/** - * Uses a binary search to determine the highest index at which value should be inserted into array in order to maintain its sort order. - */ -function sortedLastIndex(array: number[], value: number): number { - let lower = 0; - let upper = array.length; - - while (lower < upper) { - const mid = Math.floor(lower + (upper - lower) / 2); - const target = array[mid]; - if (target < value) { - lower = mid + 1; - } else if (target > value) { - upper = mid; - } else { - return mid + 1; - } + /** + * Get the location information of the given indexes. + */ + public getLocations(start: number, end: number): Locations { + return { + range: [start, end], + loc: { + start: this.getLocFromIndex(start), + end: this.getLocFromIndex(end), + }, + }; } - - return upper; } diff --git a/src/context/script-let.ts b/src/context/script-let.ts index 52f9d085..8ed818ee 100644 --- a/src/context/script-let.ts +++ b/src/context/script-let.ts @@ -1,4 +1,4 @@ -import type { ScopeManager, Scope, Reference } from "eslint-scope"; +import type { ScopeManager, Scope } from "eslint-scope"; import type * as ESTree from "estree"; import type { Context, ScriptsSourceCode } from "."; import type { @@ -12,7 +12,15 @@ import type { } from "../ast"; import type { ESLintExtendedProgram } from "../parser"; import { getWithLoc } from "../parser/converts/common"; +import { + getInnermostScopeFromNode, + getScopeFromNode, + removeAllScopeAndVariableAndReference, + removeReference, + removeScope, +} from "../scope"; import { traverseNodes } from "../traverse"; +import { UniqueIdGenerator } from "./unique"; type TSAsExpression = { type: "TSAsExpression"; @@ -112,11 +120,7 @@ export class ScriptLetContext { private readonly closeScopeCallbacks: (() => void)[] = []; - private uniqueIdSeq = 1; - - private readonly usedUniqueIds = new Set(); - - private storeValueTypeName: string | undefined; + private readonly unique = new UniqueIdGenerator(); public constructor(ctx: Context) { this.script = ctx.sourceCode.scripts; @@ -410,7 +414,7 @@ export class ScriptLetContext { (node, tokens, comments, result) => { tokens.length = 0; comments.length = 0; - removeAllScope(node, result); + removeAllScopeAndVariableAndReference(node, result); } ); } @@ -484,7 +488,7 @@ export class ScriptLetContext { column: typeAnnotation.loc.start.column, }; - removeAllScope(typeAnnotation, result); + removeAllScopeAndVariableAndReference(typeAnnotation, result); } } @@ -530,56 +534,6 @@ export class ScriptLetContext { this.closeScopeCallbacks.pop()!(); } - public appendDeclareMaybeStores(maybeStores: Set): void { - const reservedNames = new Set([ - "$$props", - "$$restProps", - "$$slots", - ]); - for (const nm of maybeStores) { - if (reservedNames.has(nm)) continue; - - if (!this.storeValueTypeName) { - this.storeValueTypeName = this.generateUniqueId("StoreValueType"); - - this.appendScriptWithoutOffset( - `type ${this.storeValueTypeName} = T extends null | undefined - ? T - : T extends object & { subscribe(run: infer F, ...args: any): any } - ? F extends (value: infer V, ...args: any) => any - ? V - : never - : T;`, - (node, tokens, comments, result) => { - tokens.length = 0; - comments.length = 0; - removeAllScope(node, result); - } - ); - } - - this.appendScriptWithoutOffset( - `declare let $${nm}: ${this.storeValueTypeName};`, - (node, tokens, comments, result) => { - tokens.length = 0; - comments.length = 0; - removeAllScope(node, result); - } - ); - } - } - - public appendDeclareReactiveVar(assignmentExpression: string): void { - this.appendScriptWithoutOffset( - `let ${assignmentExpression};`, - (node, tokens, comments, result) => { - tokens.length = 0; - comments.length = 0; - removeAllScope(node, result); - } - ); - } - private appendScript( text: string, offset: number, @@ -629,7 +583,7 @@ export class ScriptLetContext { private pushScope(restoreCallback: RestoreCallback, closeToken: string) { this.closeScopeCallbacks.push(() => { this.script.addLet(closeToken); - restoreCallback.end = this.script.vcode.length; + restoreCallback.end = this.script.getCurrentVirtualCodeLength(); }); } @@ -735,8 +689,8 @@ export class ScriptLetContext { endIndex.comment - startIndex.comment ); restoreCallback.callback(node, targetTokens, targetComments, { - getScope: getScopeFromNode, - getInnermostScope: getInnermostScopeFromNode, + getScope, + getInnermostScope, registerNodeToScope, scopeManager: result.scopeManager!, visitorKeys: result.visitorKeys, @@ -756,13 +710,13 @@ export class ScriptLetContext { // Helpers /** Get scope */ - function getScopeFromNode(node: ESTree.Node) { - return getScope(result.scopeManager!, node); + function getScope(node: ESTree.Node) { + return getScopeFromNode(result.scopeManager!, node); } /** Get innermost scope */ - function getInnermostScopeFromNode(node: ESTree.Node) { - return getInnermostScope(getScopeFromNode(node), node); + function getInnermostScope(node: ESTree.Node) { + return getInnermostScopeFromNode(result.scopeManager!, node); } /** Register node to scope */ @@ -887,55 +841,12 @@ export class ScriptLetContext { } private generateUniqueId(base: string) { - let candidate = `$_${base.replace(/\W/g, "_")}${this.uniqueIdSeq++}`; - while ( - this.usedUniqueIds.has(candidate) || - this.ctx.code.includes(candidate) || - this.script.vcode.includes(candidate) - ) { - candidate = `$_${base.replace(/\W/g, "_")}${this.uniqueIdSeq++}`; - } - this.usedUniqueIds.add(candidate); - return candidate; - } -} - -/** - * Gets the scope for the current node - */ -function getScope(scopeManager: ScopeManager, currentNode: ESTree.Node): Scope { - let node: any = currentNode; - for (; node; node = node.parent || null) { - const scope = scopeManager.acquire(node, false); - if (scope) { - if (scope.type === "function-expression-name") { - return scope.childScopes[0]; - } - return scope; - } - } - const global = scopeManager.globalScope; - return global; -} - -/** - * Get the innermost scope which contains a given location. - * @param initialScope The initial scope to search. - * @param node The location to search. - * @returns The innermost scope. - */ -function getInnermostScope(initialScope: Scope, node: ESTree.Node): Scope { - const location = node.range![0]; - - for (const childScope of initialScope.childScopes) { - const range = childScope.block.range!; - - if (range[0] <= location && location < range[1]) { - return getInnermostScope(childScope, node); - } + return this.unique.generate( + base, + this.ctx.code, + this.script.getCurrentVirtualCode() + ); } - - return initialScope; } /** @@ -952,168 +863,6 @@ function applyLocs(target: Locations | ESTree.Node, locs: Locations) { } } -/** Remove all reference */ -function removeAllScope(target: ESTree.Node, result: ScriptLetCallbackOption) { - const targetScopes = new Set(); - traverseNodes(target, { - visitorKeys: result.visitorKeys, - enterNode(node) { - const scope = result.scopeManager.acquire(node); - if (scope) { - targetScopes.add(scope); - return; - } - if (node.type === "Identifier") { - let scope = result.getInnermostScope(node); - while ( - target.range![0] <= scope.block.range![0] && - scope.block.range![1] <= target.range![1] - ) { - scope = scope.upper!; - } - if (targetScopes.has(scope)) { - return; - } - - removeIdentifierVariable(node, scope); - removeIdentifierReference(node, scope); - } - }, - leaveNode() { - // noop - }, - }); - - for (const scope of targetScopes) { - removeScope(result.scopeManager, scope); - } -} - -/** Remove variable */ -function removeIdentifierVariable(node: ESTree.Identifier, scope: Scope): void { - for (let varIndex = 0; varIndex < scope.variables.length; varIndex++) { - const variable = scope.variables[varIndex]; - const defIndex = variable.defs.findIndex((def) => def.name === node); - if (defIndex < 0) { - continue; - } - variable.defs.splice(defIndex, 1); - if (variable.defs.length === 0) { - // Remove variable - referencesToThrough(variable.references, scope); - scope.variables.splice(varIndex, 1); - const name = node.name; - if (variable === scope.set.get(name)) { - scope.set.delete(name); - } - } else { - const idIndex = variable.identifiers.indexOf(node); - if (idIndex >= 0) { - variable.identifiers.splice(idIndex, 1); - } - } - return; - } -} - -/** Move reference to through */ -function referencesToThrough(references: Reference[], baseScope: Scope) { - let scope: Scope | null = baseScope; - while (scope) { - scope.through.push(...references); - scope = scope.upper; - } -} - -/** Remove reference */ -function removeIdentifierReference( - node: ESTree.Identifier, - scope: Scope -): boolean { - const reference = scope.references.find((ref) => ref.identifier === node); - if (reference) { - removeReference(reference, scope); - return true; - } - const location = node.range![0]; - - const pendingScopes = []; - for (const childScope of scope.childScopes) { - const range = childScope.block.range!; - - if (range[0] <= location && location < range[1]) { - if (removeIdentifierReference(node, childScope)) { - return true; - } - } else { - pendingScopes.push(childScope); - } - } - for (const childScope of pendingScopes) { - if (removeIdentifierReference(node, childScope)) { - return true; - } - } - return false; -} - -/** Remove reference */ -function removeReference(reference: Reference, baseScope: Scope) { - if (reference.resolved) { - if (reference.resolved.defs.some((d) => d.name === reference.identifier)) { - // remove var - const varIndex = baseScope.variables.indexOf(reference.resolved); - if (varIndex >= 0) { - baseScope.variables.splice(varIndex, 1); - } - const name = reference.identifier.name; - if (reference.resolved === baseScope.set.get(name)) { - baseScope.set.delete(name); - } - } else { - const refIndex = reference.resolved.references.indexOf(reference); - if (refIndex >= 0) { - reference.resolved.references.splice(refIndex, 1); - } - } - } - - let scope: Scope | null = baseScope; - while (scope) { - const refIndex = scope.references.indexOf(reference); - if (refIndex >= 0) { - scope.references.splice(refIndex, 1); - } - const throughIndex = scope.through.indexOf(reference); - if (throughIndex >= 0) { - scope.through.splice(throughIndex, 1); - } - scope = scope.upper; - } -} - -/** Remove scope */ -function removeScope(scopeManager: ScopeManager, scope: Scope) { - for (const childScope of scope.childScopes) { - removeScope(scopeManager, childScope); - } - - while (scope.references[0]) { - removeReference(scope.references[0], scope); - } - const upper = scope.upper; - if (upper) { - const index = upper.childScopes.indexOf(scope); - if (index >= 0) { - upper.childScopes.splice(index, 1); - } - } - const index = scopeManager.scopes.indexOf(scope); - if (index >= 0) { - scopeManager.scopes.splice(index, 1); - } -} - /** Get the node to scope map from given scope manager */ function getNodeToScope( scopeManager: ScopeManager diff --git a/src/context/unique.ts b/src/context/unique.ts new file mode 100644 index 00000000..0c2d9b7f --- /dev/null +++ b/src/context/unique.ts @@ -0,0 +1,17 @@ +export class UniqueIdGenerator { + private uniqueIdSeq = 1; + + private readonly usedUniqueIds = new Set(); + + public generate(base: string, ...texts: string[]): string { + const hasId = (id: string) => + this.usedUniqueIds.has(id) || texts.some((t) => t.includes(id)); + + let candidate = `$_${base.replace(/\W/g, "_")}${this.uniqueIdSeq++}`; + while (hasId(candidate)) { + candidate = `$_${base.replace(/\W/g, "_")}${this.uniqueIdSeq++}`; + } + this.usedUniqueIds.add(candidate); + return candidate; + } +} diff --git a/src/parser/analyze-scope.ts b/src/parser/analyze-scope.ts index 52fe8a4b..9cec85bc 100644 --- a/src/parser/analyze-scope.ts +++ b/src/parser/analyze-scope.ts @@ -3,6 +3,8 @@ import type { Scope, ScopeManager } from "eslint-scope"; import { Variable, Reference, analyze } from "eslint-scope"; import { getFallbackKeys } from "../traverse"; import type { SvelteReactiveStatement, SvelteScriptElement } from "../ast"; +import { addReference, addVariable } from "../scope"; +import { addElementToSortedArray } from "../utils"; /** * Analyze scope */ @@ -66,16 +68,24 @@ export function analyzeReactiveScope(scopeManager: ScopeManager): void { variable = new Variable(); (variable as any).scope = referenceScope; variable.name = name; - variable.defs.push({ - type: "ComputedVariable" as "Variable", - node: node as any, - parent: parent as any, - name: reference.identifier, - }); - referenceScope.variables.push(variable); + addElementToSortedArray( + variable.defs, + { + type: "ComputedVariable" as "Variable", + node: node as any, + parent: parent as any, + name: reference.identifier, + }, + (a, b) => a.node.range[0] - b.node.range[0] + ); + addVariable(referenceScope.variables, variable); referenceScope.set.set(name, variable); } - variable.identifiers.push(reference.identifier); + addElementToSortedArray( + variable.identifiers, + reference.identifier, + (a, b) => a.range![0] - b.range![0] + ); reference.resolved = variable; removeReferenceFromThrough(reference, referenceScope); } @@ -111,7 +121,7 @@ export function analyzeStoreScope(scopeManager: ScopeManager): void { reference.isReadOnly = () => true; reference.isRead = () => true; - variable.references.push(reference); + addReference(variable.references, reference); reference.resolved = variable; removeReferenceFromThrough(reference, moduleScope); } @@ -219,7 +229,7 @@ function removeReferenceFromThrough(reference: Reference, baseScope: Scope) { } else if (ref.identifier.name === name) { ref.resolved = variable; if (!variable.references.includes(ref)) { - variable.references.push(ref); + addReference(variable.references, ref); } return false; } @@ -248,7 +258,7 @@ function addVirtualReference( reference.isReadOnly = () => Boolean(readWrite.read) && !readWrite.write; reference.isReadWrite = () => Boolean(readWrite.read && readWrite.write); - variable.references.push(reference); + addReference(variable.references, reference); reference.resolved = variable; return reference; diff --git a/src/parser/analyze-type/index.ts b/src/parser/analyze-type/index.ts deleted file mode 100644 index b99941f9..00000000 --- a/src/parser/analyze-type/index.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { ESLintExtendedProgram } from ".."; -import type { Context } from "../../context"; -import { traverseNodes } from "../../traverse"; -import { parseScriptWithoutAnalyzeScope } from "../script"; - -/** - * Append type declarations for svelte variables. - * - Append TypeScript code like - * `declare let $foo: Parameters[0]>[0];` - * to define the type information for like `$foo` variable. - * - Append TypeScript code like `let foo = bar;` to define the type information for like `$: foo = bar` variable. - */ -export function appendDeclareSvelteVarsTypes(ctx: Context): void { - const vcode = ctx.sourceCode.scripts.vcode; - - if (/\$\s*:\s*[\p{ID_Start}$(_]/u.test(vcode)) { - // Probably have a reactive variable, so we will need to parse TypeScript once to extract the reactive variables. - const result = parseScriptWithoutAnalyzeScope( - vcode, - ctx.sourceCode.scripts.attrs, - { - ...ctx.parserOptions, - // Without typings - project: null, - } - ); - appendDeclareSvelteVarsTypesFromAST(result, vcode, ctx); - } else { - appendDeclareStoreTypesFromText(vcode, ctx); - } -} - -/** - * Append type declarations for svelte variables from AST. - */ -function appendDeclareSvelteVarsTypesFromAST( - result: ESLintExtendedProgram, - code: string, - ctx: Context -) { - const maybeStores = new Set(); - - traverseNodes(result.ast, { - visitorKeys: result.visitorKeys, - enterNode: (node, parent) => { - if (node.type === "Identifier") { - if (!node.name.startsWith("$") || node.name.length <= 1) { - return; - } - maybeStores.add(node.name.slice(1)); - } else if (node.type === "LabeledStatement") { - if ( - node.label.name !== "$" || - parent !== result.ast || - node.body.type !== "ExpressionStatement" || - node.body.expression.type !== "AssignmentExpression" || - // Must be a pattern that can be used in the LHS of variable declarations. - // https://github.com/ota-meshi/svelte-eslint-parser/issues/213 - (node.body.expression.left.type !== "Identifier" && - node.body.expression.left.type !== "ArrayPattern" && - node.body.expression.left.type !== "ObjectPattern" && - node.body.expression.left.type !== "AssignmentPattern" && - node.body.expression.left.type !== "RestElement") - ) { - return; - } - // It is reactive variable declaration. - const text = code.slice(...node.body.expression.range!); - ctx.scriptLet.appendDeclareReactiveVar(text); - } - }, - leaveNode() { - /* noop */ - }, - }); - ctx.scriptLet.appendDeclareMaybeStores(maybeStores); -} - -/** - * Append type declarations for store access. - * Append TypeScript code like - * `declare let $foo: Parameters[0]>[0];` - * to define the type information for like `$foo` variable. - */ -function appendDeclareStoreTypesFromText(vcode: string, ctx: Context): void { - const extractStoreRe = /\$[\p{ID_Start}$_][\p{ID_Continue}$\u200c\u200d]*/giu; - let m; - const maybeStores = new Set(); - while ((m = extractStoreRe.exec(vcode))) { - const storeName = m[0]; - const originalName = storeName.slice(1); - maybeStores.add(originalName); - } - - ctx.scriptLet.appendDeclareMaybeStores(maybeStores); -} diff --git a/src/parser/index.ts b/src/parser/index.ts index 609ab959..c39caae3 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -19,7 +19,8 @@ import { analyzeStoreScope, } from "./analyze-scope"; import { ParseError } from "../errors"; -import { appendDeclareSvelteVarsTypes } from "./analyze-type"; +import { parseTypeScript } from "./typescript"; +import { addReference } from "../scope"; export interface ESLintProgram extends Program { comments: Comment[]; @@ -77,9 +78,18 @@ export function parseForESLint( parserOptions ); - if (ctx.isTypeScript()) appendDeclareSvelteVarsTypes(ctx); - - const resultScript = parseScript(ctx.sourceCode.scripts, parserOptions); + const scripts = ctx.sourceCode.scripts; + const resultScript = ctx.isTypeScript() + ? parseTypeScript( + scripts.getCurrentVirtualCodeInfo(), + scripts.attrs, + parserOptions + ) + : parseScript( + scripts.getCurrentVirtualCode(), + scripts.attrs, + parserOptions + ); ctx.scriptLet.restore(resultScript); ctx.tokens.push(...resultScript.ast.tokens); ctx.comments.push(...resultScript.ast.comments); @@ -103,7 +113,7 @@ export function parseForESLint( // Links the variable and the reference. // And this reference is removed from `Scope#through`. reference.resolved = variable; - variable.references.push(reference); + addReference(variable.references, reference); return false; } return true; diff --git a/src/parser/parser-object.ts b/src/parser/parser-object.ts index b7afa19d..f0bcbf1d 100644 --- a/src/parser/parser-object.ts +++ b/src/parser/parser-object.ts @@ -40,7 +40,7 @@ export function isBasicParserObject( return Boolean(value && typeof (value as any).parse === "function"); } -/** Checks whether given object is "@typescript-eslint/parser" */ +/** Checks whether given object maybe "@typescript-eslint/parser" */ export function maybeTSESLintParserObject( value: unknown ): value is TSESLintParser { @@ -52,3 +52,22 @@ export function maybeTSESLintParserObject( typeof (value as any).version === "string" ); } + +/** Checks whether given object is "@typescript-eslint/parser" */ +export function isTSESLintParserObject( + value: unknown +): value is TSESLintParser { + if (!isEnhancedParserObject(value)) return false; + try { + const result = (value as unknown as TSESLintParser).parseForESLint("", {}); + const services = result.services; + return Boolean( + services && + services.esTreeNodeToTSNodeMap && + services.tsNodeToESTreeNodeMap && + services.program + ); + } catch { + return false; + } +} diff --git a/src/parser/script.ts b/src/parser/script.ts index 74628ab2..13e3f724 100644 --- a/src/parser/script.ts +++ b/src/parser/script.ts @@ -1,7 +1,6 @@ import type { ESLintExtendedProgram } from "."; import { analyzeScope } from "./analyze-scope"; import { traverseNodes } from "../traverse"; -import type { ScriptsSourceCode } from "../context"; import { getParser } from "./resolve-parser"; import { isEnhancedParserObject } from "./parser-object"; @@ -9,10 +8,15 @@ import { isEnhancedParserObject } from "./parser-object"; * Parse for script */ export function parseScript( - script: ScriptsSourceCode, + code: string, + attrs: Record, parserOptions: any = {} ): ESLintExtendedProgram { - const result = parseScriptWithoutAnalyzeScopeFromVCode(script, parserOptions); + const result = parseScriptWithoutAnalyzeScopeFromVCode( + code, + attrs, + parserOptions + ); if (!result.scopeManager) { const scopeManager = analyzeScope(result.ast, parserOptions); @@ -63,10 +67,11 @@ export function parseScriptWithoutAnalyzeScope( * Parse for script without analyze scope */ function parseScriptWithoutAnalyzeScopeFromVCode( - { vcode, attrs }: ScriptsSourceCode, + code: string, + attrs: Record, options: any ): ESLintExtendedProgram { - const result = parseScriptWithoutAnalyzeScope(vcode, attrs, options); - result._virtualScriptCode = vcode; + const result = parseScriptWithoutAnalyzeScope(code, attrs, options); + result._virtualScriptCode = code; return result; } diff --git a/src/parser/sort.ts b/src/parser/sort.ts index 494c1f17..4a60adf5 100644 --- a/src/parser/sort.ts +++ b/src/parser/sort.ts @@ -1,7 +1,12 @@ /** * Sort tokens */ -export function sort(tokens: T[]): T[] { +export function sort( + tokens: T[] | null | undefined +): T[] { + if (!tokens) { + return []; + } return tokens.sort((a, b) => { if (a.range[0] !== b.range[0]) { return a.range[0] - b.range[0]; diff --git a/src/parser/typescript/analyze/index.ts b/src/parser/typescript/analyze/index.ts new file mode 100644 index 00000000..aa565c6e --- /dev/null +++ b/src/parser/typescript/analyze/index.ts @@ -0,0 +1,509 @@ +import type { TSESTree } from "@typescript-eslint/types"; +import type { ScopeManager } from "eslint-scope"; +import { + addAllReferences, + addVariable, + getAllReferences, + getProgramScope, + removeAllScopeAndVariableAndReference, + removeIdentifierReference, + removeIdentifierVariable, + replaceScope, +} from "../../../scope"; +import { addElementsToSortedArray, sortedLastIndex } from "../../../utils"; +import { parseScriptWithoutAnalyzeScope } from "../../script"; +import { VirtualTypeScriptContext } from "../context"; +import type { TSESParseForESLintResult } from "../types"; +import type ESTree from "estree"; + +const RESERVED_NAMES = new Set(["$$props", "$$restProps", "$$slots"]); +/** + * Analyze TypeScript source code. + * Generate virtual code to provide correct type information for Svelte store reference namess and scopes. + * See https://github.com/ota-meshi/svelte-eslint-parser/blob/main/docs/internal-mechanism.md#scope-types + */ +export function analyzeTypeScript( + code: { script: string; render: string }, + attrs: Record, + parserOptions: any +): VirtualTypeScriptContext { + const ctx = new VirtualTypeScriptContext(code.script + code.render); + ctx.appendOriginal(/^\s*/u.exec(code.script)![0].length); + + const result = parseScriptWithoutAnalyzeScope( + code.script + code.render, + attrs, + { + ...parserOptions, + // Without typings + project: null, + } + ) as unknown as TSESParseForESLintResult; + + ctx._beforeResult = result; + + analyzeStoreReferenceNames(result, ctx); + + analyzeReactiveScopes(result, ctx); + + analyzeRenderScopes(code, ctx); + + return ctx; +} + +/** + * Analyze the store reference names. + * Insert type definitions code to provide correct type information for variables that begin with `$`. + */ +function analyzeStoreReferenceNames( + result: TSESParseForESLintResult, + ctx: VirtualTypeScriptContext +) { + const scopeManager = result.scopeManager; + const programScope = getProgramScope(scopeManager as ScopeManager); + const maybeStoreRefNames = new Set(); + + for (const reference of scopeManager.globalScope!.through) { + if ( + // Begin with `$`. + reference.identifier.name.startsWith("$") && + // Ignore it is a reserved variable. + !RESERVED_NAMES.has(reference.identifier.name) && + // Ignore if it is already defined. + !programScope.set.has(reference.identifier.name) + ) { + maybeStoreRefNames.add(reference.identifier.name); + } + } + + if (maybeStoreRefNames.size) { + const storeValueTypeName = ctx.generateUniqueId("StoreValueType"); + ctx.appendVirtualScript( + `type ${storeValueTypeName} = T extends null | undefined +? T +: T extends object & { subscribe(run: infer F, ...args: any): any } +? F extends (value: infer V, ...args: any) => any +? V +: never +: T;` + ); + ctx.restoreContext.addRestoreStatementProcess((node, result) => { + if ( + node.type !== "TSTypeAliasDeclaration" || + node.id.name !== storeValueTypeName + ) { + return false; + } + const program = result.ast; + program.body.splice(program.body.indexOf(node), 1); + + const scopeManager = result.scopeManager as ScopeManager; + // Remove `type` scope + removeAllScopeAndVariableAndReference(node, { + visitorKeys: result.visitorKeys, + scopeManager, + }); + return true; + }); + + for (const nm of maybeStoreRefNames) { + const realName = nm.slice(1); + ctx.appendVirtualScript( + `declare let ${nm}: ${storeValueTypeName};` + ); + ctx.restoreContext.addRestoreStatementProcess((node, result) => { + if ( + node.type !== "VariableDeclaration" || + !node.declare || + node.declarations.length !== 1 || + node.declarations[0].id.type !== "Identifier" || + node.declarations[0].id.name !== nm + ) { + return false; + } + const program = result.ast; + program.body.splice(program.body.indexOf(node), 1); + + const scopeManager = result.scopeManager as ScopeManager; + + // Remove `declare` variable + removeAllScopeAndVariableAndReference(node, { + visitorKeys: result.visitorKeys, + scopeManager, + }); + + return true; + }); + } + } +} + +/** + * Analyze the reactive scopes. + * Transform source code to provide the correct type information in the `$:` statements. + */ +function analyzeReactiveScopes( + result: TSESParseForESLintResult, + ctx: VirtualTypeScriptContext +) { + const scopeManager = result.scopeManager; + const throughIds = scopeManager.globalScope!.through.map( + (reference) => reference.identifier + ); + for (const statement of result.ast.body) { + if (statement.type === "LabeledStatement" && statement.label.name === "$") { + if ( + statement.body.type === "ExpressionStatement" && + statement.body.expression.type === "AssignmentExpression" && + statement.body.expression.operator === "=" && + // Must be a pattern that can be used in the LHS of variable declarations. + // https://github.com/ota-meshi/svelte-eslint-parser/issues/213 + (statement.body.expression.left.type === "Identifier" || + statement.body.expression.left.type === "ArrayPattern" || + statement.body.expression.left.type === "ObjectPattern") + ) { + const left = statement.body.expression.left; + if ( + throughIds.some( + (id) => left.range[0] <= id.range[0] && id.range[1] <= left.range[1] + ) + ) { + transformForDeclareReactiveVar( + statement, + statement.body.expression.left, + statement.body.expression, + result.ast.tokens!, + ctx + ); + continue; + } + } + transformForReactiveStatement(statement, ctx); + } + } +} + +/** + * Analyze the render scopes. + * Transform source code to provide the correct type information in the HTML templates. + */ +function analyzeRenderScopes( + code: { script: string; render: string }, + ctx: VirtualTypeScriptContext +) { + ctx.appendOriginal(code.script.length); + const renderFunctionName = ctx.generateUniqueId("render"); + ctx.appendVirtualScript(`function ${renderFunctionName}(){`); + ctx.appendOriginalToEnd(); + ctx.appendVirtualScript(`}`); + ctx.restoreContext.addRestoreStatementProcess((node, result) => { + if ( + node.type !== "FunctionDeclaration" || + node.id.name !== renderFunctionName + ) { + return false; + } + const program = result.ast; + program.body.splice(program.body.indexOf(node), 1, ...node.body.body); + for (const body of node.body.body) { + body.parent = program; + } + + const scopeManager = result.scopeManager as ScopeManager; + removeFunctionScope(node, scopeManager); + return true; + }); +} + +/** + * Transform for `$: id = ...` to `$: let id = ...` + */ +function transformForDeclareReactiveVar( + statement: TSESTree.LabeledStatement, + id: TSESTree.Identifier | TSESTree.ArrayPattern | TSESTree.ObjectPattern, + expression: TSESTree.AssignmentExpression, + tokens: TSESTree.Token[], + ctx: VirtualTypeScriptContext +): void { + // e.g. + // From: + // $: id = x + y; + // + // To: + // $: let id = fn() + // function fn () { return x + y; } + // + // + // From: + // $: ({id} = foo); + // + // To: + // $: let {id} = fn() + // function fn () { return foo; } + + /** + * The opening paren tokens for + * `$: ({id} = foo);` + * ^ + */ + const openParens: TSESTree.Token[] = []; + /** + * The equal token for + * `$: ({id} = foo);` + * ^ + */ + let eq: TSESTree.Token | null = null; + /** + * The closing paren tokens for + * `$: ({id} = foo);` + * ^ + */ + const closeParens: TSESTree.Token[] = []; + /** + * The closing paren token for + * `$: id = (foo);` + * ^ + */ + let expressionCloseParen: TSESTree.Token | null = null; + const startIndex = sortedLastIndex( + tokens, + (target) => target.range[0] - statement.range[0] + ); + for (let index = startIndex; index < tokens.length; index++) { + const token = tokens[index]; + if (statement.range[1] <= token.range[0]) { + break; + } + if (token.range[1] <= statement.range[0]) { + continue; + } + if (token.value === "(" && token.range[1] <= expression.range[0]) { + openParens.push(token); + } + if ( + token.value === "=" && + expression.left.range[1] <= token.range[0] && + token.range[1] <= expression.right.range[0] + ) { + eq = token; + } + if (token.value === ")") { + if (expression.range[1] <= token.range[0]) { + closeParens.push(token); + } else if (expression.right.range[1] <= token.range[0]) { + expressionCloseParen = token; + } + } + } + + const functionId = ctx.generateUniqueId("reactiveVariableScopeFunction"); + for (const token of openParens) { + ctx.appendOriginal(token.range[0]); + ctx.skipOriginalOffset(token.range[1] - token.range[0]); + } + ctx.appendOriginal(expression.range[0]); + ctx.skipUntilOriginalOffset(id.range[0]); + ctx.appendVirtualScript("let "); + ctx.appendOriginal(eq ? eq.range[1] : expression.right.range[0]); + ctx.appendVirtualScript(`${functionId}();\nfunction ${functionId}(){return `); + for (const token of closeParens) { + ctx.appendOriginal(token.range[0]); + ctx.skipOriginalOffset(token.range[1] - token.range[0]); + } + ctx.appendOriginal(statement.range[1]); + ctx.appendVirtualScript(`}`); + + // eslint-disable-next-line complexity -- ignore X( + ctx.restoreContext.addRestoreStatementProcess((node, result) => { + if ((node as any).type !== "SvelteReactiveStatement") { + return false; + } + const reactiveStatement = node as TSESTree.LabeledStatement; + if ( + reactiveStatement.body.type !== "VariableDeclaration" || + reactiveStatement.body.kind !== "let" || + reactiveStatement.body.declarations.length !== 1 + ) { + return false; + } + const [idDecl] = reactiveStatement.body.declarations; + if ( + idDecl.type !== "VariableDeclarator" || + idDecl.id.type !== id.type || + idDecl.init?.type !== "CallExpression" || + idDecl.init.callee.type !== "Identifier" || + idDecl.init.callee.name !== functionId + ) { + return false; + } + const program = result.ast; + const nextIndex = program.body.indexOf(node) + 1; + const fnDecl = program.body[nextIndex]; + if ( + !fnDecl || + fnDecl.type !== "FunctionDeclaration" || + fnDecl.id.name !== functionId || + fnDecl.body.body.length !== 1 || + fnDecl.body.body[0].type !== "ReturnStatement" + ) { + return false; + } + const returnStatement = fnDecl.body.body[0]; + if (returnStatement.argument?.type !== expression.right.type) { + return false; + } + // Remove function declaration + program.body.splice(nextIndex, 1); + // Restore expression statement + const newExpression: TSESTree.AssignmentExpression = { + type: "AssignmentExpression" as TSESTree.AssignmentExpression["type"], + operator: "=", + left: idDecl.id, + right: returnStatement.argument, + loc: { + start: idDecl.id.loc.start, + end: expressionCloseParen + ? expressionCloseParen.loc.end + : returnStatement.argument.loc.end, + }, + range: [ + idDecl.id.range[0], + expressionCloseParen + ? expressionCloseParen.range[1] + : returnStatement.argument.range[1], + ], + }; + idDecl.id.parent = newExpression; + returnStatement.argument.parent = newExpression; + const newBody: TSESTree.ExpressionStatement = { + type: "ExpressionStatement" as TSESTree.ExpressionStatement["type"], + expression: newExpression, + loc: statement.body.loc, + range: statement.body.range, + parent: reactiveStatement, + }; + newExpression.parent = newBody; + reactiveStatement.body = newBody; + // Restore statement end location + reactiveStatement.range[1] = returnStatement.range[1]; + reactiveStatement.loc.end.line = returnStatement.loc.end.line; + reactiveStatement.loc.end.column = returnStatement.loc.end.column; + + // Restore tokens + addElementsToSortedArray( + program.tokens!, + [...openParens, ...closeParens], + (a, b) => a.range[0] - b.range[0] + ); + + const scopeManager = result.scopeManager as ScopeManager; + removeFunctionScope(fnDecl, scopeManager); + const scope = getProgramScope(scopeManager); + for (const reference of getAllReferences(idDecl.id, scope)) { + reference.writeExpr = newExpression.right as ESTree.Expression; + } + + removeIdentifierReference(idDecl.init.callee, scope); + removeIdentifierVariable(idDecl.id, scope); + return true; + }); +} + +/** + * Transform for `$: ...` to `$: function foo(){...}` + */ +function transformForReactiveStatement( + statement: TSESTree.LabeledStatement, + ctx: VirtualTypeScriptContext +) { + const functionId = ctx.generateUniqueId("reactiveStatementScopeFunction"); + const originalBody = statement.body; + ctx.appendOriginal(originalBody.range[0]); + ctx.appendVirtualScript(`function ${functionId}()`); + if (originalBody.type !== "BlockStatement") { + ctx.appendVirtualScript(`{`); + } + ctx.appendOriginal(originalBody.range[1]); + if (originalBody.type !== "BlockStatement") { + ctx.appendVirtualScript(`}`); + } + ctx.appendOriginal(statement.range[1]); + + ctx.restoreContext.addRestoreStatementProcess((node, result) => { + if ((node as any).type !== "SvelteReactiveStatement") { + return false; + } + const reactiveStatement = node as TSESTree.LabeledStatement; + const body = reactiveStatement.body; + if (body.type !== "FunctionDeclaration" || body.id.name !== functionId) { + return false; + } + if (originalBody.type === "BlockStatement") { + reactiveStatement.body = body.body; + } else { + reactiveStatement.body = body.body.body[0]; + } + reactiveStatement.body.parent = reactiveStatement; + + const scopeManager = result.scopeManager as ScopeManager; + removeFunctionScope(body, scopeManager); + return true; + }); +} + +/** Remove function scope and marge child scopes to upper scope */ +function removeFunctionScope( + node: + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression, + scopeManager: ScopeManager +) { + const scope = scopeManager.acquire(node)!; + const upper = scope.upper!; + // Remove render function variable + if (node.id) { + removeIdentifierVariable(node.id, upper); + removeIdentifierReference(node.id, upper); + } + + replaceScope(scopeManager, scope, scope.childScopes); + // Marge scope + // * marge variables + for (const variable of scope.variables) { + if (variable.name === "arguments" && variable.defs.length === 0) { + continue; + } + const upperVariable = upper.set.get(variable.name); + if (upperVariable) { + addElementsToSortedArray( + upperVariable.identifiers, + variable.identifiers, + (a, b) => a.range![0] - b.range![0] + ); + addElementsToSortedArray( + upperVariable.defs, + variable.defs, + (a, b) => a.node.range![0] - b.node.range![0] + ); + addAllReferences(upperVariable.references, variable.references); + } else { + upper.set.set(variable.name, variable); + addVariable(upper.variables, variable); + variable.scope = upper; + } + for (const reference of variable.references) { + if (reference.from === scope) { + reference.from = upper; + } + reference.resolved = upperVariable || variable; + } + } + // * marge references + addAllReferences(upper.references, scope.references); + for (const reference of scope.references) { + if (reference.from === scope) { + reference.from = upper; + } + } +} diff --git a/src/parser/typescript/context.ts b/src/parser/typescript/context.ts new file mode 100644 index 00000000..afb797aa --- /dev/null +++ b/src/parser/typescript/context.ts @@ -0,0 +1,60 @@ +import { UniqueIdGenerator } from "../../context/unique"; +import { RestoreContext } from "./restore"; +import type { TSESParseForESLintResult } from "./types"; + +/** + * Context for virtual TypeScript code. + * See https://github.com/ota-meshi/svelte-eslint-parser/blob/main/docs/internal-mechanism.md#scope-types + */ +export class VirtualTypeScriptContext { + private readonly originalCode: string; + + public readonly restoreContext: RestoreContext; + + public script = ""; + + private consumedIndex = 0; + + private readonly unique = new UniqueIdGenerator(); + + public _beforeResult: TSESParseForESLintResult | null = null; + + public constructor(code: string) { + this.originalCode = code; + this.restoreContext = new RestoreContext(code); + } + + public skipOriginalOffset(offset: number): void { + this.consumedIndex += offset; + } + + public skipUntilOriginalOffset(offset: number): void { + this.consumedIndex = Math.max(offset, this.consumedIndex); + } + + public appendOriginalToEnd(): void { + this.appendOriginal(this.originalCode.length); + } + + public appendOriginal(index: number): void { + if (this.consumedIndex >= index) { + return; + } + this.restoreContext.addOffset({ + original: this.consumedIndex, + dist: this.script.length, + }); + this.script += this.originalCode.slice(this.consumedIndex, index); + this.consumedIndex = index; + } + + public appendVirtualScript(virtualFragment: string): void { + const start = this.script.length; + this.script += virtualFragment; + this.restoreContext.addVirtualFragmentRange(start, this.script.length); + } + + public generateUniqueId(base: string): string { + return this.unique.generate(base, this.originalCode, this.script); + } +} diff --git a/src/parser/typescript/index.ts b/src/parser/typescript/index.ts new file mode 100644 index 00000000..f55f66ba --- /dev/null +++ b/src/parser/typescript/index.ts @@ -0,0 +1,21 @@ +import type { ESLintExtendedProgram } from ".."; +import { parseScript } from "../script"; +import { analyzeTypeScript } from "./analyze"; +import type { TSESParseForESLintResult } from "./types"; + +/** + * Parse for type script + */ +export function parseTypeScript( + code: { script: string; render: string }, + attrs: Record, + parserOptions: any = {} +): ESLintExtendedProgram { + const tsCtx = analyzeTypeScript(code, attrs, parserOptions); + + const result = parseScript(tsCtx.script, attrs, parserOptions); + + tsCtx.restoreContext.restore(result as unknown as TSESParseForESLintResult); + + return result; +} diff --git a/src/parser/typescript/restore.ts b/src/parser/typescript/restore.ts new file mode 100644 index 00000000..8e23c368 --- /dev/null +++ b/src/parser/typescript/restore.ts @@ -0,0 +1,197 @@ +import type { TSESTree } from "@typescript-eslint/types"; +import { traverseNodes } from "../../traverse"; +import { LinesAndColumns } from "../../context"; +import type { TSESParseForESLintResult } from "./types"; + +/** + * A function that restores the statement. + * @param node The node to restore. + * @param result The result of parsing. + * @returns + * If `false`, it indicates that the specified node was not processed. + * + * If `true`, it indicates that the specified node was processed for processing. + * This process will no longer be called. + */ +type RestoreStatementProcess = ( + node: TSESTree.Statement, + result: TSESParseForESLintResult +) => boolean; + +export class RestoreContext { + private readonly originalLocs: LinesAndColumns; + + private readonly offsets: { original: number; dist: number }[] = []; + + private readonly virtualFragments: { start: number; end: number }[] = []; + + private readonly restoreStatementProcesses: RestoreStatementProcess[] = []; + + public constructor(code: string) { + this.originalLocs = new LinesAndColumns(code); + } + + public addRestoreStatementProcess(process: RestoreStatementProcess): void { + this.restoreStatementProcesses.push(process); + } + + public addOffset(offset: { original: number; dist: number }): void { + this.offsets.push(offset); + } + + public addVirtualFragmentRange(start: number, end: number): void { + const peek = this.virtualFragments[this.virtualFragments.length - 1]; + if (peek && peek.end === start) { + peek.end = end; + return; + } + this.virtualFragments.push({ start, end }); + } + + /** + * Restore AST nodes + */ + public restore(result: TSESParseForESLintResult): void { + remapLocations(result, { + remapLocation: (n) => this.remapLocation(n), + removeToken: (token) => + this.virtualFragments.some( + (f) => f.start <= token.range[0] && token.range[1] <= f.end + ), + }); + + restoreStatements(result, this.restoreStatementProcesses); + + // Adjust program node location + const firstOffset = Math.min( + ...[result.ast.body[0], result.ast.tokens?.[0], result.ast.comments?.[0]] + .filter((t): t is NonNullable => Boolean(t)) + .map((t) => t.range[0]) + ); + if (firstOffset < result.ast.range[0]) { + result.ast.range[0] = firstOffset; + result.ast.loc.start = this.originalLocs.getLocFromIndex(firstOffset); + } + } + + private remapLocation(node: TSESTree.Node | TSESTree.Token): void { + let [start, end] = node.range; + const startFragment = this.virtualFragments.find( + (f) => f.start <= start && start < f.end + ); + if (startFragment) { + start = startFragment.end; + } + const endFragment = this.virtualFragments.find( + (f) => f.start < end && end <= f.end + ); + if (endFragment) { + if (startFragment === endFragment) { + end = start; + } else { + end = endFragment.start; + } + } + + if (end < start) { + const w = start; + start = end; + end = w; + } + + const locs = this.originalLocs.getLocations( + ...this.getRemapRange(start, end) + ); + + node.loc = locs.loc; + node.range = locs.range; + + if ((node as any).start != null) { + delete (node as any).start; + } + if ((node as any).end != null) { + delete (node as any).end; + } + } + + private getRemapRange(start: number, end: number): TSESTree.Range { + if (!this.offsets.length) { + return [start, end]; + } + let lastStart = this.offsets[0]; + let lastEnd = this.offsets[0]; + for (const offset of this.offsets) { + if (offset.dist <= start) { + lastStart = offset; + } + if (offset.dist < end) { + lastEnd = offset; + } else { + break; + } + } + + const remapStart = lastStart.original + (start - lastStart.dist); + const remapEnd = lastEnd.original + (end - lastEnd.dist); + return [remapStart, remapEnd]; + } +} + +/** Restore locations */ +function remapLocations( + result: TSESParseForESLintResult, + { + remapLocation, + removeToken, + }: { + remapLocation: (node: TSESTree.Node | TSESTree.Token) => void; + removeToken: (node: TSESTree.Token) => boolean; + } +) { + const traversed = new Map(); + // remap locations + traverseNodes(result.ast, { + visitorKeys: result.visitorKeys, + enterNode: (node, p) => { + if (!traversed.has(node)) { + traversed.set(node, p); + + remapLocation(node); + } + }, + leaveNode: (_node) => { + // noop + }, + }); + const tokens: TSESTree.Token[] = []; + for (const token of result.ast.tokens || []) { + if (removeToken(token)) { + continue; + } + remapLocation(token); + tokens.push(token); + } + result.ast.tokens = tokens; + for (const token of result.ast.comments || []) { + remapLocation(token); + } +} + +/** Restore statement nodes */ +function restoreStatements( + result: TSESParseForESLintResult, + restoreStatementProcesses: RestoreStatementProcess[] +) { + const restoreStatementProcessesSet = new Set(restoreStatementProcesses); + for (const node of [...result.ast.body]) { + if (!restoreStatementProcessesSet.size) { + break; + } + for (const proc of restoreStatementProcessesSet) { + if (proc(node, result)) { + restoreStatementProcessesSet.delete(proc); + break; + } + } + } +} diff --git a/src/parser/typescript/types.ts b/src/parser/typescript/types.ts new file mode 100644 index 00000000..c2b0ba64 --- /dev/null +++ b/src/parser/typescript/types.ts @@ -0,0 +1,2 @@ +import type { parseForESLint } from "@typescript-eslint/parser"; +export type TSESParseForESLintResult = ReturnType; diff --git a/src/scope/index.ts b/src/scope/index.ts new file mode 100644 index 00000000..f2537dec --- /dev/null +++ b/src/scope/index.ts @@ -0,0 +1,414 @@ +import type { ScopeManager, Scope, Reference, Variable } from "eslint-scope"; +import type * as ESTree from "estree"; +import type { TSESTree } from "@typescript-eslint/types"; +import { traverseNodes } from "../traverse"; +import { addElementsToSortedArray, addElementToSortedArray } from "../utils"; + +/** Remove all scope, variable, and reference */ +export function removeAllScopeAndVariableAndReference( + target: ESTree.Node | TSESTree.Node, + info: { + visitorKeys?: + | { [type: string]: string[] } + | { + readonly [type: string]: readonly string[] | undefined; + }; + scopeManager: ScopeManager; + } +): void { + const targetScopes = new Set(); + traverseNodes(target, { + visitorKeys: info.visitorKeys, + enterNode(node) { + const scope = info.scopeManager.acquire(node); + if (scope) { + targetScopes.add(scope); + return; + } + if (node.type === "Identifier") { + let scope = getInnermostScopeFromNode(info.scopeManager, node); + while ( + scope && + scope.block.type !== "Program" && + target.range![0] <= scope.block.range![0] && + scope.block.range![1] <= target.range![1] + ) { + scope = scope.upper!; + } + if (targetScopes.has(scope)) { + return; + } + + removeIdentifierVariable(node, scope); + removeIdentifierReference(node, scope); + } + }, + leaveNode() { + // noop + }, + }); + + for (const scope of targetScopes) { + removeScope(info.scopeManager, scope); + } +} + +/** + * Gets the scope for the current node + */ +export function getScopeFromNode( + scopeManager: ScopeManager, + currentNode: ESTree.Node +): Scope { + let node: ESTree.Node | null = currentNode; + for (; node; node = (node as any).parent || null) { + const scope = scopeManager.acquire(node, false); + if (scope) { + if (scope.type === "function-expression-name") { + return scope.childScopes[0]; + } + if ( + scope.type === "global" && + node.type === "Program" && + node.sourceType === "module" + ) { + return scope.childScopes.find((s) => s.type === "module") || scope; + } + return scope; + } + } + const global = scopeManager.globalScope; + return global; +} +/** + * Gets the scope for the Program node + */ +export function getProgramScope(scopeManager: ScopeManager): Scope { + const globalScope = scopeManager.globalScope; + return ( + globalScope.childScopes.find((s) => s.type === "module") || globalScope + ); +} + +/** + * Get the innermost scope which contains a given node. + * @returns The innermost scope. + */ +export function getInnermostScopeFromNode( + scopeManager: ScopeManager, + currentNode: ESTree.Node +): Scope { + return getInnermostScope( + getScopeFromNode(scopeManager, currentNode), + currentNode + ); +} + +/** + * Get the innermost scope which contains a given location. + * @param initialScope The initial scope to search. + * @param node The location to search. + * @returns The innermost scope. + */ +export function getInnermostScope( + initialScope: Scope, + node: ESTree.Node +): Scope { + const location = node.range![0]; + + for (const childScope of initialScope.childScopes) { + const range = childScope.block.range!; + + if (range[0] <= location && location < range[1]) { + return getInnermostScope(childScope, node); + } + } + + return initialScope; +} + +/* eslint-disable complexity -- ignore X( */ +/** Remove variable */ +export function removeIdentifierVariable( + /* eslint-enable complexity -- ignore X( */ + node: + | ESTree.Pattern + | TSESTree.BindingName + | TSESTree.RestElement + | TSESTree.DestructuringPattern, + scope: Scope +): void { + if (node.type === "ObjectPattern") { + for (const prop of node.properties) { + if (prop.type === "Property") { + removeIdentifierVariable(prop.value, scope); + } else if (prop.type === "RestElement") { + removeIdentifierVariable(prop, scope); + } + } + return; + } + if (node.type === "ArrayPattern") { + for (const element of node.elements) { + if (!element) continue; + removeIdentifierVariable(element, scope); + } + return; + } + if (node.type === "AssignmentPattern") { + removeIdentifierVariable(node.left, scope); + return; + } + if (node.type === "RestElement") { + removeIdentifierVariable(node.argument, scope); + return; + } + if (node.type === "MemberExpression") { + return; + } + if (node.type !== "Identifier") { + return; + } + for (let varIndex = 0; varIndex < scope.variables.length; varIndex++) { + const variable = scope.variables[varIndex]; + const defIndex = variable.defs.findIndex((def) => def.name === node); + if (defIndex < 0) { + continue; + } + variable.defs.splice(defIndex, 1); + if (variable.defs.length === 0) { + // Remove variable + referencesToThrough(variable.references, scope); + variable.references.forEach((r) => { + if (r.init) r.init = false; + r.resolved = null; + }); + scope.variables.splice(varIndex, 1); + const name = node.name; + if (variable === scope.set.get(name)) { + scope.set.delete(name); + } + } else { + const idIndex = variable.identifiers.indexOf(node); + if (idIndex >= 0) { + variable.identifiers.splice(idIndex, 1); + } + } + return; + } +} + +/** Get all references */ +export function* getAllReferences( + node: + | ESTree.Pattern + | TSESTree.BindingName + | TSESTree.RestElement + | TSESTree.DestructuringPattern, + scope: Scope +): Iterable { + if (node.type === "ObjectPattern") { + for (const prop of node.properties) { + if (prop.type === "Property") { + yield* getAllReferences(prop.value, scope); + } else if (prop.type === "RestElement") { + yield* getAllReferences(prop, scope); + } + } + return; + } + if (node.type === "ArrayPattern") { + for (const element of node.elements) { + if (!element) continue; + yield* getAllReferences(element, scope); + } + return; + } + if (node.type === "AssignmentPattern") { + yield* getAllReferences(node.left, scope); + return; + } + if (node.type === "RestElement") { + yield* getAllReferences(node.argument, scope); + return; + } + if (node.type === "MemberExpression") { + return; + } + if (node.type !== "Identifier") { + return; + } + + const ref = scope.references.find((ref) => ref.identifier === node); + if (ref) yield ref; +} + +/** Remove reference */ +export function removeIdentifierReference( + node: ESTree.Identifier, + scope: Scope +): boolean { + const reference = scope.references.find((ref) => ref.identifier === node); + if (reference) { + removeReference(reference, scope); + return true; + } + const location = node.range![0]; + + const pendingScopes = []; + for (const childScope of scope.childScopes) { + const range = childScope.block.range!; + + if (range[0] <= location && location < range[1]) { + if (removeIdentifierReference(node, childScope)) { + return true; + } + } else { + pendingScopes.push(childScope); + } + } + for (const childScope of pendingScopes) { + if (removeIdentifierReference(node, childScope)) { + return true; + } + } + return false; +} + +/** Remove reference */ +export function removeReference(reference: Reference, baseScope: Scope): void { + if (reference.resolved) { + if (reference.resolved.defs.some((d) => d.name === reference.identifier)) { + // remove var + const varIndex = baseScope.variables.indexOf(reference.resolved); + if (varIndex >= 0) { + baseScope.variables.splice(varIndex, 1); + } + const name = reference.identifier.name; + if (reference.resolved === baseScope.set.get(name)) { + baseScope.set.delete(name); + } + } else { + const refIndex = reference.resolved.references.indexOf(reference); + if (refIndex >= 0) { + reference.resolved.references.splice(refIndex, 1); + } + } + } + + let scope: Scope | null = baseScope; + while (scope) { + const refIndex = scope.references.indexOf(reference); + if (refIndex >= 0) { + scope.references.splice(refIndex, 1); + } + const throughIndex = scope.through.indexOf(reference); + if (throughIndex >= 0) { + scope.through.splice(throughIndex, 1); + } + scope = scope.upper; + } +} + +/** Move reference to through */ +function referencesToThrough(references: Reference[], baseScope: Scope) { + let scope: Scope | null = baseScope; + while (scope) { + addAllReferences(scope.through, references); + scope = scope.upper; + } +} + +/** Remove scope */ +export function removeScope(scopeManager: ScopeManager, scope: Scope): void { + for (const childScope of scope.childScopes) { + removeScope(scopeManager, childScope); + } + + while (scope.references[0]) { + removeReference(scope.references[0], scope); + } + const upper = scope.upper; + if (upper) { + const index = upper.childScopes.indexOf(scope); + if (index >= 0) { + upper.childScopes.splice(index, 1); + } + } + const index = scopeManager.scopes.indexOf(scope); + if (index >= 0) { + scopeManager.scopes.splice(index, 1); + } +} +/** Replace scope */ +export function replaceScope( + scopeManager: ScopeManager, + scope: Scope, + newChildScopes: Scope[] = [] +): void { + // remove scope from scopeManager + scopeManager.scopes = scopeManager.scopes.filter((s) => s !== scope); + + const upper = scope.upper; + if (upper) { + // remove scope from upper and marge childScopes + upper.childScopes.splice( + upper.childScopes.indexOf(scope), + 1, + ...newChildScopes + ); + for (const child of newChildScopes) { + child.upper = upper; + replaceVariableScope(child, scope); + } + } + + /** Replace variableScope */ + function replaceVariableScope(child: Scope, replaceTarget: Scope) { + if (child.variableScope === replaceTarget) { + child.variableScope = child.upper!.variableScope; + for (const c of child.childScopes) { + replaceVariableScope(c, replaceTarget); + } + } + } +} + +/** + * Add variable to array + */ +export function addVariable(list: Variable[], variable: Variable): void { + addElementToSortedArray(list, variable, (a, b) => { + const idA = getFirstId(a); + const idB = getFirstId(b); + return idA.range![0] - idB.range![0]; + }); + + /** Get first id from give variable */ + function getFirstId(v: Variable): ESTree.Identifier { + return v.identifiers[0] || v.defs[0]?.name || v.references[0]?.identifier; + } +} +/** + * Add reference to array + */ +export function addReference(list: Reference[], reference: Reference): void { + addElementToSortedArray( + list, + reference, + (a, b) => a.identifier.range![0] - b.identifier.range![0] + ); +} +/** + * Add all references to array + */ +export function addAllReferences( + list: Reference[], + elements: Reference[] +): void { + addElementsToSortedArray( + list, + elements, + (a, b) => a.identifier.range![0] - b.identifier.range![0] + ); +} diff --git a/src/traverse.ts b/src/traverse.ts index dd091ea8..82d35cc8 100644 --- a/src/traverse.ts +++ b/src/traverse.ts @@ -2,6 +2,8 @@ import type { VisitorKeys } from "eslint-visitor-keys"; import { KEYS } from "./visitor-keys"; import type ESTree from "estree"; import type { SvelteNode } from "./ast"; +import type { TSESTree } from "@typescript-eslint/types"; +import type { VisitorKeys as TSESVisitorKeys } from "@typescript-eslint/visitor-keys"; /** * Check that the given key should be traversed or not. @@ -39,7 +41,10 @@ export function getFallbackKeys(node: any): string[] { * @param node The node to get. * @returns The keys to traverse. */ -export function getKeys(node: any, visitorKeys?: VisitorKeys): string[] { +export function getKeys( + node: any, + visitorKeys?: VisitorKeys | TSESVisitorKeys +): string[] { const keys = (visitorKeys || KEYS)[node.type] || getFallbackKeys(node); return keys.filter((key) => !getNodes(node, key).next().done); @@ -52,7 +57,7 @@ export function getKeys(node: any, visitorKeys?: VisitorKeys): string[] { export function* getNodes( node: any, key: string -): IterableIterator { +): IterableIterator { const child = node[key]; if (Array.isArray(child)) { for (const c of child) { @@ -80,7 +85,7 @@ function isNode(x: any): x is SvelteNode { * @param parent The parent node. * @param visitor The node visitor. */ -function traverse( +function traverse( node: N, parent: N | null, visitor: Visitor @@ -102,7 +107,7 @@ function traverse( //------------------------------------------------------------------------------ export interface Visitor { - visitorKeys?: VisitorKeys; + visitorKeys?: VisitorKeys | TSESVisitorKeys; enterNode(node: N, parent: N | null): void; leaveNode(node: N, parent: N | null): void; } @@ -115,14 +120,22 @@ export function traverseNodes( node: ESTree.Node, visitor: Visitor ): void; +export function traverseNodes( + node: TSESTree.Node, + visitor: Visitor +): void; +export function traverseNodes( + node: ESTree.Node | TSESTree.Node, + visitor: Visitor +): void; /** * Traverse the given AST tree. * @param node Root node to traverse. * @param visitor Visitor. */ export function traverseNodes( - node: ESTree.Node | SvelteNode, - visitor: Visitor | Visitor + node: ESTree.Node | SvelteNode | TSESTree.Node, + visitor: Visitor ): void { traverse(node, null, visitor); } diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..f4e967ee --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,61 @@ +/** + * Add element to a sorted array + */ +export function addElementToSortedArray( + array: T[], + element: T, + compare: (a: T, b: T) => number +): void { + const index = sortedLastIndex(array, (target) => compare(target, element)); + array.splice(index, 0, element); +} + +/** + * Add element to a sorted array + */ +export function addElementsToSortedArray( + array: T[], + elements: T[], + compare: (a: T, b: T) => number +): void { + if (!elements.length) { + return; + } + let last = elements[0]; + let index = sortedLastIndex(array, (target) => compare(target, last)); + for (const element of elements) { + if (compare(last, element) > 0) { + index = sortedLastIndex(array, (target) => compare(target, element)); + } + let e = array[index]; + while (e && compare(e, element) <= 0) { + e = array[++index]; + } + array.splice(index, 0, element); + last = element; + } +} +/** + * Uses a binary search to determine the highest index at which value should be inserted into array in order to maintain its sort order. + */ +export function sortedLastIndex( + array: T[], + compare: (target: T) => number +): number { + let lower = 0; + let upper = array.length; + + while (lower < upper) { + const mid = Math.floor(lower + (upper - lower) / 2); + const target = compare(array[mid]); + if (target < 0) { + lower = mid + 1; + } else if (target > 0) { + upper = mid; + } else { + return mid + 1; + } + } + + return upper; +} diff --git a/tests/fixtures/integrations/type-info-tests/issue226-input.svelte b/tests/fixtures/integrations/type-info-tests/issue226-input.svelte new file mode 100644 index 00000000..29d9ac83 --- /dev/null +++ b/tests/fixtures/integrations/type-info-tests/issue226-input.svelte @@ -0,0 +1,15 @@ + + + diff --git a/tests/fixtures/integrations/type-info-tests/issue226-output.json b/tests/fixtures/integrations/type-info-tests/issue226-output.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/tests/fixtures/integrations/type-info-tests/issue226-output.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/tests/fixtures/integrations/type-info-tests/issue226-setup.ts b/tests/fixtures/integrations/type-info-tests/issue226-setup.ts new file mode 100644 index 00000000..9782542f --- /dev/null +++ b/tests/fixtures/integrations/type-info-tests/issue226-setup.ts @@ -0,0 +1,24 @@ +/* eslint eslint-comments/require-description: 0, @typescript-eslint/explicit-module-boundary-types: 0 */ +import type { Linter } from "eslint"; +import { BASIC_PARSER_OPTIONS } from "../../../src/parser/test-utils"; +import { rules } from "@typescript-eslint/eslint-plugin"; +export function setupLinter(linter: Linter) { + linter.defineRule( + "@typescript-eslint/no-unsafe-argument", + rules["no-unsafe-argument"] as never + ); +} + +export function getConfig() { + return { + parser: "svelte-eslint-parser", + parserOptions: BASIC_PARSER_OPTIONS, + rules: { + "@typescript-eslint/no-unsafe-argument": "error", + }, + env: { + browser: true, + es2021: true, + }, + }; +} diff --git a/tests/fixtures/integrations/type-info-tests/plugin-issue254-input.svelte b/tests/fixtures/integrations/type-info-tests/plugin-issue254-input.svelte new file mode 100644 index 00000000..74b48552 --- /dev/null +++ b/tests/fixtures/integrations/type-info-tests/plugin-issue254-input.svelte @@ -0,0 +1,26 @@ + + +

Welcome to SvelteKit

+

Visit kit.svelte.dev to read the documentation

+ +