Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 70 additions & 58 deletions crates/oxc_angular_compiler/src/linker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,29 +417,28 @@ fn get_metadata_object<'a>(call: &'a CallExpression<'a>) -> Option<&'a ObjectExp
})
}

/// Extract a string property value from an object expression.
/// Extract a static string from an expression.
/// Handles both regular string literals (`"..."`) and template literals with no expressions (`` `...` ``).
fn as_static_string<'a>(expr: &'a Expression<'a>) -> Option<&'a str> {
match expr {
Expression::StringLiteral(lit) => Some(lit.value.as_str()),
Expression::TemplateLiteral(tl) if tl.expressions.is_empty() => {
tl.quasis.first()?.value.cooked.as_deref()
}
_ => None,
}
}

/// Extract a string property value from an object expression.
fn get_string_property<'a>(obj: &'a ObjectExpression<'a>, name: &str) -> Option<&'a str> {
for prop in &obj.properties {
if let ObjectPropertyKind::ObjectProperty(prop) = prop
&& matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name)
obj.properties.iter().find_map(|prop| match prop {
ObjectPropertyKind::ObjectProperty(prop)
if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) =>
{
match &prop.value {
Expression::StringLiteral(lit) => {
return Some(lit.value.as_str());
}
Expression::TemplateLiteral(tl) if tl.expressions.is_empty() => {
if let Some(quasi) = tl.quasis.first()
&& let Some(cooked) = &quasi.value.cooked
{
return Some(cooked.as_str());
}
}
_ => {}
}
as_static_string(&prop.value)
}
}
None
_ => None,
})
}

/// Extract the source text of a property value from an object expression.
Expand Down Expand Up @@ -626,14 +625,13 @@ fn is_property_false(obj: &ObjectExpression<'_>, name: &str) -> bool {
})
}

/// Check if a property exists and its value is a specific string literal.
/// Check if a property exists and its value is a specific static string
/// (string literal or template literal with no substitutions).
fn is_property_string(obj: &ObjectExpression<'_>, name: &str, value: &str) -> bool {
obj.properties.iter().any(|prop| {
matches!(prop,
ObjectPropertyKind::ObjectProperty(p)
if matches!(&p.key, PropertyKey::StaticIdentifier(ident) if ident.name == name)
&& matches!(&p.value, Expression::StringLiteral(s) if s.value == value)
)
let ObjectPropertyKind::ObjectProperty(p) = prop else { return false };
matches!(&p.key, PropertyKey::StaticIdentifier(ident) if ident.name == name)
&& as_static_string(&p.value) == Some(value)
})
}

Expand Down Expand Up @@ -849,10 +847,17 @@ fn build_host_attrs(host_obj: &ObjectExpression<'_>, source: &str) -> String {
PropertyKey::StringLiteral(s) => s.value.to_string(),
_ => continue,
};
let value_span = p.value.span();
let value = &source[value_span.start as usize..value_span.end as usize];
attrs.push(format!("\"{key}\""));
attrs.push(value.to_string());
// Static strings (including template literals a bundler may have
// emitted) are emitted cooked and quoted like Angular's output.
let value = as_static_string(&p.value).map_or_else(
|| {
let span = p.value.span();
source[span.start as usize..span.end as usize].to_string()
},
|s| crate::output::emitter::escape_string(s, false),
);
attrs.push(crate::output::emitter::escape_string(&key, false));
attrs.push(value);
}
}
}
Expand Down Expand Up @@ -1345,17 +1350,27 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source

let quoted_key = quote_key(&key);

// Simple string: propertyName: "publicName" → keep as is. The value may
// arrive as a template literal when a bundler rewrote the string.
if let Some(public_name) = as_static_string(&p.value) {
entries.push(format!(
"{quoted_key}: {}",
crate::output::emitter::escape_string(public_name, false)
));
continue;
}

