diff --git a/crates/fervid_napi/__tests__/__snapshots__/importUsageCheck.spec.ts.snap b/crates/fervid_napi/__tests__/__snapshots__/importUsageCheck.spec.ts.snap new file mode 100644 index 0000000..3bf30c5 --- /dev/null +++ b/crates/fervid_napi/__tests__/__snapshots__/importUsageCheck.spec.ts.snap @@ -0,0 +1,384 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TS annotations 1`] = ` +"import { Foo, Bar, Baz, Qux, Fred } from './x'; +import { createCommentVNode as _createCommentVNode, createElementBlock as _createElementBlock, createElementVNode as _createElementVNode, createTextVNode as _createTextVNode, createVNode as _createVNode, defineComponent as _defineComponent, Fragment as _Fragment, openBlock as _openBlock, renderList as _renderList, resolveComponent as _resolveComponent, toDisplayString as _toDisplayString, withCtx as _withCtx } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + const _component_Comp = _resolveComponent("Comp"); + return (_openBlock(), _createElementBlock(_Fragment, null, [ + _createTextVNode(_toDisplayString(a as Foo) + " "), + _createCommentVNode(" {{ b() }} "), + _createTextVNode(" " + _toDisplayString(Baz) + " "), + _createVNode(_component_Comp, null, { + "default": _withCtx(()=>[ + _createTextVNode(_toDisplayString(data), 1) + ]) + }), + (_openBlock(), _createElementBlock(_Fragment, null, _renderList(_ctx.list as Fred, ({ + z=x as Qux + })=>_createElementVNode("div")), 256)) + ])); + }, + setup (__props, { expose: __expose }) { + __expose(); + const a = 1; + function b() {} + return { + get Baz () { + return Baz; + }, + a, + b + }; + } +}); +" +`; + +exports[`attribute expressions 1`] = ` +"import { bar, baz } from './x'; +import { createElementBlock as _createElementBlock, defineComponent as _defineComponent, normalizeClass as _normalizeClass, normalizeStyle as _normalizeStyle, openBlock as _openBlock } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createElementBlock("div", { + class: _normalizeClass([ + cond ? '' : bar(), + 'default' + ]), + style: _normalizeStyle(baz) + })); + }, + setup (__props, { expose: __expose }) { + __expose(); + const cond = true; + return { + get bar () { + return bar; + }, + get baz () { + return baz; + }, + cond + }; + } +}); +" +`; + +exports[`components 1`] = ` +"import { FooBar, FooBaz, FooQux, foo } from './x'; +import { createElementBlock as _createElementBlock, createTextVNode as _createTextVNode, createVNode as _createVNode, defineComponent as _defineComponent, Fragment as _Fragment, openBlock as _openBlock, resolveComponent as _resolveComponent } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createElementBlock(_Fragment, null, [ + _createVNode(FooBaz), + _createVNode(FooQux), + _createVNode(foo), + _createTextVNode(" FooBar ") + ])); + }, + setup (__props, { expose: __expose }) { + __expose(); + const fooBar: FooBar = 1; + return { + get FooBaz () { + return FooBaz; + }, + get FooQux () { + return FooQux; + }, + get foo () { + return foo; + }, + fooBar + }; + } +}); +" +`; + +exports[`directive 1`] = ` +"import { vMyDir } from './x'; +import { createElementBlock as _createElementBlock, defineComponent as _defineComponent, openBlock as _openBlock, resolveDirective as _resolveDirective, withDirectives as _withDirectives } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return _withDirectives((_openBlock(), _createElementBlock("div", null, null, 512)), [ + [ + vMyDir + ] + ]); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get vMyDir () { + return vMyDir; + } + }; + } +}); +" +`; + +exports[`dynamic arguments 1`] = ` +"import { FooBar, foo, bar, unused, baz, msg } from './x'; +import { createElementBlock as _createElementBlock, createElementVNode as _createElementVNode, createVNode as _createVNode, defineComponent as _defineComponent, Fragment as _Fragment, openBlock as _openBlock, resolveComponent as _resolveComponent } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createElementBlock(_Fragment, null, [ + _createVNode(FooBar), + _createVNode(FooBar), + _createElementVNode("div", { + [bar.attrName || ""]: 15 + }, null, 16), + _createElementVNode("div", { + unused: "unused" + }), + _createElementVNode("div"), + _createVNode(FooBar, { + msg: msg + }) + ])); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get FooBar () { + return FooBar; + }, + get foo () { + return foo; + }, + get bar () { + return bar; + }, + get baz () { + return baz; + }, + get msg () { + return msg; + } + }; + } +}); +" +`; + +exports[`js template string interpolations 1`] = ` +"import { VAR, VAR2, VAR3 } from './x'; +import { defineComponent as _defineComponent, toDisplayString as _toDisplayString } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return _toDisplayString(\`\${VAR}VAR2\${VAR3}\`); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get VAR () { + return VAR; + }, + get VAR3 () { + return VAR3; + } + }; + } +}); +" +`; + +exports[`last tag 1`] = ` +"import { FooBaz, Last } from './x'; +import { createElementBlock as _createElementBlock, createVNode as _createVNode, defineComponent as _defineComponent, Fragment as _Fragment, openBlock as _openBlock, resolveComponent as _resolveComponent } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createElementBlock(_Fragment, null, [ + _createVNode(FooBaz), + _createVNode(Last) + ])); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get FooBaz () { + return FooBaz; + }, + get Last () { + return Last; + } + }; + } +}); +" +`; + +exports[`namespace / dot component usage 1`] = ` +"import * as Foo from './foo'; +import { createBlock as _createBlock, defineComponent as _defineComponent, openBlock as _openBlock, resolveComponent as _resolveComponent } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createBlock(Foo.Bar)); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get Foo () { + return Foo; + } + }; + } +}); +" +`; + +exports[`namespace / dot component usage lowercase 1`] = ` +"import * as Foo from './foo'; +import { createBlock as _createBlock, defineComponent as _defineComponent, openBlock as _openBlock, resolveComponent as _resolveComponent } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createBlock(Foo.bar)); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get Foo () { + return Foo; + } + }; + } +}); +" +`; + +exports[`property access (whitespace) 1`] = ` +"import { Foo, Bar, Baz } from './foo'; +import { createElementBlock as _createElementBlock, defineComponent as _defineComponent, openBlock as _openBlock, toDisplayString as _toDisplayString } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createElementBlock("div", null, _toDisplayString(Foo.Bar.Baz))); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get Foo () { + return Foo; + } + }; + } +}); +" +`; + +exports[`property access 1`] = ` +"import { Foo, Bar, Baz } from './foo'; +import { createElementBlock as _createElementBlock, defineComponent as _defineComponent, openBlock as _openBlock, toDisplayString as _toDisplayString } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createElementBlock("div", null, _toDisplayString(Foo.Bar.Baz))); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get Foo () { + return Foo; + } + }; + } +}); +" +`; + +exports[`spread operator 1`] = ` +"import { Foo, Bar, Baz } from './foo'; +import { createElementBlock as _createElementBlock, defineComponent as _defineComponent, openBlock as _openBlock } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createElementBlock("div", null, null, 16)); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get Foo () { + return Foo; + } + }; + } +}); +" +`; + +exports[`template ref 1`] = ` +"import { foo, bar, Baz } from './foo'; +import { createElementBlock as _createElementBlock, createElementVNode as _createElementVNode, createVNode as _createVNode, defineComponent as _defineComponent, Fragment as _Fragment, openBlock as _openBlock, resolveComponent as _resolveComponent } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createElementBlock(_Fragment, null, [ + _createElementVNode("div", { + ref: "foo" + }, null, 512), + _createElementVNode("div", { + ref: "" + }, null, 512), + _createVNode(Baz, { + ref: "bar" + }, null, 512) + ])); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get foo () { + return foo; + }, + get bar () { + return bar; + }, + get Baz () { + return Baz; + } + }; + } +}); +" +`; + +exports[`vue interpolations 1`] = ` +"import { x, y, z, x$y } from './x'; +import { createElementBlock as _createElementBlock, defineComponent as _defineComponent, openBlock as _openBlock, toDisplayString as _toDisplayString } from "vue"; +export default _defineComponent({ + __name: "anonymous", + render (_ctx, _cache, $props, $setup, $data, $options) { + return (_openBlock(), _createElementBlock("div", { + id: z + 'y' + }, _toDisplayString(x) + " " + _toDisplayString(_ctx.yy) + " " + _toDisplayString(x$y), 1)); + }, + setup (__props, { expose: __expose }) { + __expose(); + return { + get x () { + return x; + }, + get z () { + return z; + }, + get x$y () { + return x$y; + } + }; + } +}); +" +`; diff --git a/crates/fervid_napi/__tests__/importUsageCheck.spec.ts b/crates/fervid_napi/__tests__/importUsageCheck.spec.ts new file mode 100644 index 0000000..11c57df --- /dev/null +++ b/crates/fervid_napi/__tests__/importUsageCheck.spec.ts @@ -0,0 +1,385 @@ +import { expect, test } from 'vitest' +import { assertCode, compile } from './utils' + +// in dev mode, declared bindings are returned as an object from setup() +// when using TS, users may import types which should not be returned as +// values, so we need to check import usage in the template to determine +// what to be returned. + +test('components', () => { + const { content } = compile(` + + + `) + // FooBar: should not be matched by plain text or incorrect case + // FooBaz: used as PascalCase component + // FooQux: used as kebab-case component + // foo: lowercase component + expect(content).toMatch( + `return { + get FooBaz () { + return FooBaz; + }, + get FooQux () { + return FooQux; + }, + get foo () { + return foo; + }, + fooBar + }`, + ) + assertCode(content) +}) + +test('directive', () => { + const { content } = compile(` + + + `) + expect(content).toMatch(`return { + get vMyDir () { + return vMyDir; + } + }`) + assertCode(content) +}) + +test('dynamic arguments', () => { + const { content } = compile(` + + + `) + expect(content).toMatch( + `return { + get FooBar () { + return FooBar; + }, + get foo () { + return foo; + }, + get bar () { + return bar; + }, + get baz () { + return baz; + }, + get msg () { + return msg; + } + }`, + ) + assertCode(content) +}) + +// https://github.com/vuejs/core/issues/4599 +test('attribute expressions', () => { + const { content } = compile(` + + + `) + expect(content).toMatch( + `return { + get bar () { + return bar; + }, + get baz () { + return baz; + }, + cond + }`, + ) + assertCode(content) +}) + +test('vue interpolations', () => { + const { content } = compile(` + + + `) + // x: used in interpolation + // y: should not be matched by {{ yy }} or 'y' in binding exps + // x$y: #4274 should escape special chars when creating Regex + expect(content).toMatch( + `return { + get x () { + return x; + }, + get z () { + return z; + }, + get x$y () { + return x$y; + } + }`, + ) + assertCode(content) +}) + +// #4340 interpolations in template strings +test('js template string interpolations', () => { + const { content } = compile(` + + + `) + // VAR2 should not be matched + expect(content).toMatch( + `return { + get VAR () { + return VAR; + }, + get VAR3 () { + return VAR3; + } + }`, + ) + assertCode(content) +}) + +// edge case: last tag in template +test('last tag', () => { + const { content } = compile(` + + + `) + expect(content).toMatch( + `return { + get FooBaz () { + return FooBaz; + }, + get Last () { + return Last; + } + }`, + ) + assertCode(content) +}) + +test('TS annotations', () => { + // This test case provided by the official library is actually incorrect HTML, + // because `` is being interpreted as an HTML element before any JS processing is done + const { content } = compile(` + + + `) + expect(content).toMatch(`return { + get Baz () { + return Baz; + }, + a, + b + }`) + assertCode(content) +}) + +// vuejs/vue#12591 +test('v-on inline statement', () => { + // should not error + const { errors } = compile(` + + + `) + expect(errors.length).toBe(0) +}) + +test('template ref', () => { + const { content } = compile(` + + + `) + expect(content).toMatch( + `return { + get foo () { + return foo; + }, + get bar () { + return bar; + }, + get Baz () { + return Baz; + } + }`, + ) + assertCode(content) +}) + +// https://github.com/nuxt/nuxt/issues/22416 +test('property access', () => { + const { content } = compile(` + + + `) + expect(content).toMatch(`return { + get Foo () { + return Foo; + } + }`) + assertCode(content) +}) + +test('spread operator', () => { + const { content } = compile(` + + + `) + expect(content).toMatch(`return { + get Foo () { + return Foo; + } + }`) + assertCode(content) +}) + +test('property access (whitespace)', () => { + const { content } = compile(` + + + `) + expect(content).toMatch(`return { + get Foo () { + return Foo; + } + }`) + assertCode(content) +}) + +// #9974 +test('namespace / dot component usage', () => { + const { content } = compile(` + + + `) + expect(content).toMatch(`return { + get Foo () { + return Foo; + } + }`) + assertCode(content) +}) + +test('namespace / dot component usage lowercase', () => { + const { content } = compile(` + + + `) + expect(content).toMatch(`return { + get Foo () { + return Foo; + } + }`) + assertCode(content) +}) + +// This is not supported +// test('check when has explicit parse options', () => { +// const { content } = compile( +// ` +// +// +// `, +// undefined, +// { templateParseOptions: {} }, +// ) +// expect(content).toMatch('return { get x() { return x } }') +// }) + +// #11745 +test('shorthand binding w/ kebab-case', () => { + const { content } = compile( + ` + + + `, + ) + expect(content).toMatch(`return { + get fooBar () { + return fooBar; + }`) +}) diff --git a/crates/fervid_parser/src/attributes.rs b/crates/fervid_parser/src/attributes.rs index 0aed724..15bbb27 100644 --- a/crates/fervid_parser/src/attributes.rs +++ b/crates/fervid_parser/src/attributes.rs @@ -1,8 +1,13 @@ +use std::borrow::Cow; + use fervid_core::{ AttributeOrBinding, FervidAtom, StrOrExpr, VBindDirective, VCustomDirective, VForDirective, VModelDirective, VOnDirective, VSlotDirective, VueDirectives, }; -use swc_core::{common::{BytePos, Span}, ecma::ast::Expr}; +use swc_core::{ + common::{BytePos, Span}, + ecma::ast::Expr, +}; use swc_ecma_parser::Syntax; use swc_html_ast::Attribute; @@ -80,10 +85,18 @@ impl SfcParser<'_, '_, '_> { }); return Err(raw_attribute); }; + // Parsing expression failed (js, $parse_error: expr) => { self.errors.push($parse_error); return Err(raw_attribute); }; + ($err_kind: expr, $span: expr) => { + self.errors.push(ParseError { + kind: $err_kind, + span: $span, + }); + return Err(raw_attribute); + }; } macro_rules! ts { @@ -92,124 +105,218 @@ impl SfcParser<'_, '_, '_> { }; } - // TODO Fix and test parsing of directives - // TODO Should the span be narrower? (It can be narrowed with lo = lo + name.len() + 1 and hi = hi - 1) let span = raw_attribute.span; let raw_name: &str = &raw_attribute.name; - let mut chars_iter = raw_name.chars().enumerate().peekable(); - - // Every directive starts with a prefix: `@`, `:`, `.`, `#` or `v-` - let Some((_, prefix)) = chars_iter.next() else { - bail!(ParseErrorKind::DirectiveSyntax); - }; + let mut chars_iter = raw_name.chars().enumerate(); + + enum ParsingMode { + DirectivePrefix, + DirectiveName, + Argument, + DynamicArgument, + AfterDynamicArgument, + Modifier, + } // https://vuejs.org/api/built-in-directives.html#v-bind + let mut current_start = 0; + let mut directive_name = ""; + let mut argument_name = ""; + let mut is_argument_dynamic = false; let mut is_bind_prop = false; - let mut expect_argument = true; - let mut argument_start = 0; - let mut argument_end = raw_name.len(); - - let directive_name = match prefix { - '@' => "on", - ':' => "bind", - '.' => { - is_bind_prop = true; - "bind" - } - '#' => "slot", - 'v' if matches!(chars_iter.next(), Some((_, '-'))) => { - // Read directive name - let mut start = 0; - let mut end = raw_name.len(); - while let Some((idx, c)) = chars_iter.next() { - if c == '.' { - expect_argument = false; - argument_end = idx; - end = idx; - break; - } - if c == ':' { - end = idx; - break; + let mut current_bracket_level = 0; // for counting `[]` inside dynamic argument + let mut dynamic_argument_start = 0; + let mut dynamic_argument_end = 0; + let mut modifiers = Vec::::new(); + let mut parsing_mode = ParsingMode::DirectivePrefix; + + while let Some((idx, c)) = chars_iter.next() { + match (&parsing_mode, c) { + // Every directive starts with a prefix: `@`, `:`, `.`, `#` or `v-` + (ParsingMode::DirectivePrefix, '@') => { + directive_name = "on"; + parsing_mode = ParsingMode::Argument; + } + (ParsingMode::DirectivePrefix, ':') => { + directive_name = "bind"; + parsing_mode = ParsingMode::Argument; + } + (ParsingMode::DirectivePrefix, '.') => { + directive_name = "bind"; + is_bind_prop = true; + parsing_mode = ParsingMode::Argument; + } + (ParsingMode::DirectivePrefix, '#') => { + directive_name = "slot"; + parsing_mode = ParsingMode::Argument; + } + (ParsingMode::DirectivePrefix, 'v') + if matches!(chars_iter.next(), Some((_, '-'))) => + { + parsing_mode = ParsingMode::DirectiveName; + } + (ParsingMode::DirectivePrefix, _) => { + // Not a directive + bail!(); + } + + (ParsingMode::DirectiveName, c) => { + if c == '.' || c == ':' { + if current_start == 0 { + bail!(ParseErrorKind::DirectiveSyntaxDirectiveName); + } + + directive_name = &raw_name[current_start..idx]; + current_start = 0; + + if c == '.' { + parsing_mode = ParsingMode::Modifier; + } else { + parsing_mode = ParsingMode::Argument; + } + + continue; } - if start == 0 { - // `idx` is never 0 because zero-th char is `prefix` - start = idx; + + if current_start == 0 { + current_start = idx; } } - // Directive syntax is bad if we could not read the directive name - if start == 0 { - bail!(ParseErrorKind::DirectiveSyntax); + (ParsingMode::Argument, '.') => { + if current_start == 0 { + bail!(ParseErrorKind::DirectiveSyntaxArgument); + } + + argument_name = &raw_name[current_start..idx]; + current_start = 0; + parsing_mode = ParsingMode::Modifier; + } + (ParsingMode::Argument, '[') => { + if current_start == 0 { + parsing_mode = ParsingMode::DynamicArgument; + } + // Ignore otherwise - argument will be treated as non-dynamic. + // For example, `:foo[bar]` is not dynamic, while `:[foo]` is + } + (ParsingMode::Argument, _) => { + if current_start == 0 { + current_start = idx; + } } - &raw_name[start..end] - } - _ => { - bail!(); - } - }; + (ParsingMode::DynamicArgument, '[') => { + current_bracket_level += 1; + } + (ParsingMode::DynamicArgument, ']') => { + if current_bracket_level == 0 { + if current_start == 0 { + bail!(ParseErrorKind::DirectiveSyntaxDynamicArgument); + } - // Try parsing argument (it is optional and may be empty though) - let mut argument: Option = None; - if expect_argument { - while let Some((idx, c)) = chars_iter.next() { - if c == '.' { - argument_end = idx; - break; + argument_name = &raw_name[current_start..idx]; + is_argument_dynamic = true; + dynamic_argument_end = idx; + current_start = 0; + parsing_mode = ParsingMode::AfterDynamicArgument; + } else { + current_bracket_level -= 1; + } } - if argument_start == 0 { - argument_start = idx; + (ParsingMode::DynamicArgument, _) => { + if current_start == 0 { + current_start = idx; + dynamic_argument_start = idx; + } } - } - if argument_start != 0 { - let mut raw_argument = &raw_name[argument_start..argument_end]; - let mut is_dynamic_argument = false; + (ParsingMode::AfterDynamicArgument, '.') => { + parsing_mode = ParsingMode::Modifier; + } + (ParsingMode::AfterDynamicArgument, _) => { + bail!(ParseErrorKind::DirectiveSyntaxUnexpectedCharacterAfterDynamicArgument); + } - // Dynamic argument: `:[dynamic-argument]` - if raw_argument.starts_with('[') { - // Check syntax - if !raw_argument.ends_with(']') { - bail!(ParseErrorKind::DynamicArgument); + (ParsingMode::Modifier, '.') => { + if current_start == 0 { + bail!(ParseErrorKind::DirectiveSyntaxModifier); } - raw_argument = - &raw_argument['['.len_utf8()..(raw_argument.len() - ']'.len_utf8())]; - if raw_argument.is_empty() { - bail!(ParseErrorKind::DynamicArgument); + let modifier_name = &raw_name[current_start..idx]; + modifiers.push(FervidAtom::from(modifier_name)); + current_start = 0; + } + (ParsingMode::Modifier, _) => { + if current_start == 0 { + current_start = idx; } - - is_dynamic_argument = true; } + } + } - if is_dynamic_argument { - // TODO Narrower span? - let parsed_argument = match self.parse_expr(raw_argument, ts!(), span) { - Ok(parsed) => parsed, - Err(expr_err) => { - bail!(js, expr_err); - } - }; - - argument = Some(StrOrExpr::Expr(parsed_argument)); + // Handle end of argument name + match parsing_mode { + ParsingMode::DirectivePrefix => { + bail!(); + } + ParsingMode::DirectiveName => { + if current_start == 0 { + bail!(ParseErrorKind::DirectiveSyntaxDirectiveName); } else { - argument = Some(StrOrExpr::Str(FervidAtom::from(raw_argument))); + directive_name = &raw_name[current_start..] } } + ParsingMode::Argument => { + if current_start == 0 { + bail!(ParseErrorKind::DirectiveSyntaxArgument); + } else { + argument_name = &raw_name[current_start..]; + } + } + ParsingMode::Modifier => { + if current_start == 0 { + bail!(ParseErrorKind::DirectiveSyntaxModifier); + } else { + let modifier = &raw_name[current_start..]; + modifiers.push(FervidAtom::from(modifier)); + } + } + ParsingMode::DynamicArgument => { + // Doesn't matter if it was started or not - it was not closed + bail!(ParseErrorKind::DirectiveSyntaxDynamicArgument); + } + ParsingMode::AfterDynamicArgument => { + // this mode means that we just parsed a dynamic argument + // and expect either start of modifier or end of attribute name + } } - // Try parsing modifiers, it is a simple string split - let mut modifiers = Vec::::new(); - if argument_end != 0 { - for modifier in raw_name[argument_end..] - .split('.') - .filter(|m| !m.is_empty()) - { - modifiers.push(FervidAtom::from(modifier)); + // Try parsing argument (it is optional and may be empty though) + let argument = match (argument_name, is_argument_dynamic) { + ("", _) => None, + + (static_name, false) => Some(StrOrExpr::Str(FervidAtom::from(static_name))), + + (dynamic_name, true) => { + let attr_lo = raw_attribute.span.lo.0; + let span_lo = attr_lo + dynamic_argument_start as u32; + let span_hi = attr_lo + dynamic_argument_end as u32; + let span = Span { + lo: BytePos(span_lo), + hi: BytePos(span_hi), + }; + + let parsed = match self.parse_expr(&dynamic_name, ts!(), span) { + Ok(parsed) => parsed, + Err(expr_err) => { + bail!(js, expr_err); + } + }; + + Some(StrOrExpr::Expr(parsed)) } - } + }; /// Unwrapping the value or failing macro_rules! expect_value { @@ -264,7 +371,20 @@ impl SfcParser<'_, '_, '_> { } } - let value = expect_value!(); + let value = match raw_attribute.value { + Some(ref value) => Cow::Borrowed(value.as_str()), + None => { + // v-bind without a value is a shorthand (e.g. just `:foo-bar` is `:foo-bar="fooBar"`). + // This only works for static arguments + if let Some(StrOrExpr::Str(ref s)) = argument { + let mut out = String::with_capacity(raw_name.len()); + to_camel_case(&s, &mut out); + Cow::Owned(out) + } else { + bail!(ParseErrorKind::DirectiveSyntax); + } + } + }; let parsed_expr = match self.parse_expr(&value, ts!(), span) { Ok(parsed) => parsed, @@ -508,10 +628,512 @@ fn split_itervar_and_iterable<'a>( Some(((itervar, new_span_itervar), (iterable, new_span_iterable))) } +/// `foo-bar-baz` -> `fooBarBaz` +#[inline] +fn to_camel_case(raw: &str, out: &mut String) { + for (idx, word) in raw.split('-').enumerate() { + if idx == 0 { + out.push_str(word); + continue; + } + + let first_char = word.chars().next(); + if let Some(ch) = first_char { + // Uppercase the first char and append to buf + for ch_component in ch.to_uppercase() { + out.push(ch_component); + } + + // Push the rest of the word + out.push_str(&word[ch.len_utf8()..]); + } + } +} + #[cfg(test)] mod tests { + use swc_core::common::DUMMY_SP; + use super::*; + #[test] + fn it_parses_regular_attr() { + test_parse_into_attr("disabled", "true"); + test_parse_into_attr("foo.bar", "true"); + test_parse_into_attr("foo:bar", "true"); + test_parse_into_attr("foo@bar", "true"); + test_parse_into_attr("foo#bar", "true"); + test_parse_into_attr("v.", "true"); + } + + #[test] + fn it_parses_v_on() { + assert!(matches!( + test_parse_into_attr_or_binding("v-on", "handle"), + Some(AttributeOrBinding::VOn(VOnDirective { + event: None, + handler: Some(_), + modifiers, + .. + })) if modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_attr_or_binding("v-on:click", "handle"), + Some(AttributeOrBinding::VOn(VOnDirective { + event: Some(StrOrExpr::Str(s)), + handler: Some(_), + modifiers, + .. + })) if s == "click" && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_attr_or_binding("@click", "handle"), + Some(AttributeOrBinding::VOn(VOnDirective { + event: Some(StrOrExpr::Str(s)), + handler: Some(_), + modifiers, + .. + })) if s == "click" && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_attr_or_binding("@click.mod1.mod2", "handle"), + Some(AttributeOrBinding::VOn(VOnDirective { + event: Some(StrOrExpr::Str(s)), + handler: Some(_), + modifiers, + .. + })) if s == "click" && modifiers.len() == 2 + )); + assert!(matches!( + test_parse_into_attr_or_binding("@[click]", "handle"), + Some(AttributeOrBinding::VOn(VOnDirective { + event: Some(StrOrExpr::Expr(expr)), + handler: Some(_), + modifiers, + .. + })) if expr.is_ident() && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_attr_or_binding("@[click.click]", "handle"), + Some(AttributeOrBinding::VOn(VOnDirective { + event: Some(StrOrExpr::Expr(expr)), + handler: Some(_), + modifiers, + .. + })) if expr.is_member() && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_attr_or_binding("@[click[click]]", "handle"), + Some(AttributeOrBinding::VOn(VOnDirective { + event: Some(StrOrExpr::Expr(expr)), + handler: Some(_), + modifiers, + .. + })) if expr.is_member() && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_attr_or_binding("@[click].mod1", "handle"), + Some(AttributeOrBinding::VOn(VOnDirective { + event: Some(StrOrExpr::Expr(expr)), + handler: Some(_), + modifiers, + .. + })) if expr.is_ident() && modifiers.len() == 1 + )); + } + + #[test] + fn it_parses_v_bind() { + assert!(matches!( + test_parse_into_attr_or_binding("v-bind", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: None, + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() + )); + assert!(matches!( + test_parse_into_attr_or_binding("v-bind:arg-name", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() && arg == "arg-name" + )); + assert!(matches!( + test_parse_into_attr_or_binding(":arg-name", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() && arg == "arg-name" + )); + assert!(matches!( + test_parse_into_attr_or_binding(":arg.mod1", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() && arg == "arg" + )); + assert!(matches!( + test_parse_into_attr_or_binding(":arg.camel", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: true, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() && arg == "arg" + )); + assert!(matches!( + test_parse_into_attr_or_binding(":arg.prop", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: false, + is_prop: true, + is_attr: false, + .. + })) if value.is_ident() && arg == "arg" + )); + assert!(matches!( + test_parse_into_attr_or_binding(":arg.attr", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: true, + .. + })) if value.is_ident() && arg == "arg" + )); + assert!(matches!( + test_parse_into_attr_or_binding(":arg.camel.attr.prop.mod", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: true, + is_prop: true, + is_attr: true, + .. + })) if value.is_ident() && arg == "arg" + )); + assert!(matches!( + test_parse_into_attr_or_binding(".foo", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: false, + is_prop: true, + is_attr: false, + .. + })) if value.is_ident() && arg == "foo" + )); + assert!(matches!( + test_parse_into_attr_or_binding(":[arg]", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Expr(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() && arg.is_ident() + )); + assert!(matches!( + test_parse_into_attr_or_binding(":[arg.name]", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Expr(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() && arg.is_member() + )); + assert!(matches!( + test_parse_into_attr_or_binding(":[arg[name]]", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Expr(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() && arg.is_member() + )); + assert!(matches!( + test_parse_into_attr_or_binding(":[arg].mod", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Expr(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() && arg.is_ident() + )); + assert!(matches!( + test_parse_into_attr_or_binding(":arg[name]", "value"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if value.is_ident() && arg == "arg[name]" + )); + } + + #[test] + fn it_supports_shorthand_v_bind() { + fn test_parse_into_bind(name: &str) -> Option { + let mut errors = Vec::new(); + let mut parser = SfcParser::new("", &mut errors); + + let mut attrs_or_bindings = Vec::new(); + let mut vue_directives = None; + let result = parser.try_parse_directive( + Attribute { + span: DUMMY_SP, + namespace: None, + prefix: None, + name: FervidAtom::from(name), + raw_name: None, + value: None, + raw_value: None, + }, + &mut attrs_or_bindings, + &mut vue_directives, + ); + assert!(result.is_ok()); + assert!(attrs_or_bindings.len() <= 1); + assert!(vue_directives.is_none()); + assert!(errors.is_empty()); + + attrs_or_bindings.pop() + } + + assert!(matches!( + test_parse_into_bind(":msg"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if arg == "msg" && value.as_ident().is_some_and(|v| v.sym == "msg") + )); + assert!(matches!( + test_parse_into_bind(":foo-bar"), + Some(AttributeOrBinding::VBind(VBindDirective { + argument: Some(StrOrExpr::Str(arg)), + value, + is_camel: false, + is_prop: false, + is_attr: false, + .. + })) if arg == "foo-bar" && value.as_ident().is_some_and(|v| v.sym == "fooBar") + )); + } + + #[test] + fn it_parses_v_slot() { + fn test_parse_into_slot(name: &str, value: &str) -> VSlotDirective { + let directives = test_parse_into_vue_directive(name, value); + directives.v_slot.expect("Slot directive should exist") + } + + assert!(matches!( + test_parse_into_slot("v-slot", ""), + VSlotDirective { + slot_name: None, + value: None + } + )); + assert!(matches!( + test_parse_into_slot("v-slot", "value"), + VSlotDirective { + slot_name: None, + value: Some(value) + } if value.is_ident() + )); + assert!(matches!( + test_parse_into_slot("v-slot:default", "value"), + VSlotDirective { + slot_name: Some(StrOrExpr::Str(name)), + value: Some(value) + } if value.is_ident() && name == "default" + )); + assert!(matches!( + test_parse_into_slot("v-slot:[slot]", "value"), + VSlotDirective { + slot_name: Some(StrOrExpr::Expr(name)), + value: Some(value) + } if value.is_ident() && name.is_ident() + )); + assert!(matches!( + test_parse_into_slot("v-slot:[slot.name]", "value"), + VSlotDirective { + slot_name: Some(StrOrExpr::Expr(name)), + value: Some(value) + } if value.is_ident() && name.is_member() + )); + assert!(matches!( + test_parse_into_slot("v-slot:[slot[name]]", "value"), + VSlotDirective { + slot_name: Some(StrOrExpr::Expr(name)), + value: Some(value) + } if value.is_ident() && name.is_member() + )); + assert!(matches!( + test_parse_into_slot("#default", "value"), + VSlotDirective { + slot_name: Some(StrOrExpr::Str(name)), + value: Some(value) + } if value.is_ident() && name == "default" + )); + assert!(matches!( + test_parse_into_slot("#[slot]", "value"), + VSlotDirective { + slot_name: Some(StrOrExpr::Expr(name)), + value: Some(value) + } if value.is_ident() && name.is_ident() + )); + assert!(matches!( + test_parse_into_slot("#[slot.name]", "value"), + VSlotDirective { + slot_name: Some(StrOrExpr::Expr(name)), + value: Some(value) + } if value.is_ident() && name.is_member() + )); + assert!(matches!( + test_parse_into_slot("#[slot[name]]", "value"), + VSlotDirective { + slot_name: Some(StrOrExpr::Expr(name)), + value: Some(value) + } if value.is_ident() && name.is_member() + )); + } + + #[test] + fn it_parses_custom_directive() { + fn test_parse_into_custom(name: &str, value: &str) -> VCustomDirective { + let mut directives = test_parse_into_vue_directive(name, value); + directives + .custom + .pop() + .expect("Custom directive should exist") + } + + assert!(matches!( + test_parse_into_custom("v-custom-dir", "value"), + VCustomDirective { + name, + argument: None, + modifiers, + value: Some(v) + } if name == "custom-dir" && v.is_ident() && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_custom("v-custom:arg-name", "value"), + VCustomDirective { + name, + argument: Some(StrOrExpr::Str(arg)), + modifiers, + value: Some(v) + } if name == "custom" && arg == "arg-name" && v.is_ident() && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_custom("v-custom:[arg-name]", "value"), + VCustomDirective { + name, + argument: Some(StrOrExpr::Expr(arg)), + modifiers, + value: Some(v) + } if name == "custom" && arg.is_bin() && v.is_ident() && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_custom("v-custom:[arg.name]", "value"), + VCustomDirective { + name, + argument: Some(StrOrExpr::Expr(arg)), + modifiers, + value: Some(v) + } if name == "custom" && arg.is_member() && v.is_ident() && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_custom("v-custom:[arg[name]]", "value"), + VCustomDirective { + name, + argument: Some(StrOrExpr::Expr(arg)), + modifiers, + value: Some(v) + } if name == "custom" && arg.is_member() && v.is_ident() && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_custom("v-custom:arg[name]", "value"), + VCustomDirective { + name, + argument: Some(StrOrExpr::Str(arg)), + modifiers, + value: Some(v) + } if name == "custom" && arg == "arg[name]" && v.is_ident() && modifiers.is_empty() + )); + assert!(matches!( + test_parse_into_custom("v-custom.mod1.mod2", "value"), + VCustomDirective { + name, + argument: None, + modifiers, + value: Some(v) + } if name == "custom" && v.is_ident() && modifiers.len() == 2 + )); + assert!(matches!( + test_parse_into_custom("v-custom.[mod1.mod2]", "value"), + VCustomDirective { + name, + argument: None, + modifiers, + value: Some(v) + } if name == "custom" && v.is_ident() && modifiers.len() == 2 + )); + assert!(matches!( + test_parse_into_custom("v-custom:arg.mod1", "value"), + VCustomDirective { + name, + argument: Some(StrOrExpr::Str(arg)), + modifiers, + value: Some(v) + } if name == "custom" && arg == "arg" && v.is_ident() && modifiers.len() == 1 + )); + assert!(matches!( + test_parse_into_custom("v-custom:[arg].mod1", "value"), + VCustomDirective { + name, + argument: Some(StrOrExpr::Expr(arg)), + modifiers, + value: Some(v) + } if name == "custom" && arg.is_ident() && v.is_ident() && modifiers.len() == 1 + )); + } + #[test] fn it_correctly_splits_itervar_iterable() { macro_rules! check { @@ -544,4 +1166,79 @@ mod tests { // A bit harder check!(" item in \n \t list ", "item", 4, 8, "list", 19, 23); } + + fn test_parse_into_attr(name: &str, value: &str) { + let mut errors = Vec::new(); + let mut parser = SfcParser::new("", &mut errors); + + let mut attrs_or_bindings = Vec::new(); + let mut vue_directives = None; + let result = parser.try_parse_directive( + Attribute { + span: DUMMY_SP, + namespace: None, + prefix: None, + name: FervidAtom::from(name), + raw_name: None, + value: Some(FervidAtom::from(value)), + raw_value: None, + }, + &mut attrs_or_bindings, + &mut vue_directives, + ); + assert!(result.is_err()); + } + + fn test_parse_into_attr_or_binding(name: &str, value: &str) -> Option { + let mut errors = Vec::new(); + let mut parser = SfcParser::new("", &mut errors); + + let mut attrs_or_bindings = Vec::new(); + let mut vue_directives = None; + let result = parser.try_parse_directive( + Attribute { + span: DUMMY_SP, + namespace: None, + prefix: None, + name: FervidAtom::from(name), + raw_name: None, + value: Some(FervidAtom::from(value)), + raw_value: None, + }, + &mut attrs_or_bindings, + &mut vue_directives, + ); + assert!(result.is_ok()); + assert!(attrs_or_bindings.len() <= 1); + assert!(vue_directives.is_none()); + assert!(errors.is_empty()); + + attrs_or_bindings.pop() + } + + fn test_parse_into_vue_directive(name: &str, value: &str) -> Box { + let mut errors = Vec::new(); + let mut parser = SfcParser::new("", &mut errors); + + let mut attrs_or_bindings = Vec::new(); + let mut vue_directives = None; + let result = parser.try_parse_directive( + Attribute { + span: DUMMY_SP, + namespace: None, + prefix: None, + name: FervidAtom::from(name), + raw_name: None, + value: Some(FervidAtom::from(value)), + raw_value: None, + }, + &mut attrs_or_bindings, + &mut vue_directives, + ); + assert!(result.is_ok()); + assert!(attrs_or_bindings.is_empty()); + assert!(errors.is_empty()); + + vue_directives.expect("Directives should exist") + } } diff --git a/crates/fervid_parser/src/error.rs b/crates/fervid_parser/src/error.rs index 9917b04..f351af4 100644 --- a/crates/fervid_parser/src/error.rs +++ b/crates/fervid_parser/src/error.rs @@ -10,6 +10,16 @@ pub struct ParseError { pub enum ParseErrorKind { /// Malformed directive (e.g. `:`, `@`) DirectiveSyntax, + /// Malformed directive name (e.g. `v-.`) + DirectiveSyntaxDirectiveName, + /// Malformed directive argument (e.g. `v-bind:`) + DirectiveSyntaxArgument, + /// Malformed directive dynamic argument (e.g. `:[foo`) + DirectiveSyntaxDynamicArgument, + /// Malformed directive - expected modifiers, but got something else (e.g. `:[foo]v`) + DirectiveSyntaxUnexpectedCharacterAfterDynamicArgument, + /// Malformed directive modifier (e.g. `:smth.`) + DirectiveSyntaxModifier, /// More than one `