1use oxc_diagnostics::OxcDiagnostic;
2use oxc_span::Span;
3
4use crate::{
5 err,
6 marko_ast::*,
7 typescript::{
8 parse_call_arguments, parse_expr, parse_expr_without_gt, parse_fn_params_and_body,
9 parse_var_binding,
10 },
11};
12
13/// HTML void elements that are implicitly self-closing.
14/// These elements cannot have content and don't need a closing tag.
15const VOID_ELEMENTS: &[&str] = &[
16 "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
17 "track", "wbr",
18];
19
20pub fn parse_tag(src: &str) -> Result<OpenOrClose, OxcDiagnostic> {
21 assert_eq!(src.as_bytes()[0], b'<');
22 if src.starts_with("</") {
23 parse_close(src).map(OpenOrClose::Close)
24 } else {
25 parse_open(src).map(OpenOrClose::Open)
26 }
27}
28
29pub fn parse_open(src: &str) -> Result<Open, OxcDiagnostic> {
30 assert!(src.starts_with("<"));
31 let mut l = LexState { src, offset: 1 };
32 let tag_name = parse_tag_name(&mut l)?;
33 let is_attribute_tag = tag_name.starts_with('@');
34
35 // class and id shorthand
36 let mut id_attr = AttributeValue::None;
37 while let Some(byte @ b'#' | byte @ b'.') = l.peek_byte() {
38 let id_start = l.offset;
39 l.advance(1);
40 let name = parse_class_name_shorthand(&mut l, byte == b'#')?;
41 let id_end = l.offset;
42
43 if byte == b'#' {
44 if !matches!(id_attr, AttributeValue::None) {
45 return Err(err("Cannot specify two IDs", id_start, name.len()));
46 }
47
48 // Check if the shorthand contains ${} (dynamic)
49 if name.contains("${") {
50 id_attr = AttributeValue::Dynamic;
51 } else {
52 id_attr = AttributeValue::Static {
53 span: Span::new(id_start + 1, id_end),
54 is_quoted: false,
55 };
56 }
57 }
58 }
59
60 // Track where shorthands end (for inserting new #id if needed)
61 let shorthand_end = l.offset;
62
63 // in any order: parameters, arguments, variable
64 let mut has_js_arguments = false;
65 let mut has_js_params = false;
66 let mut has_js_variable = false;
67 l.skip_whitespace();
68 while let Some(byte @ b'|' | byte @ b'(' | byte @ b'/') = l.peek_byte() {
69 let offset = l.offset;
70
71 match byte {
72 b'|' => {
73 // parameters
74 if has_js_params {
75 return Err(err("Params already specified", offset, 1));
76 }
77 l.offset += 1;
78 l.skip_whitespace();
79 while l.peek_byte() != Some(b'|') && l.peek_byte().is_some() {
80 _ = l.expect("...");
81 l.skip_whitespace();
82 parse_var_binding(&mut l)?;
83 l.skip_whitespace();
84 if l.expect(",").is_err() {
85 break;
86 }
87 l.skip_whitespace();
88 }
89 l.expect("|")?;
90 l.skip_whitespace();
91 has_js_params = true;
92 }
93 b'(' => {
94 if has_js_arguments {
95 return Err(err("Function call already specified", offset, 1));
96 }
97
98 let start_offset = l.offset;
99 if parse_call_arguments(&mut l).is_ok() {
100 let after_call = l.peek_rest().trim_start();
101 if after_call.starts_with('{') {
102 l.offset = start_offset;
103 l.skip_whitespace();
104 if l.peek_byte() == Some(b'=') {
105 l.offset += 1;
106 l.skip_whitespace();
107 }
108 parse_fn_params_and_body(&mut l)?;
109 }
110 } else {
111 l.offset = start_offset;
112 l.skip_whitespace();
113 if l.peek_byte() == Some(b'=') {
114 l.offset += 1;
115 l.skip_whitespace();
116 }
117 parse_fn_params_and_body(&mut l)?;
118 }
119
120 if is_attribute_tag {
121 return Err(err(
122 "Attribute tags do not support arguments",
123 offset,
124 (l.offset - offset) as usize,
125 ));
126 }
127
128 l.skip_whitespace();
129 has_js_arguments = true;
130 }
131 b'/' => {
132 if l.peek_rest().starts_with("/>") {
133 break;
134 }
135 if has_js_variable {
136 return Err(err("Variable already specified", offset, 1));
137 }
138 l.offset += 1;
139 l.skip_whitespace();
140 parse_var_binding(&mut l)?;
141 if is_attribute_tag {
142 return Err(err(
143 "Attribute tags do not support variables",
144 offset,
145 (l.offset - offset) as usize,
146 ));
147 }
148 l.skip_whitespace();
149 has_js_variable = true;
150 }
151 _ => unreachable!(),
152 }
153 }
154
155 let mut self_closing = false;
156 if !has_js_arguments {
157 // keyless value
158 if l.peek_byte() == Some(b'=') {
159 l.offset += 1;
160 l.skip_whitespace();
161 parse_expr_without_gt(&mut l)?;
162 l.skip_whitespace();
163 }
164
165 // attributes
166 loop {
167 match l.peek_byte() {
168 None => return Err(l.expect_byte().expect_err("err")),
169 Some(b'>') => {
170 l.advance(1);
171 break;
172 }
173 Some(_) => {
174 if l.expect("/>").is_ok() {
175 self_closing = true;
176 break;
177 }
178 // Check for id= attribute (only if we don't already have an id)
179 if l.peek_rest().starts_with("id=") {
180 if !matches!(id_attr, AttributeValue::None) {
181 return Err(err(
182 "Cannot combine the 'id' attribute with ID shorthand",
183 l.offset(),
184 3,
185 ));
186 }
187
188 l.advance(3); // skip "id="
189 l.skip_whitespace();
190 let value_start = l.offset;
191 let first_byte = l.peek_byte();
192 parse_expr_without_gt(&mut l)?;
193 let value_end = l.offset;
194 l.skip_whitespace();
195
196 if first_byte == Some(b'"') || first_byte == Some(b'\'') {
197 id_attr = AttributeValue::Static {
198 span: Span::new(value_start, value_end),
199 is_quoted: true,
200 };
201 } else {
202 id_attr = AttributeValue::Dynamic;
203 }
204 } else {
205 parse_attribute(&mut l)?;
206 l.skip_whitespace();
207 }
208 }
209 }
210 }
211 } else if l.expect("/>").is_ok() {
212 self_closing = true;
213 } else {
214 l.expect(">")?;
215 }
216
217 // Automatically mark void elements as self-closing
218 if !self_closing && VOID_ELEMENTS.contains(&tag_name) {
219 self_closing = true;
220 }
221
222 Ok(Open::new(
223 &src[0..l.offset as usize],
224 Span::new(1, (tag_name.len() + 1) as u32),
225 self_closing,
226 shorthand_end,
227 id_attr,
228 ))
229}
230
231pub fn parse_close(src: &str) -> Result<Close, OxcDiagnostic> {
232 assert!(src.starts_with("</"));
233 let mut l = LexState { src, offset: 2 };
234 if l.peek_byte() == Some(b'>') {
235 l.offset += 1;
236 Ok(Close {
237 tag_name: None,
238 length: l.offset,
239 })
240 } else {
241 let tag_name = parse_tag_name(&mut l)?;
242 l.expect(">")?;
243 Ok(Close {
244 tag_name: Some(tag_name),
245 length: l.offset,
246 })
247 }
248}
249
250fn parse_tag_name<'a>(l: &mut LexState<'a>) -> Result<&'a str, OxcDiagnostic> {
251 if l.peek_rest().starts_with("${") {
252 l.advance(2);
253 parse_expr(l)?;
254 l.expect("}")?;
255 return Ok("");
256 }
257 let start = l.offset;
258 while let Some(b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'-' | b'$' | b'@' | b'0'..=b'9') =
259 l.peek_byte()
260 {
261 l.offset += 1
262 }
263 let tag_name = &l.src[start as usize..l.offset as usize];
264 if !tag_name.is_empty() {
265 Ok(tag_name)
266 } else {
267 Err(err("Expected tag name", l.offset, 1))
268 }
269}
270
271fn parse_attr_name<'a>(l: &mut LexState<'a>) -> Result<&'a str, OxcDiagnostic> {
272 let start = l.offset;
273 while let Some(b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'-' | b':') = l.peek_byte() {
274 l.offset += 1;
275 }
276 let tag_name = &l.src[start as usize..l.offset as usize];
277 if !tag_name.is_empty() {
278 Ok(tag_name)
279 } else {
280 Err(err("Expected attribute name", l.offset, 1))
281 }
282}
283
284fn parse_attribute(l: &mut LexState) -> Result<(), OxcDiagnostic> {
285 if l.expect("...").is_ok() {
286 l.skip_whitespace();
287 parse_expr_without_gt(l)?;
288 l.skip_whitespace();
289 return Ok(());
290 }
291
292 // attribute name
293 parse_attr_name(l)?;
294 let has_ws_or_end = l
295 .peek_byte()
296 .map(|x| x.is_ascii_whitespace())
297 .unwrap_or(false)
298 || l.peek_rest().starts_with(">")
299 || l.peek_rest().starts_with("/>");
300 l.skip_whitespace();
301
302 // attribute value
303 if l.expect(":=").is_ok() || l.expect("=").is_ok() {
304 l.skip_whitespace();
305 parse_expr_without_gt(l)?;
306 l.skip_whitespace();
307 } else if l.peek_byte() == Some(b'(') {
308 parse_fn_params_and_body(l)?;
309 l.skip_whitespace();
310 } else if !has_ws_or_end {
311 return Err(err("Expected whitespace or '>'", l.offset, 1));
312 }
313
314 Ok(())
315}
316
317fn parse_class_name_shorthand<'a>(
318 l: &mut LexState<'a>,
319 id: bool,
320) -> Result<&'a str, OxcDiagnostic> {
321 let start = l.offset;
322 loop {
323 match l.peek_byte() {
324 Some(b' ' | b'\n' | b'\t' | b'\r' | b'.' | b'#' | b'>' | b'<' | b'(' | b'|') | None => {
325 break
326 }
327 Some(b'$') => {
328 if l.peek_rest().starts_with("${") {
329 l.advance(2);
330 parse_expr(l)?;
331 l.expect("}")?;
332 } else {
333 l.offset += 1;
334 }
335 }
336 _ => l.offset += 1,
337 }
338 }
339 let tag_name = &l.src[start as usize..l.offset as usize];
340 if !tag_name.is_empty() {
341 Ok(tag_name)
342 } else {
343 Err(err(
344 if id {
345 "Expected id"
346 } else {
347 "Expected class name"
348 },
349 l.offset,
350 1,
351 ))
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 /// Helper to check basic Open properties without worrying about id_info/shorthand_end
360 fn check_open(src: &str, tag_name: &str, content: &str, self_closing: bool) {
361 let open = parse_open(src).unwrap();
362 assert_eq!(open.tag_name(), tag_name, "tag_name mismatch for {src}");
363 assert_eq!(open.src, content, "content mismatch for {src}");
364 assert_eq!(
365 open.self_closing, self_closing,
366 "self_closing mismatch for {src}"
367 );
368 }
369
370 #[test]
371 fn test_open_basic() {
372 check_open("<div>", "div", "<div>", false);
373 check_open(
374 "<footer#id.class meow=purr yolo=4.2 /> etfdas",
375 "footer",
376 "<footer#id.class meow=purr yolo=4.2 />",
377 true,
378 );
379 check_open(
380 "<movie value=x > 4 /> etfdas",
381 "movie",
382 "<movie value=x >",
383 false,
384 );
385 }
386
387 #[test]
388 fn test_open_id() {
389 check_open(
390 "<h2#technical-review>A Technical Review",
391 "h2",
392 "<h2#technical-review>",
393 false,
394 );
395 check_open(
396 "<footer#id.class meow=purr yolo=4.2 /> etfdas",
397 "footer",
398 "<footer#id.class meow=purr yolo=4.2 />",
399 true,
400 );
401 check_open(
402 "<movie value=x > 4 /> etfdas",
403 "movie",
404 "<movie value=x >",
405 false,
406 );
407 }
408
409 #[test]
410 fn test_open_self_closing_with_space() {
411 check_open("<input />", "input", "<input />", true);
412 }
413
414 #[test]
415 fn test_open_self_closing_no_space() {
416 check_open("<br/>", "br", "<br/>", true);
417 check_open("<my-component/>", "my-component", "<my-component/>", true);
418 }
419
420 #[test]
421 fn test_open_tag_names() {
422 check_open("<my-custom-tag>", "my-custom-tag", "<my-custom-tag>", false);
423 check_open("<MyComponent>", "MyComponent", "<MyComponent>", false);
424 check_open("<my_tag>", "my_tag", "<my_tag>", false);
425 check_open("<$tag>", "$tag", "<$tag>", false);
426 }
427
428 #[test]
429 fn test_close() {
430 assert_eq!(
431 parse_close("</>"),
432 Ok(Close {
433 tag_name: None,
434 length: 3
435 })
436 );
437 assert_eq!(
438 parse_close("</>ZZZZZZ"),
439 Ok(Close {
440 tag_name: None,
441 length: 3
442 })
443 );
444 assert_eq!(
445 parse_close("</div>ZZZZZZ"),
446 Ok(Close {
447 tag_name: Some("div"),
448 length: 6
449 })
450 );
451 }
452
453 #[test]
454 fn test_close_named() {
455 assert_eq!(
456 parse_close("</my-component>"),
457 Ok(Close {
458 tag_name: Some("my-component"),
459 length: 15
460 })
461 );
462 assert_eq!(
463 parse_close("</MyComponent>"),
464 Ok(Close {
465 tag_name: Some("MyComponent"),
466 length: 14
467 })
468 );
469 }
470
471 #[test]
472 fn test_attribute_tag_basic_open() {
473 check_open("<@header>", "@header", "<@header>", false);
474 }
475
476 #[test]
477 fn test_attribute_tag_self_closing() {
478 check_open("<@item/>", "@item", "<@item/>", true);
479 }
480
481 #[test]
482 fn test_attribute_tag_with_string_attr() {
483 check_open(
484 "<@option value=\"foo\">",
485 "@option",
486 "<@option value=\"foo\">",
487 false,
488 );
489 }
490
491 #[test]
492 fn test_attribute_tag_no_variable() {
493 assert!(parse_open("<@header/myVar>").is_err());
494 }
495
496 #[test]
497 fn test_attribute_tag_no_arguments() {
498 assert!(parse_open("<@header(arg)>").is_err());
499 }
500
501 // ===========================================
502 // Shorthand class and id
503 // ===========================================
504
505 #[test]
506 fn test_shorthand_id() {
507 check_open("<div#myId>", "div", "<div#myId>", false);
508 }
509
510 #[test]
511 fn test_shorthand_class() {
512 check_open("<div.myClass>", "div", "<div.myClass>", false);
513 }
514
515 #[test]
516 fn test_shorthand_multiple_classes() {
517 check_open("<div.cls1.cls2.cls3>", "div", "<div.cls1.cls2.cls3>", false);
518 }
519
520 #[test]
521 fn test_shorthand_id_and_classes() {
522 check_open("<div#myId.cls1.cls2>", "div", "<div#myId.cls1.cls2>", false);
523 }
524
525 #[test]
526 fn test_shorthand_classes_then_id() {
527 check_open("<div.cls1#myId.cls2>", "div", "<div.cls1#myId.cls2>", false);
528 }
529
530 #[test]
531 fn test_shorthand_duplicate_id_error() {
532 assert!(parse_open("<div#id1#id2>").is_err());
533 }
534
535 #[test]
536 fn test_shorthand_with_hyphen() {
537 check_open("<div.my-class#my-id>", "div", "<div.my-class#my-id>", false);
538 }
539
540 #[test]
541 fn test_shorthand_empty_class_error() {
542 assert!(parse_open("<div.>").is_err());
543 }
544
545 #[test]
546 fn test_shorthand_empty_id_error() {
547 assert!(parse_open("<div#>").is_err());
548 }
549
550 // ===========================================
551 // Tag variables (/var)
552 // ===========================================
553
554 #[test]
555 fn test_tag_variable_simple() {
556 check_open("<my-tag/foo>", "my-tag", "<my-tag/foo>", false);
557 }
558
559 #[test]
560 fn test_tag_variable_self_closing() {
561 check_open("<input/myInput/>", "input", "<input/myInput/>", true);
562 }
563
564 #[test]
565 fn test_tag_variable_destructure_object() {
566 check_open("<my-tag/{ a, b }>", "my-tag", "<my-tag/{ a, b }>", false);
567 }
568
569 #[test]
570 fn test_tag_variable_destructure_array() {
571 check_open("<my-tag/[x, y]>", "my-tag", "<my-tag/[x, y]>", false);
572 }
573
574 #[test]
575 fn test_tag_variable_with_type() {
576 check_open(
577 "<const/items: Item[]>",
578 "const",
579 "<const/items: Item[]>",
580 false,
581 );
582 }
583
584 #[test]
585 fn test_tag_variable_with_attrs() {
586 check_open(
587 "<my-tag/result value=42>",
588 "my-tag",
589 "<my-tag/result value=42>",
590 false,
591 );
592 }
593
594 // ===========================================
595 // Tag arguments (args)
596 // ===========================================
597
598 #[test]
599 fn test_tag_arguments_empty() {
600 check_open("<my-tag()>", "my-tag", "<my-tag()>", false);
601 }
602
603 #[test]
604 fn test_tag_arguments_simple() {
605 check_open("<my-tag(1, 2, 3)>", "my-tag", "<my-tag(1, 2, 3)>", false);
606 }
607
608 #[test]
609 fn test_tag_arguments_expressions() {
610 check_open(
611 "<my-tag(a + b, fn())>",
612 "my-tag",
613 "<my-tag(a + b, fn())>",
614 false,
615 );
616 }
617
618 #[test]
619 fn test_tag_arguments_spread() {
620 check_open("<my-tag(...args)>", "my-tag", "<my-tag(...args)>", false);
621 }
622
623 #[test]
624 fn test_tag_arguments_object() {
625 check_open(
626 "<my-tag({ a: 1, b: 2 })>",
627 "my-tag",
628 "<my-tag({ a: 1, b: 2 })>",
629 false,
630 );
631 }
632
633 #[test]
634 fn test_tag_arguments_with_space() {
635 check_open("<my-tag (1, 2, 3)>", "my-tag", "<my-tag (1, 2, 3)>", false);
636 }
637
638 // ===========================================
639 // Attributes
640 // ===========================================
641
642 #[test]
643 fn test_attr_simple_string() {
644 check_open("<div class=\"foo\">", "div", "<div class=\"foo\">", false);
645 }
646
647 #[test]
648 fn test_attr_expression() {
649 check_open("<div count=1 + 1>", "div", "<div count=1 + 1>", false);
650 }
651
652 #[test]
653 fn test_attr_template_literal() {
654 check_open(
655 "<div class=`hello ${name}`>",
656 "div",
657 "<div class=`hello ${name}`>",
658 false,
659 );
660 }
661
662 #[test]
663 fn test_attr_spread() {
664 check_open("<div ...props>", "div", "<div ...props>", false);
665 }
666
667 #[test]
668 fn test_attr_spread_object() {
669 check_open(
670 "<div ...{ a: 1, b: 2 }>",
671 "div",
672 "<div ...{ a: 1, b: 2 }>",
673 false,
674 );
675 }
676
677 #[test]
678 fn test_attr_multiple_spreads() {
679 check_open(
680 "<div ...a ...b foo=\"bar\">",
681 "div",
682 "<div ...a ...b foo=\"bar\">",
683 false,
684 );
685 }
686
687 #[test]
688 fn test_attr_two_way_binding() {
689 check_open(
690 "<counter value:=count>",
691 "counter",
692 "<counter value:=count>",
693 false,
694 );
695 }
696
697 #[test]
698 fn test_attr_two_way_binding_property() {
699 check_open(
700 "<counter value:=input.count>",
701 "counter",
702 "<counter value:=input.count>",
703 false,
704 );
705 }
706
707 #[test]
708 fn test_attr_method_shorthand() {
709 check_open(
710 "<button onClick(e) { console.log(e) }>",
711 "button",
712 "<button onClick(e) { console.log(e) }>",
713 false,
714 );
715 }
716
717 #[test]
718 fn test_attr_method_with_return_type() {
719 check_open(
720 "<button onClick(e): void { console.log(e) }>",
721 "button",
722 "<button onClick(e): void { console.log(e) }>",
723 false,
724 );
725 }
726
727 #[test]
728 fn test_attr_boolean() {
729 check_open(
730 "<input type=\"checkbox\" checked>",
731 "input",
732 "<input type=\"checkbox\" checked>",
733 true, // input is a void element
734 );
735 }
736
737 #[test]
738 fn test_attr_boolean_then_attr() {
739 check_open(
740 "<input checked foo=1>",
741 "input",
742 "<input checked foo=1>",
743 true, // input is a void element
744 );
745 }
746
747 #[test]
748 fn test_attr_with_colon() {
749 check_open(
750 "<div data:value=\"test\">",
751 "div",
752 "<div data:value=\"test\">",
753 false,
754 );
755 }
756
757 #[test]
758 fn test_shorthand_value() {
759 check_open("<my-tag=1>", "my-tag", "<my-tag=1>", false);
760 }
761
762 #[test]
763 fn test_shorthand_value_expression() {
764 check_open("<my-tag=a + b>", "my-tag", "<my-tag=a + b>", false);
765 }
766
767 #[test]
768 fn test_shorthand_value_with_other_attrs() {
769 check_open(
770 "<my-tag=42 foo=\"bar\">",
771 "my-tag",
772 "<my-tag=42 foo=\"bar\">",
773 false,
774 );
775 }
776
777 #[test]
778 fn test_value_method_shorthand() {
779 check_open(
780 "<my-tag() { console.log(\"hi\") }>",
781 "my-tag",
782 "<my-tag() { console.log(\"hi\") }>",
783 false,
784 );
785 }
786
787 #[test]
788 fn test_value_method_shorthand_self_closing() {
789 check_open(
790 "<my-tag() { console.log(\"hi\") }/>",
791 "my-tag",
792 "<my-tag() { console.log(\"hi\") }/>",
793 true,
794 );
795 }
796
797 #[test]
798 fn test_value_method_shorthand_with_param() {
799 check_open(
800 "<my-tag(e) { console.log(e) }>",
801 "my-tag",
802 "<my-tag(e) { console.log(e) }>",
803 false,
804 );
805 }
806
807 #[test]
808 fn test_combined_variable_and_args() {
809 check_open("<for/item (items)>", "for", "<for/item (items)>", false);
810 }
811
812 #[test]
813 fn test_combined_id_class_variable() {
814 check_open("<div#myId.cls/ref>", "div", "<div#myId.cls/ref>", false);
815 }
816
817 #[test]
818 fn test_combined_complex() {
819 check_open(
820 "<my-tag#id.cls/result|arg1, arg2| foo=bar ...spread>",
821 "my-tag",
822 "<my-tag#id.cls/result|arg1, arg2| foo=bar ...spread>",
823 false,
824 );
825 }
826
827 #[test]
828 fn test_attr_with_gt_in_parens() {
829 check_open("<div value=(a > b)>", "div", "<div value=(a > b)>", false);
830 }
831
832 #[test]
833 fn test_attr_with_gt_in_brackets() {
834 check_open(
835 "<div value=arr[a > 0]>",
836 "div",
837 "<div value=arr[a > 0]>",
838 false,
839 );
840 }
841
842 #[test]
843 fn test_attr_arrow_function() {
844 check_open(
845 "<div onClick=() => console.log(1)>",
846 "div",
847 "<div onClick=() => console.log(1)>",
848 false,
849 );
850 }
851
852 #[test]
853 fn test_attr_ternary() {
854 check_open(
855 "<div class=isActive ? a : b> > > > >",
856 "div",
857 "<div class=isActive ? a : b>",
858 false,
859 );
860 }
861
862 #[test]
863 fn test_error_empty_tag_name() {
864 let result = parse_open("<>");
865 assert!(result.is_err());
866 }
867
868 #[test]
869 fn test_error_missing_close() {
870 let result = parse_open("<div");
871 assert!(result.is_err());
872 }
873
874 #[test]
875 fn test_error_close_missing_gt() {
876 let result = parse_close("</div");
877 assert!(result.is_err());
878 }
879
880 #[test]
881 fn test_parse_tag_open() {
882 let result = parse_tag("<div>").unwrap();
883 match result {
884 OpenOrClose::Open(open) => {
885 assert_eq!(open.tag_name(), "div");
886 }
887 OpenOrClose::Close(_) => panic!("Expected Open"),
888 }
889 }
890
891 #[test]
892 fn test_parse_tag_close() {
893 let result = parse_tag("</>").unwrap();
894 match result {
895 OpenOrClose::Open(_) => panic!("Expected Close"),
896 OpenOrClose::Close(close) => {
897 assert_eq!(close.tag_name, None);
898 }
899 }
900 }
901
902 #[test]
903 fn test_parse_tag_close_named() {
904 let result = parse_tag("</div>").unwrap();
905 match result {
906 OpenOrClose::Open(_) => panic!("Expected Close"),
907 OpenOrClose::Close(close) => {
908 assert_eq!(close.tag_name, Some("div"));
909 }
910 }
911 }
912
913 #[test]
914 fn test_lexstate_peek_byte() {
915 let l = LexState::new("abc");
916 assert_eq!(l.peek_byte(), Some(b'a'));
917 }
918
919 #[test]
920 fn test_lexstate_peek_byte_empty() {
921 let l = LexState::new("");
922 assert_eq!(l.peek_byte(), None);
923 }
924
925 #[test]
926 fn test_lexstate_advance() {
927 let mut l = LexState::new("abc");
928 l.advance(1);
929 assert_eq!(l.peek_byte(), Some(b'b'));
930 l.advance(1);
931 assert_eq!(l.peek_byte(), Some(b'c'));
932 }
933
934 #[test]
935 fn test_lexstate_skip_whitespace() {
936 let mut l = LexState::new(" abc");
937 l.skip_whitespace();
938 assert_eq!(l.peek_byte(), Some(b'a'));
939 }
940
941 #[test]
942 fn test_lexstate_skip_whitespace_mixed() {
943 let mut l = LexState::new(" \t\n\rabc");
944 l.skip_whitespace();
945 assert_eq!(l.peek_byte(), Some(b'a'));
946 }
947
948 #[test]
949 fn test_lexstate_expect() {
950 let mut l = LexState::new("hello world");
951 assert!(l.expect("hello").is_ok());
952 assert_eq!(l.offset(), 5);
953 }
954
955 #[test]
956 fn test_lexstate_expect_fail() {
957 let mut l = LexState::new("hello");
958 assert!(l.expect("world").is_err());
959 assert_eq!(l.offset(), 0); // offset unchanged on failure
960 }
961
962 #[test]
963 fn test_lexstate_restore() {
964 let mut l = LexState::new("abcdef");
965 l.advance(3);
966 assert_eq!(l.offset(), 3);
967 l.restore(1);
968 assert_eq!(l.offset(), 1);
969 assert_eq!(l.peek_byte(), Some(b'b'));
970 }
971
972 #[test]
973 fn test_lexstate_peek_rest() {
974 let mut l = LexState::new("hello world");
975 l.advance(6);
976 assert_eq!(l.peek_rest(), "world");
977 }
978
979 #[test]
980 fn test_tag_params_simple() {
981 parse_open("<for|item| of=items>").unwrap();
982 }
983
984 #[test]
985 fn test_tag_params_destructure() {
986 parse_open("<for|{ name, age }| of=people>").unwrap();
987 }
988
989 #[test]
990 fn test_shorthand_dynamic_class() {
991 let result = parse_open("<div.color-${iconName}/>");
992 assert!(result.is_ok());
993 let open = result.unwrap();
994 assert_eq!(open.src, "<div.color-${iconName}/>");
995 }
996
997 #[test]
998 fn test_shorthand_dynamic_id() {
999 let result = parse_open("<div#${myId}>");
1000 assert!(result.is_ok());
1001 let open = result.unwrap();
1002 assert_eq!(open.src, "<div#${myId}>");
1003 }
1004
1005 #[test]
1006 fn test_shorthand_dynamic_mixed() {
1007 let result = parse_open("<button.variant-${meow}#${id}>");
1008 assert!(result.is_ok());
1009 let open = result.unwrap();
1010 assert_eq!(open.src, "<button.variant-${meow}#${id}>");
1011 }
1012
1013 #[test]
1014 fn test_shorthand_dynamic_multiple_classes() {
1015 let result = parse_open("<div.class1.${dynamic}.class3>");
1016 assert!(result.is_ok());
1017 let open = result.unwrap();
1018 assert_eq!(open.src, "<div.class1.${dynamic}.class3>");
1019 }
1020
1021 #[test]
1022 fn test_shorthand_template_expression() {
1023 let result = parse_open("<div.class-${a + b}>");
1024 assert!(result.is_ok());
1025 let open = result.unwrap();
1026 assert_eq!(open.src, "<div.class-${a + b}>");
1027 }
1028
1029 #[test]
1030 fn test_shorthand_template_with_dollar() {
1031 let result = parse_open("<div.class-$value>");
1032 assert!(result.is_ok());
1033 let open = result.unwrap();
1034 assert_eq!(open.src, "<div.class-$value>");
1035 }
1036
1037 #[test]
1038 fn test_dynamic_tag() {
1039 let result = parse_open("<${MyComponent}/>");
1040 assert!(result.is_ok());
1041 }
1042
1043 // ===========================================
1044 // Void elements (implicitly self-closing)
1045 // ===========================================
1046
1047 #[test]
1048 fn test_void_element_img() {
1049 check_open(
1050 "<img src=\"cat.jpg\">",
1051 "img",
1052 "<img src=\"cat.jpg\">",
1053 true,
1054 );
1055 }
1056
1057 #[test]
1058 fn test_void_element_img_explicit() {
1059 check_open(
1060 "<img src=\"cat.jpg\" />",
1061 "img",
1062 "<img src=\"cat.jpg\" />",
1063 true,
1064 );
1065 }
1066
1067 #[test]
1068 fn test_void_element_br() {
1069 check_open("<br>", "br", "<br>", true);
1070 }
1071
1072 #[test]
1073 fn test_void_element_hr() {
1074 check_open("<hr>", "hr", "<hr>", true);
1075 }
1076
1077 #[test]
1078 fn test_void_element_input() {
1079 check_open(
1080 "<input type=\"text\">",
1081 "input",
1082 "<input type=\"text\">",
1083 true,
1084 );
1085 }
1086
1087 #[test]
1088 fn test_void_element_meta() {
1089 check_open(
1090 "<meta charset=\"utf-8\">",
1091 "meta",
1092 "<meta charset=\"utf-8\">",
1093 true,
1094 );
1095 }
1096
1097 #[test]
1098 fn test_void_element_link() {
1099 check_open(
1100 "<link rel=\"stylesheet\" href=\"style.css\">",
1101 "link",
1102 "<link rel=\"stylesheet\" href=\"style.css\">",
1103 true,
1104 );
1105 }
1106
1107 #[test]
1108 fn test_void_element_source() {
1109 check_open(
1110 "<source src=\"video.mp4\">",
1111 "source",
1112 "<source src=\"video.mp4\">",
1113 true,
1114 );
1115 }
1116
1117 #[test]
1118 fn test_void_element_wbr() {
1119 check_open("<wbr>", "wbr", "<wbr>", true);
1120 }
1121
1122 #[test]
1123 fn test_non_void_element_div() {
1124 // div is NOT a void element, should not be self-closing
1125 check_open("<div>", "div", "<div>", false);
1126 }
1127
1128 #[test]
1129 fn test_non_void_element_span() {
1130 // span is NOT a void element, should not be self-closing
1131 check_open("<span>", "span", "<span>", false);
1132 }
1133
1134 #[test]
1135 fn test_id_attr_shorthand_static() {
1136 let open = parse_open("<div#myId>").unwrap();
1137 assert_eq!(open.id_attr(), Some(("myId", false)));
1138 assert!(!open.id_is_dynamic());
1139 }
1140
1141 #[test]
1142 fn test_id_attr_shorthand_with_hyphen() {
1143 let open = parse_open("<h2#technical-review>").unwrap();
1144 assert_eq!(open.id_attr(), Some(("technical-review", false)));
1145 }
1146
1147 #[test]
1148 fn test_id_attr_shorthand_dynamic() {
1149 let open = parse_open("<div#${myId}>").unwrap();
1150 assert_eq!(open.id_attr(), None);
1151 assert!(open.id_is_dynamic());
1152 }
1153
1154 #[test]
1155 fn test_id_attr_attribute_static() {
1156 let open = parse_open("<div id=\"my-id\">").unwrap();
1157 assert_eq!(open.id_attr(), Some(("\"my-id\"", true)));
1158 assert!(!open.id_is_dynamic());
1159 }
1160
1161 #[test]
1162 fn test_id_attr_attribute_single_quotes() {
1163 let open = parse_open("<div id='my-id'>").unwrap();
1164 assert_eq!(open.id_attr(), Some(("'my-id'", true)));
1165 }
1166
1167 #[test]
1168 fn test_id_attr_attribute_dynamic() {
1169 let open = parse_open("<div id=myVariable>").unwrap();
1170 assert_eq!(open.id_attr(), None);
1171 assert!(open.id_is_dynamic());
1172 }
1173
1174 #[test]
1175 fn test_id_attr_none() {
1176 let open = parse_open("<div class=\"foo\">").unwrap();
1177 assert_eq!(open.id_attr(), None);
1178 assert!(!open.id_is_dynamic());
1179 }
1180
1181 #[test]
1182 fn test_id_attr_shorthand_with_classes() {
1183 let open = parse_open("<div.cls1#myId.cls2>").unwrap();
1184 assert_eq!(open.id_attr(), Some(("myId", false)));
1185 }
1186
1187 #[test]
1188 fn test_set_id_attr_insert_new() {
1189 let open = parse_open("<div>").unwrap();
1190 let mut owned = open.to_owned();
1191 owned.insert_id_attr("new-id");
1192 assert_eq!(owned.src, "<div#new-id>");
1193 assert_eq!(owned.as_ref().id_attr(), Some(("new-id", false)));
1194 }
1195
1196 #[test]
1197 fn test_set_id_attr_insert_with_attrs() {
1198 let open = parse_open("<div class=\"foo\">").unwrap();
1199 let mut owned = open.to_owned();
1200 owned.insert_id_attr("new-id");
1201 assert_eq!(owned.src, "<div#new-id class=\"foo\">");
1202 }
1203
1204 #[test]
1205 fn test_set_id_attr_self_closing() {
1206 let open = parse_open("<input/>").unwrap();
1207 let mut owned = open.to_owned();
1208 owned.insert_id_attr("my-input");
1209 assert_eq!(owned.src, "<input#my-input/>");
1210 }
1211}