feat(nuxt): support local functions in definePageMeta (#30241)

This commit is contained in:
Matej Černý 2024-12-18 10:42:43 +01:00 committed by GitHub
parent d61d239b43
commit 59f5a76d51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1514 additions and 154 deletions

View File

@ -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
<script setup lang="ts">
import { someData } from '~/utils/example'
function validateIdParam(route) {
return route.params.id && !isNaN(Number(route.params.id))
}
const title = ref('')
definePageMeta({
title, // This will create an error
someData
validate: validateIdParam,
someData,
title, // do not do this, the ref will be hoisted out of the component
})
</script>
```

View File

@ -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<string, Set<string>>
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<string, { source?: string | RegExp }>) {

View File

@ -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> = T & { start: number, end: number }
type WalkerCallback = (this: ThisParameterType<SyncHandler>, node: WithLocations<Node>, parent: WithLocations<Node> | 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<WalkOptions>) {
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<Node>)
callback.enter?.call(this, node as WithLocations<Node>, parent as WithLocations<Node> | null, { key, index, ast })
},
leave (node, parent, key, index) {
// @ts-expect-error - accessing a protected property
callback.scopeTracker?.processNodeLeave(node as WithLocations<Node>)
callback.leave?.call(this, node as WithLocations<Node>, parent as WithLocations<Node> | 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<WalkOptions>): Program
export function parseAndWalk (code: string, _sourceFilename: string, callback: Partial<WalkOptions> | 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<T> (node: T): WithLocations<T> {
return node as WithLocations<T>
}
abstract class BaseNode<T extends Node = Node> {
abstract type: string
node: WithLocations<T>
constructor (node: WithLocations<T>) {
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<Identifier> {
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<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression>
constructor (node: WithLocations<Node>, fnNode: WithLocations<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression>) {
super(node)
this.fnNode = fnNode
}
get start () {
return this.fnNode.start
}
get end () {
return this.fnNode.end
}
}
class FunctionNode extends BaseNode<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression> {
type = 'Function' as const
get start () {
return this.node.start
}
get end () {
return this.node.end
}
}
class VariableNode extends BaseNode<Identifier> {
type = 'Variable' as const
variableNode: WithLocations<VariableDeclaration>
constructor (node: WithLocations<Identifier>, variableNode: WithLocations<VariableDeclaration>) {
super(node)
this.variableNode = variableNode
}
get start () {
return this.variableNode.start
}
get end () {
return this.variableNode.end
}
}
class ImportNode extends BaseNode<ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier> {
type = 'Import' as const
importNode: WithLocations<Node>
constructor (node: WithLocations<ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier>, importNode: WithLocations<Node>) {
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<CatchClause>
constructor (node: WithLocations<Node>, catchNode: WithLocations<CatchClause>) {
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<string, Map<string, WithLocations<ScopeTrackerNode>>> = new Map()
protected options: Partial<ScopeTrackerOptions>
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<Node>, fn: WithLocations<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression>) {
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<Node>, parent: WithLocations<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression | VariableDeclaration | CatchClause>) {
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<Node>) {
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<Node>) {
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<Node>) {
const identifiers: WithLocations<Identifier>[] = []
function collectIdentifiers (pattern: WithLocations<Node>) {
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<Node>, parent: WithLocations<Node> | 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<string>()
function isIdentifierUndeclared (node: WithLocations<Identifier>, parent: WithLocations<Node> | 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)
}

View File

@ -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<string>()
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')) {

View File

@ -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
}
}

View File

@ -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 = `
<script setup lang="ts">
function isNumber(value) {
return value && !isNaN(Number(value))
}
function validateIdParam (route) {
return isNumber(route.params.id)
}
definePageMeta({
validate: validateIdParam,
test: () => 'hello',
})
</script>
`
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 = `
<script setup lang="ts">
import { validateIdParam } from './utils'
definePageMeta({
validate: validateIdParam,
dynamic: ref(true),
})
</script>
`
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 = `
<script setup lang="ts">
import { foo } from './utils'
const checkNum = (value) => {
return !isNaN(Number(foo(value)))
}
function isNumber (value) {
return value && checkNum(value)
}
definePageMeta({
validate: ({ params }) => {
return isNumber(params.id)
},
})
</script>
`
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 = `
<script setup lang="ts">
const asyncValue = await Promise.resolve('test')
definePageMeta({
key: asyncValue,
})
</script>
`
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)
})
})

View File

@ -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)
})
})