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
12 changes: 11 additions & 1 deletion crates/oxc_angular_compiler/src/parser/expression/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,14 @@ impl<'a> Parser<'a> {
}

let value = self.parse_pipe_for_template_binding();
// A token that starts no expression is left unread, and the value is empty. Angular's
// `parsePrimary` reports it and skips it.
if self.current_end_index() < expr_start
&& let Some(token) = self.peek()
{
let token_str = token.str_value.to_string();
self.error(&format!("Unexpected token {token_str}"));
}
// Value source is just the expression (up to current_end_index)
let value_end = self.current_end_index();

Expand All @@ -752,7 +760,9 @@ impl<'a> Parser<'a> {
self.peek().map(|t| t.index).unwrap_or(self.source.len() as u32)
};

let value_source = &self.source[expr_start as usize..value_end as usize];
// A value that fails to parse ends before it starts: Angular's `substring` swaps the two.
let value_source =
&self.source[expr_start.min(value_end) as usize..expr_start.max(value_end) as usize];
let ast_with_source = ASTWithSource {
ast: value,
source: Some(Ident::from_in(value_source, &self.allocator)),
Expand Down
145 changes: 89 additions & 56 deletions crates/oxc_angular_compiler/src/pipeline/selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ impl SelectorFlags {
}
}

/// A `start..end` range of char indices into a selector.
type CharRange = (usize, usize);

