vue_oxc_toolkit 0.5.0

A parser to generate semantically correct AST from Vue SFCs for code linting purposes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
use oxc_allocator::{CloneIn, TakeIn, Vec as ArenaVec};
use oxc_ast::{
  Comment, CommentKind, NONE,
  ast::{Expression, JSXAttributeItem, JSXChild, JSXExpression, PropertyKind, Statement},
};
use oxc_span::{GetSpanMut, SPAN, Span};
use vue_compiler_core::parser::{
  AstNode, Directive, DirectiveArg, ElemProp, Element, SourceNode, TextNode,
};

use crate::{
  is_void_tag,
  parser::{
    ParserImpl,
    elements::{
      v_for::VForWrapper,
      v_if::{VIf, VIfManager},
      v_slot::VSlotWrapper,
    },
    parse::SourceLocatonSpan,
  },
};

mod directive;
mod v_for;
mod v_if;
mod v_slot;

impl<'a> ParserImpl<'a> {
  fn parse_children(
    &mut self,
    start: u32,
    end: u32,
    children: Vec<AstNode<'a>>,
  ) -> ArenaVec<'a, JSXChild<'a>> {
    let ast = self.ast;
    if children.is_empty() {
      return ast.vec();
    }
    let mut result = self.ast.vec_with_capacity(children.len() + 2);

    // Process the whitespaces text there <div>____<br>_____</div>
    if let Some(first) = children.first()
      && matches!(first, AstNode::Element(_) | AstNode::Interpolation(_))
      && start != first.get_location().start.offset as u32
    {
      let span = Span::new(start, first.get_location().start.offset as u32);
      let value = span.source_text(self.source_text);
      result.push(ast.jsx_child_text(span, value, Some(ast.atom(value))));
    }

    let last = if let Some(last) = children.last()
      && matches!(last, AstNode::Element(_) | AstNode::Interpolation(_))
      && end != last.get_location().end.offset as u32
    {
      let span = Span::new(last.get_location().end.offset as u32, end);
      let value = span.source_text(self.source_text);
      Some(ast.jsx_child_text(span, value, Some(ast.atom(value))))
    } else {
      None
    };

    let mut v_if_manager = VIfManager::new(&ast);
    for child in children {
      match child {
        AstNode::Element(node) => {
          let (child, v_if) = self.parse_element(node, None);

          if let Some(v_if) = v_if {
            if let Some(child) = self.add_v_if(child, v_if, &mut v_if_manager) {
              // There are three cases to return Some(child) for add_v_if function
              // 1. meet v-else, means the v-if/v-else-if chain is finished
              // 2. meet v-if while the v_if_manager is not empty, means the previous v-if/v-else-if chain is finished
              // 3. meet v-else/v-else-if with no v-if, v_if_manager won't add it to the chain, so add it to result there
              result.push(child);
            }
          } else {
            if let Some(chain) = v_if_manager.take_chain() {
              result.push(chain);
            }
            result.push(child);
          }
        }
        AstNode::Text(text) => result.push(self.parse_text(&text)),
        AstNode::Comment(comment) => result.push(self.parse_comment(&comment)),
        AstNode::Interpolation(interp) => result.push(self.parse_interpolation(&interp)),
      }
    }

    if let Some(chain) = v_if_manager.take_chain() {
      // If the last element is v-if / v-else-if / v-else, push all the children
      result.push(chain);
    }
    if let Some(last) = last {
      result.push(last);
    }

    result
  }

