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
106 changes: 104 additions & 2 deletions crates/oxc_angular_compiler/src/directive/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,13 @@ pub enum InputFlags {
HasDecoratorInputTransform = 2, // 1 << 1
}

/// Check if an object property key needs quoting because it contains unsafe characters.
///
/// Matches Angular's `UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/` from `render3/view/util.ts`.
fn needs_object_key_quoting(key: &str) -> bool {
key.contains('.') || key.contains('-')
}

/// Creates the inputs literal map.
///
/// Ported from Angular's `conditionallyCreateDirectiveBindingLiteral` in `render3/view/util.ts`.
Expand Down Expand Up @@ -512,7 +519,8 @@ pub fn create_inputs_literal<'a>(
))
};

entries.push(LiteralMapEntry { key: declared_name.clone(), value, quoted: false });
let quoted = needs_object_key_quoting(declared_name);
entries.push(LiteralMapEntry { key: declared_name.clone(), value, quoted });
}

Some(OutputExpression::LiteralMap(Box::new_in(
Expand All @@ -533,6 +541,7 @@ pub fn create_outputs_literal<'a>(
let mut entries = Vec::new_in(allocator);

for (class_name, binding_name) in outputs {
let quoted = needs_object_key_quoting(class_name);
entries.push(LiteralMapEntry {
key: class_name.clone(),
value: OutputExpression::Literal(Box::new_in(
Expand All @@ -542,7 +551,7 @@ pub fn create_outputs_literal<'a>(
},
allocator,
)),
quoted: false,
quoted,
});
}

Expand Down Expand Up @@ -1547,4 +1556,97 @@ mod tests {
output
);
}

#[test]
fn test_create_inputs_literal_quotes_dotted_key() {
let allocator = Allocator::default();
let inputs = vec![R3InputMetadata {
class_property_name: Atom::from("fxFlexAlign.xs"),
binding_property_name: Atom::from("fxFlexAlign.xs"),
required: false,
is_signal: false,
transform_function: None,
}];
let expr = create_inputs_literal(&allocator, &inputs).unwrap();
let emitter = JsEmitter::new();
let output = emitter.emit_expression(&expr);
assert!(
output.contains(r#""fxFlexAlign.xs""#),
"Dotted key should be quoted. Got:\n{output}"
);
}

#[test]
fn test_create_inputs_literal_quotes_hyphenated_key() {
let allocator = Allocator::default();
let inputs = vec![R3InputMetadata {
class_property_name: Atom::from("fxFlexAlign.lt-sm"),
binding_property_name: Atom::from("fxFlexAlign.lt-sm"),
required: false,
is_signal: false,
transform_function: None,
}];
let expr = create_inputs_literal(&allocator, &inputs).unwrap();
let emitter = JsEmitter::new();
let output = emitter.emit_expression(&expr);
assert!(
output.contains(r#""fxFlexAlign.lt-sm""#),
"Hyphenated key should be quoted. Got:\n{output}"
);
}

#[test]
fn test_create_inputs_literal_no_quotes_for_simple_identifier() {
let allocator = Allocator::default();
let inputs = vec![R3InputMetadata {
class_property_name: Atom::from("fxFlexAlign"),
binding_property_name: Atom::from("fxFlexAlign"),
required: false,
is_signal: false,
transform_function: None,
}];
let expr = create_inputs_literal(&allocator, &inputs).unwrap();
let emitter = JsEmitter::new();
let output = emitter.emit_expression(&expr);
// Key should be bare (unquoted), followed by colon
assert!(
output.contains("fxFlexAlign:"),
"Simple identifier key should be bare. Got:\n{output}"
);
// Key should NOT be quoted — check that no quoted form appears before the colon
assert!(
!output.contains(r#""fxFlexAlign":"#),
"Simple identifier key should not be quoted. Got:\n{output}"
);
}

#[test]
fn test_create_outputs_literal_quotes_dotted_key() {
let allocator = Allocator::default();
let outputs = vec![(Atom::from("activate.xs"), Atom::from("activateXs"))];
let expr = create_outputs_literal(&allocator, &outputs).unwrap();
let emitter = JsEmitter::new();
let output = emitter.emit_expression(&expr);
assert!(
output.contains(r#""activate.xs""#),
"Dotted output key should be quoted. Got:\n{output}"
);
}

#[test]
fn test_create_outputs_literal_no_quotes_for_simple_identifier() {
let allocator = Allocator::default();
let outputs = vec![(Atom::from("activate"), Atom::from("activate"))];
let expr = create_outputs_literal(&allocator, &outputs).unwrap();
let emitter = JsEmitter::new();
let output = emitter.emit_expression(&expr);
assert!(
output.contains("activate:"),
"Simple identifier output key should be bare. Got:\n{output}"
);
assert!(
!output.contains(r#""activate":"#),
"Simple identifier output key should not be quoted. Got:\n{output}"
);
}
}
33 changes: 24 additions & 9 deletions crates/oxc_angular_compiler/src/linker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ use oxc_span::{GetSpan, SourceType};
use crate::optimizer::Edit;
use crate::pipeline::selector::{R3SelectorElement, parse_selector_to_r3_selector};

/// Check if an object property key needs quoting because it contains unsafe characters.
///
/// Matches Angular's `UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/` from `render3/view/util.ts`.
fn needs_object_key_quoting(key: &str) -> bool {
key.contains('.') || key.contains('-')
}

/// Quote a property key if it contains unsafe characters (dots or hyphens).
fn quote_key(key: &str) -> String {
if needs_object_key_quoting(key) { format!("\"{key}\"") } else { key.to_string() }
}

/// Partial declaration function names to link.
const DECLARE_FACTORY: &str = "\u{0275}\u{0275}ngDeclareFactory";
const DECLARE_INJECTABLE: &str = "\u{0275}\u{0275}ngDeclareInjectable";
Expand Down Expand Up @@ -1234,10 +1246,12 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source
}
};

let quoted_key = quote_key(&key);

match &p.value {
// Simple string: propertyName: "publicName" → keep as is
Expression::StringLiteral(lit) => {
entries.push(format!("{key}: \"{}\"", lit.value));
entries.push(format!("{quoted_key}: \"{}\"", lit.value));
}
// Array: check if it's declaration format [publicName, classPropertyName]
// and convert to definition format [InputFlags, publicName, classPropertyName]
Expand All @@ -1253,17 +1267,17 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source
// Convert to: [0, "publicName", "classPropertyName"]
let arr_source =
&source[arr.span.start as usize + 1..arr.span.end as usize - 1];
entries.push(format!("{key}: [0, {arr_source}]"));
entries.push(format!("{quoted_key}: [0, {arr_source}]"));
} else {
// Already in definition format or unknown, keep as is
let val =
&source[p.value.span().start as usize..p.value.span().end as usize];
entries.push(format!("{key}: {val}"));
entries.push(format!("{quoted_key}: {val}"));
}
} else {
// 3+ elements likely already in definition format, keep as is
let val = &source[p.value.span().start as usize..p.value.span().end as usize];
entries.push(format!("{key}: {val}"));
entries.push(format!("{quoted_key}: {val}"));
}
}
// Object: Angular 16+ format with classPropertyName, publicName, isRequired, etc.
Expand All @@ -1290,20 +1304,21 @@ 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!("{key}: \"{public_name}\""));
entries.push(format!("{quoted_key}: \"{public_name}\""));
} else if let Some(transform_fn) = transform {
entries.push(format!(
"{key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]"
"{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]"
));
} else {
entries
.push(format!("{key}: [{flags}, \"{public_name}\", \"{declared_name}\"]"));
entries.push(format!(
"{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\"]"
));
}
}
// Unknown format, keep as is
_ => {
let val = &source[p.value.span().start as usize..p.value.span().end as usize];
entries.push(format!("{key}: {val}"));
entries.push(format!("{quoted_key}: {val}"));
}
}
}
Expand Down
88 changes: 88 additions & 0 deletions crates/oxc_angular_compiler/tests/linker_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! Tests for Angular linker input/output key quoting.

