Skip to content

Commit 3ed9b70

Browse files
lahmaclaude
andauthored
Add custom JavaScript regex engine (QuickJS libregexp port) (#2380)
Port of QuickJS libregexp to C# for patterns where .NET Regex diverges from ECMAScript semantics. Three-tier architecture: direct .NET Regex for simple patterns, Jint's converter for patterns needing rewriting, and custom bytecode engine for ECMAScript-specific semantics. 97,638 test262 passed, 0 failures (+1,624 from baseline). Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d4d231b commit 3ed9b70

22 files changed

Lines changed: 8440 additions & 137 deletions

Directory.Packages.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
55
</PropertyGroup>
66
<ItemGroup>
7-
<PackageVersion Include="Acornima" Version="1.3.2" />
8-
<PackageVersion Include="Acornima.Extras" Version="1.3.2" />
7+
<PackageVersion Include="Acornima" Version="1.4.0" />
8+
<PackageVersion Include="Acornima.Extras" Version="1.4.0" />
99
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
1010
<PackageVersion Include="BenchmarkDotNet.TestAdapter" Version="0.13.12" />
1111
<PackageVersion Include="FluentAssertions" Version="[7.2.2]" />

Jint.Tests.Test262/Test262Harness.settings.json

Lines changed: 1 addition & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
"ExcludedFeatures": [
88
"import-defer",
99
"import-text",
10-
"regexp-unicode-property-escapes",
11-
"regexp-v-flag",
1210
"source-phase-imports",
1311
"tail-call-optimization"
1412
],
@@ -26,55 +24,6 @@
2624
// Currently quite impossible to detect if assignment target is CoverParenthesizedExpression
2725
"language/expressions/assignment/fn-name-lhs-cover.js",
2826

29-
// === REGEX EXCLUSIONS ===
30-
31-
// CharacterClassEscapes tests internally use v flag which is not supported
32-
"built-ins/RegExp/CharacterClassEscapes/*.js",
33-
34-
// .NET regex engine: forward backreferences not supported in ECMAScript mode
35-
"built-ins/RegExp/S15.10.2.11_A1_T5.js",
36-
"built-ins/RegExp/S15.10.2.11_A1_T7.js",
37-
"language/literals/regexp/named-groups/forward-reference.js",
38-
"built-ins/RegExp/named-groups/non-unicode-references.js",
39-
"built-ins/RegExp/named-groups/unicode-references.js",
40-
41-
// .NET regex engine: lookbehind backreference capture semantics differ from JS
42-
"built-ins/RegExp/lookBehind/back-references-to-captures.js",
43-
"built-ins/RegExp/lookBehind/mutual-recursive.js",
44-
45-
// .NET regex engine: nullable quantifier and lookahead capture group semantics differ
46-
"built-ins/RegExp/nullable-quantifier.js",
47-
"built-ins/RegExp/lookahead-quantifier-match-groups.js",
48-
49-
// .NET regex engine: scoped regexp modifiers don't fully match ECMAScript boundary and multiline semantics
50-
"built-ins/RegExp/regexp-modifiers/add-ignoreCase-affects-slash-lower-b.js",
51-
"built-ins/RegExp/regexp-modifiers/add-ignoreCase-affects-slash-lower-w.js",
52-
"built-ins/RegExp/regexp-modifiers/add-ignoreCase-affects-slash-upper-b.js",
53-
"built-ins/RegExp/regexp-modifiers/add-ignoreCase-affects-slash-upper-w.js",
54-
"built-ins/RegExp/regexp-modifiers/add-multiline.js",
55-
"built-ins/RegExp/regexp-modifiers/remove-multiline-does-not-affect-dotAll-flag.js",
56-
57-
// .NET regex engine: complex quantifier capture group tracking differs
58-
"built-ins/RegExp/S15.10.2.5_A1_T4.js",
59-
"built-ins/RegExp/prototype/exec/S15.10.6.2_A1_T6.js",
60-
61-
// .NET regex engine: Unicode case folding tables differ from ECMAScript CaseFolding.txt
62-
"language/literals/regexp/u-case-mapping.js",
63-
"built-ins/RegExp/unicode_full_case_folding.js",
64-
65-
// .NET regex engine: quantifier values exceeding int.MaxValue
66-
"built-ins/RegExp/quantifier-integer-limit.js",
67-
68-
// .NET regex engine: quantified groups with alternation retain captures from previous iterations
69-
// (JavaScript resets non-participating groups per iteration, .NET does not)
70-
"built-ins/RegExp/named-groups/duplicate-names-exec.js",
71-
"built-ins/RegExp/named-groups/duplicate-names-match.js",
72-
"built-ins/RegExp/named-groups/duplicate-names-test.js",
73-
"built-ins/RegExp/prototype/exec/duplicate-named-groups-properties.js",
74-
"built-ins/RegExp/prototype/exec/duplicate-named-indices-groups-properties.js",
75-
"built-ins/String/prototype/match/duplicate-named-groups-properties.js",
76-
"built-ins/String/prototype/match/duplicate-named-indices-groups-properties.js",
77-
7827
// requires investigation how to process complex function name evaluation for property
7928
"built-ins/Function/prototype/toString/method-computed-property-name.js",
8029

@@ -355,13 +304,7 @@
355304
"intl402/Temporal/ZonedDateTime/prototype/with/leap-year-hebrew.js",
356305
"intl402/Temporal/ZonedDateTime/prototype/withCalendar/extreme-dates.js",
357306
"intl402/Temporal/ZonedDateTime/prototype/year/arithmetic-year.js",
358-
"intl402/Temporal/ZonedDateTime/prototype/year/epoch-year.js",
359-
360-
// === ANNEX B EXCLUSIONS ===
361-
362-
// Acornima parser/RegExp limitation: malformed named groups in non-unicode mode
363-
// Per B.1.2, non-unicode RegExp should accept some malformed named group syntax
364-
"annexB/built-ins/RegExp/named-groups/non-unicode-malformed.js"
307+
"intl402/Temporal/ZonedDateTime/prototype/year/epoch-year.js"
365308

366309
]
367310
}

