diff --git a/packages/compiler-core/__tests__/parse.spec.ts b/packages/compiler-core/__tests__/parse.spec.ts index 05a2afcdc6e..9d84e80c2a6 100644 --- a/packages/compiler-core/__tests__/parse.spec.ts +++ b/packages/compiler-core/__tests__/parse.spec.ts @@ -14,6 +14,7 @@ import { } from '../src/ast' import { baseParse } from '../src/parser' +import { Program } from '@babel/types' /* eslint jest/no-disabled-tests: "off" */ @@ -2170,6 +2171,63 @@ describe('compiler: parse', () => { }) }) + describe('expression parsing', () => { + test('interpolation', () => { + const ast = baseParse(`{{ a + b }}`, { prefixIdentifiers: true }) + // @ts-ignore + expect((ast.children[0] as InterpolationNode).content.ast?.type).toBe( + 'BinaryExpression' + ) + }) + + test('v-bind', () => { + const ast = baseParse(`
`, { + prefixIdentifiers: true + }) + const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode + // @ts-ignore + expect(dir.arg?.ast?.type).toBe('BinaryExpression') + // @ts-ignore + expect(dir.exp?.ast?.type).toBe('CallExpression') + }) + + test('v-on multi statements', () => { + const ast = baseParse(`
`, { + prefixIdentifiers: true + }) + const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode + // @ts-ignore + expect(dir.exp?.ast?.type).toBe('Program') + expect((dir.exp?.ast as Program).body).toMatchObject([ + { type: 'ExpressionStatement' }, + { type: 'ExpressionStatement' } + ]) + }) + + test('v-slot', () => { + const ast = baseParse(``, { + prefixIdentifiers: true + }) + const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode + // @ts-ignore + expect(dir.exp?.ast?.type).toBe('ArrowFunctionExpression') + }) + + test('v-for', () => { + const ast = baseParse(`
`, { + prefixIdentifiers: true + }) + const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode + const { source, value, key, index } = dir.forParseResult! + // @ts-ignore + expect(source.ast?.type).toBe('MemberExpression') + // @ts-ignore + expect(value?.ast?.type).toBe('ArrowFunctionExpression') + expect(key?.ast).toBeNull() // simple ident + expect(index?.ast).toBeNull() // simple ident + }) + }) + describe('Errors', () => { // HTML parsing errors as specified at // https://html.spec.whatwg.org/multipage/parsing.html#parse-errors diff --git a/packages/compiler-core/__tests__/transforms/transformExpressions.spec.ts b/packages/compiler-core/__tests__/transforms/transformExpressions.spec.ts index a9697930c95..b33cbbd80f6 100644 --- a/packages/compiler-core/__tests__/transforms/transformExpressions.spec.ts +++ b/packages/compiler-core/__tests__/transforms/transformExpressions.spec.ts @@ -18,7 +18,7 @@ function parseWithExpressionTransform( template: string, options: CompilerOptions = {} ) { - const ast = parse(template) + const ast = parse(template, options) transform(ast, { prefixIdentifiers: true, nodeTransforms: [transformIf, transformExpression], diff --git a/packages/compiler-core/src/ast.ts b/packages/compiler-core/src/ast.ts index 2bc85bf53d8..203fa8b2c6b 100644 --- a/packages/compiler-core/src/ast.ts +++ b/packages/compiler-core/src/ast.ts @@ -14,6 +14,7 @@ import { } from './runtimeHelpers' import { PropsExpression } from './transforms/transformElement' import { ImportItem, TransformContext } from './transform' +import { Node as BabelNode } from '@babel/types' // Vue template is a platform-agnostic superset of HTML (syntax only). // More namespaces can be declared by platform specific compilers. @@ -226,6 +227,12 @@ export interface SimpleExpressionNode extends Node { content: string isStatic: boolean constType: ConstantTypes + /** + * - `null` means the expression is a simple identifier that doesn't need + * parsing + * - `false` means there was a parsing error + */ + ast?: BabelNode | null | false /** * Indicates this is an identifier for a hoist vnode call and points to the * hoisted node. @@ -246,6 +253,12 @@ export interface InterpolationNode extends Node { export interface CompoundExpressionNode extends Node { type: NodeTypes.COMPOUND_EXPRESSION + /** + * - `null` means the expression is a simple identifier that doesn't need + * parsing + * - `false` means there was a parsing error + */ + ast?: BabelNode | null | false children: ( | SimpleExpressionNode | CompoundExpressionNode diff --git a/packages/compiler-core/src/babelUtils.ts b/packages/compiler-core/src/babelUtils.ts index 1f1e3896a1e..f3ef5df29db 100644 --- a/packages/compiler-core/src/babelUtils.ts +++ b/packages/compiler-core/src/babelUtils.ts @@ -28,9 +28,9 @@ export function walkIdentifiers( } const rootExp = - root.type === 'Program' && - root.body[0].type === 'ExpressionStatement' && - root.body[0].expression + root.type === 'Program' + ? root.body[0].type === 'ExpressionStatement' && root.body[0].expression + : root walk(root, { enter(node: Node & { scopeIds?: Set }, parent: Node | undefined) { diff --git a/packages/compiler-core/src/options.ts b/packages/compiler-core/src/options.ts index e0c4099e40e..5710039ca10 100644 --- a/packages/compiler-core/src/options.ts +++ b/packages/compiler-core/src/options.ts @@ -86,6 +86,17 @@ export interface ParserOptions * This defaults to `true` in development and `false` in production builds. */ comments?: boolean + /** + * Parse JavaScript expressions with Babel. + * @default false + */ + prefixIdentifiers?: boolean + /** + * A list of parser plugins to enable for `@babel/parser`, which is used to + * parse expressions in bindings and interpolations. + * https://babeljs.io/docs/en/next/babel-parser#plugins + */ + expressionPlugins?: ParserPlugin[] } export type HoistTransform = ( diff --git a/packages/compiler-core/src/parser.ts b/packages/compiler-core/src/parser.ts index f4399d7c67f..f1d712b3643 100644 --- a/packages/compiler-core/src/parser.ts +++ b/packages/compiler-core/src/parser.ts @@ -38,14 +38,25 @@ import { defaultOnError, defaultOnWarn } from './errors' -import { forAliasRE, isCoreComponent, isStaticArgOf } from './utils' +import { + forAliasRE, + isCoreComponent, + isSimpleIdentifier, + isStaticArgOf +} from './utils' import { decodeHTML } from 'entities/lib/decode.js' +import { + parse, + parseExpression, + type ParserOptions as BabelOptions +} from '@babel/parser' type OptionalOptions = | 'decodeEntities' | 'whitespace' | 'isNativeTag' | 'isBuiltInComponent' + | 'expressionPlugins' | keyof CompilerCompatOptions export type MergedParserOptions = Omit< @@ -64,7 +75,8 @@ export const defaultParserOptions: MergedParserOptions = { isCustomElement: NO, onError: defaultOnError, onWarn: defaultOnWarn, - comments: __DEV__ + comments: __DEV__, + prefixIdentifiers: false } let currentOptions: MergedParserOptions = defaultParserOptions @@ -116,7 +128,7 @@ const tokenizer = new Tokenizer(stack, { } addNode({ type: NodeTypes.INTERPOLATION, - content: createSimpleExpression(exp, false, getLoc(innerStart, innerEnd)), + content: createExp(exp, false, getLoc(innerStart, innerEnd)), loc: getLoc(start, end) }) }, @@ -245,7 +257,7 @@ const tokenizer = new Tokenizer(stack, { setLocEnd((currentProp as AttributeNode).nameLoc, end) } else { const isStatic = arg[0] !== `[` - ;(currentProp as DirectiveNode).arg = createSimpleExpression( + ;(currentProp as DirectiveNode).arg = createExp( isStatic ? arg : arg.slice(1, -1), isStatic, getLoc(start, end), @@ -346,10 +358,25 @@ const tokenizer = new Tokenizer(stack, { } } else { // directive - currentProp.exp = createSimpleExpression( + let expParseMode = ExpParseMode.Normal + if (!__BROWSER__) { + if (currentProp.name === 'for') { + expParseMode = ExpParseMode.Skip + } else if (currentProp.name === 'slot') { + expParseMode = ExpParseMode.Params + } else if ( + currentProp.name === 'on' && + currentAttrValue.includes(';') + ) { + expParseMode = ExpParseMode.Statements + } + } + currentProp.exp = createExp( currentAttrValue, false, - getLoc(currentAttrStartIndex, currentAttrEndIndex) + getLoc(currentAttrStartIndex, currentAttrEndIndex), + ConstantTypes.NOT_CONSTANT, + expParseMode ) if (currentProp.name === 'for') { currentProp.forParseResult = parseForExpression(currentProp.exp) @@ -477,10 +504,20 @@ function parseForExpression( const [, LHS, RHS] = inMatch - const createAliasExpression = (content: string, offset: number) => { + const createAliasExpression = ( + content: string, + offset: number, + asParam = false + ) => { const start = loc.start.offset + offset const end = start + content.length - return createSimpleExpression(content, false, getLoc(start, end)) + return createExp( + content, + false, + getLoc(start, end), + ConstantTypes.NOT_CONSTANT, + asParam ? ExpParseMode.Params : ExpParseMode.Normal + ) } const result: ForParseResult = { @@ -502,7 +539,7 @@ function parseForExpression( let keyOffset: number | undefined if (keyContent) { keyOffset = exp.indexOf(keyContent, trimmedOffset + valueContent.length) - result.key = createAliasExpression(keyContent, keyOffset) + result.key = createAliasExpression(keyContent, keyOffset, true) } if (iteratorMatch[2]) { @@ -516,14 +553,15 @@ function parseForExpression( result.key ? keyOffset! + keyContent.length : trimmedOffset + valueContent.length - ) + ), + true ) } } } if (valueContent) { - result.value = createAliasExpression(valueContent, trimmedOffset) + result.value = createAliasExpression(valueContent, trimmedOffset, true) } return result @@ -929,8 +967,58 @@ function dirToAttr(dir: DirectiveNode): AttributeNode { return attr } -function emitError(code: ErrorCodes, index: number) { - currentOptions.onError(createCompilerError(code, getLoc(index, index))) +enum ExpParseMode { + Normal, + Params, + Statements, + Skip +} + +function createExp( + content: SimpleExpressionNode['content'], + isStatic: SimpleExpressionNode['isStatic'] = false, + loc: SourceLocation, + constType: ConstantTypes = ConstantTypes.NOT_CONSTANT, + parseMode = ExpParseMode.Normal +) { + const exp = createSimpleExpression(content, isStatic, loc, constType) + if ( + !__BROWSER__ && + !isStatic && + currentOptions.prefixIdentifiers && + parseMode !== ExpParseMode.Skip && + content.trim() + ) { + if (isSimpleIdentifier(content)) { + exp.ast = null // fast path + return exp + } + try { + const plugins = currentOptions.expressionPlugins + const options: BabelOptions = { + plugins: plugins ? [...plugins, 'typescript'] : ['typescript'] + } + if (parseMode === ExpParseMode.Statements) { + // v-on with multi-inline-statements, pad 1 char + exp.ast = parse(` ${content} `, options).program + } else if (parseMode === ExpParseMode.Params) { + exp.ast = parseExpression(`(${content})=>{}`, options) + } else { + // normal exp, wrap with parens + exp.ast = parseExpression(`(${content})`, options) + } + } catch (e: any) { + exp.ast = false // indicate an error + emitError(ErrorCodes.X_INVALID_EXPRESSION, loc.start.offset, e.message) + } + } + return exp +} + +function emitError(code: ErrorCodes, index: number, message?: string) { + currentOptions.onError( + createCompilerError(code, getLoc(index, index), undefined, message) + ) } function reset() { diff --git a/packages/compiler-core/src/transforms/transformExpression.ts b/packages/compiler-core/src/transforms/transformExpression.ts index 4d9a2497886..263ada4f137 100644 --- a/packages/compiler-core/src/transforms/transformExpression.ts +++ b/packages/compiler-core/src/transforms/transformExpression.ts @@ -223,7 +223,14 @@ export function processExpression( // bail constant on parens (function invocation) and dot (member access) const bailConstant = constantBailRE.test(rawExp) - if (isSimpleIdentifier(rawExp)) { + let ast = node.ast + + if (ast === false) { + // ast being false means it has caused an error already during parse phase + return node + } + + if (ast === null || (!ast && isSimpleIdentifier(rawExp))) { const isScopeVarReference = context.identifiers[rawExp] const isAllowedGlobal = isGloballyAllowed(rawExp) const isLiteral = isLiteralWhitelisted(rawExp) @@ -249,29 +256,30 @@ export function processExpression( return node } - let ast: any - // exp needs to be parsed differently: - // 1. Multiple inline statements (v-on, with presence of `;`): parse as raw - // exp, but make sure to pad with spaces for consistent ranges - // 2. Expressions: wrap with parens (for e.g. object expressions) - // 3. Function arguments (v-for, v-slot): place in a function argument position - const source = asRawStatements - ? ` ${rawExp} ` - : `(${rawExp})${asParams ? `=>{}` : ``}` - try { - ast = parse(source, { - plugins: context.expressionPlugins - }).program - } catch (e: any) { - context.onError( - createCompilerError( - ErrorCodes.X_INVALID_EXPRESSION, - node.loc, - undefined, - e.message + if (!ast) { + // exp needs to be parsed differently: + // 1. Multiple inline statements (v-on, with presence of `;`): parse as raw + // exp, but make sure to pad with spaces for consistent ranges + // 2. Expressions: wrap with parens (for e.g. object expressions) + // 3. Function arguments (v-for, v-slot): place in a function argument position + const source = asRawStatements + ? ` ${rawExp} ` + : `(${rawExp})${asParams ? `=>{}` : ``}` + try { + ast = parse(source, { + plugins: context.expressionPlugins + }).program + } catch (e: any) { + context.onError( + createCompilerError( + ErrorCodes.X_INVALID_EXPRESSION, + node.loc, + undefined, + e.message + ) ) - ) - return node + return node + } } type QualifiedId = Identifier & PrefixMeta @@ -351,6 +359,7 @@ export function processExpression( let ret if (children.length) { ret = createCompoundExpression(children, node.loc) + ret.ast = ast } else { ret = node ret.constType = bailConstant diff --git a/packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap b/packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap index 4cc3cf611d8..e26dfef53d9 100644 --- a/packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap +++ b/packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap @@ -748,6 +748,51 @@ return { get FooBaz() { return FooBaz }, get Last() { return Last } } })" `; +exports[`SFC compile - +