diff --git a/packages/angular-html-parser/test/index_spec.ts b/packages/angular-html-parser/test/index_spec.ts
index 89e101cb94f9..8488ed5c9d62 100644
--- a/packages/angular-html-parser/test/index_spec.ts
+++ b/packages/angular-html-parser/test/index_spec.ts
@@ -188,6 +188,54 @@ describe("AST format", () => {
]);
}
+ {
+ // A lowercase `@` is not a selectorless directive — those are referenced by their
+ // PascalCase class name — so it stays an attribute. This used to hang the lexer:
+ // https://github.com/prettier/angular-html-parser/issues/57
+ const result = parse("
", {
+ enableAngularSelectorlessSyntax: true,
+ });
+ expect(result.errors).toEqual([]);
+ expect(result.rootNodes).toEqual([
+ expect.objectContaining({
+ name: "div",
+ kind: "element",
+ directives: [],
+ attrs: [expect.objectContaining({ name: "@myDir" })],
+ }),
+ ]);
+ }
+
+ {
+ const result = parse("", {
+ enableAngularSelectorlessSyntax: true,
+ });
+ expect(result.rootNodes).toEqual([
+ expect.objectContaining({
+ fullName: "MyComp",
+ componentName: "MyComp",
+ kind: "component",
+ directives: [],
+ attrs: [expect.objectContaining({ name: "@myDir" })],
+ }),
+ ]);
+ }
+
+ {
+ // Any `@` that cannot start a directive has to fall back to an attribute name.
+ const result = parse("", {
+ enableAngularSelectorlessSyntax: true,
+ });
+ expect(result.errors).toEqual([]);
+ expect(result.rootNodes).toEqual([
+ expect.objectContaining({
+ name: "div",
+ kind: "element",
+ attrs: [expect.objectContaining({ name: "@" })],
+ }),
+ ]);
+ }
+
{
const result = parse("Hello", {
enableAngularSelectorlessSyntax: true,
diff --git a/packages/compiler/src/ml_parser/lexer.ts b/packages/compiler/src/ml_parser/lexer.ts
index d47944a1ce4c..9d489662e191 100644
--- a/packages/compiler/src/ml_parser/lexer.ts
+++ b/packages/compiler/src/ml_parser/lexer.ts
@@ -980,13 +980,18 @@ class _Tokenizer {
const nameStart = start.clone();
nameStart.advance();
+ // Selectorless directives are referenced by their PascalCase class name (`@Dir`).
if (isSelectorlessNameStart(nameStart.peek())) {
this._consumeDirective(start, nameStart);
+ continue;
}
- } else {
- const attr = this._consumeAttribute();
- attrs.push(attr);
}
+
+ // Any other `@` — a lowercase animation trigger like `@triggerName`, or a lone `@` — is
+ // part of an attribute name. Falling through is also what makes the loop advance: before
+ // this, a lowercase `@` was consumed by neither branch and the lexer looped forever.
+ const attr = this._consumeAttribute();
+ attrs.push(attr);
}
if (openToken.type === TokenType.COMPONENT_OPEN_START) {
diff --git a/packages/compiler/test/ml_parser/lexer_spec.ts b/packages/compiler/test/ml_parser/lexer_spec.ts
index db6d270fc540..0b8054a70b56 100644
--- a/packages/compiler/test/ml_parser/lexer_spec.ts
+++ b/packages/compiler/test/ml_parser/lexer_spec.ts
@@ -664,6 +664,44 @@ describe('HtmlLexer', () => {
[TokenType.EOF, ''],
]);
});
+
+ // https://github.com/prettier/angular-html-parser/issues/57
+ it('should treat a lowercase `@` as an attribute name instead of looping forever', () => {
+ expect(tokenizeAndHumanizeParts('', options)).toEqual([
+ [TokenType.TAG_OPEN_START, '', 'div'],
+ [TokenType.ATTR_NAME, '', '@myDir'],
+ [TokenType.TAG_OPEN_END],
+ [TokenType.TAG_CLOSE, '', 'div'],
+ [TokenType.EOF],
+ ]);
+ expect(tokenizeAndHumanizeParts('', options)).toEqual([
+ [TokenType.TAG_OPEN_START, '', 'div'],
+ [TokenType.ATTR_NAME, '', '@myDir'],
+ [TokenType.ATTR_QUOTE, '"'],
+ [TokenType.ATTR_VALUE_TEXT, 'x'],
+ [TokenType.ATTR_QUOTE, '"'],
+ [TokenType.TAG_OPEN_END],
+ [TokenType.TAG_CLOSE, '', 'div'],
+ [TokenType.EOF],
+ ]);
+ });
+
+ it('should treat any other `@` that cannot start a directive as an attribute name', () => {
+ expect(tokenizeAndHumanizeParts('', options)).toEqual([
+ [TokenType.TAG_OPEN_START, '', 'div'],
+ [TokenType.ATTR_NAME, '', '@'],
+ [TokenType.TAG_OPEN_END],
+ [TokenType.TAG_CLOSE, '', 'div'],
+ [TokenType.EOF],
+ ]);
+ expect(tokenizeAndHumanizeParts('', options)).toEqual([
+ [TokenType.TAG_OPEN_START, '', 'div'],
+ [TokenType.ATTR_NAME, '', '@1'],
+ [TokenType.TAG_OPEN_END],
+ [TokenType.TAG_CLOSE, '', 'div'],
+ [TokenType.EOF],
+ ]);
+ });
});
describe('escapable raw text', () => {