feat(core): support regular expressions in templates (#63857)

Updates the template syntax to support inline regular expressions.

PR Close #63857
This commit is contained in:
Kristiyan Kostadinov 2025-09-17 08:58:02 +02:00 committed by Jessica Janiuk
parent 8a69c0629b
commit 328a2bf719
8 changed files with 138 additions and 0 deletions

View file

@ -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;
}
}

View file

@ -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) {

View file

@ -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<string>();
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.
*/

View file

@ -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);
}

View file

@ -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', () => {

View file

@ -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);
}

View file

@ -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 {

View file

@ -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);