/// A parsed CSS selector.
#[derive(Debug, Default)]
pub struct CssSelector {
Expand Down Expand Up @@ -150,6 +153,10 @@ impl CssSelector {

let chars: Vec<char> = selector.chars().collect();
let len = chars.len();
// `i` counts chars; slices of `selector` take the byte offset of each.
let offsets: Vec<usize> =
selector.char_indices().map(|(at, _)| at).chain([selector.len()]).collect();
let text = |start: usize, end: usize| &selector[offsets[start]..offsets[end]];
let mut i = 0;

while i < len {
Expand Down Expand Up @@ -177,7 +184,7 @@ impl CssSelector {
}

// Handle :not(
if c == ':' && i + 4 < len && &selector[i..i + 5] == ":not(" {
if c == ':' && i + 4 < len && text(i, i + 5) == ":not(" {
// Nesting :not in a selector is not allowed - return what we have
if in_not {
Self::finalize_selector(&mut results, &mut current, false, CssSelector::new());
Expand Down Expand Up @@ -211,7 +218,7 @@ impl CssSelector {
i += 1;
}
if i > start {
target.add_class_name(&selector[start..i]);
target.add_class_name(text(start, i));
}
continue;
}
Expand All @@ -224,69 +231,30 @@ impl CssSelector {
i += 1;
}
if i > start {
target.add_attribute("id", Some(&selector[start..i]));
target.add_attribute("id", Some(text(start, i)));
}
continue;
}

// Handle attribute selector
if c == '[' {
i += 1;
let attr_start = i;
// Find attribute name (may contain escape sequences like \$)
while i < len && chars[i] != '=' && chars[i] != ']' {
i += 1;
}
let raw_attr_name = selector[attr_start..i].trim();
// Handle attribute selector. When it does not match, Angular's regexp goes on from
// the char after the `[`, as an unknown char.
if c == '['
&& let Some((name, value, end)) = Self::attribute_at(&chars, i + 1)
{
// Unescape attribute name (like Angular's unescapeAttribute)
let attr_name = Self::unescape_attribute(raw_attr_name);

if i < len && chars[i] == '=' {
i += 1;
// Skip optional quote
let quote = if i < len && (chars[i] == '"' || chars[i] == '\'') {
let q = chars[i];
i += 1;
Some(q)
} else {
None
};

let value_start = i;
if let Some(q) = quote {
while i < len && chars[i] != q {
i += 1;
}
let attr_value = &selector[value_start..i];
target.add_attribute(&attr_name, Some(attr_value));
if i < len {
i += 1; // Skip closing quote
}
} else {
while i < len && chars[i] != ']' {
i += 1;
}
let attr_value = selector[value_start..i].trim();
target.add_attribute(&attr_name, Some(attr_value));
}
} else {
target.add_attribute(&attr_name, None);
}

// Skip closing bracket
if i < len && chars[i] == ']' {
i += 1;
}
let attr_name = Self::unescape_attribute(text(name.0, name.1));
target.add_attribute(&attr_name, value.map(|(start, end)| text(start, end)));
i = end;
continue;
}

// Handle element name (identifier at current position)
if Self::is_ident_start(c) {
if Self::is_ident_char(c) {
let start = i;
while i < len && Self::is_ident_char(chars[i]) {
i += 1;
}
target.set_element(&selector[start..i]);
target.set_element(text(start, i));
continue;
}

Expand Down Expand Up @@ -340,12 +308,49 @@ impl CssSelector {
result
}

fn is_ident_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_' || c == '-' || c > '\x7f'
/// `[-\w]`, the chars of a name in Angular's selector regexp. `\w` has no `u` flag there, so
/// it is ASCII only: `.ação` is the class `a` and then the element `o`.
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}

fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-' || c > '\x7f'
/// Angular's attribute production, `\[([-.\w*\\$]+)(?:=(["']?)([^\]"']*)\5)?\]`, from just
/// after the `[`: the char ranges of the name and of the value, and the index after the `]`.
fn attribute_at(chars: &[char], start: usize) -> Option<(CharRange, Option<CharRange>, usize)> {
let mut i = start;
while chars
.get(i)
.is_some_and(|&c| Self::is_ident_char(c) || matches!(c, '.' | '*' | '\\' | '$'))
{
i += 1;
}
if i == start {
return None;
}
let name = (start, i);
match chars.get(i)? {
']' => Some((name, None, i + 1)),
'=' => {
i += 1;
let quote = chars.get(i).copied().filter(|c| matches!(c, '"' | '\''));
if quote.is_some() {
i += 1;
}
let value_start = i;
while chars.get(i).is_some_and(|c| !matches!(c, ']' | '"' | '\'')) {
i += 1;
}
let value = (value_start, i);
if let Some(quote) = quote {
if chars.get(i) != Some(&quote) {
return None;
}
i += 1;
}
(chars.get(i) == Some(&']')).then_some((name, Some(value), i + 1))
}
_ => None,
}
}

fn finalize_selector(
Expand Down Expand Up @@ -544,6 +549,34 @@ mod tests {
assert_eq!(selectors[0].attrs, vec!["name", "value"]);
}

/// As Angular's selector regexp reads them: values keep any char, names only `[-\w]` (ASCII).
#[test]
fn test_parse_non_ascii() {
let selectors = CssSelector::parse("[slot=título]");
assert_eq!(selectors[0].attrs, vec!["slot", "título"]);
let selectors = CssSelector::parse("[lang='ção'].ação#é");
assert_eq!(selectors[0].attrs, vec!["lang", "ção"]);
assert_eq!(selectors[0].class_names, vec!["a"]);
assert_eq!(selectors[0].element, Some("o".to_string()));
let selectors = CssSelector::parse("x-é:not([é=ü])");
assert_eq!(selectors[0].element, Some("x-".to_string()));
let not = &selectors[0].not_selectors[0];
assert!(not.element.is_none() && not.attrs.is_empty() && not.class_names.is_empty());
}

/// An attribute that does not match Angular's production is read on from the char after `[`.
#[test]
fn test_parse_attribute_like_angular() {
let selectors = CssSelector::parse("[slot= olá ]");
assert_eq!(selectors[0].attrs, vec!["slot", " olá "]);
let selectors = CssSelector::parse("[ a ]");
assert_eq!(selectors[0].element, Some("a".to_string()));
assert!(selectors[0].attrs.is_empty());
let selectors = CssSelector::parse("[a=\"b]");
assert_eq!(selectors[0].element, Some("b".to_string()));
assert!(selectors[0].attrs.is_empty());
}

#[test]
fn test_parse_comma_separated() {
let selectors = CssSelector::parse("div, span");
Expand Down
35 changes: 35 additions & 0 deletions crates/oxc_angular_compiler/tests/expression_parser_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,41 @@ mod parse_template_bindings {
result.bindings
}

#[test]
fn should_report_a_value_that_fails_to_parse() {
for value in ["b; in/", "let é of ü; trackBy: ñ"] {
let allocator = Allocator::default();
let key = TemplateBindingIdentifier {
source: oxc_str::Ident::from("a"),
span: oxc_angular_compiler::ast::expression::AbsoluteSourceSpan::new(0, 1),
};
let result = Parser::new(&allocator, value).parse_template_bindings(key);
assert!(!result.errors.is_empty(), "{value}");
}
}

#[test]
fn should_report_a_token_that_starts_no_value() {
for (value, token) in [
("let i of xs; trackBy: }", "}"),
("b; c: )", ")"),
("b; c ]", "]"),
("let i of xs; trackBy: =>", "=>"),
] {
let allocator = Allocator::default();
let key = TemplateBindingIdentifier {
source: oxc_str::Ident::from("a"),
span: oxc_angular_compiler::ast::expression::AbsoluteSourceSpan::new(0, 1),
};
let result = Parser::new(&allocator, value).parse_template_bindings(key);
let messages: Vec<_> = result.errors.iter().map(String::as_str).collect();
assert!(
messages.iter().any(|m| m.starts_with(&format!("Unexpected token {token} "))),
"{value}: {messages:?}"
);
}
}

// NOTE: Many tests below document the CURRENT behavior of the Rust implementation.
// Some behaviors differ from Angular's TypeScript implementation.
// Tests marked with TODO comments indicate expected Angular behavior for future fixes.
Expand Down
4 changes: 2 additions & 2 deletions crates/oxc_angular_compiler/tests/linker_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,14 +643,14 @@ fn test_link_escapes_metadata_strings() {
export class P {}
P.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.0", ngImport: i0, type: P, isStandalone: true, name: 'a"+x+"' });
export class D {}
D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.0", ngImport: i0, type: D, isStandalone: true, selector: "[t='a\"b']", inputs: { 'a.b"c': "c" }, host: { classAttribute: 'k"+x+"' }, controlCreate: { passThroughInput: 'f"+x+"' } });
D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.0", ngImport: i0, type: D, isStandalone: true, selector: "[t='a\\b']", inputs: { 'a.b"c': "c" }, host: { classAttribute: 'k"+x+"' }, controlCreate: { passThroughInput: 'f"+x+"' } });
"#;
let allocator = Allocator::default();
let result = link(&allocator, code, "test.mjs");
assert!(result.linked);
for expected in [
r#"name: "a\"+x+\"""#,
r#"selectors: [["", "t", "a\"b"]]"#,
r#"selectors: [["", "t", "a\\b"]]"#,
r#"inputs: { "a.b\"c": "c" }"#,
r#"hostAttrs: [1, "k\"+x+\""]"#,
r#"ɵɵControlFeature("f\"+x+\"")"#,
Expand Down
Loading