From 59f5a76d5126daf0f3e5934a9e5326281f36323b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Matej=20=C4=8Cern=C3=BD?=
<112722215+cernymatej@users.noreply.github.com>
Date: Wed, 18 Dec 2024 10:42:43 +0100
Subject: [PATCH] feat(nuxt): support local functions in `definePageMeta`
(#30241)
---
docs/2.guide/2.directory-structure/1.pages.md | 16 +-
.../nuxt/src/core/plugins/composable-keys.ts | 132 +---
packages/nuxt/src/core/utils/parse.ts | 532 +++++++++++++-
packages/nuxt/src/pages/plugins/page-meta.ts | 128 +++-
packages/nuxt/test/fixture/scope-tracker.ts | 31 +
packages/nuxt/test/page-metadata.test.ts | 159 ++++-
packages/nuxt/test/parse.test.ts | 670 ++++++++++++++++++
7 files changed, 1514 insertions(+), 154 deletions(-)
create mode 100644 packages/nuxt/test/fixture/scope-tracker.ts
create mode 100644 packages/nuxt/test/parse.test.ts
diff --git a/docs/2.guide/2.directory-structure/1.pages.md b/docs/2.guide/2.directory-structure/1.pages.md
index 57d88ddd5c..1efaacc2aa 100644
--- a/docs/2.guide/2.directory-structure/1.pages.md
+++ b/docs/2.guide/2.directory-structure/1.pages.md
@@ -266,17 +266,27 @@ console.log(route.meta.title) // My home page
If you are using nested routes, the page metadata from all these routes will be merged into a single object. For more on route meta, see the [vue-router docs](https://router.vuejs.org/guide/advanced/meta.html#route-meta-fields).
-Much like `defineEmits` or `defineProps` (see [Vue docs](https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits)), `definePageMeta` is a **compiler macro**. It will be compiled away so you cannot reference it within your component. Instead, the metadata passed to it will be hoisted out of the component. Therefore, the page meta object cannot reference the component (or values defined on the component). However, it can reference imported bindings.
+Much like `defineEmits` or `defineProps` (see [Vue docs](https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits)), `definePageMeta` is a **compiler macro**. It will be compiled away so you cannot reference it within your component. Instead, the metadata passed to it will be hoisted out of the component.
+Therefore, the page meta object cannot reference the component. However, it can reference imported bindings, as well as locally defined **pure functions**.
+
+::warning
+Make sure not to reference any reactive data or functions that cause side effects. This can lead to unexpected behavior.
+::
```vue
```
diff --git a/packages/nuxt/src/core/plugins/composable-keys.ts b/packages/nuxt/src/core/plugins/composable-keys.ts
index c9ddfc07a5..ea603c4e1c 100644
--- a/packages/nuxt/src/core/plugins/composable-keys.ts
+++ b/packages/nuxt/src/core/plugins/composable-keys.ts
@@ -3,11 +3,10 @@ import { createUnplugin } from 'unplugin'
import { isAbsolute, relative } from 'pathe'
import MagicString from 'magic-string'
import { hash } from 'ohash'
-import type { Pattern } from 'estree'
import { parseQuery, parseURL } from 'ufo'
import escapeRE from 'escape-string-regexp'
import { findStaticImports, parseStaticImport } from 'mlly'
-import { parseAndWalk, walk } from '../../core/utils/parse'
+import { ScopeTracker, parseAndWalk, walk } from '../utils/parse'
import { matchWithStringOrRegex } from '../utils/plugins'
@@ -53,33 +52,18 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn
const { pathname: relativePathname } = parseURL(relativeID)
// To handle variables hoisting we need a pre-pass to collect variable and function declarations with scope info.
- let scopeTracker = new ScopeTracker()
- const varCollector = new ScopedVarsCollector()
+ const scopeTracker = new ScopeTracker({
+ keepExitedScopes: true,
+ })
const ast = parseAndWalk(script, id, {
- enter (node) {
- if (node.type === 'BlockStatement') {
- scopeTracker.enterScope()
- varCollector.refresh(scopeTracker.curScopeKey)
- } else if (node.type === 'FunctionDeclaration' && node.id) {
- varCollector.addVar(node.id.name)
- } else if (node.type === 'VariableDeclarator') {
- varCollector.collect(node.id)
- }
- },
- leave (_node) {
- if (_node.type === 'BlockStatement') {
- scopeTracker.leaveScope()
- varCollector.refresh(scopeTracker.curScopeKey)
- }
- },
+ scopeTracker,
})
- scopeTracker = new ScopeTracker()
+ scopeTracker.freeze()
+
walk(ast, {
+ scopeTracker,
enter (node) {
- if (node.type === 'BlockStatement') {
- scopeTracker.enterScope()
- }
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
const name = node.callee.name
if (!name || !keyedFunctions.has(name) || node.arguments.length >= maxLength) { return }
@@ -89,7 +73,9 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn
const meta = composableMeta[name]
- if (varCollector.hasVar(scopeTracker.curScopeKey, name)) {
+ const declaration = scopeTracker.getDeclaration(name)
+
+ if (declaration && declaration.type !== 'Import') {
let skip = true
if (meta.source) {
skip = !matchWithStringOrRegex(relativePathname, meta.source)
@@ -125,11 +111,6 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn
(node.arguments.length && !endsWithComma ? ', ' : '') + '\'$' + hash(`${relativeID}-${++count}`) + '\'',
)
},
- leave (_node) {
- if (_node.type === 'BlockStatement') {
- scopeTracker.leaveScope()
- }
- },
})
if (s.hasChanged()) {
return {
@@ -143,97 +124,6 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn
}
})
-/*
-* track scopes with unique keys. for example
-* ```js
-* // root scope, marked as ''
-* function a () { // '0'
-* function b () {} // '0-0'
-* function c () {} // '0-1'
-* }
-* function d () {} // '1'
-* // ''
-* ```
-* */
-class ScopeTracker {
- // the top of the stack is not a part of current key, it is used for next level
- scopeIndexStack: number[]
- curScopeKey: string
-
- constructor () {
- this.scopeIndexStack = [0]
- this.curScopeKey = ''
- }
-
- getKey () {
- return this.scopeIndexStack.slice(0, -1).join('-')
- }
-
- enterScope () {
- this.scopeIndexStack.push(0)
- this.curScopeKey = this.getKey()
- }
-
- leaveScope () {
- this.scopeIndexStack.pop()
- this.curScopeKey = this.getKey()
- this.scopeIndexStack[this.scopeIndexStack.length - 1]!++
- }
-}
-
-class ScopedVarsCollector {
- curScopeKey: string
- all: Map>
-
- constructor () {
- this.all = new Map()
- this.curScopeKey = ''
- }
-
- refresh (scopeKey: string) {
- this.curScopeKey = scopeKey
- }
-
- addVar (name: string) {
- let vars = this.all.get(this.curScopeKey)
- if (!vars) {
- vars = new Set()
- this.all.set(this.curScopeKey, vars)
- }
- vars.add(name)
- }
-
- hasVar (scopeKey: string, name: string) {
- const indices = scopeKey.split('-').map(Number)
- for (let i = indices.length; i >= 0; i--) {
- if (this.all.get(indices.slice(0, i).join('-'))?.has(name)) {
- return true
- }
- }
- return false
- }
-
- collect (pattern: Pattern) {
- if (pattern.type === 'Identifier') {
- this.addVar(pattern.name)
- } else if (pattern.type === 'RestElement') {
- this.collect(pattern.argument)
- } else if (pattern.type === 'AssignmentPattern') {
- this.collect(pattern.left)
- } else if (pattern.type === 'ArrayPattern') {
- for (const element of pattern.elements) {
- if (element) {
- this.collect(element.type === 'RestElement' ? element.argument : element)
- }
- }
- } else if (pattern.type === 'ObjectPattern') {
- for (const property of pattern.properties) {
- this.collect(property.type === 'RestElement' ? property.argument : property.value)
- }
- }
- }
-}
-
const NUXT_IMPORT_RE = /nuxt|#app|#imports/
export function detectImportNames (code: string, composableMeta: Record) {
diff --git a/packages/nuxt/src/core/utils/parse.ts b/packages/nuxt/src/core/utils/parse.ts
index 34f7af6d79..c60aefa82a 100644
--- a/packages/nuxt/src/core/utils/parse.ts
+++ b/packages/nuxt/src/core/utils/parse.ts
@@ -1,6 +1,17 @@
import { walk as _walk } from 'estree-walker'
import type { Node, SyncHandler } from 'estree-walker'
-import type { Program as ESTreeProgram } from 'estree'
+import type {
+ ArrowFunctionExpression,
+ CatchClause,
+ Program as ESTreeProgram,
+ FunctionDeclaration,
+ FunctionExpression,
+ Identifier,
+ ImportDefaultSpecifier,
+ ImportNamespaceSpecifier,
+ ImportSpecifier,
+ VariableDeclaration,
+} from 'estree'
import { parse } from 'acorn'
import type { Program } from 'acorn'
@@ -9,20 +20,30 @@ export type { Node }
type WithLocations = T & { start: number, end: number }
type WalkerCallback = (this: ThisParameterType, node: WithLocations, parent: WithLocations | null, ctx: { key: string | number | symbol | null | undefined, index: number | null | undefined, ast: Program | Node }) => void
-export function walk (ast: Program | Node, callback: { enter?: WalkerCallback, leave?: WalkerCallback }) {
+interface WalkOptions {
+ enter: WalkerCallback
+ leave: WalkerCallback
+ scopeTracker: ScopeTracker
+}
+
+export function walk (ast: Program | Node, callback: Partial) {
return _walk(ast as unknown as ESTreeProgram | Node, {
enter (node, parent, key, index) {
+ // @ts-expect-error - accessing a protected property
+ callback.scopeTracker?.processNodeEnter(node as WithLocations)
callback.enter?.call(this, node as WithLocations, parent as WithLocations | null, { key, index, ast })
},
leave (node, parent, key, index) {
+ // @ts-expect-error - accessing a protected property
+ callback.scopeTracker?.processNodeLeave(node as WithLocations)
callback.leave?.call(this, node as WithLocations, parent as WithLocations | null, { key, index, ast })
},
}) as Program | Node | null
}
export function parseAndWalk (code: string, sourceFilename: string, callback: WalkerCallback): Program
-export function parseAndWalk (code: string, sourceFilename: string, object: { enter?: WalkerCallback, leave?: WalkerCallback }): Program
-export function parseAndWalk (code: string, _sourceFilename: string, callback: { enter?: WalkerCallback, leave?: WalkerCallback } | WalkerCallback) {
+export function parseAndWalk (code: string, sourceFilename: string, object: Partial): Program
+export function parseAndWalk (code: string, _sourceFilename: string, callback: Partial | WalkerCallback) {
const ast = parse (code, { sourceType: 'module', ecmaVersion: 'latest', locations: true })
walk(ast, typeof callback === 'function' ? { enter: callback } : callback)
return ast
@@ -31,3 +52,506 @@ export function parseAndWalk (code: string, _sourceFilename: string, callback: {
export function withLocations (node: T): WithLocations {
return node as WithLocations
}
+
+abstract class BaseNode {
+ abstract type: string
+ node: WithLocations
+
+ constructor (node: WithLocations) {
+ this.node = node
+ }
+
+ /**
+ * The starting position of the entire relevant node in the code.
+ * For instance, for a function parameter, this would be the start of the function declaration.
+ */
+ abstract get start (): number
+
+ /**
+ * The ending position of the entire relevant node in the code.
+ * For instance, for a function parameter, this would be the end of the function declaration.
+ */
+ abstract get end (): number
+}
+
+class IdentifierNode extends BaseNode {
+ override type = 'Identifier' as const
+
+ get start () {
+ return this.node.start
+ }
+
+ get end () {
+ return this.node.end
+ }
+}
+
+class FunctionParamNode extends BaseNode {
+ type = 'FunctionParam' as const
+ fnNode: WithLocations
+
+ constructor (node: WithLocations, fnNode: WithLocations) {
+ super(node)
+ this.fnNode = fnNode
+ }
+
+ get start () {
+ return this.fnNode.start
+ }
+
+ get end () {
+ return this.fnNode.end
+ }
+}
+
+class FunctionNode extends BaseNode {
+ type = 'Function' as const
+
+ get start () {
+ return this.node.start
+ }
+
+ get end () {
+ return this.node.end
+ }
+}
+
+class VariableNode extends BaseNode {
+ type = 'Variable' as const
+ variableNode: WithLocations
+
+ constructor (node: WithLocations, variableNode: WithLocations) {
+ super(node)
+ this.variableNode = variableNode
+ }
+
+ get start () {
+ return this.variableNode.start
+ }
+
+ get end () {
+ return this.variableNode.end
+ }
+}
+
+class ImportNode extends BaseNode {
+ type = 'Import' as const
+ importNode: WithLocations
+
+ constructor (node: WithLocations, importNode: WithLocations) {
+ super(node)
+ this.importNode = importNode
+ }
+
+ get start () {
+ return this.importNode.start
+ }
+
+ get end () {
+ return this.importNode.end
+ }
+}
+
+class CatchParamNode extends BaseNode {
+ type = 'CatchParam' as const
+ catchNode: WithLocations
+
+ constructor (node: WithLocations, catchNode: WithLocations) {
+ super(node)
+ this.catchNode = catchNode
+ }
+
+ get start () {
+ return this.catchNode.start
+ }
+
+ get end () {
+ return this.catchNode.end
+ }
+}
+
+export type ScopeTrackerNode =
+ | FunctionParamNode
+ | FunctionNode
+ | VariableNode
+ | IdentifierNode
+ | ImportNode
+ | CatchParamNode
+
+interface ScopeTrackerOptions {
+ /**
+ * If true, the scope tracker will keep exited scopes in memory.
+ * @default false
+ */
+ keepExitedScopes?: boolean
+}
+
+/**
+ * A class to track variable scopes and declarations of identifiers within a JavaScript AST.
+ * It maintains a stack of scopes, where each scope is a map of identifier names to their corresponding
+ * declaration nodes - allowing to get to the declaration easily.
+ *
+ * The class has integration with the `walk` function to automatically track scopes and declarations
+ * and that's why only the informative methods are exposed.
+ *
+ * ### Scope tracking
+ * Scopes are created when entering a block statement, however, they are also created
+ * for function parameters, loop variable declarations, etc. (e.g. `i` in `for (let i = 0; i < 10; i++) { ... }`).
+ * This means that the behaviour is not 100% equivalent to JavaScript's scoping rules, because internally,
+ * one JavaScript scope can be spread across multiple scopes in this class.
+ *
+ * @example
+ * ```ts
+ * const scopeTracker = new ScopeTracker()
+ * walk(code, {
+ * scopeTracker,
+ * enter(node) {
+ * // ...
+ * },
+ * })
+ * ```
+ *
+ * @see parseAndWalk
+ * @see walk
+ */
+export class ScopeTracker {
+ protected scopeIndexStack: number[] = []
+ protected scopeIndexKey = ''
+ protected scopes: Map>> = new Map()
+
+ protected options: Partial
+ protected isFrozen = false
+
+ constructor (options: ScopeTrackerOptions = {}) {
+ this.options = options
+ }
+
+ protected updateScopeIndexKey () {
+ this.scopeIndexKey = this.scopeIndexStack.slice(0, -1).join('-')
+ }
+
+ protected pushScope () {
+ this.scopeIndexStack.push(0)
+ this.updateScopeIndexKey()
+ }
+
+ protected popScope () {
+ this.scopeIndexStack.pop()
+ if (this.scopeIndexStack[this.scopeIndexStack.length - 1] !== undefined) {
+ this.scopeIndexStack[this.scopeIndexStack.length - 1]!++
+ }
+
+ if (!this.options.keepExitedScopes) {
+ this.scopes.delete(this.scopeIndexKey)
+ }
+
+ this.updateScopeIndexKey()
+ }
+
+ protected declareIdentifier (name: string, data: ScopeTrackerNode) {
+ if (this.isFrozen) { return }
+
+ let scope = this.scopes.get(this.scopeIndexKey)
+ if (!scope) {
+ scope = new Map()
+ this.scopes.set(this.scopeIndexKey, scope)
+ }
+ scope.set(name, data)
+ }
+
+ protected declareFunctionParameter (param: WithLocations, fn: WithLocations) {
+ if (this.isFrozen) { return }
+
+ const identifiers = getPatternIdentifiers(param)
+ for (const identifier of identifiers) {
+ this.declareIdentifier(identifier.name, new FunctionParamNode(identifier, fn))
+ }
+ }
+
+ protected declarePattern (pattern: WithLocations, parent: WithLocations) {
+ if (this.isFrozen) { return }
+
+ const identifiers = getPatternIdentifiers(pattern)
+ for (const identifier of identifiers) {
+ this.declareIdentifier(
+ identifier.name,
+ parent.type === 'VariableDeclaration'
+ ? new VariableNode(identifier, parent)
+ : parent.type === 'CatchClause'
+ ? new CatchParamNode(identifier, parent)
+ : new FunctionParamNode(identifier, parent),
+ )
+ }
+ }
+
+ protected processNodeEnter (node: WithLocations) {
+ switch (node.type) {
+ case 'Program':
+ case 'BlockStatement':
+ case 'StaticBlock':
+ this.pushScope()
+ break
+
+ case 'FunctionDeclaration':
+ // declare function name for named functions, skip for `export default`
+ if (node.id?.name) {
+ this.declareIdentifier(node.id.name, new FunctionNode(node))
+ }
+ this.pushScope()
+ for (const param of node.params) {
+ this.declareFunctionParameter(withLocations(param), node)
+ }
+ break
+
+ case 'FunctionExpression':
+ // make the name of the function available only within the function
+ // e.g. const foo = function bar() { // bar is only available within the function body
+ this.pushScope()
+ // can be undefined, for example in class method definitions
+ if (node.id?.name) {
+ this.declareIdentifier(node.id.name, new FunctionNode(node))
+ }
+
+ this.pushScope()
+ for (const param of node.params) {
+ this.declareFunctionParameter(withLocations(param), node)
+ }
+ break
+ case 'ArrowFunctionExpression':
+ this.pushScope()
+ for (const param of node.params) {
+ this.declareFunctionParameter(withLocations(param), node)
+ }
+ break
+
+ case 'VariableDeclaration':
+ for (const decl of node.declarations) {
+ this.declarePattern(withLocations(decl.id), node)
+ }
+ break
+
+ case 'ClassDeclaration':
+ // declare class name for named classes, skip for `export default`
+ if (node.id?.name) {
+ this.declareIdentifier(node.id.name, new IdentifierNode(withLocations(node.id)))
+ }
+ break
+
+ case 'ClassExpression':
+ // make the name of the class available only within the class
+ // e.g. const MyClass = class InternalClassName { // InternalClassName is only available within the class body
+ this.pushScope()
+ if (node.id?.name) {
+ this.declareIdentifier(node.id.name, new IdentifierNode(withLocations(node.id)))
+ }
+ break
+
+ case 'ImportDeclaration':
+ for (const specifier of node.specifiers) {
+ this.declareIdentifier(specifier.local.name, new ImportNode(withLocations(specifier), node))
+ }
+ break
+
+ case 'CatchClause':
+ this.pushScope()
+ if (node.param) {
+ this.declarePattern(withLocations(node.param), node)
+ }
+ break
+
+ case 'ForStatement':
+ case 'ForOfStatement':
+ case 'ForInStatement':
+ // make the variables defined in for loops available only within the loop
+ // e.g. for (let i = 0; i < 10; i++) { // i is only available within the loop block scope
+ this.pushScope()
+
+ if (node.type === 'ForStatement' && node.init?.type === 'VariableDeclaration') {
+ for (const decl of node.init.declarations) {
+ this.declarePattern(withLocations(decl.id), withLocations(node.init))
+ }
+ } else if ((node.type === 'ForOfStatement' || node.type === 'ForInStatement') && node.left.type === 'VariableDeclaration') {
+ for (const decl of node.left.declarations) {
+ this.declarePattern(withLocations(decl.id), withLocations(node.left))
+ }
+ }
+ break
+ }
+ }
+
+ protected processNodeLeave (node: WithLocations) {
+ switch (node.type) {
+ case 'Program':
+ case 'BlockStatement':
+ case 'CatchClause':
+ case 'FunctionDeclaration':
+ case 'ArrowFunctionExpression':
+ case 'StaticBlock':
+ case 'ClassExpression':
+ case 'ForStatement':
+ case 'ForOfStatement':
+ case 'ForInStatement':
+ this.popScope()
+ break
+ case 'FunctionExpression':
+ this.popScope()
+ this.popScope()
+ break
+ }
+ }
+
+ isDeclared (name: string) {
+ if (!this.scopeIndexKey) {
+ return this.scopes.get('')?.has(name) || false
+ }
+
+ const indices = this.scopeIndexKey.split('-').map(Number)
+ for (let i = indices.length; i >= 0; i--) {
+ if (this.scopes.get(indices.slice(0, i).join('-'))?.has(name)) {
+ return true
+ }
+ }
+ return false
+ }
+
+ getDeclaration (name: string): ScopeTrackerNode | null {
+ if (!this.scopeIndexKey) {
+ return this.scopes.get('')?.get(name) ?? null
+ }
+
+ const indices = this.scopeIndexKey.split('-').map(Number)
+ for (let i = indices.length; i >= 0; i--) {
+ const node = this.scopes.get(indices.slice(0, i).join('-'))?.get(name)
+ if (node) {
+ return node
+ }
+ }
+ return null
+ }
+
+ /**
+ * Freezes the scope tracker, preventing further declarations.
+ * It also resets the scope index stack to its initial state, so that the scope tracker can be reused.
+ *
+ * This is useful for second passes through the AST.
+ */
+ freeze () {
+ this.isFrozen = true
+ this.scopeIndexStack = []
+ this.updateScopeIndexKey()
+ }
+}
+
+function getPatternIdentifiers (pattern: WithLocations) {
+ const identifiers: WithLocations[] = []
+
+ function collectIdentifiers (pattern: WithLocations) {
+ switch (pattern.type) {
+ case 'Identifier':
+ identifiers.push(pattern)
+ break
+ case 'AssignmentPattern':
+ collectIdentifiers(withLocations(pattern.left))
+ break
+ case 'RestElement':
+ collectIdentifiers(withLocations(pattern.argument))
+ break
+ case 'ArrayPattern':
+ for (const element of pattern.elements) {
+ if (element) {
+ collectIdentifiers(withLocations(element.type === 'RestElement' ? element.argument : element))
+ }
+ }
+ break
+ case 'ObjectPattern':
+ for (const property of pattern.properties) {
+ collectIdentifiers(withLocations(property.type === 'RestElement' ? property.argument : property.value))
+ }
+ break
+ }
+ }
+
+ collectIdentifiers(pattern)
+
+ return identifiers
+}
+
+function isNotReferencePosition (node: WithLocations, parent: WithLocations | null) {
+ if (!parent || node.type !== 'Identifier') { return false }
+
+ switch (parent.type) {
+ case 'FunctionDeclaration':
+ case 'FunctionExpression':
+ case 'ArrowFunctionExpression':
+ // function name or parameters
+ if (parent.type !== 'ArrowFunctionExpression' && parent.id === node) { return true }
+ if (parent.params.length) {
+ for (const param of parent.params) {
+ const identifiers = getPatternIdentifiers(withLocations(param))
+ if (identifiers.includes(node)) { return true }
+ }
+ }
+ return false
+
+ case 'ClassDeclaration':
+ case 'ClassExpression':
+ // class name
+ return parent.id === node
+
+ case 'MethodDefinition':
+ // class method name
+ return parent.key === node
+
+ case 'PropertyDefinition':
+ // class property name
+ return parent.key === node
+
+ case 'VariableDeclarator':
+ // variable name
+ return getPatternIdentifiers(withLocations(parent.id)).includes(node)
+
+ case 'CatchClause':
+ // catch clause param
+ if (!parent.param) { return false }
+ return getPatternIdentifiers(withLocations(parent.param)).includes(node)
+
+ case 'Property':
+ // property key if not used as a shorthand
+ return parent.key === node && parent.value !== node
+
+ case 'MemberExpression':
+ // member expression properties
+ return parent.property === node
+ }
+
+ return false
+}
+
+export function getUndeclaredIdentifiersInFunction (node: FunctionDeclaration | FunctionExpression | ArrowFunctionExpression) {
+ const scopeTracker = new ScopeTracker({
+ keepExitedScopes: true,
+ })
+ const undeclaredIdentifiers = new Set()
+
+ function isIdentifierUndeclared (node: WithLocations, parent: WithLocations | null) {
+ return !isNotReferencePosition(node, parent) && !scopeTracker.isDeclared(node.name)
+ }
+
+ // first pass to collect all declarations and hoist them
+ walk(node, {
+ scopeTracker,
+ })
+
+ scopeTracker.freeze()
+
+ walk(node, {
+ scopeTracker,
+ enter (node, parent) {
+ if (node.type === 'Identifier' && isIdentifierUndeclared(node, parent)) {
+ undeclaredIdentifiers.add(node.name)
+ }
+ },
+ })
+
+ return Array.from(undeclaredIdentifiers)
+}
diff --git a/packages/nuxt/src/pages/plugins/page-meta.ts b/packages/nuxt/src/pages/plugins/page-meta.ts
index 56e88c8f3a..ff179fb68d 100644
--- a/packages/nuxt/src/pages/plugins/page-meta.ts
+++ b/packages/nuxt/src/pages/plugins/page-meta.ts
@@ -8,7 +8,13 @@ import MagicString from 'magic-string'
import { isAbsolute } from 'pathe'
import { logger } from '@nuxt/kit'
-import { parseAndWalk, withLocations } from '../../core/utils/parse'
+import {
+ ScopeTracker,
+ type ScopeTrackerNode,
+ getUndeclaredIdentifiersInFunction,
+ parseAndWalk,
+ withLocations,
+} from '../../core/utils/parse'
interface PageMetaPluginOptions {
dev?: boolean
@@ -119,38 +125,110 @@ export const PageMetaPlugin = (options: PageMetaPluginOptions = {}) => createUnp
}
}
- parseAndWalk(code, id, (node) => {
- if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
- if (!('name' in node.callee) || node.callee.name !== 'definePageMeta') { return }
+ function isStaticIdentifier (name: string | false): name is string {
+ return !!(name && importMap.has(name))
+ }
- const meta = withLocations(node.arguments[0])
+ function addImport (name: string | false) {
+ if (!isStaticIdentifier(name)) { return }
+ const importValue = importMap.get(name)!.code.trim()
+ if (!addedImports.has(importValue)) {
+ addedImports.add(importValue)
+ }
+ }
- if (!meta) { return }
+ const declarationNodes: ScopeTrackerNode[] = []
+ const addedDeclarations = new Set()
- let contents = `const __nuxt_page_meta = ${code!.slice(meta.start, meta.end) || 'null'}\nexport default __nuxt_page_meta` + (options.dev ? CODE_HMR : '')
+ function addDeclaration (node: ScopeTrackerNode) {
+ const codeSectionKey = `${node.start}-${node.end}`
+ if (addedDeclarations.has(codeSectionKey)) { return }
+ addedDeclarations.add(codeSectionKey)
+ declarationNodes.push(node)
+ }
- function addImport (name: string | false) {
- if (name && importMap.has(name)) {
- const importValue = importMap.get(name)!.code
- if (!addedImports.has(importValue)) {
- contents = importMap.get(name)!.code + '\n' + contents
- addedImports.add(importValue)
- }
+ function addImportOrDeclaration (name: string) {
+ if (isStaticIdentifier(name)) {
+ addImport(name)
+ } else {
+ const declaration = scopeTracker.getDeclaration(name)
+ if (declaration) {
+ processDeclaration(declaration)
}
}
+ }
- walk(meta, {
- enter (node) {
- if (node.type === 'CallExpression' && 'name' in node.callee) {
- addImport(node.callee.name)
- }
- if (node.type === 'Identifier') {
- addImport(node.name)
- }
- },
- })
+ const scopeTracker = new ScopeTracker()
- s.overwrite(0, code.length, contents)
+ function processDeclaration (node: ScopeTrackerNode | null) {
+ if (node?.type === 'Variable') {
+ addDeclaration(node)
+
+ for (const decl of node.variableNode.declarations) {
+ if (!decl.init) { continue }
+ walk(decl.init, {
+ enter: (node) => {
+ if (node.type === 'AwaitExpression') {
+ logger.error(`[nuxt] Await expressions are not supported in definePageMeta. File: '${id}'`)
+ throw new Error('await in definePageMeta')
+ }
+ if (node.type !== 'Identifier') { return }
+
+ addImportOrDeclaration(node.name)
+ },
+ })
+ }
+ } else if (node?.type === 'Function') {
+ // arrow functions are going to be assigned to a variable
+ if (node.node.type === 'ArrowFunctionExpression') { return }
+ const name = node.node.id?.name
+ if (!name) { return }
+ addDeclaration(node)
+
+ const undeclaredIdentifiers = getUndeclaredIdentifiersInFunction(node.node)
+ for (const name of undeclaredIdentifiers) {
+ addImportOrDeclaration(name)
+ }
+ }
+ }
+
+ parseAndWalk(code, id, {
+ scopeTracker,
+ enter: (node) => {
+ if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
+ if (!('name' in node.callee) || node.callee.name !== 'definePageMeta') { return }
+
+ const meta = withLocations(node.arguments[0])
+
+ if (!meta) { return }
+
+ walk(meta, {
+ enter (node) {
+ if (node.type !== 'Identifier') { return }
+
+ if (isStaticIdentifier(node.name)) {
+ addImport(node.name)
+ } else {
+ processDeclaration(scopeTracker.getDeclaration(node.name))
+ }
+ },
+ })
+
+ const importStatements = Array.from(addedImports).join('\n')
+
+ const declarations = declarationNodes
+ .sort((a, b) => a.start - b.start)
+ .map(node => code.slice(node.start, node.end))
+ .join('\n')
+
+ const extracted = [
+ importStatements,
+ declarations,
+ `const __nuxt_page_meta = ${code!.slice(meta.start, meta.end) || 'null'}\nexport default __nuxt_page_meta` + (options.dev ? CODE_HMR : ''),
+ ].join('\n')
+
+ s.overwrite(0, code.length, extracted.trim())
+ },
})
if (!s.hasChanged() && !code.includes('__nuxt_page_meta')) {
diff --git a/packages/nuxt/test/fixture/scope-tracker.ts b/packages/nuxt/test/fixture/scope-tracker.ts
new file mode 100644
index 0000000000..de812588c8
--- /dev/null
+++ b/packages/nuxt/test/fixture/scope-tracker.ts
@@ -0,0 +1,31 @@
+import { ScopeTracker } from '../../src/core/utils/parse'
+
+export class TestScopeTracker extends ScopeTracker {
+ getScopes () {
+ return this.scopes
+ }
+
+ getScopeIndexKey () {
+ return this.scopeIndexKey
+ }
+
+ getScopeIndexStack () {
+ return this.scopeIndexStack
+ }
+
+ isDeclaredInScope (identifier: string, scope: string) {
+ const oldKey = this.scopeIndexKey
+ this.scopeIndexKey = scope
+ const result = this.isDeclared(identifier)
+ this.scopeIndexKey = oldKey
+ return result
+ }
+
+ getDeclarationFromScope (identifier: string, scope: string) {
+ const oldKey = this.scopeIndexKey
+ this.scopeIndexKey = scope
+ const result = this.getDeclaration(identifier)
+ this.scopeIndexKey = oldKey
+ return result
+ }
+}
diff --git a/packages/nuxt/test/page-metadata.test.ts b/packages/nuxt/test/page-metadata.test.ts
index 62b8bfe580..0513b97148 100644
--- a/packages/nuxt/test/page-metadata.test.ts
+++ b/packages/nuxt/test/page-metadata.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { compileScript, parse } from '@vue/compiler-sfc'
import * as Parser from 'acorn'
-
+import { transform as esbuildTransform } from 'esbuild'
import { PageMetaPlugin } from '../src/pages/plugins/page-meta'
import { getRouteMeta, normalizeRoutes } from '../src/pages/utils'
import type { NuxtPage } from '../schema'
@@ -310,4 +310,161 @@ definePageMeta({
export default __nuxt_page_meta"
`)
})
+
+ it('should extract local functions', () => {
+ const sfc = `
+
+ `
+ const res = compileScript(parse(sfc).descriptor, { id: 'component.vue' })
+ expect(transformPlugin.transform.call({
+ parse: (code: string, opts: any = {}) => Parser.parse(code, {
+ sourceType: 'module',
+ ecmaVersion: 'latest',
+ locations: true,
+ ...opts,
+ }),
+ }, res.content, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(`
+ "function isNumber(value) {
+ return value && !isNaN(Number(value))
+ }
+ function validateIdParam (route) {
+ return isNumber(route.params.id)
+ }
+ const __nuxt_page_meta = {
+ validate: validateIdParam,
+ test: () => 'hello',
+ }
+ export default __nuxt_page_meta"
+ `)
+ })
+
+ it('should extract user imports', () => {
+ const sfc = `
+
+ `
+ const res = compileScript(parse(sfc).descriptor, { id: 'component.vue' })
+ expect(transformPlugin.transform.call({
+ parse: (code: string, opts: any = {}) => Parser.parse(code, {
+ sourceType: 'module',
+ ecmaVersion: 'latest',
+ locations: true,
+ ...opts,
+ }),
+ }, res.content, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(`
+ "import { validateIdParam } from './utils'
+
+ const __nuxt_page_meta = {
+ validate: validateIdParam,
+ dynamic: ref(true),
+ }
+ export default __nuxt_page_meta"
+ `)
+ })
+
+ it('should work with esbuild.keepNames = true', async () => {
+ const sfc = `
+
+ `
+ const compiled = compileScript(parse(sfc).descriptor, { id: 'component.vue' })
+ const res = await esbuildTransform(compiled.content, {
+ loader: 'ts',
+ keepNames: true,
+ })
+ expect(transformPlugin.transform.call({
+ parse: (code: string, opts: any = {}) => Parser.parse(code, {
+ sourceType: 'module',
+ ecmaVersion: 'latest',
+ locations: true,
+ ...opts,
+ }),
+ }, res.code, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(`
+ "import { foo } from "./utils";
+ var __defProp = Object.defineProperty;
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
+ const checkNum = /* @__PURE__ */ __name((value) => {
+ return !isNaN(Number(foo(value)));
+ }, "checkNum");
+ function isNumber(value) {
+ return value && checkNum(value);
+ }
+ const __nuxt_page_meta = {
+ validate: /* @__PURE__ */ __name(({ params }) => {
+ return isNumber(params.id);
+ }, "validate")
+ }
+ export default __nuxt_page_meta"
+ `)
+ })
+
+ it('should throw for await expressions', async () => {
+ const sfc = `
+
+ `
+ const compiled = compileScript(parse(sfc).descriptor, { id: 'component.vue' })
+ const res = await esbuildTransform(compiled.content, {
+ loader: 'ts',
+ })
+
+ let wasErrorThrown = false
+
+ try {
+ transformPlugin.transform.call({
+ parse: (code: string, opts: any = {}) => Parser.parse(code, {
+ sourceType: 'module',
+ ecmaVersion: 'latest',
+ locations: true,
+ ...opts,
+ }),
+ }, res.code, 'component.vue?macro=true')
+ } catch (e) {
+ if (e instanceof Error) {
+ expect(e.message).toMatch(/await in definePageMeta/)
+ wasErrorThrown = true
+ }
+ }
+
+ expect(wasErrorThrown).toBe(true)
+ })
})
diff --git a/packages/nuxt/test/parse.test.ts b/packages/nuxt/test/parse.test.ts
new file mode 100644
index 0000000000..a051a57c1d
--- /dev/null
+++ b/packages/nuxt/test/parse.test.ts
@@ -0,0 +1,670 @@
+import { describe, expect, it } from 'vitest'
+import { getUndeclaredIdentifiersInFunction, parseAndWalk } from '../src/core/utils/parse'
+import { TestScopeTracker } from './fixture/scope-tracker'
+
+const filename = 'test.ts'
+
+describe('scope tracker', () => {
+ it('should throw away exited scopes', () => {
+ const code = `
+ const a = 1
+ {
+ const b = 2
+ }
+ `
+
+ const scopeTracker = new TestScopeTracker()
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ expect(scopeTracker.getScopes().size).toBe(0)
+ })
+
+ it ('should keep exited scopes', () => {
+ const code = `
+ const a = 1
+ {
+ const b = 2
+ }
+ `
+
+ const scopeTracker = new TestScopeTracker({ keepExitedScopes: true })
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ expect(scopeTracker.getScopes().size).toBe(2)
+ })
+
+ it('should generate scope key correctly and not allocate unnecessary scopes', () => {
+ const code = `
+ // starting in global scope ("")
+ const a = 1
+ // pushing scope for function parameters ("0")
+ // pushing scope for function body ("0-0")
+ function foo (param) {
+ const b = 2
+ // pushing scope for for loop variable declaration ("0-0-0")
+ // pushing scope for for loop body ("0-0-0-0")
+ for (let i = 0; i < 10; i++) {
+ const c = 3
+
+ // pushing scope for block statement ("0-0-0-0-0")
+ try {
+ const d = 4
+ }
+ // in for loop body scope ("0-0-0-0")
+ // pushing scope for catch clause param ("0-0-0-0-1")
+ // pushing scope for block statement ("0-0-0-0-1-0")
+ catch (e) {
+ const f = 4
+ }
+
+ // in for loop body scope ("0-0-0-0")
+
+ const cc = 3
+ }
+
+ // in function body scope ("0-0")
+
+ // pushing scope for for of loop variable declaration ("0-0-1")
+ // pushing scope for for of loop body ("0-0-1-0")
+ for (const i of [1, 2, 3]) {
+ const dd = 3
+ }
+
+ // in function body scope ("0-0")
+
+ // pushing scope for for in loop variable declaration ("0-0-2")
+ // pushing scope for for in loop body ("0-0-2-0")
+ for (const i in [1, 2, 3]) {
+ const ddd = 3
+ }
+
+ // in function body scope ("0-0")
+
+ // pushing scope for while loop body ("0-0-3")
+ while (true) {
+ const e = 3
+ }
+ }
+
+ // in global scope ("")
+
+ // pushing scope for function expression name ("1")
+ // pushing scope for function parameters ("1-0")
+ // pushing scope for function body ("1-0-0")
+ const baz = function bar (param) {
+ const g = 5
+
+ // pushing scope for block statement ("1-0-0-0")
+ if (true) {
+ const h = 6
+ }
+ }
+
+ // in global scope ("")
+
+ // pushing scope for function expression name ("2")
+ {
+ const i = 7
+ // pushing scope for block statement ("2-0")
+ {
+ const j = 8
+ }
+ }
+
+ // in global scope ("")
+
+ // pushing scope for arrow function parameters ("3")
+ // pushing scope for arrow function body ("3-0")
+ const arrow = (param) => {
+ const k = 9
+ }
+
+ // in global scope ("")
+
+ // pushing scope for class expression name ("4")
+ const classExpression = class InternalClassName {
+ classAttribute = 10
+ // pushing scope for constructor function expression name ("4-0")
+ // pushing scope for constructor parameters ("4-0-0")
+ // pushing scope for constructor body ("4-0-0-0")
+ constructor(constructorParam) {
+ const l = 10
+ }
+
+ // in class body scope ("4")
+
+ // pushing scope for static block ("4-1")
+ static {
+ const m = 11
+ }
+ }
+
+ // in global scope ("")
+
+ class NoScopePushedForThis {
+ // pushing scope for constructor function expression name ("5")
+ // pushing scope for constructor parameters ("5-0")
+ // pushing scope for constructor body ("5-0-0")
+ constructor() {
+ const n = 12
+ }
+ }
+
+ `
+
+ const scopeTracker = new TestScopeTracker({
+ keepExitedScopes: true,
+ })
+
+ // is in global scope initially
+ expect(scopeTracker.getScopeIndexKey()).toBe('')
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ // is in global scope after parsing
+ expect(scopeTracker.getScopeIndexKey()).toBe('')
+
+ // check that the scopes are correct
+ const scopes = scopeTracker.getScopes()
+
+ const expectedScopesInOrder = [
+ '',
+ '0',
+ '0-0',
+ '0-0-0',
+ '0-0-0-0',
+ '0-0-0-0-0',
+ '0-0-0-0-1',
+ '0-0-0-0-1-0',
+ '0-0-1',
+ '0-0-1-0',
+ '0-0-2',
+ '0-0-2-0',
+ '0-0-3',
+ '1',
+ '1-0',
+ '1-0-0',
+ '1-0-0-0',
+ '2',
+ '2-0',
+ '3',
+ '3-0',
+ '4',
+ // '4-0', -> DO NOT UNCOMMENT - class constructor method definition doesn't provide a function expression id (scope doesn't have any identifiers)
+ '4-0-0',
+ '4-0-0-0',
+ '4-1',
+ // '5', -> DO NOT UNCOMMENT - class constructor - same as above
+ // '5-0', -> DO NOT UNCOMMENT - class constructor parameters (none in this case, so the scope isn't stored)
+ '5-0-0',
+ ]
+
+ expect(scopes.size).toBe(expectedScopesInOrder.length)
+
+ const scopeKeys = Array.from(scopes.keys())
+
+ expect(scopeKeys).toEqual(expectedScopesInOrder)
+ })
+
+ it ('should track variable declarations', () => {
+ const code = `
+ const a = 1
+ let x, y = 2
+
+ {
+ let b = 2
+ }
+ `
+
+ const scopeTracker = new TestScopeTracker({
+ keepExitedScopes: true,
+ })
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ const scopes = scopeTracker.getScopes()
+
+ const globalScope = scopes.get('')
+ expect(globalScope?.get('a')?.type).toEqual('Variable')
+ expect(globalScope?.get('b')).toBeUndefined()
+ expect(globalScope?.get('x')?.type).toEqual('Variable')
+ expect(globalScope?.get('y')?.type).toEqual('Variable')
+
+ const blockScope = scopes.get('0')
+ expect(blockScope?.get('b')?.type).toEqual('Variable')
+ expect(blockScope?.get('a')).toBeUndefined()
+ expect(blockScope?.get('x')).toBeUndefined()
+ expect(blockScope?.get('y')).toBeUndefined()
+
+ expect(scopeTracker.isDeclaredInScope('a', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('a', '0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('y', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('y', '0')).toBe(true)
+
+ expect(scopeTracker.isDeclaredInScope('b', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('b', '0')).toBe(true)
+ })
+
+ it ('should separate variables in different scopes', () => {
+ const code = `
+ const a = 1
+
+ {
+ let a = 2
+ }
+
+ function foo (a) {
+ // scope "1-0"
+ let b = a
+ }
+ `
+
+ const scopeTracker = new TestScopeTracker({
+ keepExitedScopes: true,
+ })
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ const globalA = scopeTracker.getDeclarationFromScope('a', '')
+ expect(globalA?.type).toEqual('Variable')
+ expect(globalA?.type === 'Variable' && globalA.variableNode.type).toEqual('VariableDeclaration')
+
+ const blockA = scopeTracker.getDeclarationFromScope('a', '0')
+ expect(blockA?.type).toEqual('Variable')
+ expect(blockA?.type === 'Variable' && blockA.variableNode.type).toEqual('VariableDeclaration')
+
+ // check that the two `a` variables are different
+ expect(globalA?.type === 'Variable' && globalA.variableNode).not.toBe(blockA?.type === 'Variable' && blockA.variableNode)
+
+ // check that the `a` in the function scope is a function param and not a variable
+ const fooA = scopeTracker.getDeclarationFromScope('a', '1-0')
+ expect(fooA?.type).toEqual('FunctionParam')
+ })
+
+ it ('should handle patterns', () => {
+ const code = `
+ const { a, b: c } = { a: 1, b: 2 }
+ const [d, [e]] = [3, [4]]
+ const { f: { g } } = { f: { g: 5 } }
+
+ function foo ({ h, i: j } = {}, [k, [l, m], ...rest]) {
+ }
+
+ try {} catch ({ message }) {}
+ `
+
+ const scopeTracker = new TestScopeTracker({
+ keepExitedScopes: true,
+ })
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ const scopes = scopeTracker.getScopes()
+ expect(scopes.size).toBe(3)
+
+ const globalScope = scopes.get('')
+ expect(globalScope?.size).toBe(6)
+
+ expect(globalScope?.get('a')?.type).toEqual('Variable')
+ expect(globalScope?.get('b')?.type).toBeUndefined()
+ expect(globalScope?.get('c')?.type).toEqual('Variable')
+ expect(globalScope?.get('d')?.type).toEqual('Variable')
+ expect(globalScope?.get('e')?.type).toEqual('Variable')
+ expect(globalScope?.get('f')?.type).toBeUndefined()
+ expect(globalScope?.get('g')?.type).toEqual('Variable')
+ expect(globalScope?.get('foo')?.type).toEqual('Function')
+
+ const fooScope = scopes.get('0')
+ expect(fooScope?.size).toBe(6)
+
+ expect(fooScope?.get('h')?.type).toEqual('FunctionParam')
+ expect(fooScope?.get('i')?.type).toBeUndefined()
+ expect(fooScope?.get('j')?.type).toEqual('FunctionParam')
+ expect(fooScope?.get('k')?.type).toEqual('FunctionParam')
+ expect(fooScope?.get('l')?.type).toEqual('FunctionParam')
+ expect(fooScope?.get('m')?.type).toEqual('FunctionParam')
+ expect(fooScope?.get('rest')?.type).toEqual('FunctionParam')
+
+ const catchScope = scopes.get('2')
+ expect(catchScope?.size).toBe(1)
+ expect(catchScope?.get('message')?.type).toEqual('CatchParam')
+
+ expect(scopeTracker.isDeclaredInScope('a', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('b', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('c', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('d', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('e', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('f', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('g', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('h', '0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('i', '0')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('j', '0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('k', '0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('l', '0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('m', '0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('rest', '0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('message', '2')).toBe(true)
+ })
+
+ it ('should handle loops', () => {
+ const code = `
+ for (let i = 0, getI = () => i; i < 3; i++) {
+ console.log(getI());
+ }
+
+ let j = 0;
+ for (; j < 3; j++) { }
+
+ const obj = { a: 1, b: 2, c: 3 }
+ for (const property in obj) { }
+
+ const arr = ['a', 'b', 'c']
+ for (const element of arr) { }
+ `
+
+ const scopeTracker = new TestScopeTracker({
+ keepExitedScopes: true,
+ })
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ const scopes = scopeTracker.getScopes()
+ expect(scopes.size).toBe(4)
+
+ const globalScope = scopes.get('')
+ expect(globalScope?.size).toBe(3)
+ expect(globalScope?.get('j')?.type).toEqual('Variable')
+ expect(globalScope?.get('obj')?.type).toEqual('Variable')
+ expect(globalScope?.get('arr')?.type).toEqual('Variable')
+
+ const forScope1 = scopes.get('0')
+ expect(forScope1?.size).toBe(2)
+ expect(forScope1?.get('i')?.type).toEqual('Variable')
+ expect(forScope1?.get('getI')?.type).toEqual('Variable')
+
+ const forScope2 = scopes.get('1')
+ expect(forScope2).toBeUndefined()
+
+ const forScope3 = scopes.get('2')
+ expect(forScope3?.size).toBe(1)
+ expect(forScope3?.get('property')?.type).toEqual('Variable')
+
+ const forScope4 = scopes.get('3')
+ expect(forScope4?.size).toBe(1)
+ expect(forScope4?.get('element')?.type).toEqual('Variable')
+
+ expect(scopeTracker.isDeclaredInScope('i', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('getI', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('i', '0-0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('getI', '0-0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('j', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('j', '1-0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('property', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('element', '')).toBe(false)
+ })
+
+ it ('should handle imports', () => {
+ const code = `
+ import { a, b as c } from 'module-a'
+ import d from 'module-b'
+ `
+
+ const scopeTracker = new TestScopeTracker({
+ keepExitedScopes: true,
+ })
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ expect(scopeTracker.isDeclaredInScope('a', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('b', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('c', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('d', '')).toBe(true)
+
+ expect(scopeTracker.getScopes().get('')?.size).toBe(3)
+ })
+
+ it ('should handle classes', () => {
+ const code = `
+ // ""
+
+ class Foo {
+ someProperty = 1
+
+ // "0" - function expression name
+ // "0-0" - constructor parameters
+ // "0-0-0" - constructor body
+ constructor(param) {
+ let a = 1
+ this.b = 1
+ }
+
+ // "1" - method name
+ // "1-0" - method parameters
+ // "1-0-0" - method body
+ someMethod(param) {
+ let c = 1
+ }
+
+ // "2" - method name
+ // "2-0" - method parameters
+ // "2-0-0" - method body
+ get d() {
+ let e = 1
+ return 1
+ }
+ }
+ `
+
+ const scopeTracker = new TestScopeTracker({
+ keepExitedScopes: true,
+ })
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ const scopes = scopeTracker.getScopes()
+
+ // only the scopes containing identifiers are stored
+ const expectedScopes = [
+ '',
+ '0-0',
+ '0-0-0',
+ '1-0',
+ '1-0-0',
+ '2-0-0',
+ ]
+
+ expect(scopes.size).toBe(expectedScopes.length)
+
+ const scopeKeys = Array.from(scopes.keys())
+ expect(scopeKeys).toEqual(expectedScopes)
+
+ expect(scopeTracker.isDeclaredInScope('Foo', '')).toBe(true)
+
+ // properties should be accessible through the class
+ expect(scopeTracker.isDeclaredInScope('someProperty', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('someProperty', '0')).toBe(false)
+
+ expect(scopeTracker.isDeclaredInScope('a', '0-0-0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('b', '0-0-0')).toBe(false)
+
+ // method definitions don't have names in function expressions, so it is not stored
+ // they should be accessed through the class
+ expect(scopeTracker.isDeclaredInScope('someMethod', '1')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('someMethod', '1-0-0')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('someMethod', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('c', '1-0-0')).toBe(true)
+
+ expect(scopeTracker.isDeclaredInScope('d', '2')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('d', '2-0-0')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('d', '')).toBe(false)
+ expect(scopeTracker.isDeclaredInScope('e', '2-0-0')).toBe(true)
+ })
+
+ it ('should freeze scopes', () => {
+ let code = `
+ const a = 1
+ {
+ const b = 2
+ }
+ `
+
+ const scopeTracker = new TestScopeTracker({
+ keepExitedScopes: true,
+ })
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ expect(scopeTracker.getScopes().size).toBe(2)
+
+ code = code + '\n' + `
+ {
+ const c = 3
+ }
+ `
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ expect(scopeTracker.getScopes().size).toBe(3)
+
+ scopeTracker.freeze()
+
+ code = code + '\n' + `
+ {
+ const d = 4
+ }
+ `
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ })
+
+ expect(scopeTracker.getScopes().size).toBe(3)
+
+ expect(scopeTracker.isDeclaredInScope('a', '')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('b', '0')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('c', '1')).toBe(true)
+ expect(scopeTracker.isDeclaredInScope('d', '2')).toBe(false)
+ })
+})
+
+describe('parsing', () => {
+ it ('should correctly get identifiers not declared in a function', () => {
+ const functionParams = `(param, { param1, temp: param2 } = {}, [param3, [param4]], ...rest)`
+ const functionBody = `{
+ const c = 1, d = 2
+ console.log(undeclaredIdentifier1, foo)
+ const obj = {
+ key1: param,
+ key2: undeclaredIdentifier1,
+ undeclaredIdentifier2: undeclaredIdentifier2,
+ undeclaredIdentifier3,
+ undeclaredIdentifier4,
+ }
+ nonExistentFunction()
+
+ console.log(a, b, c, d, param, param1, param2, param3, param4, param['test']['key'], rest)
+ console.log(param3[0].access['someKey'], obj, obj.key1, obj.key2, obj.undeclaredIdentifier2, obj.undeclaredIdentifier3)
+
+ try {} catch (error) { console.log(error) }
+
+ class Foo { constructor() { console.log(Foo) } }
+ const cls = class Bar { constructor() { console.log(Bar, cls) } }
+ const cls2 = class Baz {
+ someProperty = someValue
+ someMethod() { }
+ }
+ console.log(Baz)
+
+ function f() {
+ console.log(hoisted, nonHoisted)
+ }
+ let hoisted = 1
+ f()
+ }`
+
+ const code = `
+ import { a } from 'module-a'
+ const b = 1
+
+ // "0"
+ function foo ${functionParams} ${functionBody}
+
+ // "1"
+ const f = ${functionParams} => ${functionBody}
+
+ // "2-0"
+ const bar = function ${functionParams} ${functionBody}
+
+ // "3-0"
+ const baz = function foo ${functionParams} ${functionBody}
+
+ // "4"
+ function emptyParams() {
+ console.log(param)
+ }
+ `
+
+ const scopeTracker = new TestScopeTracker({
+ keepExitedScopes: true,
+ })
+
+ let processedFunctions = 0
+
+ parseAndWalk(code, filename, {
+ scopeTracker,
+ enter: (node) => {
+ const currentScope = scopeTracker.getScopeIndexKey()
+ if ((node.type !== 'FunctionDeclaration' && node.type !== 'FunctionExpression' && node.type !== 'ArrowFunctionExpression') || !['0', '1', '2-0', '3-0', '4'].includes(currentScope)) { return }
+
+ const undeclaredIdentifiers = getUndeclaredIdentifiersInFunction(node)
+ expect(undeclaredIdentifiers).toEqual(currentScope === '4'
+ ? [
+ 'console',
+ 'param',
+ ]
+ : [
+ 'console',
+ 'undeclaredIdentifier1',
+ ...(node.type === 'ArrowFunctionExpression' || (node.type === 'FunctionExpression' && !node.id) ? ['foo'] : []),
+ 'undeclaredIdentifier2',
+ 'undeclaredIdentifier3',
+ 'undeclaredIdentifier4',
+ 'nonExistentFunction',
+ 'a', // import is outside the scope of the function
+ 'b', // variable is outside the scope of the function
+ 'someValue',
+ 'Baz',
+ 'nonHoisted',
+ ])
+
+ processedFunctions++
+ },
+ })
+
+ expect(processedFunctions).toBe(5)
+ })
+})