Jint.Tests.Test262/Test262Test.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#nullable enable
22

33
using Jint.Native;
4-
using Jint.Native.Error;
54
using Jint.Native.Object;
65
using Jint.Runtime;
76
using Jint.Runtime.Interop;

Jint.Tests/Runtime/ScriptModulePreparationTests.ScriptPreparation.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ public void CanPreCompileRegex()
2222
var declaration = Assert.IsType<VariableDeclaration>(script.Program.Body[0]);
2323
var init = Assert.IsType<RegExpLiteral>(declaration.Declarations[0].Init);
2424

25-
init.Value.ToString().Should().Be("[cgt]");
26-
(init.Value.Options & RegexOptions.Compiled).Should().Be(RegexOptions.Compiled);
25+
// Regex is pre-compiled during preparation with Compiled flag
26+
var regex = init.ParseResult.Regex;
27+
regex.Should().NotBeNull();
28+
(regex!.Options & RegexOptions.Compiled).Should().Be(RegexOptions.Compiled);
29+
30+
// Prepared script executes correctly
2731
new Engine().Evaluate(script).AsNumber().Should().Be(1);
2832
}
2933

Jint/Engine.Ast.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public static Prepared<Script> PrepareScript(string code, string? source = null,
2929
Script preparedScript;
3030
var sourceOffset = options.ParsingOptions.SourceOffset;
3131
var padding = AcornimaExtensions.CreateSourceOffsetPadding(sourceOffset);
32+
3233
if (padding.Length > 0)
3334
{
3435
var paddedCode = padding + code;
@@ -38,6 +39,7 @@ public static Prepared<Script> PrepareScript(string code, string? source = null,
3839
{
3940
preparedScript = parser.ParseScript(code, source, strict);
4041
}
42+
4143
return new Prepared<Script>(preparedScript, parserOptions);
4244
}
4345
catch (Exception e)
@@ -64,6 +66,7 @@ public static Prepared<Module> PrepareModule(string code, string? source = null,
6466
try
6567
{
6668
var preparedModule = parser.ParseModule(code, source);
69+
6770
return new Prepared<Module>(preparedModule, parserOptions);
6871
}
6972
catch (Exception e)

Jint/Engine.Defaults.cs

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,93 @@
1+
using Jint.Native.RegExp;
2+
using Jint.Runtime.RegExp;
3+
14
namespace Jint;
25

36
public partial class Engine
47
{
58
internal const bool FoldConstantsOnPrepareByDefault = true;
69

10+
private static readonly TimeSpan DefaultPrepareRegexTimeout = TimeSpan.FromSeconds(10);
11+
12+
/// <summary>
13+
/// OnRegExp callback that validates regex syntax at parse time without converting.
14+
/// Conversion is done at runtime in RegExpInitialize with fallback to the custom engine.
15+
/// </summary>
16+
internal static readonly OnRegExpHandler ValidateRegExpHandler = static (in RegExpParsingContext ctx) =>
17+
{
18+
ctx.Validate();
19+
return RegExpParseResult.ForSuccess();
20+
};
21+
22+
/// <summary>
23+
/// OnRegExp callback that validates and converts regex at parse time (interpreted .NET Regex).
24+
/// Used during script/module preparation when CompileRegex is false.
25+
/// </summary>
26+
internal static readonly OnRegExpHandler ConvertRegExpHandler = static (in RegExpParsingContext ctx) =>
27+
{
28+
ctx.Validate();
29+
// Skip pre-compilation for patterns that need the custom QuickJS engine at runtime
30+
if (!RegExpConstructor.NeedCustomEngine(ctx.Pattern, ctx.Flags)
31+
&& JsRegExpConverter.TryConvert(ctx.Pattern, ctx.Flags, DefaultPrepareRegexTimeout, out var regex, out var groupCount))
32+
{
33+
return RegExpParseResult.ForSuccess(regex, groupCount);
34+
}
35+
36+
return RegExpParseResult.ForSuccess();
37+
};
38+
39+
/// <summary>
40+
/// OnRegExp callback that validates and converts regex at parse time (compiled .NET Regex).
41+
/// Used during script/module preparation when CompileRegex is true (default for preparation).
42+
/// </summary>
43+
internal static readonly OnRegExpHandler CompileRegExpHandler = static (in RegExpParsingContext ctx) =>
44+
{
45+
ctx.Validate();
46+
// Skip pre-compilation for patterns that need the custom QuickJS engine at runtime
47+
if (!RegExpConstructor.NeedCustomEngine(ctx.Pattern, ctx.Flags)
48+
&& JsRegExpConverter.TryConvert(ctx.Pattern, ctx.Flags, DefaultPrepareRegexTimeout, out var regex, out var groupCount, compiled: true))
49+
{
50+
return RegExpParseResult.ForSuccess(regex, groupCount);
51+
}
52+
53+
return RegExpParseResult.ForSuccess();
54+
};
55+
56+
/// <summary>
57+
/// Creates an OnRegExp handler with a caller-specified timeout (interpreted .NET Regex).
58+
/// </summary>
59+
internal static OnRegExpHandler CreateConvertRegExpHandler(TimeSpan timeout) => (in RegExpParsingContext ctx) =>
60+
{
61+
ctx.Validate();
62+
if (!RegExpConstructor.NeedCustomEngine(ctx.Pattern, ctx.Flags)
63+
&& JsRegExpConverter.TryConvert(ctx.Pattern, ctx.Flags, timeout, out var regex, out var groupCount))
64+
{
65+
return RegExpParseResult.ForSuccess(regex, groupCount);
66+
}
67+
68+
return RegExpParseResult.ForSuccess();
69+
};
70+
71+
/// <summary>
72+
/// Creates an OnRegExp handler with a caller-specified timeout (compiled .NET Regex).
73+
/// </summary>
74+
internal static OnRegExpHandler CreateCompileRegExpHandler(TimeSpan timeout) => (in RegExpParsingContext ctx) =>
75+
{
76+
ctx.Validate();
77+
if (!RegExpConstructor.NeedCustomEngine(ctx.Pattern, ctx.Flags)
78+
&& JsRegExpConverter.TryConvert(ctx.Pattern, ctx.Flags, timeout, out var regex, out var groupCount, compiled: true))
79+
{
80+
return RegExpParseResult.ForSuccess(regex, groupCount);
81+
}
82+
83+
return RegExpParseResult.ForSuccess();
84+
};
85+
786
internal static readonly ParserOptions BaseParserOptions = ParserOptions.Default with
887
{
9-
EcmaVersion = EcmaVersion.ES2023,
10-
ExperimentalESFeatures = ExperimentalESFeatures.ImportAttributes
11-
| ExperimentalESFeatures.RegExpDuplicateNamedCapturingGroups
12-
| ExperimentalESFeatures.RegExpModifiers
13-
| ExperimentalESFeatures.ExplicitResourceManagement
14-
| ExperimentalESFeatures.Decorators,
88+
EcmaVersion = EcmaVersion.ES2026,
89+
ExperimentalESFeatures = ExperimentalESFeatures.Decorators,
90+
OnRegExp = ValidateRegExpHandler,
1591
Tolerant = false,
1692
};
1793
}

Jint/Native/Iterator/IteratorInstance.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ public override bool TryIteratorStep(out ObjectInstance nextItem)
227227
if (macthStr == "")
228228
{
229229
var thisIndex = TypeConverter.ToLength(_iteratingRegExp.Get(JsRegExp.PropertyLastIndex));
230-
var nextIndex = thisIndex + 1;
230+
var nextIndex = RegExpPrototype.AdvanceStringIndex(_s, thisIndex, _unicode);
231231
_iteratingRegExp.Set(JsRegExp.PropertyLastIndex, nextIndex, true);
232232
}
233233
}

Jint/Native/JsRegExp.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Jint.Native.RegExp;
44
using Jint.Runtime;
55
using Jint.Runtime.Descriptors;
6+
using Jint.Runtime.RegExp;
67

78
namespace Jint.Native;
89

@@ -24,6 +25,12 @@ public JsRegExp(Engine engine)
2425
public Regex Value { get; set; } = null!;
2526
public string Source { get; set; }
2627

28+
/// <summary>
29+
/// Custom regex engine used when .NET Regex cannot handle the pattern.
30+
/// When set, this takes priority over <see cref="Value"/>.
31+
/// </summary>
32+
internal JintRegExpEngine? CustomEngine { get; set; }
33+
2734
public string Flags
2835
{
2936
get => _flags;
@@ -37,6 +44,7 @@ public string Flags
3744
IgnoreCase = false;
3845
Multiline = false;
3946
Sticky = false;
47+
Unicode = false;
4048
FullUnicode = false;
4149
UnicodeSets = false;
4250
foreach (var c in _flags)
@@ -62,29 +70,51 @@ public string Flags
6270
Sticky = true;
6371
break;
6472
case 'u':
73+
Unicode = true;
6574
FullUnicode = true;
6675
break;
6776
case 'v':
6877
UnicodeSets = true;
78+
FullUnicode = true; // v-flag implies unicode semantics
6979
break;
7080
}
7181
}
7282
}
7383
}
7484