match &p.value {
// Simple string: propertyName: "publicName" → keep as is
Expression::StringLiteral(lit) => {
entries.push(format!("{quoted_key}: \"{}\"", lit.value.as_str()));
}
// Array: check if it's declaration format [publicName, classPropertyName]
// and convert to definition format [InputFlags, publicName, classPropertyName]
Expression::ArrayExpression(arr) => {
let val = &source[p.value.span().start as usize..p.value.span().end as usize];
let first_is_string =
matches!(arr.elements.first(), Some(ArrayExpressionElement::StringLiteral(_)));
let first_is_string = arr
.elements
.first()
.and_then(ArrayExpressionElement::as_expression)
.and_then(as_static_string)
.is_some();

if !first_is_string {
// Already in definition format or unknown, keep as is.
Expand All @@ -1378,8 +1393,14 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source
}
// Object: Angular 16+ format with classPropertyName, publicName, isRequired, etc.
Expression::ObjectExpression(obj) => {
let public_name = get_string_property(obj, "publicName").unwrap_or(&key);
let declared_name = get_string_property(obj, "classPropertyName").unwrap_or(&key);
let public_name = crate::output::emitter::escape_string(
get_string_property(obj, "publicName").unwrap_or(&key),
false,
);
let declared_name = crate::output::emitter::escape_string(
get_string_property(obj, "classPropertyName").unwrap_or(&key),
false,
);
let is_signal = get_bool_property(obj, "isSignal").unwrap_or(false);
let is_required = get_bool_property(obj, "isRequired").unwrap_or(false);
// Angular emits `transformFunction: null` for signal inputs without
Expand All @@ -1400,15 +1421,14 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source

if flags == 0 && transform.is_none() && public_name == declared_name {
// Simple case: no flags, no transform, names match
entries.push(format!("{quoted_key}: \"{public_name}\""));
entries.push(format!("{quoted_key}: {public_name}"));
} else if let Some(transform_fn) = transform {
entries.push(format!(
"{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]"
"{quoted_key}: [{flags}, {public_name}, {declared_name}, {transform_fn}]"
));
} else {
entries.push(format!(
"{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\"]"
));
entries
.push(format!("{quoted_key}: [{flags}, {public_name}, {declared_name}]"));
}
}
// Unknown format, keep as is
Expand Down Expand Up @@ -1557,11 +1577,8 @@ fn extract_host_metadata_input(
PropertyKey::StringLiteral(s) => s.value.to_string(),
_ => continue,
};
let value = match &p.value {
Expression::StringLiteral(s) => s.value.to_string(),
_ => continue,
};
input.properties.push((key, value));
let Some(value) = as_static_string(&p.value) else { continue };
input.properties.push((key, value.to_string()));
}
}

Expand All @@ -1573,11 +1590,8 @@ fn extract_host_metadata_input(
PropertyKey::StringLiteral(s) => s.value.to_string(),
_ => continue,
};
let value = match &p.value {
Expression::StringLiteral(s) => s.value.to_string(),
_ => continue,
};
input.listeners.push((key, value));
let Some(value) = as_static_string(&p.value) else { continue };
input.listeners.push((key, value.to_string()));
}
}