  pub fn parse_element(
    &mut self,
    node: Element<'a>,
    children: Option<ArenaVec<'a, JSXChild<'a>>>,
  ) -> (JSXChild<'a>, Option<VIf<'a>>) {
    let ast = self.ast;

    let open_element_span = {
      let start = node.location.start.offset;
      let tag_name_end = if let Some(prop) = node.properties.last() {
        match prop {
          ElemProp::Attr(prop) => prop.location.end.offset,
          ElemProp::Dir(prop) => prop.location.end.offset,
        }
      } else {
        start + 1 /* < */ + node.tag_name.len()
      };
      let end = memchr::memchr(b'>', &self.source_text.as_bytes()[tag_name_end..])
        .map(|i| tag_name_end + i + 1)
        .unwrap(); // SAFETY: The tag must be closed. Or parser will treat it as panicked.
      Span::new(start as u32, end as u32)
    };

    let location_span = node.location.span();
    let tag_name = node.tag_name;
    let end_element_span = {
      if location_span.source_text(self.source_text).ends_with("/>") || is_void_tag!(tag_name) {
        node.location.span()
      } else {
        let end = node.location.end.offset;
        let start =
          memchr::memrchr(b'<', &self.source_text.as_bytes()[..end]).map(|i| i as u32).unwrap();
        Span::new(start, end as u32)
      }
    };

    // Use different JSXElementName for component and normal element
    let mut element_name = {
      let name_span = Span::new(
        open_element_span.start + 1,
        open_element_span.start + 1 + node.tag_name.len() as u32,
      );

      if tag_name.contains('.')
        // Directly call oxc_parser because it's too complex to process <a.b.c.d.e />
        && let Some(expr) = self.parse_expression(
          self.ast.atom(&format!("<{tag_name}/>")).as_str(),
          name_span.start as usize - 1,
        )
        && let Expression::JSXElement(mut jsx_element) = expr
      {
        // For namespace tag name, e.g. <motion.div />
        jsx_element.opening_element.name.take_in(self.allocator)
      } else if tag_name.contains('-') {
        // For <keep-alive />
        let name = tag_name
          .split('-')
          .map(|s| {
            // SAFETY to use ascii and not check bytes length
            let mut bytes = s.as_bytes().to_vec();
            bytes[0] = bytes[0].to_ascii_uppercase();
            String::from_utf8(bytes).unwrap()
          })
          .collect::<String>();

        ast.jsx_element_name_identifier_reference(name_span, ast.atom(&name))
      } else {
        let name = ast.atom(node.tag_name);
        if node.is_component() {
          // For <KeepAlive />
          ast.jsx_element_name_identifier_reference(name_span, name)
        } else {
          // For normal element, like <div>, use identifier
          ast.jsx_element_name_identifier(name_span, name)
        }
      }
    };

    let mut v_for_wrapper = VForWrapper::new(&ast);
    let mut v_slot_wrapper = VSlotWrapper::new(&ast);
    let mut v_if_state: Option<VIf<'a>> = None;
    let mut attributes = ast.vec();
    for prop in node.properties {
      attributes.push(self.parse_prop(
        prop,
        &mut v_for_wrapper,
        &mut v_slot_wrapper,
        &mut v_if_state,
      ));
    }

    let children = match children {
      Some(children) => children,
      None => v_slot_wrapper.wrap(self.parse_children(
        open_element_span.end,
        end_element_span.start,
        node.children,
      )),
    };

    (
      v_for_wrapper.wrap(ast.jsx_element(
        location_span,
        ast.jsx_opening_element(
          open_element_span,
          element_name.clone_in(self.allocator),
          NONE,
          attributes,
        ),
        children,
        if end_element_span.eq(&location_span) {
          None
        } else {
          Some(ast.jsx_closing_element(end_element_span, {
            let span = Span::new(
              end_element_span.start + 2,
              end_element_span.start + 2 + node.tag_name.len() as u32,
            );
            *element_name.span_mut() = span;
            element_name
          }))
        },
      )),
      v_if_state,
    )
  }

