From 328a2bf7199849a2fbc7bb3a87d0b6ecda9f25ce Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 17 Sep 2025 08:58:02 +0200 Subject: [PATCH] feat(core): support regular expressions in templates (#63857) Updates the template syntax to support inline regular expressions. PR Close #63857 --- .../src/ngtsc/typecheck/src/expression.ts | 8 +++ .../compiler/src/expression_parser/ast.ts | 17 +++++++ .../compiler/src/expression_parser/parser.ts | 49 +++++++++++++++++++ .../src/expression_parser/serializer.ts | 4 ++ .../test/expression_parser/parser_spec.ts | 46 +++++++++++++++++ .../test/expression_parser/utils/unparser.ts | 5 ++ .../test/expression_parser/utils/validator.ts | 5 ++ .../compiler/test/render3/util/expression.ts | 4 ++ 8 files changed, 138 insertions(+) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts index cf64bf38a47..1f968ab8a35 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts @@ -26,6 +26,7 @@ import { ParenthesizedExpression, PrefixNot, PropertyRead, + RegularExpressionLiteral, SafeCall, SafeKeyedRead, SafePropertyRead, @@ -199,6 +200,10 @@ class AstTranslator implements AstVisitor { throw new Error('Method not implemented.'); } + visitRegularExpressionLiteral(ast: RegularExpressionLiteral, context: any) { + throw new Error('TODO'); + } + visitInterpolation(ast: Interpolation): ts.Expression { // Build up a chain of binary + operations to simulate the string concatenation of the // interpolation's expressions. The chain is started using an actual string literal to ensure @@ -603,4 +608,7 @@ class VeSafeLhsInferenceBugDetector implements AstVisitor { visitParenthesizedExpression(ast: ParenthesizedExpression, context: any) { return ast.expression.visit(this); } + visitRegularExpressionLiteral(ast: RegularExpressionLiteral, context: any) { + return false; + } } diff --git a/packages/compiler/src/expression_parser/ast.ts b/packages/compiler/src/expression_parser/ast.ts index ba63feb9c23..f1b8a9d53a1 100644 --- a/packages/compiler/src/expression_parser/ast.ts +++ b/packages/compiler/src/expression_parser/ast.ts @@ -488,6 +488,21 @@ export class ParenthesizedExpression extends AST { } } +export class RegularExpressionLiteral extends AST { + constructor( + span: ParseSpan, + sourceSpan: AbsoluteSourceSpan, + readonly body: string, + readonly flags: string | null, + ) { + super(span, sourceSpan); + } + + override visit(visitor: AstVisitor, context?: any) { + return visitor.visitRegularExpressionLiteral(this, context); + } +} + /** * Records the absolute position of a text span in a source file, where `start` and `end` are the * starting and ending byte offsets, respectively, of the text span in a source file. @@ -617,6 +632,7 @@ export interface AstVisitor { visitTemplateLiteralElement(ast: TemplateLiteralElement, context: any): any; visitTaggedTemplateLiteral(ast: TaggedTemplateLiteral, context: any): any; visitParenthesizedExpression(ast: ParenthesizedExpression, context: any): any; + visitRegularExpressionLiteral(ast: RegularExpressionLiteral, context: any): any; visitASTWithSource?(ast: ASTWithSource, context: any): any; /** * This function is optionally defined to allow classes that implement this @@ -719,6 +735,7 @@ export class RecursiveAstVisitor implements AstVisitor { visitParenthesizedExpression(ast: ParenthesizedExpression, context: any) { this.visit(ast.expression, context); } + visitRegularExpressionLiteral(ast: RegularExpressionLiteral, context: any) {} // This is not part of the AstVisitor interface, just a helper method visitAll(asts: AST[], context: any): any { for (const ast of asts) { diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index 05e779ad610..e8f8100a1a1 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -40,6 +40,7 @@ import { PrefixNot, PropertyRead, RecursiveAstVisitor, + RegularExpressionLiteral, SafeCall, SafeKeyedRead, SafePropertyRead, @@ -581,6 +582,9 @@ enum ParseContextFlags { Writable = 1, } +/** Possible flags that can be used in a regex literal. */ +const SUPPORTED_REGEX_FLAGS = new Set(['d', 'g', 'i', 'm', 's', 'u', 'v', 'y']); + class _ParseAST { private rparensExpected = 0; private rbracketsExpected = 0; @@ -1178,6 +1182,8 @@ class _ParseAST { } else if (this.next.isPrivateIdentifier()) { this._reportErrorForPrivateIdentifier(this.next, null); return new EmptyExpr(this.span(start), this.sourceSpan(start)); + } else if (this.next.isRegExpBody()) { + return this.parseRegularExpressionLiteral(); } else if (this.index >= this.tokens.length) { this.error(`Unexpected end of expression: ${this.input}`); return new EmptyExpr(this.span(start), this.sourceSpan(start)); @@ -1618,6 +1624,49 @@ class _ParseAST { return new TemplateLiteral(this.span(start), this.sourceSpan(start), elements, expressions); } + private parseRegularExpressionLiteral() { + const bodyToken = this.next; + this.advance(); + + if (!bodyToken.isRegExpBody()) { + return new EmptyExpr(this.span(this.inputIndex), this.sourceSpan(this.inputIndex)); + } + + let flagsToken: Token | null = null; + + if (this.next.isRegExpFlags()) { + flagsToken = this.next; + this.advance(); + const seenFlags = new Set(); + + for (let i = 0; i < flagsToken.strValue.length; i++) { + const char = flagsToken.strValue[i]; + + if (!SUPPORTED_REGEX_FLAGS.has(char)) { + this.error( + `Unsupported regular expression flag "${char}". The supported flags are: ` + + Array.from(SUPPORTED_REGEX_FLAGS, (f) => `"${f}"`).join(', '), + flagsToken.index + i, + ); + } else if (seenFlags.has(char)) { + this.error(`Duplicate regular expression flag "${char}"`, flagsToken.index + i); + } else { + seenFlags.add(char); + } + } + } + + const start = bodyToken.index; + const end = flagsToken ? flagsToken.end : bodyToken.end; + + return new RegularExpressionLiteral( + this.span(start, end), + this.sourceSpan(start, end), + bodyToken.strValue, + flagsToken ? flagsToken.strValue : null, + ); + } + /** * Consume the optional statement terminator: semicolon or comma. */ diff --git a/packages/compiler/src/expression_parser/serializer.ts b/packages/compiler/src/expression_parser/serializer.ts index bc8569bfc45..e8b166eebe4 100644 --- a/packages/compiler/src/expression_parser/serializer.ts +++ b/packages/compiler/src/expression_parser/serializer.ts @@ -129,6 +129,10 @@ class SerializeExpressionVisitor implements expr.AstVisitor { return `void ${ast.expression.visit(this, context)}`; } + visitRegularExpressionLiteral(ast: expr.RegularExpressionLiteral, context: any) { + return `/${ast.body}/${ast.flags || ''}`; + } + visitASTWithSource(ast: expr.ASTWithSource, context: any): string { return ast.ast.visit(this, context); } diff --git a/packages/compiler/test/expression_parser/parser_spec.ts b/packages/compiler/test/expression_parser/parser_spec.ts index 47043bb3a57..f86b3b68818 100644 --- a/packages/compiler/test/expression_parser/parser_spec.ts +++ b/packages/compiler/test/expression_parser/parser_spec.ts @@ -510,6 +510,36 @@ describe('parser', () => { checkBinding('typeof `hello ${name}!`'); }); }); + + describe('regular expression literals', () => { + it('should parse a regular expression literal without flags', () => { + checkBinding('/abc/'); + checkBinding('/[a/]$/'); + checkBinding('/a\\w+/'); + checkBinding('/^http:\\/\\/foo\\.bar/'); + }); + + it('should parse a regular expression literal with flags', () => { + checkBinding('/abc/g'); + checkBinding('/[a/]$/gi'); + checkBinding('/a\\w+/gim'); + checkBinding('/^http:\\/\\/foo\\.bar/i'); + }); + + it('should parse a regular expression that is a part of other expressions', () => { + checkBinding('/abc/.test("foo")'); + checkBinding('"foo".match(/(abc)/)[1].toUpperCase()'); + checkBinding('/abc/.test("foo") && something || somethingElse'); + }); + + it('should report invalid regular expression flag', () => { + expectBindingError('"foo".match(/abc/O)', 'Unsupported regular expression flag "O"'); + }); + + it('should report duplicated regular expression flags', () => { + expectBindingError('"foo".match(/abc/gig)', 'Duplicate regular expression flag "g"'); + }); + }); }); describe('parse spans', () => { @@ -691,6 +721,22 @@ describe('parser', () => { expect(unparseWithSpan(parseBinding(input))).toContain([jasmine.any(String), input]); } }); + + it('should record span for a regex without flags', () => { + const ast = parseBinding('/^http:\\/\\/foo\\.bar/'); + expect(unparseWithSpan(ast)).toContain([ + '/^http:\\/\\/foo\\.bar/', + '/^http:\\/\\/foo\\.bar/', + ]); + }); + + it('should record span for a regex with flags', () => { + const ast = parseBinding('/^http:\\/\\/foo\\.bar/gim'); + expect(unparseWithSpan(ast)).toContain([ + '/^http:\\/\\/foo\\.bar/gim', + '/^http:\\/\\/foo\\.bar/gim', + ]); + }); }); describe('general error handling', () => { diff --git a/packages/compiler/test/expression_parser/utils/unparser.ts b/packages/compiler/test/expression_parser/utils/unparser.ts index e9cbe45e2d5..e1e0b4185e5 100644 --- a/packages/compiler/test/expression_parser/utils/unparser.ts +++ b/packages/compiler/test/expression_parser/utils/unparser.ts @@ -26,6 +26,7 @@ import { PrefixNot, PropertyRead, RecursiveAstVisitor, + RegularExpressionLiteral, SafeCall, SafeKeyedRead, SafePropertyRead, @@ -237,6 +238,10 @@ class Unparser implements AstVisitor { this._expression += ')'; } + visitRegularExpressionLiteral(ast: RegularExpressionLiteral, context: any) { + this._expression += `/${ast.body}/${ast.flags || ''}`; + } + private _visit(ast: AST) { ast.visit(this); } diff --git a/packages/compiler/test/expression_parser/utils/validator.ts b/packages/compiler/test/expression_parser/utils/validator.ts index bb97c56ce4e..77d93569826 100644 --- a/packages/compiler/test/expression_parser/utils/validator.ts +++ b/packages/compiler/test/expression_parser/utils/validator.ts @@ -24,6 +24,7 @@ import { PrefixNot, PropertyRead, RecursiveAstVisitor, + RegularExpressionLiteral, SafeCall, SafeKeyedRead, SafePropertyRead, @@ -155,6 +156,10 @@ class ASTValidator extends RecursiveAstVisitor { override visitParenthesizedExpression(ast: ParenthesizedExpression, context: any): void { this.validate(ast, () => super.visitParenthesizedExpression(ast, context)); } + + override visitRegularExpressionLiteral(ast: RegularExpressionLiteral, context: any): void { + this.validate(ast, () => super.visitRegularExpressionLiteral(ast, context)); + } } function inSpan(span: ParseSpan, parentSpan: ParseSpan | undefined): parentSpan is ParseSpan { diff --git a/packages/compiler/test/render3/util/expression.ts b/packages/compiler/test/render3/util/expression.ts index 33a39da8374..6c071c62e9e 100644 --- a/packages/compiler/test/render3/util/expression.ts +++ b/packages/compiler/test/render3/util/expression.ts @@ -127,6 +127,10 @@ class ExpressionSourceHumanizer extends e.RecursiveAstVisitor implements t.Visit this.recordAst(ast); super.visitParenthesizedExpression(ast, null); } + override visitRegularExpressionLiteral(ast: e.RegularExpressionLiteral, context: any): void { + this.recordAst(ast); + super.visitRegularExpressionLiteral(ast, null); + } visitTemplate(ast: t.Template) { t.visitAll(this, ast.directives);