Expand Down Expand Up @@ -2036,12 +2050,10 @@ fn link_component(
if let Some(styles_arr) = get_array_property(meta, "styles") {
let mut scoped_styles: Vec<String> = Vec::new();
for el in &styles_arr.elements {
let expr = match el {
ArrayExpressionElement::SpreadElement(_) => continue,
_ => el.to_expression(),
};
if let Expression::StringLiteral(s) = expr {
let style = s.value.as_str();
// `as_expression` returns None for spreads and elisions (e.g. `[, "a"]`),
// where `to_expression` would panic.
let Some(expr) = el.as_expression() else { continue };
if let Some(style) = as_static_string(expr) {
if is_emulated {
let scoped =
crate::styles::shim_css_text(style, "_ngcontent-%COMP%", "_nghost-%COMP%");
Expand Down
131 changes: 131 additions & 0 deletions crates/oxc_angular_compiler/tests/linker_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,134 @@ MyControl.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21
let result = link(&allocator, code, "test.mjs");
insta::assert_snapshot!(result.code);
}

/// Helper to build a ɵɵngDeclareComponent source with a given `styles` array.
fn make_component_source_with_styles(styles: &str) -> String {
format!(
r#"import * as i0 from "@angular/core";
export class MyCmp {{}}
MyCmp.ɵcmp = i0.ɵɵngDeclareComponent({{ minVersion: "14.0.0", version: "17.0.0", type: MyCmp, selector: "my-cmp", template: "<div></div>", styles: [{styles}] }});"#
)
}

#[test]
fn test_link_styles_template_literal_matches_string_literal() {
for (template_styles, string_styles) in [
("`.a { color: red }`", r#"".a { color: red }""#),
(r#"`.a { content: "\\f101\n" }`"#, r#"".a { content: \"\\f101\n\" }""#),
] {
let allocator = Allocator::default();
let template =
link(&allocator, &make_component_source_with_styles(template_styles), "test.mjs");
let string =
link(&allocator, &make_component_source_with_styles(string_styles), "test.mjs");
assert!(template.code.contains("styles: ["), "styles dropped:\n{}", template.code);
assert_eq!(template.code, string.code);
}
}

#[test]
fn test_link_host_bindings_template_literal_matches_string_literal() {
let make = |prop: &str, listener: &str| {
format!(
r#"import * as i0 from "@angular/core";
export class MyDir {{}}
MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", host: {{ properties: {{ "id": {prop} }}, listeners: {{ "click": {listener} }} }} }});"#
)
};
let allocator = Allocator::default();
let template = link(&allocator, &make("`this.dirId`", "`onClick($event)`"), "test.mjs");
let string = link(&allocator, &make(r#""this.dirId""#, r#""onClick($event)""#), "test.mjs");
assert!(template.code.contains("dirId"), "host property dropped:\n{}", template.code);
assert!(template.code.contains("onClick"), "host listener dropped:\n{}", template.code);
assert_eq!(template.code, string.code);
}

/// Static `host.attributes` values rewritten as template literals keep their
/// cooked value and emit byte-identical hostAttrs to the quoted form.
#[test]
fn test_link_host_attributes_template_literal_matches_string_literal() {
let make = |value: &str| {
format!(
r#"import * as i0 from "@angular/core";
export class MyDir {{}}
MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", host: {{ attributes: {{ "aria-label": {value} }} }} }});"#
)
};
let allocator = Allocator::default();
let template = link(&allocator, &make(r#"`say "hi"`"#), "test.mjs");
let string = link(&allocator, &make(r#""say \"hi\"""#), "test.mjs");
assert!(
template.code.contains(r#"hostAttrs: ["aria-label", "say \"hi\""]"#),
"host attribute dropped or unescaped:\n{}",
template.code
);
assert_eq!(template.code, string.code);
}

/// `inputs` values rewritten as template literals are treated like string
/// literals. For the aliased array form `[publicName, classPropertyName]` this
/// preserves the declaration-format detection — otherwise the array would be
/// passed through verbatim and the runtime would read the public name as
/// `InputFlags`. Array elements keep their original source spelling, so only
/// the simple and object forms are asserted byte-identical.
#[test]
fn test_link_inputs_template_literal_matches_string_literal() {
// Simple value form: same emitted output either way.
let allocator = Allocator::default();
let template = link(&allocator, &make_directive_source(r#"prop: `publicProp`"#), "test.mjs");
let string = link(&allocator, &make_directive_source(r#"prop: "publicProp""#), "test.mjs");
assert_eq!(template.code, string.code);

// Aliased array form: the first template-literal element must be recognised
// as a string so the [0, ...] flags element is inserted.
let allocator = Allocator::default();
let template =
link(&allocator, &make_directive_source(r#"prop: [`publicProp`, `prop`]"#), "test.mjs");
assert!(
template.code.contains(r#"prop: [0, `publicProp`, `prop`]"#),
"aliased input lost its InputFlags element:\n{}",
template.code
);
let string =
link(&allocator, &make_directive_source(r#"prop: ["publicProp", "prop"]"#), "test.mjs");
assert!(
string.code.contains(r#"prop: [0, "publicProp", "prop"]"#),
"quoted input changed behaviour:\n{}",
string.code
);

// Object format: publicName/classPropertyName may also be template literals.
let allocator = Allocator::default();
let template = link(
&allocator,
&make_directive_source(
r#"prop: { classPropertyName: `prop`, publicName: `publicProp`, isRequired: false, isSignal: false }"#,
),
"test.mjs",
);
let string = link(
&allocator,
&make_directive_source(
r#"prop: { classPropertyName: "prop", publicName: "publicProp", isRequired: false, isSignal: false }"#,
),
"test.mjs",
);
assert_eq!(template.code, string.code);
}

/// `deps: "invalid"` rewritten as a template literal still selects the
/// ɵɵinvalidFactory path instead of falling through to `new Type()`.
#[test]
fn test_link_factory_invalid_deps_template_literal() {
let allocator = Allocator::default();
let code = r#"
import * as i0 from "@angular/core";
class MyService {
}
MyService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: MyService, deps: `invalid`, target: i0.ɵɵFactoryTarget.Injectable });
"#;
let result = link(&allocator, code, "test.mjs");
assert!(result.linked);
assert!(result.code.contains("ɵɵinvalidFactory"), "deps not invalid:\n{}", result.code);
}
Loading