75-
public RegExpParseResult ParseResult { get; set; }
85+
/// <summary>
86+
/// JS group count (including group 0 for full match) from Jint's converter.
87+
/// 0 means not set — fall back to .NET Regex's own group count.
88+
/// </summary>
89+
internal int ConvertedGroupCount { get; set; }
90+
91+
/// <summary>
92+
/// Provides backward-compatible access to the regex parse result.
93+
/// </summary>
94+
[Obsolete("The ParseResult property is no longer populated. Use Value to access the .NET Regex instance directly.")]
95+
public RegExpParseResult ParseResult
96+
{
97+
get => UsesDotNetEngine && Value is not null ? RegExpParseResult.ForSuccess(Value) : default;
98+
set { /* no-op for backward compatibility */ }
99+
}
76100

77101
public bool DotAll { get; private set; }
78102
public bool Global { get; private set; }
79103
public bool Indices { get; private set; }
80104
public bool IgnoreCase { get; private set; }
81105
public bool Multiline { get; private set; }
82106
public bool Sticky { get; private set; }
107+
/// <summary>Whether the 'u' flag was explicitly set (for the unicode accessor).</summary>
108+
public bool Unicode { get; private set; }
109+
/// <summary>Whether unicode semantics apply (true for both 'u' and 'v' flags).</summary>
83110
public bool FullUnicode { get; private set; }
84111
public bool UnicodeSets { get; private set; }
85112

86113
internal bool HasDefaultRegExpExec => Properties == null && Prototype is RegExpPrototype { HasDefaultExec: true };
87114

115+
/// <summary>Whether this regex uses the .NET Regex engine (not the custom engine).</summary>
116+
internal bool UsesDotNetEngine => CustomEngine is null;
117+
88118
public override PropertyDescriptor GetOwnProperty(JsValue property)
89119
{
90120
if (PropertyLastIndex.Equals(property))

0 commit comments

Comments
 (0)