diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index 0712e1f99..20c321e0f 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -62,6 +62,38 @@ const EVENT_END: char = ')'; /// Template attribute prefix for structural directives. const TEMPLATE_ATTR_PREFIX: char = '*'; +/// The prefix of a legacy animation property: `[animate-x]`, `bind-animate-x`. +/// Reference: binding_parser.ts line 48 (`LEGACY_ANIMATE_PROP_PREFIX`) +const LEGACY_ANIMATE_PROP_PREFIX: &str = "animate-"; + +/// The key span of a binding: `identifier`, which starts `prefix` into the attribute. +/// For `[user]="x"` it is `user`, for `(click)="f()"` it is `click`. +/// Reference: r3_template_transform.ts lines 751-757 (`createKeySpan` in `parseAttribute`) +fn create_key_span(attr: &HtmlAttribute<'_>, prefix: &str, identifier: &str) -> Span { + let start = attr.span.start + prefix.len() as u32; + Span::new(start, start + identifier.len() as u32) +} + +/// The key span of a property binding named `name` (the identifier `parseAttribute` passes +/// to `parsePropertyBinding`): a legacy animation's `animate-` or `@` is not part of it. +/// Reference: binding_parser.ts lines 365-386 (`parsePropertyBinding`) +fn property_binding_key_span(name: &str, key_span: Span) -> Span { + if name.starts_with(LEGACY_ANIMATE_PROP_PREFIX) { + Span::new(key_span.start + LEGACY_ANIMATE_PROP_PREFIX.len() as u32, key_span.end) + } else if name.starts_with('@') { + Span::new(key_span.start + 1, key_span.end) + } else { + key_span + } +} + +/// The key span of an event named `name` (the identifier `parseAttribute` passes to +/// `parseEvent`): a legacy animation event's `@` is not part of it. +/// Reference: binding_parser.ts lines 660-667 (`parseEvent`) +fn event_key_span(name: &str, key_span: Span) -> Span { + if name.starts_with('@') { Span::new(key_span.start + 1, key_span.end) } else { key_span } +} + /// Tags that cannot be used as selectorless component tags. /// These are special HTML or Angular elements that have reserved semantics. const UNSUPPORTED_SELECTORLESS_TAGS: &[&str] = @@ -1060,6 +1092,12 @@ impl<'a> HtmlToR3Transform<'a> { if attr_name.starts_with('#') || attr_name.starts_with("ref-") { let ref_name = if attr_name.starts_with('#') { &attr_name[1..] } else { &attr_name[4..] }; + // Reference: r3_template_transform.ts line 789 + let key_span = create_key_span( + attr, + &attr_name[..attr_name.len() - ref_name.len()], + ref_name, + ); // Validate reference - no value allowed in directive context // Reference: r3_template_transform.ts lines 1121-1140 @@ -1081,7 +1119,7 @@ impl<'a> HtmlToR3Transform<'a> { name: Ident::from_in(ref_name, &self.allocator), value: Ident::from_in("", &self.allocator), source_span: attr.span, - key_span: attr.name_span, + key_span, value_span: None, }); } @@ -1090,6 +1128,10 @@ impl<'a> HtmlToR3Transform<'a> { // Check for binding prefix syntax (bind-, on-, bindon-) if let Some((prefix, rest)) = self.parse_binding_prefix(attr_name) { + // Reference: r3_template_transform.ts lines 765-811 (`createKeySpan(srcSpan, + // bindParts[KW_*_IDX], identifier)`) + let key_span = + create_key_span(attr, &attr_name[..attr_name.len() - rest.len()], rest); match prefix { BindingPrefix::Bind => { // bind-prop="value" - property binding @@ -1126,7 +1168,7 @@ impl<'a> HtmlToR3Transform<'a> { value: parse_result.ast, unit: None, source_span: attr.span, - key_span: attr.name_span, + key_span: property_binding_key_span(rest, key_span), value_span: Some(value_span), i18n: None, security_context: self.security_context(element_name, prop_name), @@ -1145,7 +1187,7 @@ impl<'a> HtmlToR3Transform<'a> { event_type: ParsedEventType::Regular, phase: None, source_span: attr.span, - key_span: attr.name_span, + key_span: event_key_span(rest, key_span), handler_span: value_span, }); } @@ -1161,7 +1203,7 @@ impl<'a> HtmlToR3Transform<'a> { value: parse_result.ast, unit: None, source_span: attr.span, - key_span: attr.name_span, + key_span: property_binding_key_span(rest, key_span), value_span: Some(value_span), i18n: None, security_context: self.security_context(element_name, rest), @@ -1174,13 +1216,13 @@ impl<'a> HtmlToR3Transform<'a> { self.binding_parser.parse_event(attr_value, value_span); outputs.push(R3BoundEvent { + key_span: event_key_span(&event_name, key_span), name: event_name, handler: event_parse_result.ast, target: None, event_type: ParsedEventType::TwoWay, phase: None, source_span: attr.span, - key_span: attr.name_span, handler_span: value_span, }); } @@ -1200,7 +1242,9 @@ impl<'a> HtmlToR3Transform<'a> { value: parse_result.ast, unit: None, source_span: attr.span, - key_span: attr.name_span, + // `parseLiteralAttr` drops the `@` from the key span. + // Reference: binding_parser.ts lines 308-314 + key_span: create_key_span(attr, "@", rest), value_span: Some(value_span), i18n: None, security_context: crate::ast::r3::SecurityContext::None, @@ -1217,6 +1261,8 @@ impl<'a> HtmlToR3Transform<'a> { if attr_name.starts_with("[(") && attr_name.ends_with(")]") { // Two-way binding: [(prop)]="value" - allowed let prop_name = &attr_name[2..attr_name.len() - 2]; + // Reference: r3_template_transform.ts line 871 + let key_span = create_key_span(attr, BANANA_BOX_START, prop_name); let value_str = self.allocator.alloc_str(attr_value); let value_span = attr.value_span.unwrap_or(attr.span); let parse_result = self.binding_parser.parse_binding(value_str, value_span); @@ -1227,7 +1273,7 @@ impl<'a> HtmlToR3Transform<'a> { value: parse_result.ast, unit: None, source_span: attr.span, - key_span: attr.name_span, + key_span: property_binding_key_span(prop_name, key_span), value_span: Some(value_span), i18n: None, security_context: self.security_context(element_name, prop_name), @@ -1240,13 +1286,13 @@ impl<'a> HtmlToR3Transform<'a> { self.binding_parser.parse_event(event_value_str, value_span); outputs.push(R3BoundEvent { + key_span: event_key_span(&event_name, key_span), name: event_name, handler: event_parse_result.ast, target: None, event_type: ParsedEventType::TwoWay, phase: None, source_span: attr.span, - key_span: attr.name_span, handler_span: value_span, }); } else if attr_name.starts_with('[') && attr_name.ends_with(']') { @@ -1298,7 +1344,11 @@ impl<'a> HtmlToR3Transform<'a> { value: parse_result.ast, unit: None, source_span: attr.span, - key_span: attr.name_span, + // Reference: r3_template_transform.ts line 871 + key_span: property_binding_key_span( + prop_name, + create_key_span(attr, "[", prop_name), + ), value_span: Some(value_span), i18n: None, security_context: self.security_context(element_name, prop_name), @@ -1306,11 +1356,14 @@ impl<'a> HtmlToR3Transform<'a> { } else if attr_name.starts_with('(') && attr_name.ends_with(')') { // Event binding: (event)="handler" let event_name = &attr_name[1..attr_name.len() - 1]; + // Reference: r3_template_transform.ts line 871 + let key_span = + event_key_span(event_name, create_key_span(attr, "(", event_name)); // Check for legacy animation syntax (@) or (@name.phase) if event_name.starts_with('@') { let anim_name = event_name.strip_prefix('@').unwrap_or(""); - outputs.push(self.create_legacy_animation_event(anim_name, attr)); + outputs.push(self.create_legacy_animation_event(anim_name, attr, key_span)); } else { // Regular event handling let value_str = self.allocator.alloc_str(attr_value); @@ -1324,7 +1377,7 @@ impl<'a> HtmlToR3Transform<'a> { event_type: ParsedEventType::Regular, phase: None, source_span: attr.span, - key_span: attr.name_span, + key_span, handler_span: value_span, }); } @@ -3251,9 +3304,14 @@ impl<'a> HtmlToR3Transform<'a> { // Check for binding prefixes (normalization already applied to name) if let Some((prefix, rest)) = self.parse_binding_prefix(name) { + // The key span covers the identifier after the keyword prefix. + // Reference: r3_template_transform.ts lines 765-811 (`createKeySpan(srcSpan, + // bindParts[KW_*_IDX], identifier)`) + let key_span = create_key_span(attr, &name[..name.len() - rest.len()], rest); match prefix { BindingPrefix::Bind => { // Check for animation bindings: bind-@prop or bind-animate-prop + let key_span = property_binding_key_span(rest, key_span); if let Some(anim_name) = rest.strip_prefix('@') { inputs.push(self.create_bound_attribute( element_name, @@ -3261,6 +3319,7 @@ impl<'a> HtmlToR3Transform<'a> { attr, BindingType::Animation, None, + key_span, )); } else if let Some(anim_name) = rest.strip_prefix("animate-") { inputs.push(self.create_bound_attribute( @@ -3269,6 +3328,7 @@ impl<'a> HtmlToR3Transform<'a> { attr, BindingType::Animation, None, + key_span, )); } else { let i18n = i18n_attrs_meta.remove(rest); @@ -3278,6 +3338,7 @@ impl<'a> HtmlToR3Transform<'a> { attr, BindingType::Property, None, + key_span, ); bound_attr.i18n = i18n; inputs.push(bound_attr); @@ -3286,7 +3347,8 @@ impl<'a> HtmlToR3Transform<'a> { BindingPrefix::Let => { if is_template { // Validate and create variable - if let Some(var) = self.create_validated_variable(rest, attr) { + if let Some(var) = self.create_validated_variable(rest, attr, key_span) + { variables.push(var); } } else { @@ -3300,13 +3362,17 @@ impl<'a> HtmlToR3Transform<'a> { BindingPrefix::Ref => { // Validate and create reference (with duplicate detection) if let Some(reference) = - self.create_validated_reference(rest, attr, &references) + self.create_validated_reference(rest, attr, key_span, &references) { references.push(reference); } } BindingPrefix::On => { - outputs.push(self.create_bound_event(rest, attr)); + outputs.push(self.create_bound_event( + rest, + attr, + event_key_span(rest, key_span), + )); } BindingPrefix::BindOn => { // Two-way binding creates both input and output @@ -3316,10 +3382,12 @@ impl<'a> HtmlToR3Transform<'a> { attr, BindingType::TwoWay, None, + property_binding_key_span(rest, key_span), )); // Create the output event (propChange) let event_name = format!("{rest}Change"); - outputs.push(self.create_two_way_event(&event_name, attr)); + let event_key = event_key_span(&event_name, key_span); + outputs.push(self.create_two_way_event(&event_name, attr, event_key)); } BindingPrefix::At => { // Legacy animation trigger syntax (@prop) @@ -3332,10 +3400,15 @@ impl<'a> HtmlToR3Transform<'a> { } // Still create as a legacy animation input for error recovery // Use create_legacy_animation_attribute to default empty values to "undefined" + // `parseLiteralAttr` takes the key span of the whole name and drops the + // `@`. Reference: r3_template_transform.ts line 836, binding_parser.ts + // lines 308-314 + let key_span = create_key_span(attr, "@", rest); inputs.push(self.create_legacy_animation_attribute( element_name, rest, attr, + key_span, )); } } @@ -3345,6 +3418,9 @@ impl<'a> HtmlToR3Transform<'a> { // Check for banana-in-a-box [(prop)] if name.starts_with(BANANA_BOX_START) && name.ends_with(BANANA_BOX_END) { let prop_name = &name[2..name.len() - 2]; + // Reference: r3_template_transform.ts line 871 (`createKeySpan(srcSpan, + // delims.start, identifier)`) + let key_span = create_key_span(attr, BANANA_BOX_START, prop_name); // Two-way binding creates both input and output inputs.push(self.create_bound_attribute( element_name, @@ -3352,16 +3428,22 @@ impl<'a> HtmlToR3Transform<'a> { attr, BindingType::TwoWay, None, + property_binding_key_span(prop_name, key_span), )); // Create the output event (propChange) let event_name = format!("{prop_name}Change"); - outputs.push(self.create_two_way_event(&event_name, attr)); + let event_key = event_key_span(&event_name, key_span); + outputs.push(self.create_two_way_event(&event_name, attr, event_key)); continue; } // Check for property binding [prop] if name.starts_with(PROPERTY_START) && name.ends_with(PROPERTY_END) { let prop_name = &name[1..name.len() - 1]; + // Reference: r3_template_transform.ts line 871 (`createKeySpan(srcSpan, + // delims.start, identifier)`) + let key_span = + property_binding_key_span(prop_name, create_key_span(attr, "[", prop_name)); let (binding_type, final_name, unit) = if let Some(stripped) = prop_name.strip_prefix("attr.") { (BindingType::Attribute, stripped, None) @@ -3389,8 +3471,14 @@ impl<'a> HtmlToR3Transform<'a> { // Look up i18n metadata for this property binding (e.g., i18n-heading for [heading]) // Ported from Angular's categorizePropertyAttributes in r3_template_transform.ts let i18n = i18n_attrs_meta.remove(final_name); - let mut bound_attr = - self.create_bound_attribute(element_name, final_name, attr, binding_type, unit); + let mut bound_attr = self.create_bound_attribute( + element_name, + final_name, + attr, + binding_type, + unit, + key_span, + ); bound_attr.i18n = i18n; inputs.push(bound_attr); continue; @@ -3399,21 +3487,27 @@ impl<'a> HtmlToR3Transform<'a> { // Check for event binding (event) if name.starts_with(EVENT_START) && name.ends_with(EVENT_END) { let event_name = &name[1..name.len() - 1]; + // Reference: r3_template_transform.ts line 871 (`createKeySpan(srcSpan, + // delims.start, identifier)`) + let key_span = event_key_span(event_name, create_key_span(attr, "(", event_name)); // Check for legacy animation syntax (@) or (@name) if event_name.starts_with('@') { let anim_name = event_name.strip_prefix('@').unwrap_or(""); - outputs.push(self.create_legacy_animation_event(anim_name, attr)); + outputs.push(self.create_legacy_animation_event(anim_name, attr, key_span)); } else { - outputs.push(self.create_bound_event_with_target(event_name, attr)); + outputs.push(self.create_bound_event_with_target(event_name, attr, key_span)); } continue; } // Check for reference #ref if name.starts_with('#') { - if let Some(reference) = - self.create_validated_reference(&name[1..], attr, &references) - { + if let Some(reference) = self.create_validated_reference( + &name[1..], + attr, + create_key_span(attr, "#", &name[1..]), + &references, + ) { references.push(reference); } continue; @@ -3522,6 +3616,7 @@ impl<'a> HtmlToR3Transform<'a> { attr: &HtmlAttribute<'a>, binding_type: BindingType, unit: Option<&str>, + key_span: Span, ) -> R3BoundAttribute<'a> { let value_span = attr.value_span.unwrap_or(attr.span); let value = self.parse_binding_expression(&attr.value, value_span); @@ -3537,7 +3632,7 @@ impl<'a> HtmlToR3Transform<'a> { value, unit: unit.map(|u| Ident::from(self.allocator.alloc_str(u))), source_span: attr.span, - key_span: attr.name_span, + key_span, value_span: attr.value_span, i18n: None, } @@ -3568,6 +3663,7 @@ impl<'a> HtmlToR3Transform<'a> { _element_name: &str, property_name: &str, attr: &HtmlAttribute<'a>, + key_span: Span, ) -> R3BoundAttribute<'a> { let value_span = attr.value_span.unwrap_or(attr.span); // For legacy animation triggers, parse "undefined" when there's no expression. @@ -3589,7 +3685,7 @@ impl<'a> HtmlToR3Transform<'a> { value: result.ast, unit: None, source_span: attr.span, - key_span: attr.name_span, + key_span, value_span: attr.value_span, i18n: None, } @@ -3602,6 +3698,7 @@ impl<'a> HtmlToR3Transform<'a> { &mut self, name: &str, attr: &HtmlAttribute<'a>, + key_span: Span, ) -> R3BoundEvent<'a> { let handler_span = self.calculate_event_handler_span(attr); let handler = self.parse_event_expression(&attr.value, handler_span); @@ -3632,12 +3729,17 @@ impl<'a> HtmlToR3Transform<'a> { phase: None, source_span: attr.span, handler_span, - key_span: attr.name_span, + key_span, } } /// Creates a bound event (without target parsing). - fn create_bound_event(&mut self, name: &str, attr: &HtmlAttribute<'a>) -> R3BoundEvent<'a> { + fn create_bound_event( + &mut self, + name: &str, + attr: &HtmlAttribute<'a>, + key_span: Span, + ) -> R3BoundEvent<'a> { let handler_span = self.calculate_event_handler_span(attr); let handler = self.parse_event_expression(&attr.value, handler_span); let name_atom = Ident::from(self.allocator.alloc_str(name)); @@ -3656,7 +3758,7 @@ impl<'a> HtmlToR3Transform<'a> { phase: None, source_span: attr.span, handler_span, - key_span: attr.name_span, + key_span, } } @@ -3671,6 +3773,7 @@ impl<'a> HtmlToR3Transform<'a> { &mut self, name: &str, attr: &HtmlAttribute<'a>, + key_span: Span, ) -> R3BoundEvent<'a> { let handler_span = self.calculate_event_handler_span(attr); let handler = self.parse_event_expression(&attr.value, handler_span); @@ -3700,14 +3803,19 @@ impl<'a> HtmlToR3Transform<'a> { phase, source_span: attr.span, handler_span, - key_span: attr.name_span, + key_span, } } /// Creates a bound event for two-way binding. /// The handler is just the target expression (e.g., `name`), not the full assignment. /// The `$event` is added separately in the ingest phase. - fn create_two_way_event(&mut self, name: &str, attr: &HtmlAttribute<'a>) -> R3BoundEvent<'a> { + fn create_two_way_event( + &mut self, + name: &str, + attr: &HtmlAttribute<'a>, + key_span: Span, + ) -> R3BoundEvent<'a> { let handler_span = self.calculate_event_handler_span(attr); let name_atom = Ident::from(self.allocator.alloc_str(name)); @@ -3724,7 +3832,7 @@ impl<'a> HtmlToR3Transform<'a> { phase: None, source_span: attr.span, handler_span, - key_span: attr.name_span, + key_span, } } @@ -3770,6 +3878,7 @@ impl<'a> HtmlToR3Transform<'a> { &mut self, name: &str, attr: &HtmlAttribute<'a>, + key_span: Span, ) -> Option> { // Validate variable name if name.contains('-') { @@ -3784,7 +3893,7 @@ impl<'a> HtmlToR3Transform<'a> { name: name_atom, value: attr.value, source_span: attr.span, - key_span: attr.name_span, + key_span, value_span: attr.value_span, }) } @@ -3795,6 +3904,7 @@ impl<'a> HtmlToR3Transform<'a> { &mut self, name: &str, attr: &HtmlAttribute<'a>, + key_span: Span, existing_refs: &Vec<'a, R3Reference<'a>>, ) -> Option> { // Validate reference name @@ -3815,7 +3925,7 @@ impl<'a> HtmlToR3Transform<'a> { name: name_atom, value: attr.value, source_span: attr.span, - key_span: attr.name_span, + key_span, value_span: attr.value_span, }) } @@ -4935,7 +5045,8 @@ impl<'a> HtmlToR3Transform<'a> { value: expr, unit: unit.map(|u| Ident::from(self.allocator.alloc_str(u))), source_span: attr.span, - key_span: attr.name_span, + // Reference: r3_template_transform.ts line 927 (`createKeySpan(srcSpan, '', name)`) + key_span: create_key_span(attr, "", name), value_span: attr.value_span, i18n, } @@ -5790,3 +5901,218 @@ mod template_parser_tests { ); } } + +/// Key spans, ported from Angular's `test/render3/r3_ast_spans_spec.ts`: the key span of a binding +/// is its identifier, without the binding syntax around it. +#[cfg(test)] +mod key_span_tests { + use super::*; + use crate::parser::html::HtmlParser; + use oxc_allocator::Allocator; + + type Keys = std::vec::Vec<(&'static str, String)>; + + /// `(node kind, key span text)` for every attribute, binding, reference and variable. + fn key_spans(source: &str) -> Keys { + let allocator = Allocator::default(); + let parsed = HtmlParser::new(&allocator, source, "test.html").parse(); + let result = + html_ast_to_r3_ast(&allocator, source, &parsed.nodes, TransformOptions::default()); + let mut out = Keys::new(); + walk(source, &result.nodes, &mut out); + out + } + + fn text(source: &str, span: Span) -> String { + source[span.start as usize..span.end as usize].to_string() + } + + fn push_attrs( + source: &str, + out: &mut Keys, + attributes: &[R3TextAttribute<'_>], + inputs: &[R3BoundAttribute<'_>], + outputs: &[R3BoundEvent<'_>], + references: &[R3Reference<'_>], + ) { + for attr in attributes { + out.push(("TextAttribute", attr.key_span.map(|s| text(source, s)).unwrap_or_default())); + } + for input in inputs { + out.push(("BoundAttribute", text(source, input.key_span))); + } + for output in outputs { + out.push(("BoundEvent", text(source, output.key_span))); + } + for reference in references { + out.push(("Reference", text(source, reference.key_span))); + } + } + + fn walk(source: &str, nodes: &[R3Node<'_>], out: &mut Keys) { + for node in nodes { + match node { + R3Node::Element(el) => { + push_attrs( + source, + out, + &el.attributes, + &el.inputs, + &el.outputs, + &el.references, + ); + walk(source, &el.children, out); + } + R3Node::Template(tpl) => { + for attr in &tpl.template_attrs { + match attr { + R3TemplateAttr::Text(attr) => out.push(( + "TextAttribute", + attr.key_span.map(|s| text(source, s)).unwrap_or_default(), + )), + R3TemplateAttr::Bound(input) => { + out.push(("BoundAttribute", text(source, input.key_span))); + } + } + } + for variable in &tpl.variables { + out.push(("Variable", text(source, variable.key_span))); + } + // A `*` template's own attributes are hoisted from the element it wraps, + // which reports them itself. + if tpl.template_attrs.is_empty() { + push_attrs( + source, + out, + &tpl.attributes, + &tpl.inputs, + &tpl.outputs, + &tpl.references, + ); + } + walk(source, &tpl.children, out); + } + _ => {} + } + } + } + + fn pairs(expected: &[(&'static str, &str)]) -> Keys { + expected.iter().map(|(kind, key)| (*kind, (*key).to_string())).collect() + } + + #[test] + fn bound_attribute() { + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundAttribute", "someProp")]) + ); + assert_eq!(key_spans("
"), pairs(&[("BoundAttribute", "someProp")])); + // `attr.`, `class.` and `style.` stay in the key span: `parseAttribute` does not split them. + assert_eq!( + key_spans(r#"
"#), + pairs(&[ + ("BoundAttribute", "attr.role"), + ("BoundAttribute", "class.on"), + ("BoundAttribute", "style.width.px"), + ]) + ); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundAttribute", "trigger")]) + ); + } + + #[test] + fn bind_prefix() { + assert_eq!(key_spans(r#"
"#), pairs(&[("BoundAttribute", "prop")])); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundAttribute", "animation")]) + ); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundAttribute", "animationName")]) + ); + assert_eq!(key_spans("
"), pairs(&[("BoundAttribute", "animation")])); + } + + #[test] + fn interpolated_attribute() { + assert_eq!(key_spans(r#"
"#), pairs(&[("BoundAttribute", "prop")])); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundAttribute", "data-prop")]) + ); + } + + #[test] + fn event() { + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundEvent", "someEvent")]) + ); + assert_eq!(key_spans(r#"
"#), pairs(&[("BoundEvent", "event")])); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundEvent", "window:resize")]) + ); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundEvent", "name.done")]) + ); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("TextAttribute", "data-on-event")]) + ); + } + + #[test] + fn two_way_binding() { + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundAttribute", "prop"), ("BoundEvent", "prop")]) + ); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundAttribute", "prop"), ("BoundEvent", "prop")]) + ); + } + + #[test] + fn references_and_variables() { + assert_eq!(key_spans("
"), pairs(&[("Reference", "a")])); + assert_eq!(key_spans(r#"
"#), pairs(&[("Reference", "a")])); + assert_eq!(key_spans("
"), pairs(&[("Reference", "a")])); + assert_eq!( + key_spans(r#""#), + pairs(&[("Variable", "a")]) + ); + } + + #[test] + fn template_attribute() { + assert_eq!(key_spans("
"), pairs(&[("TextAttribute", "ngIf")])); + assert_eq!( + key_spans(r#"
"#), + pairs(&[ + ("TextAttribute", "ngFor"), + ("BoundAttribute", "of"), + ("BoundAttribute", "trackBy"), + ("Variable", "item"), + ]) + ); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundAttribute", "ngFor"), ("BoundAttribute", "of")]) + ); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("BoundAttribute", "ngIf"), ("Variable", "local")]) + ); + assert_eq!( + key_spans(r#"
"#), + pairs(&[("TextAttribute", "ngIf"), ("Variable", "a")]) + ); + } +}