use oxc_allocator::Allocator;
use oxc_angular_compiler::linker::link;

/// Helper to build a ɵɵngDeclareDirective source with a given inputs block.
fn make_directive_source(inputs_block: &str) -> String {
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]", inputs: {{ {inputs_block} }} }});"#
)
}

/// Helper to build a ɵɵngDeclareDirective source with a given outputs block.
fn make_directive_source_with_outputs(outputs_block: &str) -> String {
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]", outputs: {{ {outputs_block} }} }});"#
)
}

#[test]
fn test_link_inputs_dotted_key() {
let allocator = Allocator::default();
let code = make_directive_source(r#""fxFlexAlign.xs": "fxFlexAlignXs""#);
let result = link(&allocator, &code, "test.mjs");
insta::assert_snapshot!(result.code);
}

#[test]
fn test_link_inputs_hyphenated_key() {
let allocator = Allocator::default();
let code = make_directive_source(r#""fxFlexAlign.lt-sm": "fxFlexAlignLtSm""#);
let result = link(&allocator, &code, "test.mjs");
insta::assert_snapshot!(result.code);
}

#[test]
fn test_link_inputs_simple_identifier() {
let allocator = Allocator::default();
let code = make_directive_source(r#"fxFlexAlign: "fxFlexAlign""#);
let result = link(&allocator, &code, "test.mjs");
insta::assert_snapshot!(result.code);
}

#[test]
fn test_link_inputs_object_format_dotted_key() {
let allocator = Allocator::default();
let code = make_directive_source(
r#""fxFlexAlign.xs": { classPropertyName: "fxFlexAlignXs", publicName: "fxFlexAlign.xs", isRequired: false, isSignal: false }"#,
);
let result = link(&allocator, &code, "test.mjs");
insta::assert_snapshot!(result.code);
}

#[test]
fn test_link_inputs_array_format_dotted_key() {
let allocator = Allocator::default();
let code = make_directive_source(r#""fxFlexAlign.xs": ["fxFlexAlign.xs", "fxFlexAlignXs"]"#);
let result = link(&allocator, &code, "test.mjs");
insta::assert_snapshot!(result.code);
}

#[test]
fn test_link_outputs_dotted_key() {
let allocator = Allocator::default();
let code = make_directive_source_with_outputs(r#""activate.xs": "activateXs""#);
let result = link(&allocator, &code, "test.mjs");
insta::assert_snapshot!(result.code);
}

#[test]
fn test_link_outputs_hyphenated_key() {
let allocator = Allocator::default();
let code = make_directive_source_with_outputs(r#""activate.lt-sm": "activateLtSm""#);
let result = link(&allocator, &code, "test.mjs");
insta::assert_snapshot!(result.code);
}

#[test]
fn test_link_outputs_simple_identifier() {
let allocator = Allocator::default();
let code = make_directive_source_with_outputs(r#"activate: "activate""#);
let result = link(&allocator, &code, "test.mjs");
insta::assert_snapshot!(result.code);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/oxc_angular_compiler/tests/linker_test.rs
assertion_line: 54
expression: result.code
---
import * as i0 from "@angular/core";
export class MyDir {}
MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.xs": [0, "fxFlexAlign.xs", "fxFlexAlignXs"] }, standalone: false });
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/oxc_angular_compiler/tests/linker_test.rs
assertion_line: 20
expression: result.code
---
import * as i0 from "@angular/core";
export class MyDir {}
MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.xs": "fxFlexAlignXs" }, standalone: false });
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/oxc_angular_compiler/tests/linker_test.rs
assertion_line: 28
expression: result.code
---
import * as i0 from "@angular/core";
export class MyDir {}
MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.lt-sm": "fxFlexAlignLtSm" }, standalone: false });
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/oxc_angular_compiler/tests/linker_test.rs
assertion_line: 46
expression: result.code
---
import * as i0 from "@angular/core";
export class MyDir {}
MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { "fxFlexAlign.xs": [0, "fxFlexAlign.xs", "fxFlexAlignXs"] }, standalone: false });
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/oxc_angular_compiler/tests/linker_test.rs
assertion_line: 36
expression: result.code
---
import * as i0 from "@angular/core";
export class MyDir {}
MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], inputs: { fxFlexAlign: "fxFlexAlign" }, standalone: false });
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/oxc_angular_compiler/tests/linker_test.rs
assertion_line: 71
expression: result.code
---
import * as i0 from "@angular/core";
export class MyDir {}
MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], outputs: { "activate.xs": "activateXs" }, standalone: false });
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/oxc_angular_compiler/tests/linker_test.rs
assertion_line: 79
expression: result.code
---
import * as i0 from "@angular/core";
export class MyDir {}
MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], outputs: { "activate.lt-sm": "activateLtSm" }, standalone: false });
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/oxc_angular_compiler/tests/linker_test.rs
assertion_line: 87
expression: result.code
---
import * as i0 from "@angular/core";
export class MyDir {}
MyDir.ɵdir = i0.ɵɵdefineDirective({ type: MyDir, selectors: [["", "myDir", ""]], outputs: { activate: "activate" }, standalone: false });
Loading