1use crate::parser::inlines::sink::InlineSink;
14use crate::syntax::SyntaxKind;
15#[cfg(test)]
16use rowan::GreenNodeBuilder;
17
18#[derive(Debug, PartialEq)]
19pub struct AttributeBlock {
20 pub identifier: Option<String>,
21 pub classes: Vec<String>,
22 pub key_values: Vec<(String, String)>,
23}
24
25pub fn try_parse_trailing_attributes(text: &str) -> Option<(AttributeBlock, &str)> {
28 let (attrs, before, _) = try_parse_trailing_attributes_with_pos(text)?;
29 Some((attrs, before))
30}
31
32pub fn try_parse_trailing_attributes_with_pos(text: &str) -> Option<(AttributeBlock, &str, usize)> {
35 let trimmed = text.trim_end();
36
37 if !trimmed.ends_with('}') {
39 return None;
40 }
41
42 let open_brace = find_matching_open_brace_for_trailing_block(trimmed)?;
45
46 let before_brace = &trimmed[..open_brace];
49 if before_brace.trim_end().ends_with(']') {
50 log::trace!("Skipping attribute parsing for bracketed span: {}", text);
51 return None;
52 }
53
54 let attr_content = &trimmed[open_brace + 1..trimmed.len() - 1];
56 let attr_block = parse_attribute_content(attr_content)?;
57
58 let before_attrs = trimmed[..open_brace].trim_end();
60
61 Some((attr_block, before_attrs, open_brace))
62}
63
64fn find_matching_open_brace_for_trailing_block(text: &str) -> Option<usize> {
65 if !text.ends_with('}') {
66 return None;
67 }
68
69 let mut stack: Vec<usize> = Vec::new();
70 let mut in_quote: Option<char> = None;
71 let mut escaped = false;
72 let mut end_brace_open = None;
73
74 for (idx, ch) in text.char_indices() {
75 if let Some(q) = in_quote {
76 if escaped {
77 escaped = false;
78 continue;
79 }
80 if ch == '\\' {
81 escaped = true;
82 continue;
83 }
84 if ch == q {
85 in_quote = None;
86 }
87 continue;
88 }
89
90 match ch {
91 '\'' | '"' => in_quote = Some(ch),
92 '{' => stack.push(idx),
93 '}' => {
94 let open = stack.pop()?;
95 if idx == text.len() - 1 {
96 end_brace_open = Some(open);
97 }
98 }
99 _ => {}
100 }
101 }
102
103 if in_quote.is_some() || !stack.is_empty() {
104 return None;
105 }
106
107 end_brace_open
108}
109
110#[derive(Debug, Clone, PartialEq)]
116pub(crate) enum AttrComponent {
117 Id(std::ops::Range<usize>),
119 Class(std::ops::Range<usize>),
121 KeyValue {
124 key: std::ops::Range<usize>,
125 eq: usize,
126 value: std::ops::Range<usize>,
127 },
128}
129
130#[derive(Debug, Clone, PartialEq)]
137pub(crate) struct AttributeSpans {
138 pub components: Vec<AttrComponent>,
139}
140
141fn attr_value_string(raw: &str) -> String {
147 let bytes = raw.as_bytes();
148 if let Some(&q) = bytes.first()
149 && (q == b'"' || q == b'\'')
150 {
151 let inner = &raw[1..];
152 return inner.strip_suffix(q as char).unwrap_or(inner).to_string();
153 }
154 raw.to_string()
155}
156
157pub(crate) fn attribute_content_spans(content: &str) -> Option<AttributeSpans> {
161 let bytes = content.as_bytes();
162 let mut pos = 0;
163 let mut components: Vec<AttrComponent> = Vec::new();
164 let mut have_id = false;
165
166 while pos < bytes.len() {
167 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
169 pos += 1;
170 }
171 if pos >= bytes.len() {
172 break;
173 }
174
175 if bytes[pos] == b'=' {
176 let start = pos;
179 pos += 1; while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'}' {
181 pos += 1;
182 }
183 if pos > start + 1 {
184 components.push(AttrComponent::Class(start..pos));
185 }
186 } else if bytes[pos] == b'#' {
187 let start = pos;
188 pos += 1; while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'}' {
190 pos += 1;
191 }
192 if !have_id && pos > start + 1 {
195 components.push(AttrComponent::Id(start..pos));
196 have_id = true;
197 }
198 } else if bytes[pos] == b'.' {
199 let start = pos;
200 pos += 1; while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'}' {
202 pos += 1;
203 }
204 if pos > start + 1 {
205 components.push(AttrComponent::Class(start..pos));
206 }
207 } else {
208 let key_start = pos;
210 while pos < bytes.len() && bytes[pos] != b'=' && !bytes[pos].is_ascii_whitespace() {
211 pos += 1;
212 }
213 if pos >= bytes.len() || bytes[pos] != b'=' {
214 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
216 pos += 1;
217 }
218 continue;
219 }
220 let key_end = pos;
221 let eq = pos;
222 pos += 1; let value_start = pos;
225 if pos < bytes.len() && (bytes[pos] == b'"' || bytes[pos] == b'\'') {
226 let quote = bytes[pos];
227 pos += 1; while pos < bytes.len() && bytes[pos] != quote {
229 pos += 1;
230 }
231 if pos < bytes.len() {
232 pos += 1; }
234 } else {
235 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'}' {
236 pos += 1;
237 }
238 }
239 if key_end > key_start {
240 components.push(AttrComponent::KeyValue {
241 key: key_start..key_end,
242 eq,
243 value: value_start..pos,
244 });
245 }
246 }
247 }
248
249 if components.is_empty() {
250 return None;
251 }
252 Some(AttributeSpans { components })
253}
254
255pub fn parse_attribute_content(content: &str) -> Option<AttributeBlock> {
259 let spans = attribute_content_spans(content)?;
260 let mut identifier = None;
261 let mut classes = Vec::new();
262 let mut key_values = Vec::new();
263
264 for comp in &spans.components {
265 match comp {
266 AttrComponent::Id(r) => {
267 identifier = Some(content[r.start + 1..r.end].to_string());
269 }
270 AttrComponent::Class(r) => {
271 let raw = &content[r.clone()];
272 match raw.strip_prefix('.') {
274 Some(class) => classes.push(class.to_string()),
275 None => classes.push(raw.to_string()),
276 }
277 }
278 AttrComponent::KeyValue { key, value, .. } => {
279 key_values.push((
280 content[key.clone()].to_string(),
281 attr_value_string(&content[value.clone()]),
282 ));
283 }
284 }
285 }
286
287 Some(AttributeBlock {
288 identifier,
289 classes,
290 key_values,
291 })
292}
293
294pub fn parse_html_tag_attributes(tag_text: &str) -> Option<AttributeBlock> {
305 let trimmed = tag_text.trim_start();
306 let after_lt = trimmed.strip_prefix('<')?;
307 let bytes = after_lt.as_bytes();
311 let mut tag_end = None;
312 let mut quote: Option<u8> = None;
313 for (i, &b) in bytes.iter().enumerate() {
314 match (quote, b) {
315 (None, b'"') | (None, b'\'') => quote = Some(b),
316 (Some(q), b2) if b2 == q => quote = None,
317 (None, b'>') => {
318 tag_end = Some(i);
319 break;
320 }
321 _ => {}
322 }
323 }
324 let tag_end = tag_end?;
325 let inner = &after_lt[..tag_end];
326 let inner = inner.trim_end().trim_end_matches('/').trim_end();
328 let bytes = inner.as_bytes();
330 let mut name_end = 0usize;
331 while name_end < bytes.len()
332 && !bytes[name_end].is_ascii_whitespace()
333 && bytes[name_end] != b'/'
334 {
335 name_end += 1;
336 }
337 let attrs_text = &inner[name_end..];
338 parse_html_attribute_list(attrs_text)
339}
340
341pub fn parse_html_attribute_list(attrs_text: &str) -> Option<AttributeBlock> {
351 let comps = html_attribute_spans(attrs_text);
352 if comps.is_empty() {
353 return None;
354 }
355 let mut identifier: Option<String> = None;
356 let mut classes: Vec<String> = Vec::new();
357 let mut key_values: Vec<(String, String)> = Vec::new();
358 for comp in &comps {
359 match comp {
360 HtmlAttrComponent::Id(r) => {
361 if identifier.is_none() {
362 identifier = Some(attrs_text[r.clone()].to_string());
363 }
364 }
365 HtmlAttrComponent::Class(r) => classes.push(attrs_text[r.clone()].to_string()),
366 HtmlAttrComponent::KeyValue { key, value, .. } => {
367 key_values.push((
368 attrs_text[key.clone()].to_string(),
369 attr_value_string(&attrs_text[value.clone()]),
370 ));
371 }
372 HtmlAttrComponent::Flag(r) => {
373 key_values.push((attrs_text[r.clone()].to_string(), String::new()));
374 }
375 }
376 }
377 if identifier.is_none() && classes.is_empty() && key_values.is_empty() {
378 return None;
379 }
380 Some(AttributeBlock {
381 identifier,
382 classes,
383 key_values,
384 })
385}
386
387#[derive(Debug, Clone, PartialEq)]
396enum HtmlAttrComponent {
397 Id(std::ops::Range<usize>),
399 Class(std::ops::Range<usize>),
401 KeyValue {
404 key: std::ops::Range<usize>,
405 eq: usize,
406 value: std::ops::Range<usize>,
407 },
408 Flag(std::ops::Range<usize>),
410}
411
412fn html_value_inner_range(content: &str, start: usize, end: usize) -> std::ops::Range<usize> {
417 let b = content.as_bytes();
418 if end > start && (b[start] == b'"' || b[start] == b'\'') {
419 let q = b[start];
420 if end > start + 1 && b[end - 1] == q {
421 return (start + 1)..(end - 1);
422 }
423 return (start + 1)..end;
424 }
425 start..end
426}
427
428fn html_word_ranges(content: &str, start: usize, end: usize) -> Vec<std::ops::Range<usize>> {
430 let b = content.as_bytes();
431 let mut out = Vec::new();
432 let mut i = start;
433 while i < end {
434 while i < end && b[i].is_ascii_whitespace() {
435 i += 1;
436 }
437 if i >= end {
438 break;
439 }
440 let ws = i;
441 while i < end && !b[i].is_ascii_whitespace() {
442 i += 1;
443 }
444 out.push(ws..i);
445 }
446 out
447}
448
449fn html_attribute_spans(content: &str) -> Vec<HtmlAttrComponent> {
454 let bytes = content.as_bytes();
455 let mut i = 0usize;
456 let mut comps: Vec<HtmlAttrComponent> = Vec::new();
457 let mut have_id = false;
458
459 while i < bytes.len() {
460 match bytes[i] {
461 b' ' | b'\t' | b'\n' | b'\r' | b'/' => {
462 i += 1;
463 }
464 _ => {
465 let key_start = i;
466 while i < bytes.len()
467 && !matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b'=' | b'/')
468 {
469 i += 1;
470 }
471 let key_end = i;
472 let key = &content[key_start..key_end];
473
474 if i < bytes.len() && bytes[i] == b'=' {
475 let eq = i;
476 i += 1; let value_start = i;
478 if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
479 let quote = bytes[i];
480 i += 1; while i < bytes.len() && bytes[i] != quote {
482 i += 1;
483 }
484 if i < bytes.len() {
485 i += 1; }
487 } else {
488 while i < bytes.len()
489 && !matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b'/')
490 {
491 i += 1;
492 }
493 }
494 let value_end = i;
495 match key {
496 "id" => {
497 if !have_id {
498 let inner = html_value_inner_range(content, value_start, value_end);
499 if inner.end > inner.start {
500 comps.push(HtmlAttrComponent::Id(inner));
501 have_id = true;
502 }
503 }
504 }
505 "class" => {
506 let inner = html_value_inner_range(content, value_start, value_end);
507 for w in html_word_ranges(content, inner.start, inner.end) {
508 comps.push(HtmlAttrComponent::Class(w));
509 }
510 }
511 _ => comps.push(HtmlAttrComponent::KeyValue {
512 key: key_start..key_end,
513 eq,
514 value: value_start..value_end,
515 }),
516 }
517 } else if key_end > key_start {
518 comps.push(HtmlAttrComponent::Flag(key_start..key_end));
519 }
520 }
521 }
522 }
523
524 comps
525}
526
527pub fn emit_html_attrs_node(builder: &mut impl InlineSink, attrs_text: &str) {
534 emit_html_attrs_with_kind(builder, SyntaxKind::HTML_ATTRS, attrs_text);
535}
536
537pub fn emit_html_span_attributes_node(builder: &mut impl InlineSink, attrs_text: &str) {
540 emit_html_attrs_with_kind(builder, SyntaxKind::SPAN_ATTRIBUTES, attrs_text);
541}
542
543fn emit_html_attrs_with_kind(
544 builder: &mut impl InlineSink,
545 node_kind: SyntaxKind,
546 attrs_text: &str,
547) {
548 builder.start_node(node_kind.into());
549 let comps = html_attribute_spans(attrs_text);
550 if comps.is_empty() {
551 builder.token(SyntaxKind::TEXT.into(), attrs_text);
552 } else {
553 let mut cursor = 0usize;
554 for comp in &comps {
555 let (start, end) = match comp {
556 HtmlAttrComponent::Id(r)
557 | HtmlAttrComponent::Class(r)
558 | HtmlAttrComponent::Flag(r) => (r.start, r.end),
559 HtmlAttrComponent::KeyValue { key, value, .. } => (key.start, value.end),
560 };
561 emit_attribute_gap(builder, &attrs_text[cursor..start]);
562 match comp {
563 HtmlAttrComponent::Id(r) => {
564 builder.token(SyntaxKind::ATTR_ID.into(), &attrs_text[r.clone()]);
565 }
566 HtmlAttrComponent::Class(r) => {
567 builder.token(SyntaxKind::ATTR_CLASS.into(), &attrs_text[r.clone()]);
568 }
569 HtmlAttrComponent::Flag(r) => {
570 builder.start_node(SyntaxKind::ATTR_KEY_VALUE.into());
571 builder.token(SyntaxKind::ATTR_KEY.into(), &attrs_text[r.clone()]);
572 builder.finish_node();
573 }
574 HtmlAttrComponent::KeyValue { key, eq, value } => {
575 builder.start_node(SyntaxKind::ATTR_KEY_VALUE.into());
576 builder.token(SyntaxKind::ATTR_KEY.into(), &attrs_text[key.clone()]);
577 builder.token(SyntaxKind::TEXT.into(), &attrs_text[*eq..value.start]);
578 if value.end > value.start {
579 builder.token(SyntaxKind::ATTR_VALUE.into(), &attrs_text[value.clone()]);
580 }
581 builder.finish_node();
582 }
583 }
584 cursor = end;
585 }
586 emit_attribute_gap(builder, &attrs_text[cursor..]);
587 }
588 builder.finish_node();
589}
590
591pub fn emit_attribute_node(builder: &mut impl InlineSink, raw_attr_text: &str) {
600 emit_attribute_node_with_kinds(
601 builder,
602 SyntaxKind::ATTRIBUTE,
603 SyntaxKind::ATTRIBUTE,
604 raw_attr_text,
605 );
606}
607
608pub fn emit_div_info_node(builder: &mut impl InlineSink, raw_attr_text: &str) {
614 emit_attribute_node_with_kinds(
615 builder,
616 SyntaxKind::DIV_INFO,
617 SyntaxKind::TEXT,
618 raw_attr_text,
619 );
620}
621
622pub fn emit_span_attributes_node(builder: &mut impl InlineSink, raw_attr_text: &str) {
627 emit_attribute_node_with_kinds(
628 builder,
629 SyntaxKind::SPAN_ATTRIBUTES,
630 SyntaxKind::TEXT,
631 raw_attr_text,
632 );
633}
634
635fn emit_attribute_node_with_kinds(
640 builder: &mut impl InlineSink,
641 node_kind: SyntaxKind,
642 opaque_token_kind: SyntaxKind,
643 raw_attr_text: &str,
644) {
645 builder.start_node(node_kind.into());
646
647 let body = raw_attr_text
648 .strip_prefix('{')
649 .and_then(|s| s.strip_suffix('}'));
650 let spans = body.and_then(attribute_content_spans);
651
652 match (body, spans) {
653 (Some(body), Some(spans)) => {
654 builder.token(SyntaxKind::TEXT.into(), "{");
655 let mut cursor = 0usize;
656 for comp in &spans.components {
657 let (start, end) = match comp {
658 AttrComponent::Id(r) | AttrComponent::Class(r) => (r.start, r.end),
659 AttrComponent::KeyValue { key, value, .. } => (key.start, value.end),
660 };
661 emit_attribute_gap(builder, &body[cursor..start]);
662 match comp {
663 AttrComponent::Id(r) => {
664 builder.token(SyntaxKind::ATTR_ID.into(), &body[r.clone()]);
665 }
666 AttrComponent::Class(r) => {
667 builder.token(SyntaxKind::ATTR_CLASS.into(), &body[r.clone()]);
668 }
669 AttrComponent::KeyValue { key, eq, value } => {
670 builder.start_node(SyntaxKind::ATTR_KEY_VALUE.into());
671 builder.token(SyntaxKind::ATTR_KEY.into(), &body[key.clone()]);
672 builder.token(SyntaxKind::TEXT.into(), &body[*eq..*eq + 1]);
673 if value.end > value.start {
674 builder.token(SyntaxKind::ATTR_VALUE.into(), &body[value.clone()]);
675 }
676 builder.finish_node();
677 }
678 }
679 cursor = end;
680 }
681 emit_attribute_gap(builder, &body[cursor..]);
682 builder.token(SyntaxKind::TEXT.into(), "}");
683 }
684 _ => {
685 builder.token(opaque_token_kind.into(), raw_attr_text);
688 }
689 }
690
691 builder.finish_node();
692}
693
694fn emit_attribute_gap(builder: &mut impl InlineSink, gap: &str) {
698 let bytes = gap.as_bytes();
699 let mut i = 0;
700 while i < bytes.len() {
701 match bytes[i] {
702 b'\n' => {
703 builder.token(SyntaxKind::NEWLINE.into(), "\n");
704 i += 1;
705 }
706 b'\r' => {
707 if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
708 builder.token(SyntaxKind::NEWLINE.into(), "\r\n");
709 i += 2;
710 } else {
711 builder.token(SyntaxKind::NEWLINE.into(), "\r");
712 i += 1;
713 }
714 }
715 b if b.is_ascii_whitespace() => {
716 let start = i;
717 while i < bytes.len()
718 && bytes[i].is_ascii_whitespace()
719 && bytes[i] != b'\n'
720 && bytes[i] != b'\r'
721 {
722 i += 1;
723 }
724 builder.token(SyntaxKind::WHITESPACE.into(), &gap[start..i]);
725 }
726 _ => {
727 let start = i;
728 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
729 i += 1;
730 }
731 builder.token(SyntaxKind::TEXT.into(), &gap[start..i]);
732 }
733 }
734 }
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 #[test]
742 fn test_simple_id() {
743 let result = try_parse_trailing_attributes("Heading {#my-id}");
744 assert!(result.is_some());
745 let (attrs, before) = result.unwrap();
746 assert_eq!(before, "Heading");
747 assert_eq!(attrs.identifier, Some("my-id".to_string()));
748 assert!(attrs.classes.is_empty());
749 assert!(attrs.key_values.is_empty());
750 }
751
752 #[test]
753 fn test_single_class() {
754 let result = try_parse_trailing_attributes("Text {.myclass}");
755 assert!(result.is_some());
756 let (attrs, _) = result.unwrap();
757 assert_eq!(attrs.classes, vec!["myclass"]);
758 }
759
760 #[test]
761 fn test_multiple_classes() {
762 let result = try_parse_trailing_attributes("Text {.class1 .class2 .class3}");
763 assert!(result.is_some());
764 let (attrs, _) = result.unwrap();
765 assert_eq!(attrs.classes, vec!["class1", "class2", "class3"]);
766 }
767
768 #[test]
769 fn test_key_value_unquoted() {
770 let result = try_parse_trailing_attributes("Text {key=value}");
771 assert!(result.is_some());
772 let (attrs, _) = result.unwrap();
773 assert_eq!(
774 attrs.key_values,
775 vec![("key".to_string(), "value".to_string())]
776 );
777 }
778
779 #[test]
780 fn test_key_value_quoted() {
781 let result = try_parse_trailing_attributes("Text {key=\"value with spaces\"}");
782 assert!(result.is_some());
783 let (attrs, _) = result.unwrap();
784 assert_eq!(
785 attrs.key_values,
786 vec![("key".to_string(), "value with spaces".to_string())]
787 );
788 }
789
790 #[test]
791 fn test_full_attributes() {
792 let result =
793 try_parse_trailing_attributes("Heading {#id .class1 .class2 key1=val1 key2=\"val 2\"}");
794 assert!(result.is_some());
795 let (attrs, before) = result.unwrap();
796 assert_eq!(before, "Heading");
797 assert_eq!(attrs.identifier, Some("id".to_string()));
798 assert_eq!(attrs.classes, vec!["class1", "class2"]);
799 assert_eq!(attrs.key_values.len(), 2);
800 assert_eq!(
801 attrs.key_values[0],
802 ("key1".to_string(), "val1".to_string())
803 );
804 assert_eq!(
805 attrs.key_values[1],
806 ("key2".to_string(), "val 2".to_string())
807 );
808 }
809
810 #[test]
811 fn test_trailing_attributes_with_shortcode_in_quoted_value() {
812 let text = "Slide Title {background-image='{{< placeholder 100 100 >}}' background-size=\"100px\"}";
813 let result = try_parse_trailing_attributes(text);
814 assert!(result.is_some());
815 let (attrs, before) = result.unwrap();
816 assert_eq!(before, "Slide Title");
817 assert_eq!(attrs.key_values.len(), 2);
818 assert_eq!(
819 attrs.key_values[0],
820 (
821 "background-image".to_string(),
822 "{{< placeholder 100 100 >}}".to_string()
823 )
824 );
825 assert_eq!(
826 attrs.key_values[1],
827 ("background-size".to_string(), "100px".to_string())
828 );
829 }
830
831 #[test]
832 fn test_no_attributes() {
833 let result = try_parse_trailing_attributes("Heading with no attributes");
834 assert!(result.is_none());
835 }
836
837 #[test]
838 fn test_empty_braces() {
839 let result = try_parse_trailing_attributes("Heading {}");
840 assert!(result.is_none());
841 }
842
843 #[test]
844 fn test_only_first_id_counts() {
845 let result = try_parse_trailing_attributes("Text {#id1 #id2}");
846 assert!(result.is_some());
847 let (attrs, _) = result.unwrap();
848 assert_eq!(attrs.identifier, Some("id1".to_string()));
849 }
850
851 #[test]
852 fn test_whitespace_handling() {
853 let result = try_parse_trailing_attributes("Text { #id .class key=val }");
854 assert!(result.is_some());
855 let (attrs, _) = result.unwrap();
856 assert_eq!(attrs.identifier, Some("id".to_string()));
857 assert_eq!(attrs.classes, vec!["class"]);
858 assert_eq!(
859 attrs.key_values,
860 vec![("key".to_string(), "val".to_string())]
861 );
862 }
863
864 #[test]
865 fn test_parse_html_tag_attributes_id_only() {
866 let attrs = parse_html_tag_attributes(r#"<div id="anchor-c">"#).unwrap();
867 assert_eq!(attrs.identifier.as_deref(), Some("anchor-c"));
868 assert!(attrs.classes.is_empty());
869 assert!(attrs.key_values.is_empty());
870 }
871
872 #[test]
873 fn test_parse_html_tag_attributes_inline_content_after_open() {
874 let attrs = parse_html_tag_attributes(r#"<div id="anchor-c">Content.</div>"#).unwrap();
878 assert_eq!(attrs.identifier.as_deref(), Some("anchor-c"));
879 }
880
881 #[test]
882 fn test_parse_html_tag_attributes_class_and_kv() {
883 let attrs = parse_html_tag_attributes(r#"<div id="x" class="a b" data-key="v">"#).unwrap();
884 assert_eq!(attrs.identifier.as_deref(), Some("x"));
885 assert_eq!(attrs.classes, vec!["a", "b"]);
886 assert_eq!(
887 attrs.key_values,
888 vec![("data-key".to_string(), "v".to_string())]
889 );
890 }
891
892 #[test]
893 fn test_parse_html_tag_attributes_no_attrs() {
894 assert!(parse_html_tag_attributes("<div>").is_none());
895 }
896
897 #[test]
898 fn test_trailing_whitespace_before_attrs() {
899 let result = try_parse_trailing_attributes("Heading {#id}");
900 assert!(result.is_some());
901 let (_, before) = result.unwrap();
902 assert_eq!(before, "Heading");
903 }
904
905 #[test]
910 fn inline_code_attribute_is_lossless() {
911 let input = "`code`{.r #x key=v}\n";
912 let tree = crate::parse(input, None);
913 assert_eq!(tree.text().to_string(), input);
914 }
915
916 fn structured_attr(raw: &str) -> crate::syntax::SyntaxNode {
917 let mut builder = GreenNodeBuilder::new();
918 emit_attribute_node(&mut builder, raw);
919 crate::syntax::SyntaxNode::new_root(builder.finish())
920 }
921
922 #[test]
923 fn emit_attribute_node_is_lossless_over_shapes() {
924 for raw in [
927 "{#id}",
928 "{.a .b}",
929 "{key=\"v w\"}",
930 "{ #id .c }",
931 "{#id1 #id2}",
932 "{key}",
933 "{=html}",
934 "{#id .a key=v key2='x'}",
935 "{key=}",
936 "{}",
937 "{ }",
938 ] {
939 let node = structured_attr(raw);
940 assert_eq!(node.text().to_string(), raw, "lossless emit for {raw:?}");
941 assert_eq!(node.kind(), SyntaxKind::ATTRIBUTE);
942 }
943 }
944
945 #[test]
946 fn emit_attribute_node_structures_children() {
947 let node = structured_attr("{#x .a .b k=v}");
948 let kinds: Vec<_> = node.children_with_tokens().map(|c| c.kind()).collect();
949 assert_eq!(
950 kinds.iter().filter(|k| **k == SyntaxKind::ATTR_ID).count(),
951 1
952 );
953 assert_eq!(
954 kinds
955 .iter()
956 .filter(|k| **k == SyntaxKind::ATTR_CLASS)
957 .count(),
958 2
959 );
960 assert_eq!(
961 kinds
962 .iter()
963 .filter(|k| **k == SyntaxKind::ATTR_KEY_VALUE)
964 .count(),
965 1
966 );
967 }
968
969 fn structured_html_attrs(raw: &str) -> crate::syntax::SyntaxNode {
970 let mut builder = GreenNodeBuilder::new();
971 emit_html_attrs_node(&mut builder, raw);
972 crate::syntax::SyntaxNode::new_root(builder.finish())
973 }
974
975 #[test]
976 fn emit_html_attrs_node_is_lossless_over_shapes() {
977 for raw in [
978 r#"id="x""#,
979 r#"id="x" class="a b" data-key="v""#,
980 r#"class='a b'"#,
981 r#"id=bare class=one"#,
982 "hidden",
983 r#"id="x" hidden data-n="1""#,
984 r#" id="x" /"#,
985 r#"id="""#,
986 "",
987 " ",
988 ] {
989 let node = structured_html_attrs(raw);
990 assert_eq!(node.text().to_string(), raw, "lossless emit for {raw:?}");
991 assert_eq!(node.kind(), SyntaxKind::HTML_ATTRS);
992 }
993 }
994
995 #[test]
996 fn emit_html_attrs_node_structures_children() {
997 let node = structured_html_attrs(r#"id="x" class="a b" data-key="v" hidden"#);
998 let kinds: Vec<_> = node.children_with_tokens().map(|c| c.kind()).collect();
999 assert_eq!(
1000 kinds.iter().filter(|k| **k == SyntaxKind::ATTR_ID).count(),
1001 1
1002 );
1003 assert_eq!(
1004 kinds
1005 .iter()
1006 .filter(|k| **k == SyntaxKind::ATTR_CLASS)
1007 .count(),
1008 2,
1009 "class=\"a b\" splits into two ATTR_CLASS tokens"
1010 );
1011 assert_eq!(
1013 node.children()
1014 .filter(|n| n.kind() == SyntaxKind::ATTR_KEY_VALUE)
1015 .count(),
1016 2
1017 );
1018 }
1019
1020 #[test]
1022 fn html_attribute_list_parse_parity() {
1023 let attrs =
1024 parse_html_attribute_list(r#"id="x" class="a b" data-key='v w' hidden"#).unwrap();
1025 assert_eq!(attrs.identifier.as_deref(), Some("x"));
1026 assert_eq!(attrs.classes, vec!["a", "b"]);
1027 assert_eq!(
1028 attrs.key_values,
1029 vec![
1030 ("data-key".to_string(), "v w".to_string()),
1031 ("hidden".to_string(), String::new()),
1032 ]
1033 );
1034 assert!(parse_html_attribute_list(" ").is_none());
1035 assert!(parse_html_attribute_list(r#"id="""#).is_none());
1036 }
1037
1038 fn structured_div_info(raw: &str) -> crate::syntax::SyntaxNode {
1039 let mut builder = GreenNodeBuilder::new();
1040 emit_div_info_node(&mut builder, raw);
1041 crate::syntax::SyntaxNode::new_root(builder.finish())
1042 }
1043
1044 #[test]
1045 fn emit_div_info_node_is_lossless_and_structures_brace_body() {
1046 for raw in ["{#id .a .b key=val key2=\"v w\"}", "Warning", "{}", "{ }"] {
1049 let node = structured_div_info(raw);
1050 assert_eq!(node.text().to_string(), raw, "lossless emit for {raw:?}");
1051 assert_eq!(node.kind(), SyntaxKind::DIV_INFO);
1052 }
1053
1054 let structured = structured_div_info("{#id .a .b key=val key2=\"v w\"}");
1055 let kinds: Vec<_> = structured
1056 .children_with_tokens()
1057 .map(|c| c.kind())
1058 .collect();
1059 assert_eq!(
1060 kinds.iter().filter(|k| **k == SyntaxKind::ATTR_ID).count(),
1061 1
1062 );
1063 assert_eq!(
1064 kinds
1065 .iter()
1066 .filter(|k| **k == SyntaxKind::ATTR_CLASS)
1067 .count(),
1068 2
1069 );
1070 assert_eq!(
1071 kinds
1072 .iter()
1073 .filter(|k| **k == SyntaxKind::ATTR_KEY_VALUE)
1074 .count(),
1075 2
1076 );
1077
1078 let bare = structured_div_info("Warning");
1080 let bare_kinds: Vec<_> = bare.children_with_tokens().map(|c| c.kind()).collect();
1081 assert_eq!(bare_kinds, vec![SyntaxKind::TEXT]);
1082 }
1083}