  fn parse_prop(
    &mut self,
    prop: ElemProp<'a>,
    v_for_wrapper: &mut VForWrapper<'_, 'a>,
    v_slot_wrapper: &mut VSlotWrapper<'_, 'a>,
    v_if_state: &mut Option<VIf<'a>>,
  ) -> JSXAttributeItem<'a> {
    let ast = self.ast;
    match prop {
      // For normal attributes, like <div class="w-100" />
      ElemProp::Attr(attr) => {
        let attr_end = self.roffset(attr.location.end.offset) as u32;
        let attr_span = Span::new(attr.location.start.offset as u32, attr_end);
        ast.jsx_attribute_item_attribute(
          attr_span,
          ast.jsx_attribute_name_identifier(attr.name_loc.span(), ast.atom(attr.name)),
          if let Some(value) = attr.value {
            Some(ast.jsx_attribute_value_string_literal(
              Span::new(value.location.span().start + 1, attr_end - 1),
              ast.atom(value.content.raw),
              None,
            ))
          } else {
            None
          },
        )
      }
      // Directive, starts with `v-`
      ElemProp::Dir(dir) => {
        let dir_start = dir.location.start.offset as u32;
        let dir_end = self.roffset(dir.location.end.offset) as u32;

        let dir_name = self.parse_directive_name(&dir);
        // Analyze v-slot and v-for, no matter whether there is an expression
        if dir.name == "slot" {
          self.analyze_v_slot(&dir, v_slot_wrapper, &dir_name);
        } else if dir.name == "for" {
          self.analyze_v_for(&dir, v_for_wrapper);
        } else if dir.name == "else" {
          // v-else can have no expression
          *v_if_state = Some(VIf::Else);
        }
        let value = if let Some(expr) = &dir.expression {
          // +1 to skip the opening quote
          let expr_start = expr.location.start.offset + 1;
          Some(
            ast.jsx_attribute_value_expression_container(
              Span::new(expr_start as u32, dir_end - 1),
              ((|| {
                // Use placeholder for v-for and v-slot
                if matches!(dir.name, "for" | "slot" | "else") {
                  None
                } else if dir.name == "if" {
                  *v_if_state = self.parse_expression(expr.content.raw, expr_start).map(VIf::If);
                  None
                } else if dir.name == "else-if" {
                  *v_if_state =
                    self.parse_expression(expr.content.raw, expr_start).map(VIf::ElseIf);
                  None
                } else {
                  // For possible dynamic arguments
                  let expr = self.parse_expression(expr.content.raw, expr_start)?;
                  Some(JSXExpression::from(self.parse_dynamic_argument(&dir, expr)?))
                }
              })())
              .unwrap_or_else(|| JSXExpression::EmptyExpression(ast.jsx_empty_expression(SPAN))),
            ),
          )
        } else if let Some(argument) = &dir.argument
          && let DirectiveArg::Dynamic(_) = argument
          && let Some(argument) =
            self.parse_dynamic_argument(&dir, ast.expression_identifier(SPAN, "undefined"))
        {
          // v-slot:[name]
          Some(ast.jsx_attribute_value_expression_container(SPAN, argument.into()))
        } else {
          None
        };

        ast.jsx_attribute_item_attribute(
          Span::new(dir_start, dir_end),
          // Attribute Name
          dir_name,
          // Attribute Value
          value,
        )
      }
    }
  }

  fn parse_dynamic_argument(
    &mut self,
    dir: &Directive<'a>,
    expression: Expression<'a>,
  ) -> Option<Expression<'a>> {
    let head_name = dir.head_loc.span().source_text(self.source_text);
    let dir_start = dir.location.start.offset;
    if let Some(argument) = &dir.argument
      && let DirectiveArg::Dynamic(argument_str) = argument
    {
      let dynamic_arg_expression = self.parse_expression(
        argument_str,
        if head_name.starts_with("v-") {
          dir_start + 2 + dir.name.len() + 2 // v-bind:[arg] -> skip `:[` (2 chars)
        } else {
          dir_start + 2 // :[arg] -> skip `:[` (2 chars)
        },
      )?;
      Some(self.ast.expression_object(
        SPAN,
        self.ast.vec1(self.ast.object_property_kind_object_property(
          SPAN,
          PropertyKind::Init,
          dynamic_arg_expression.into(),
          expression,
          false,
          false,
          true,
        )),
      ))
    } else {
      Some(expression)
    }
  }

  fn parse_text(&self, text: &TextNode<'a>) -> JSXChild<'a> {
    let raw = self.ast.atom(&text.text.iter().map(|t| t.raw).collect::<String>());
    self.ast.jsx_child_text(text.location.span(), raw, Some(raw))
  }

  fn parse_comment(&mut self, comment: &SourceNode<'a>) -> JSXChild<'a> {
    let ast = self.ast;
    let span = comment.location.span();
    self.comments.push(Comment::new(
      span.start + 1,
      span.end - 1,
      if comment.source.contains('\n') {
        CommentKind::MultiLineBlock
      } else {
        CommentKind::SingleLineBlock
      },
    ));
    ast.jsx_child_expression_container(span, ast.jsx_expression_empty_expression(SPAN))
  }

  fn parse_interpolation(&mut self, introp: &SourceNode<'a>) -> JSXChild<'a> {
    let ast = self.ast;
    // Use full span for container (includes {{ and }})
    let container_span = introp.location.span();
    // Expression starts after {{ (2 characters)
    let expr_start = introp.location.start.offset + 2;

    ast.jsx_child_expression_container(
      container_span,
      self
        .parse_expression(introp.source, expr_start)
        .map_or_else(|| ast.jsx_expression_empty_expression(SPAN), JSXExpression::from),
    )
  }

  pub fn parse_expression(&mut self, source: &'a str, start: usize) -> Option<Expression<'a>> {
    let (mut body, _) =
      self.oxc_parse(&format!("({source})"), self.source_type, start.saturating_sub(1))?;

    let Some(Statement::ExpressionStatement(stmt)) = body.get_mut(0) else {
      // SAFETY: We always wrap the source in parentheses, so it should always be an expression statement.
      unreachable!()
    };
    let Expression::ParenthesizedExpression(expression) = &mut stmt.expression else {
      // SAFETY: We always wrap the source in parentheses, so it should always be a parenthesized expression
      unreachable!()
    };
    Some(expression.expression.take_in(self.allocator))
  }

  fn roffset(&self, end: usize) -> usize {
    end - self.source_text[..end].chars().rev().take_while(|c| c.is_whitespace()).count()
  }
}