|
| 1 | +//! Angular `@Component` decorator parser. |
| 2 | +//! |
| 3 | +//! This module extracts metadata from `@Component({...})` decorators |
| 4 | +//! on TypeScript class declarations. |
| 5 | +
|
| 6 | +use oxc_allocator::{Allocator, Vec}; |
| 7 | +use oxc_ast::ast::{ |
| 8 | + Argument, ArrayExpressionElement, Class, Decorator, Expression, ObjectPropertyKind, PropertyKey, |
| 9 | +}; |
| 10 | +use oxc_span::Atom; |
| 11 | + |
| 12 | +use super::metadata::{ |
| 13 | + ChangeDetectionStrategy, ComponentMetadata, HostMetadata, ViewEncapsulation, |
| 14 | +}; |
| 15 | + |
| 16 | +/// Extract component metadata from a class with decorators. |
| 17 | +/// |
| 18 | +/// Searches for a `@Component({...})` decorator and parses its properties. |
| 19 | +/// Returns `None` if no `@Component` decorator is found. |
| 20 | +/// |
| 21 | +/// # Example |
| 22 | +/// |
| 23 | +/// ```typescript |
| 24 | +/// @Component({ |
| 25 | +/// selector: 'app-root', |
| 26 | +/// template: '<h1>Hello</h1>', |
| 27 | +/// standalone: true, |
| 28 | +/// }) |
| 29 | +/// export class AppComponent {} |
| 30 | +/// ``` |
| 31 | +pub fn extract_component_metadata<'a>( |
| 32 | + allocator: &'a Allocator, |
| 33 | + class: &'a Class<'a>, |
| 34 | +) -> Option<ComponentMetadata<'a>> { |
| 35 | + // Get the class name |
| 36 | + let class_name = class.id.as_ref()?.name.clone(); |
| 37 | + let class_span = class.span; |
| 38 | + |
| 39 | + // Find the @Component decorator |
| 40 | + let component_decorator = find_component_decorator(&class.decorators)?; |
| 41 | + |
| 42 | + // Get the decorator call arguments |
| 43 | + let call_expr = match &component_decorator.expression { |
| 44 | + Expression::CallExpression(call) => call, |
| 45 | + _ => return None, |
| 46 | + }; |
| 47 | + |
| 48 | + // Verify it's calling 'Component' |
| 49 | + if !is_component_call(&call_expr.callee) { |
| 50 | + return None; |
| 51 | + } |
| 52 | + |
| 53 | + // Get the first argument (the config object) |
| 54 | + let config_arg = call_expr.arguments.first()?; |
| 55 | + let config_obj = match config_arg { |
| 56 | + Argument::ObjectExpression(obj) => obj, |
| 57 | + _ => return None, |
| 58 | + }; |
| 59 | + |
| 60 | + // Create metadata with defaults |
| 61 | + let mut metadata = ComponentMetadata::new(allocator, class_name, class_span); |
| 62 | + |
| 63 | + // Parse each property in the config object |
| 64 | + for prop in &config_obj.properties { |
| 65 | + if let ObjectPropertyKind::ObjectProperty(prop) = prop { |
| 66 | + let key_name = get_property_key_name(&prop.key)?; |
| 67 | + |
| 68 | + match key_name.as_str() { |
| 69 | + "selector" => { |
| 70 | + metadata.selector = extract_string_value(&prop.value); |
| 71 | + } |
| 72 | + "template" => { |
| 73 | + metadata.template = extract_string_value(&prop.value); |
| 74 | + } |
| 75 | + "templateUrl" => { |
| 76 | + metadata.template_url = extract_string_value(&prop.value); |
| 77 | + } |
| 78 | + "styles" => { |
| 79 | + if let Some(styles) = extract_string_array(allocator, &prop.value) { |
| 80 | + metadata.styles = styles; |
| 81 | + } else if let Some(style) = extract_string_value(&prop.value) { |
| 82 | + // Single style string (legacy support) |
| 83 | + metadata.styles.push(style); |
| 84 | + } |
| 85 | + } |
| 86 | + "styleUrls" | "styleUrl" => { |
| 87 | + if let Some(urls) = extract_string_array(allocator, &prop.value) { |
| 88 | + metadata.style_urls = urls; |
| 89 | + } else if let Some(url) = extract_string_value(&prop.value) { |
| 90 | + metadata.style_urls.push(url); |
| 91 | + } |
| 92 | + } |
| 93 | + "standalone" => { |
| 94 | + metadata.standalone = extract_boolean_value(&prop.value).unwrap_or(false); |
| 95 | + } |
| 96 | + "encapsulation" => { |
| 97 | + metadata.encapsulation = extract_encapsulation(&prop.value); |
| 98 | + } |
| 99 | + "changeDetection" => { |
| 100 | + metadata.change_detection = extract_change_detection(&prop.value); |
| 101 | + } |
| 102 | + "host" => { |
| 103 | + metadata.host = extract_host_metadata(allocator, &prop.value); |
| 104 | + } |
| 105 | + "imports" => { |
| 106 | + // For standalone components - we just need the identifiers |
| 107 | + metadata.imports = extract_identifier_array(allocator, &prop.value); |
| 108 | + } |
| 109 | + "exportAs" => { |
| 110 | + metadata.export_as = extract_string_value(&prop.value); |
| 111 | + } |
| 112 | + "preserveWhitespaces" => { |
| 113 | + metadata.preserve_whitespaces = |
| 114 | + extract_boolean_value(&prop.value).unwrap_or(false); |
| 115 | + } |
| 116 | + _ => { |
| 117 | + // Unknown property - ignore |
| 118 | + } |
| 119 | + } |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + Some(metadata) |
| 124 | +} |
| 125 | + |
| 126 | +/// Find the @Component decorator in a list of decorators. |
| 127 | +fn find_component_decorator<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a Decorator<'a>> { |
| 128 | + decorators.iter().find(|d| match &d.expression { |
| 129 | + Expression::CallExpression(call) => is_component_call(&call.callee), |
| 130 | + Expression::Identifier(id) => id.name == "Component", |
| 131 | + _ => false, |
| 132 | + }) |
| 133 | +} |
| 134 | + |
| 135 | +/// Check if a callee expression is a call to 'Component'. |
| 136 | +fn is_component_call(callee: &Expression<'_>) -> bool { |
| 137 | + match callee { |
| 138 | + Expression::Identifier(id) => id.name == "Component", |
| 139 | + // Handle namespaced imports like ng.Component or core.Component |
| 140 | + Expression::StaticMemberExpression(member) => { |
| 141 | + matches!(&member.property.name.as_str(), &"Component") |
| 142 | + } |
| 143 | + _ => false, |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +/// Get the name of a property key as a string. |
| 148 | +fn get_property_key_name<'a>(key: &PropertyKey<'a>) -> Option<Atom<'a>> { |
| 149 | + match key { |
| 150 | + PropertyKey::StaticIdentifier(id) => Some(id.name.clone()), |
| 151 | + PropertyKey::StringLiteral(lit) => Some(lit.value.clone()), |
| 152 | + _ => None, |
| 153 | + } |
| 154 | +} |
| 155 | + |
| 156 | +/// Extract a string value from an expression. |
| 157 | +fn extract_string_value<'a>(expr: &Expression<'a>) -> Option<Atom<'a>> { |
| 158 | + match expr { |
| 159 | + Expression::StringLiteral(lit) => Some(lit.value.clone()), |
| 160 | + Expression::TemplateLiteral(tpl) if tpl.expressions.is_empty() => { |
| 161 | + // Simple template literal with no expressions: `template string` |
| 162 | + tpl.quasis.first().map(|q| q.value.raw.clone()) |
| 163 | + } |
| 164 | + _ => None, |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +/// Extract a boolean value from an expression. |
| 169 | +fn extract_boolean_value(expr: &Expression<'_>) -> Option<bool> { |
| 170 | + match expr { |
| 171 | + Expression::BooleanLiteral(lit) => Some(lit.value), |
| 172 | + _ => None, |
| 173 | + } |
| 174 | +} |
| 175 | + |
| 176 | +/// Extract an array of strings from an expression. |
| 177 | +fn extract_string_array<'a>( |
| 178 | + allocator: &'a Allocator, |
| 179 | + expr: &Expression<'a>, |
| 180 | +) -> Option<Vec<'a, Atom<'a>>> { |
| 181 | + let Expression::ArrayExpression(arr) = expr else { |
| 182 | + return None; |
| 183 | + }; |
| 184 | + |
| 185 | + let mut result = Vec::new_in(allocator); |
| 186 | + for element in &arr.elements { |
| 187 | + if let ArrayExpressionElement::StringLiteral(lit) = element { |
| 188 | + result.push(lit.value.clone()); |
| 189 | + } else if let ArrayExpressionElement::TemplateLiteral(tpl) = element { |
| 190 | + if tpl.expressions.is_empty() { |
| 191 | + if let Some(quasi) = tpl.quasis.first() { |
| 192 | + result.push(quasi.value.raw.clone()); |
| 193 | + } |
| 194 | + } |
| 195 | + } |
| 196 | + } |
| 197 | + |
| 198 | + Some(result) |
| 199 | +} |
| 200 | + |
| 201 | +/// Extract an array of identifiers (for imports). |
| 202 | +fn extract_identifier_array<'a>( |
| 203 | + allocator: &'a Allocator, |
| 204 | + expr: &Expression<'a>, |
| 205 | +) -> Vec<'a, Atom<'a>> { |
| 206 | + let mut result = Vec::new_in(allocator); |
| 207 | + |
| 208 | + let Expression::ArrayExpression(arr) = expr else { |
| 209 | + return result; |
| 210 | + }; |
| 211 | + |
| 212 | + for element in &arr.elements { |
| 213 | + match element { |
| 214 | + ArrayExpressionElement::Identifier(id) => { |
| 215 | + result.push(id.name.clone()); |
| 216 | + } |
| 217 | + // Handle spread elements, etc. - for now just collect identifiers |
| 218 | + _ => {} |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + result |
| 223 | +} |
| 224 | + |
| 225 | +/// Extract ViewEncapsulation from an expression. |
| 226 | +fn extract_encapsulation(expr: &Expression<'_>) -> ViewEncapsulation { |
| 227 | + // Look for patterns like: |
| 228 | + // - ViewEncapsulation.None |
| 229 | + // - ViewEncapsulation.Emulated |
| 230 | + // - ViewEncapsulation.ShadowDom |
| 231 | + // - 0, 2, 3 (numeric values) |
| 232 | + match expr { |
| 233 | + Expression::StaticMemberExpression(member) => match member.property.name.as_str() { |
| 234 | + "None" => ViewEncapsulation::None, |
| 235 | + "ShadowDom" => ViewEncapsulation::ShadowDom, |
| 236 | + "Emulated" => ViewEncapsulation::Emulated, |
| 237 | + _ => ViewEncapsulation::default(), |
| 238 | + }, |
| 239 | + Expression::NumericLiteral(num) => { |
| 240 | + // Angular's numeric values: Emulated = 0, None = 2, ShadowDom = 3 |
| 241 | + match num.value as i32 { |
| 242 | + 0 => ViewEncapsulation::Emulated, |
| 243 | + 2 => ViewEncapsulation::None, |
| 244 | + 3 => ViewEncapsulation::ShadowDom, |
| 245 | + _ => ViewEncapsulation::default(), |
| 246 | + } |
| 247 | + } |
| 248 | + _ => ViewEncapsulation::default(), |
| 249 | + } |
| 250 | +} |
| 251 | + |
| 252 | +/// Extract ChangeDetectionStrategy from an expression. |
| 253 | +fn extract_change_detection(expr: &Expression<'_>) -> ChangeDetectionStrategy { |
| 254 | + match expr { |
| 255 | + Expression::StaticMemberExpression(member) => match member.property.name.as_str() { |
| 256 | + "OnPush" => ChangeDetectionStrategy::OnPush, |
| 257 | + "Default" => ChangeDetectionStrategy::Default, |
| 258 | + _ => ChangeDetectionStrategy::default(), |
| 259 | + }, |
| 260 | + Expression::NumericLiteral(num) => { |
| 261 | + // Angular's numeric values: Default = 0, OnPush = 1 |
| 262 | + match num.value as i32 { |
| 263 | + 1 => ChangeDetectionStrategy::OnPush, |
| 264 | + _ => ChangeDetectionStrategy::default(), |
| 265 | + } |
| 266 | + } |
| 267 | + _ => ChangeDetectionStrategy::default(), |
| 268 | + } |
| 269 | +} |
| 270 | + |
| 271 | +/// Extract host metadata from a host object expression. |
| 272 | +fn extract_host_metadata<'a>( |
| 273 | + allocator: &'a Allocator, |
| 274 | + expr: &Expression<'a>, |
| 275 | +) -> Option<HostMetadata<'a>> { |
| 276 | + let Expression::ObjectExpression(obj) = expr else { |
| 277 | + return None; |
| 278 | + }; |
| 279 | + |
| 280 | + let mut host = HostMetadata { |
| 281 | + properties: Vec::new_in(allocator), |
| 282 | + attributes: Vec::new_in(allocator), |
| 283 | + listeners: Vec::new_in(allocator), |
| 284 | + }; |
| 285 | + |
| 286 | + for prop in &obj.properties { |
| 287 | + if let ObjectPropertyKind::ObjectProperty(prop) = prop { |
| 288 | + let Some(key_name) = get_property_key_name(&prop.key) else { |
| 289 | + continue; |
| 290 | + }; |
| 291 | + let Some(value) = extract_string_value(&prop.value) else { |
| 292 | + continue; |
| 293 | + }; |
| 294 | + |
| 295 | + let key_str = key_name.as_str(); |
| 296 | + |
| 297 | + if key_str.starts_with('[') && key_str.ends_with(']') { |
| 298 | + // Property binding: [class.active] |
| 299 | + host.properties.push((key_name, value)); |
| 300 | + } else if key_str.starts_with('(') && key_str.ends_with(')') { |
| 301 | + // Event listener: (click) |
| 302 | + host.listeners.push((key_name, value)); |
| 303 | + } else { |
| 304 | + // Static attribute |
| 305 | + host.attributes.push((key_name, value)); |
| 306 | + } |
| 307 | + } |
| 308 | + } |
| 309 | + |
| 310 | + Some(host) |
| 311 | +} |
| 312 | + |
| 313 | +#[cfg(test)] |
| 314 | +mod tests { |
| 315 | + // Tests will be added when oxc_ast is available as a dependency |
| 316 | +} |
0 commit comments