Skip to content

Add RegexOptions.AnyNewLine via parser lowering - #124701

Merged
danmoseley merged 27 commits into
dotnet:mainfrom
danmoseley:anynewline-lower-v2
Mar 17, 2026
Merged

Add RegexOptions.AnyNewLine via parser lowering#124701
danmoseley merged 27 commits into
dotnet:mainfrom
danmoseley:anynewline-lower-v2

Conversation

@danmoseley

@danmoseleydanmoseley commented Feb 21, 2026

Copy link
Copy Markdown
Contributor

Motivation

.NET's Regex class hardcodes \n as the only newline character. With RegexOptions.Multiline, $ matches before \n but not before \r, \r\n, or Unicode line breaks. This is "by far one of the biggest gotchas" with System.Text.RegularExpressions:

// BUG: on a file with Windows \r\n line endings, .+$ captures trailing \rvarmatch=Regex.Match("foo\r\nbar",".*$",RegexOptions.Multiline);// match.Value == "foo\r" -- not "foo"!

Users are forced into fragile workarounds like \r?$ or (\r\n|\n) to handle mixed line endings. Real-world NuGet packages show how common this is -- from the real-world regex patterns dataset:

  • (\r\n|\n) (18,474 packages) -- CSV parser manually matching both line endings
  • \r?\n in PEM key parsing (1,964 packages) -- \r?\n sprinkled throughout with Multiline
  • $(\r?\n)? in assembly attribute matching (2,108 packages) -- using Multiline with manual newline handling
  • [\r\n]+ (2,422 packages) -- matching any newline character

These workarounds are error-prone, don't compose well with ^ and $ anchors, and miss Unicode newlines (\u0085, \u2028, \u2029).

Summary

Implements RegexOptions.AnyNewLine (api-approved) which makes $, ^, \Z, and . recognize all Unicode line boundaries: \r, \r\n, \n, \u0085 (NEL), \u2028 (LS), \u2029 (PS) -- consistent with Unicode TR18 RL1.6 and PCRE2's (*ANY) behavior.

With AnyNewLine, the example above just works:

varmatch=Regex.Match("foo\r\nbar",".*$",RegexOptions.Multiline|RegexOptions.AnyNewLine);// match.Value == "foo"

Approach: Parser Lowering

All logic lives in RegexParser.cs -- no changes to the interpreter, compiler, or source generator engines. Each affected construct is lowered into an equivalent RegexNode sub-tree:

ConstructLowered to
$ (no Multiline) / \Z(?=\r\n\z|\r?\z)|(?<!\r)(?=\n\z)|(?=[\u0085\u2028\u2029]\z)
$ (Multiline)(?=\r\n|\r|[\u0085\u2028\u2029]|\z)|(?<!\r)(?=\n)
^ (Multiline)(?<=\A|\r\n|\n|[\u0085\u2028\u2029])|(?<=\r)(?!\n)
.[^\r\n\u0085\u2028\u2029] (but Singleline takes precedence)

Key design choices:

  • \r\n is atomic: $ never matches between \r and \n. This is enforced with lookbehind/lookahead guards.
  • Singleline takes precedence: . with Singleline | AnyNewLine matches everything (including newlines), consistent with Singleline's documented behavior.
  • \A and \z are unaffected: absolute start/end anchors don't change.
  • Incompatible with NonBacktracking and ECMAScript: throws ArgumentOutOfRangeException (lowered patterns use lookaround).
  • Zero perf impact on existing patterns: the lowering is gated on the AnyNewLine flag, so patterns that don't use it take the same code paths as before. The only new cost is a flag check ((_options & RegexOptions.AnyNewLine) != 0) in the parser for $, ^, \Z, and ., which is negligible.

Out of scope: \R

Unicode TR18 RL1.6 also recommends a meta-character \R for matching any newline sequence (consuming the characters), equivalent to (?:\r\n|[\n\v\f\r\u0085\u2028\u2029]). This is distinct from what AnyNewLine does: AnyNewLine modifies the behavior of existing zero-width anchors (^, $, \Z) and the character class ., while \R would be a new consuming pattern element. Adding \R could be done independently as a separate feature.

Changes

Production code

  • RegexOptions.cs -- add AnyNewLine = 0x0800
  • RegexParser.cs -- lowering methods AnyNewLineEndZNode(), AnyNewLineEolNode(), AnyNewLineBolNode(), plus . handling
  • RegexCharClass.cs -- add NotNewLineOrCarriageReturnClass constant
  • Regex.cs / RegexCompilationInfo.cs -- validation

Tests

  • ~120 new test cases covering dot, anchors ($, ^, \Z), RightToLeft, Singleline, Multiline, Replace, Split, Count, EnumerateMatches, NonBacktracking rejection, edge cases (adjacent newlines, empty lines, all-newline strings), and PCRE2-inspired scenarios

Fixes#25598

Dan Moseleyand others added 14 commits February 20, 2026 21:59
Add AnyNewLine = 0x0800 to RegexOptions enum. Update ValidateOptions to
bump MaxOptionShift to 12 and reject AnyNewLine | NonBacktracking.
ECMAScript already rejects unknown options via allowlist.
Update source generator to include AnyNewLine in SupportedOptions mask.
Update tests that used 0x800 as an invalid option value to use 0x1000.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When AnyNewLine is set without Multiline, lower $ from EndZ into an
equivalent sub-tree: (?=\r\n\z|\r?\z)|(?<!\r)(?=\n\z)
This matches at end of string, or before \r\n, \r, or \n at end of
string, but not between \r and \n. Works across all engines
(interpreter, compiled, source generator) since it's pure parser
lowering.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When AnyNewLine is set, lower \Z using the same sub-tree as $ without
Multiline. \Z is not affected by Multiline, so the same lowering
applies regardless.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When both Multiline and AnyNewLine are set, lower $ to:
(?=\r\n|\r|\z)|(?<!\r)(?=\n)
This matches at \r\n, \r, \n boundaries and end-of-string,
without matching between \r and \n of a \r\n sequence.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When both Multiline and AnyNewLine are set, lower ^ to:
(?<=\A|\r\n|\n)|(?<=\r)(?!\n)
This matches after \r\n, \n, bare \r (not followed by \n), and
at start of string. Without Multiline, ^ remains \A unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When AnyNewLine is set (without Singleline), lower . to [^\n\r]
instead of [^\n], so dot does not match \r or \n.
Add NotNewLineOrCarriageReturnClass constant to RegexCharClass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Combined ^/$/. tests, Replace/Split, RightToLeft, mixed newlines,
empty lines, \Z with trailing newlines, and edge cases.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Integration tests using a ~50 char string with all newline types
(\r\n, \r, \n, \u0085, \u2028, \u2029) exercising ^, $, \Z, and .
together. Replace/Split tests with MatchEvaluator line numbering.
Deduplicated cases moved into per-feature tests (RightToLeft,
empty lines).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Expand test coverage across all AnyNewLine-affected constructs:
- Dollar, EndZ, DollarMultiline, CaretMultiline, Dot test data
with adjacent newlines, newlines at string boundaries,
empty segments, RightToLeft, and all Unicode newline types
- Advanced tests: inline options, backreferences, conditionals,
alternation with anchors, lookahead/lookbehind, quantified dot,
lazy quantifiers, named/atomic groups, word boundaries near
newlines, explicit char classes unaffected
- Methods test: IsMatch, Count, EnumerateMatches, Match with
startat, Replace with group ref, Split
- Unicode expansion: \s/\S behavior, \w behavior, \p{Zl}/\p{Zp}
categories, adjacent Unicode+ASCII newlines, baselines without
AnyNewLine
No bugs found — all initial test failures were wrong expectations.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Verify the fixer correctly emits RegexOptions.Multiline |
RegexOptions.AnyNewLine in enum value order when upgrading
to GeneratedRegex.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Test cases derived from cross-validation with PCRE2 NEWLINE_ANY
behavior (BSD-licensed) and analysis of real-world patterns from
dotnet/runtime-assets:
- (.+)# greedy where .+ cannot cross newlines (PCRE2 JIT 472)
- (.)(.) requiring consecutive non-newlines (PCRE2 JIT 471)
- (.). with mixed newline types (PCRE2 JIT 469)
- Blank line detection (^ +$) with \n, \r\n, \u0085 separators
All 31,528 tests pass. No bugs found — our implementation is
fully consistent with PCRE2 NEWLINE_ANY behavior and handles
real-world patterns correctly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add more RightToLeft + AnyNewLine tests (various newline types, dot,
anchors, \Z)
- Add more Singleline | AnyNewLine tests (all newline types, combined
with Multiline)
- Replace RegexOptions.AnyNewLine with RegexHelpers.RegexOptionAnyNewLine
throughout tests for net481 compilation compatibility
- Wrap Count/EnumerateMatches in #if NET for net481 compat
- Add clarifying comments on Split behavior with/without AnyNewLine
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

(Finally got around to having AI finish my lowering branch..)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements RegexOptions.AnyNewLine (value 0x0800 = 2048), a new regex option that makes ^, $, \Z, and . recognize all Unicode line boundaries (\r, \r\n, \n, \u0085 NEL, \u2028 LS, \u2029 PS) instead of only \n. This addresses a major usability issue where users had to manually work around .NET's hardcoded \n-only line ending behavior.

Changes:

  • Added RegexOptions.AnyNewLine = 0x0800 enum value with incompatibility checks for NonBacktracking and ECMAScript modes
  • Implemented parser-level lowering of ^, $, \Z, and . into equivalent lookaround-based RegexNode trees when AnyNewLine is enabled
  • Added comprehensive test coverage (~800 new test lines) covering all anchor types, newline combinations, RightToLeft mode, inline options, and edge cases

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexOptions.csAdded AnyNewLine = 0x0800 enum value with XML documentation
src/libraries/System.Text.RegularExpressions/ref/System.Text.RegularExpressions.csUpdated ref assembly with AnyNewLine = 2048
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/Regex.csUpdated MaxOptionShift to 12 and added AnyNewLine to NonBacktracking incompatibility check
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexParser.csImplemented lowering methods (AnyNewLineEndZNode, AnyNewLineEolNode, AnyNewLineBolNode) and integrated into ^, $, \Z, . parsing
src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexCharClass.csAdded NotNewLineOrCarriageReturnClass constant for . with AnyNewLine
src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.csAdded AnyNewLine to source generator's supported options
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.Match.Tests.csAdded ~800 lines of comprehensive tests for all anchor types, newline combinations, and edge cases
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.Tests.Common.csAdded RegexOptionAnyNewLine constant for test compatibility
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.Ctor.Tests.csUpdated invalid option test from 0x800 to 0x1000; added NonBacktracking+AnyNewLine incompatibility test
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.MultipleMatches.Tests.csUpdated invalid option comments and tests from 0x800 to 0x1000
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/Regex.EnumerateMatches.Tests.csUpdated invalid option tests from 0x800 to 0x1000
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/RegexGeneratorParserTests.csUpdated invalid option tests from 0x800 to 0x1000
src/libraries/System.Text.RegularExpressions/tests/FunctionalTests/UpgradeToGeneratedRegexAnalyzerTests.csUpdated tests for 0x1000 as invalid option; added AnyNewLine test case for code fixer

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

@MihuBot benchmark Regex

@MihuBot

Copy link
Copy Markdown

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Mihubot confirms zero perf impact on existing patterns/options,

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

AnyNewLine Performance Analysis (Release, Compiled, .NET 11.0, BenchmarkDotNet)

Measured impact of converting existing newline-workaround patterns to simplified AnyNewLine equivalents. All scenarios use RegexOptions.Compiled (representative of source-generated too). Measured with BenchmarkDotNet (InProcess, ShortRun). All match counts verified identical between old and new patterns.

Section 1: Real-World Patterns on Windows \r\n Text

Old PatternNew Pattern (+ AnyNewLine)Old (us)New (us)Ratio
^.+\r?$ (1K lines)^.+$46.748.81.05x
^.+\r?$ (10K lines)^.+$1,6941,7601.04x
\[assembly:...\]\s*$(\r?\n)?\[assembly:...\]\s*$38.332.40.85x
^([^\s:]+):\s*(.+?)\r?$^([^\s:]+):\s*(.+?)$105.9105.91.00x
^# .+\r?$^# .+$11.19.10.83x
^.+\r?$ (CSV, 1K rows)^.+$44.449.21.11x
[^\r\n]+.+44.243.80.99x
\w+\r?$\w+$90.8128.71.42x
(?:^&#124;\r\n)\w+^\w+208.7214.51.03x

Section 2: Unix \n Text (overhead of just enabling the flag)

Old PatternNew Pattern (+ AnyNewLine)Old (us)New (us)Ratio
^.+$^.+$43.548.91.12x
[^\n]+.+39.044.91.15x

Section 3: Mixed \n/\r\n Text

Old PatternNew Pattern (+ AnyNewLine)Old (us)New (us)Ratio
[^\r\n\u0085\u2028\u2029]+.+45.444.20.97x
^.+\r?$ (1K lines)^.+$44.150.11.14x

Section 4: Non-anchor/dot Patterns (zero impact expected)

Old PatternNew Pattern (+ AnyNewLine)Old (us)New (us)Ratio
\r\n&#124;\r&#124;\n\r\n&#124;\r&#124;\n20.021.71.08x
\w+\w+322.4336.41.04x

Section 5: Pathological Cases (unlikely in practice)

Old PatternNew Pattern (+ AnyNewLine)Old (us)New (us)Ratio
$$98.2134.11.37x
^^145.6131.60.90x
\w+\r?\Z (329K chars)\w+\Z494.21,039.32.10x

Summary

  1. Real-world patterns in Compiled mode show 0.83x--1.14x -- essentially zero cost, and sometimes faster because the AnyNewLine pattern is simpler (e.g., ^# .+$ vs ^# .+\r?$ -- removing the \r? node saves more than the lowered $ costs).

  2. Where small regressions occur (1.1x--1.4x), the cause is the lowered anchor tree: a native $ (Eol) is a single "is next char \n?" check, but AnyNewLine lowers it to a lookahead alternation like (?=\r\n|\r|\n|\u0085|\u2028|\u2029|\z). Even when the input only contains \r\n, the engine must evaluate the alternation branches. This overhead is proportionally more visible when the anchor dominates the work (e.g., \w+$ where the \w+ match is short), and nearly invisible when .+ dominates each line's work (e.g., ^.+$ at 1.04x).

  3. Patterns without anchors or dot are completely unaffected (1.04--1.08x, within noise) -- the flag only changes behavior of ., ^, $, \Z.

  4. Only pathological case: \w+\Z on very large input (329K chars) at 2.1x -- the lowered \Z alternation tree is evaluated during backtracking at many positions. Unlikely in practice.

  5. In Compiled/source-generated mode, the JIT compiles the lowered alternation branches into efficient single-char comparisons, keeping overhead minimal. Interpreted mode shows larger gaps (2--3x for typical patterns) but AnyNewLine + interpreted + perf-sensitive is an unlikely combination.

Benchmark source code (BenchmarkDotNet)
usingSystem.Linq;usingSystem.Text;usingSystem.Text.RegularExpressions;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Configs;usingBenchmarkDotNet.Running;usingBenchmarkDotNet.Jobs;usingBenchmarkDotNet.Columns;usingBenchmarkDotNet.Reports;usingBenchmarkDotNet.Toolchains.InProcess.Emit;BenchmarkRunner.Run<AnyNewLineBenchmarks>(DefaultConfig.Instance.WithSummaryStyle(SummaryStyle.Default.WithRatioStyle(RatioStyle.Percentage)).AddJob(Job.ShortRun.WithToolchain(InProcessEmitToolchain.Instance)));[MemoryDiagnoser(false)][HideColumns("Job","Error","StdDev","RatioSD","Alloc Ratio")]publicclassAnyNewLineBenchmarks{privateconstRegexOptionsAnyNewLine=(RegexOptions)0x0800;privatestaticstringGenerateText(intlineCount,string[]newlines){varsb=newStringBuilder();for(inti=0;i<lineCount;i++){sb.Append("Lorem ipsum dolor sit amet ");sb.Append(i);sb.Append(newlines[i%newlines.Length]);}returnsb.ToString();}privatestaticreadonlystringWinText1K=GenerateText(1000,["\r\n"]);privatestaticreadonlystringWinText10K=GenerateText(10000,["\r\n"]);privatestaticreadonlystringUnixText1K=GenerateText(1000,["\n"]);privatestaticreadonlystringMixedNR1K=GenerateText(1000,["\n","\r\n"]);privatestaticreadonlystringMixedAll1K=GenerateText(1000,["\n","\r\n","\r","\u0085","\u2028","\u2029"]);privatestaticreadonlystringAssemblyInfo;privatestaticreadonlystringKvConfig;privatestaticreadonlystringMarkdown;privatestaticreadonlystringCsvData;staticAnyNewLineBenchmarks(){varsb=newStringBuilder();string[]attrs={"[assembly: AssemblyTitle(\"MyApp\")]","[assembly: AssemblyDescription(\"A sample app\")]","[assembly: AssemblyConfiguration(\"\")]","[assembly: AssemblyCompany(\"Contoso\")]","[assembly: AssemblyProduct(\"MyApp\")]","[assembly: AssemblyCopyright(\"Copyright 2024\")]","[assembly: AssemblyTrademark(\"\")]","[assembly: AssemblyCulture(\"\")]","[assembly: AssemblyVersion(\"1.0.0.0\")]","[assembly: AssemblyFileVersion(\"1.0.0.0\")]"};foreach(varattrinattrs){sb.Append(attr);sb.Append("\r\n");}AssemblyInfo=string.Concat(Enumerable.Repeat(sb.ToString(),50));sb.Clear();string[]keys={"Server","Database","User","Password","Timeout","MaxPool","MinPool","Encrypt","TrustCert","AppName"};for(inti=0;i<50;i++){sb.Append(keys[i%keys.Length]);sb.Append(": value_");sb.Append(i);sb.Append("\r\n");}KvConfig=string.Concat(Enumerable.Repeat(sb.ToString(),20));sb.Clear();for(inti=0;i<200;i++){sb.Append($"# Heading {i}\r\n");sb.Append($"Some paragraph text about topic {i}.\r\n");sb.Append($"Another line of content here.\r\n\r\n");}Markdown=sb.ToString();sb.Clear();sb.Append("Name,Age,City,Email\r\n");for(inti=0;i<1000;i++)sb.Append($"User{i},{20+i%50},City{i%100},user{i}@example.com\r\n");CsvData=sb.ToString();}// Section 1: Real-world on Windows \r\n textprivatestaticreadonlyRegexOld_1a=new(@"^.+\r?$",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_1a=new(@"^.+$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Baseline=true,Description="1a_Lines1K_Old")]publicintLines1K_Old()=>Old_1a.Matches(WinText1K).Count;[Benchmark(Description="1a_Lines1K_New")]publicintLines1K_New()=>New_1a.Matches(WinText1K).Count;privatestaticreadonlyRegexOld_1b=new(@"^.+\r?$",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_1b=new(@"^.+$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="1b_Lines10K_Old")]publicintLines10K_Old()=>Old_1b.Matches(WinText10K).Count;[Benchmark(Description="1b_Lines10K_New")]publicintLines10K_New()=>New_1b.Matches(WinText10K).Count;privatestaticreadonlyRegexOld_2=new(@"\[assembly:\s*\w+\(.*?\)\]\s*$(\r?\n)?",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_2=new(@"\[assembly:\s*\w+\(.*?\)\]\s*$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="2_Assembly_Old")]publicintAssembly_Old()=>Old_2.Matches(AssemblyInfo).Count;[Benchmark(Description="2_Assembly_New")]publicintAssembly_New()=>New_2.Matches(AssemblyInfo).Count;privatestaticreadonlyRegexOld_3=new(@"^([^\s:]+):\s*(.+?)\r?$",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_3=new(@"^([^\s:]+):\s*(.+?)$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="3_KeyVal_Old")]publicintKeyVal_Old()=>Old_3.Matches(KvConfig).Count;[Benchmark(Description="3_KeyVal_New")]publicintKeyVal_New()=>New_3.Matches(KvConfig).Count;privatestaticreadonlyRegexOld_4=new(@"^# .+\r?$",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_4=new(@"^# .+$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="4_Markdown_Old")]publicintMarkdown_Old()=>Old_4.Matches(Markdown).Count;[Benchmark(Description="4_Markdown_New")]publicintMarkdown_New()=>New_4.Matches(Markdown).Count;privatestaticreadonlyRegexOld_5=new(@"^.+\r?$",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_5=new(@"^.+$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="5_CSV_Old")]publicintCSV_Old()=>Old_5.Matches(CsvData).Count;[Benchmark(Description="5_CSV_New")]publicintCSV_New()=>New_5.Matches(CsvData).Count;privatestaticreadonlyRegexOld_6=new(@"[^\r\n]+",RegexOptions.Compiled);privatestaticreadonlyRegexNew_6=new(@".+",RegexOptions.Compiled|AnyNewLine);[Benchmark(Description="6_DotExcl_Old")]publicintDotExcl_Old()=>Old_6.Matches(WinText1K).Count;[Benchmark(Description="6_DotExcl_New")]publicintDotExcl_New()=>New_6.Matches(WinText1K).Count;privatestaticreadonlyRegexOld_7=new(@"\w+\r?$",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_7=new(@"\w+$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="7_WordEOL_Old")]publicintWordEOL_Old()=>Old_7.Matches(WinText1K).Count;[Benchmark(Description="7_WordEOL_New")]publicintWordEOL_New()=>New_7.Matches(WinText1K).Count;privatestaticreadonlyRegexOld_8=new(@"(?:^|\r\n)\w+",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_8=new(@"^\w+",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="8_LineSt_Old")]publicintLineStart_Old()=>Old_8.Matches(WinText1K).Count;[Benchmark(Description="8_LineSt_New")]publicintLineStart_New()=>New_8.Matches(WinText1K).Count;// Section 2: Unix \n text (control)privatestaticreadonlyRegexOld_9=new(@"^.+$",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_9=new(@"^.+$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="9_UnixLines_Old")]publicintUnixLines_Old()=>Old_9.Matches(UnixText1K).Count;[Benchmark(Description="9_UnixLines_New")]publicintUnixLines_New()=>New_9.Matches(UnixText1K).Count;privatestaticreadonlyRegexOld_10=new(@"[^\n]+",RegexOptions.Compiled);privatestaticreadonlyRegexNew_10=new(@".+",RegexOptions.Compiled|AnyNewLine);[Benchmark(Description="10_UnixDot_Old")]publicintUnixDot_Old()=>Old_10.Matches(UnixText1K).Count;[Benchmark(Description="10_UnixDot_New")]publicintUnixDot_New()=>New_10.Matches(UnixText1K).Count;// Section 3: Mixed newline textprivatestaticreadonlyRegexOld_11=new(@"[^\r\n\u0085\u2028\u2029]+",RegexOptions.Compiled);privatestaticreadonlyRegexNew_11=new(@".+",RegexOptions.Compiled|AnyNewLine);[Benchmark(Description="11_MixedDot_Old")]publicintMixedDot_Old()=>Old_11.Matches(MixedAll1K).Count;[Benchmark(Description="11_MixedDot_New")]publicintMixedDot_New()=>New_11.Matches(MixedAll1K).Count;privatestaticreadonlyRegexOld_12=new(@"^.+\r?$",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_12=new(@"^.+$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="12_MixedLines_Old")]publicintMixedLines_Old()=>Old_12.Matches(MixedNR1K).Count;[Benchmark(Description="12_MixedLines_New")]publicintMixedLines_New()=>New_12.Matches(MixedNR1K).Count;// Section 4: Non-anchor patterns (zero impact)privatestaticreadonlyRegexOld_14=new(@"\r\n|\r|\n",RegexOptions.Compiled);privatestaticreadonlyRegexNew_14=new(@"\r\n|\r|\n",RegexOptions.Compiled|AnyNewLine);[Benchmark(Description="14_Literal_Old")]publicintLiteral_Old()=>Old_14.Matches(MixedAll1K).Count;[Benchmark(Description="14_Literal_New")]publicintLiteral_New()=>New_14.Matches(MixedAll1K).Count;privatestaticreadonlyRegexOld_15=new(@"\w+",RegexOptions.Compiled);privatestaticreadonlyRegexNew_15=new(@"\w+",RegexOptions.Compiled|AnyNewLine);[Benchmark(Description="15_Words_Old")]publicintWords_Old()=>Old_15.Matches(WinText1K).Count;[Benchmark(Description="15_Words_New")]publicintWords_New()=>New_15.Matches(WinText1K).Count;// Section 5: PathologicalprivatestaticreadonlyRegexOld_P1=new(@"$",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_P1=new(@"$",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="P1_BareEOL_Old")]publicintBareEOL_Old()=>Old_P1.Matches(WinText1K).Count;[Benchmark(Description="P1_BareEOL_New")]publicintBareEOL_New()=>New_P1.Matches(WinText1K).Count;privatestaticreadonlyRegexOld_P2=new(@"^",RegexOptions.Compiled|RegexOptions.Multiline);privatestaticreadonlyRegexNew_P2=new(@"^",RegexOptions.Compiled|RegexOptions.Multiline|AnyNewLine);[Benchmark(Description="P2_BareBOL_Old")]publicintBareBOL_Old()=>Old_P2.Matches(WinText1K).Count;[Benchmark(Description="P2_BareBOL_New")]publicintBareBOL_New()=>New_P2.Matches(WinText1K).Count;privatestaticreadonlyRegexOld_P3=new(@"\w+\r?\Z",RegexOptions.Compiled);privatestaticreadonlyRegexNew_P3=new(@"\w+\Z",RegexOptions.Compiled|AnyNewLine);[Benchmark(Description="P3_EndZ_Old")]publicboolEndZ_Old()=>Old_P3.IsMatch(WinText10K);[Benchmark(Description="P3_EndZ_New")]publicboolEndZ_New()=>New_P3.IsMatch(WinText10K);}

@danmoseley

Copy link
Copy Markdown
ContributorAuthor

For interest, once we've taken this we can consider \R. We'd need to decide we actually want it as a feature first (there are good reasons, including parity with other major engines). But here's what the code looks like -- it's a small change, non breaking and pay for play: danmoseley#35

@jzabroski

Copy link
Copy Markdown
Contributor

I'm excited to use it.

One interesting use case for more powerful Regex functionality is AI models with large context windows. There's been some interesting studies that suggest agents are more effective using grep than RAG pipelines using vector databases, and the inflection point is largely due to large context windows. It seems the main advantage to using a vector database is GDPR compliance and other privacy laws compliance, as you can mask with embeddings the data using GUIDs, and havestrong data governance controls over what parts of an ontology graph a given user has rights to. For anything not sensitive, grep with regex wins.

Restructure all three anchor lowering methods (Eol, Bol, EndZ) to
replace the 2-branch outer Alternate node with a sequential
Concatenate of: primary lookaround + shared CRLF guard.
Key idea: include ALL newline chars (including \n for $, \r for ^) in
the primary lookaround's character class, then append (?!(?<=\r)\n) as
a guard to block matching at the \r\n boundary.
Before ($ example): (?=[\r\v\f\u0085\u2028\u2029]|\z)|(?<!\r)(?=\n)
After: (?=[\n\r\v\f\u0085\u2028\u2029]|\z)(?!(?<=\r)\n)
At non-newline positions (the vast majority during backtracking), the
primary lookaround fails immediately and the Concatenate short-circuits
— the CRLF guard is never evaluated. The old structure evaluated both
branches of the outer Alternate at every position.
Extract shared AnyNewLineCrLfGuardNode() helper used by all three
methods. Replace AnyNewLineExceptLfClass / AnyNewLineExceptCrClass
with unified AnyNewLineClass constant.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings March 6, 2026 18:20
@danmoseley

danmoseley commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

Optimized lowering given the observation that newlines are less common than non newlines. . can't be improved without engine changes, which we're avoiding. -- Dan

====

Optimize anchor lowering: eliminate outer alternation

The previous lowerings used a two-branch Alternate at the top level. The problem: at non-newline positions, both branches must be evaluated and fail. Since most characters in typical input are non-newline, this doubles the per-character rejection cost for anchors inside loops like \w+$.

The new structure replaces the outer Alternate with a sequential Concatenate: a single primary lookaround that matches all newline characters (including \n and \r), followed by a shared CRLF guard (?!(?<=\r)\n) that blocks the \r-side of a \r\n pair. At non-newline positions the primary lookaround fails immediately and the guard is never evaluated.

Before/after lowerings:

ConstructBeforeAfter
$ (multiline)(?=[\r\u0085\u2028\u2029]|\z)|(?<!\r)(?=\n)(?=[\n\r\v\f\u0085\u2028\u2029]|\z)(?!(?<=\r)\n)
$ (non-multiline) / \Z(?=\r\n\z|[\r\u0085\u2028\u2029]?\z)|(?<!\r)(?=\n\z)(?=\r\n\z|[\n\r\v\f\u0085\u2028\u2029]?\z)(?!(?<=\r)\n)
^ (multiline)(?<=[\n\u0085\u2028\u2029]|\A)|(?<=\r)(?!\n)(?<=[\n\r\v\f\u0085\u2028\u2029]|\A)(?!(?<=\r)\n)

The key structural change in each case: branch1 \| branch2 becomes unified_lookaround + guard. The AnyNewLineExceptLfClass / AnyNewLineExceptCrClass constants are replaced by a single AnyNewLineClass constant since the CRLF guard handles the split.

This change is entirely within the AnyNewLine lowering code path -- it has no effect on patterns that don't use RegexOptions.AnyNewLine.

@danmoseley

danmoseley commented Mar 6, 2026

Copy link
Copy Markdown
ContributorAuthor

Perf results after anchor lowering optimization

Measured locally with BenchmarkDotNet MediumRun (15 iterations), RegexOptions.Compiled, Release build, .NET 11.0. Same methodology as the PR's "AnyNewLine vs Workaround Patterns" table -- ratio is New(AnyNewLine) / Old(manual workaround). All match counts verified identical.

Section 1: Real-world patterns on Windows \r\n text

#Previous WorkaroundAnyNewLinePrevious WorkaroundAnyNewLineRatioNotes
1a^.+\r?$ (1K lines)^.+$47.5 us50.8 us1.07x. overhead (Set vs Notone)
1b^.+\r?$ (10K lines)^.+$1717 us1766 us1.03xSame, amortized over longer input
2\[assembly:...\]$(\r?\n)?\[assembly:...\]$39.4 us35.4 us0.90xSimpler pattern wins
3^([^\s:]+):\s*(.+?)\r?$^([^\s:]+):\s*(.+?)$111.2 us105.5 us0.95xSimpler pattern wins
4^# .+\r?$^# .+$11.8 us11.0 us0.93xFaster: literal # prefix optimizes well
5^.+\r?$ (CSV)^.+$48.4 us52.3 us1.08x. overhead
6[^\r\n]+.+46.3 us47.3 us1.02x. overhead, minimal
7\w+\r?$\w+$91.6 us92.0 us1.00xWas 1.33x before this optimization
8(?:^|\r\n)\w+^\w+200.8 us189.3 us0.94xSimpler pattern wins

Section 2: Unix \n text (overhead of just enabling the flag)

#Previous WorkaroundAnyNewLinePrevious WorkaroundAnyNewLineRatioNotes
9^.+$^.+$48.0 us51.0 us1.06x. overhead
10[^\n]+.+41.2 us47.9 us1.16x. overhead (Notone vs Set)

Section 3: Mixed \n/\r\n text

#Previous WorkaroundAnyNewLinePrevious WorkaroundAnyNewLineRatioNotes
11[^\r\n\u0085\u2028\u2029]+.+47.1 us52.4 us1.11x. overhead
12^.+\r?$ (mixed 1K)^.+$46.6 us51.7 us1.11x. + anchor overhead

Section 4: Non-anchor/dot patterns (zero impact expected)

#Previous WorkaroundAnyNewLinePrevious WorkaroundAnyNewLineRatioNotes
14\r\n|\r|\n\r\n|\r|\n41.1 us42.8 us1.04xNo lowering, within noise
15\w+\w+314.3 us310.7 us0.99xNo lowering, within noise

Section 5: Bare anchors (no simple workaround exists for these)

#Pattern (no workaround)Pattern (+ AnyNewLine)Without AnyNewLineAnyNewLineRatioNotes
P1$ (multiline, \n-only)$ (all newlines)106.0 us122.9 us1.16xNow correct; was 1.37x before optimization
P2^ (multiline, \n-only)^ (all newlines)152.9 us119.4 us0.78xNow correct; faster here due to a curiosity (issue)
P3\w+\r?\Z (partial)\w+\Z113.4 us114.9 us1.01xWas ~1.9x before optimization

Summary:

  • The remaining overhead in dot-heavy patterns (1.02x--1.16x) comes entirely from . being lowered to a Set node ([^\n\r\v\f\u0085\u2028\u2029]) instead of the engine's native Notone node -- this is inherent to the lowering approach and would require engine changes to address.
  • The anchor optimization eliminated the worst regressions: \w+$ from 1.33x to 1.00x, bare $ from 1.37x to 1.16x, and \w+\Z from ~1.9x to 1.01x.
  • Patterns where AnyNewLine simplifies the regex (removing \r?, (\r?\n)?, (?:^|\r\n)) are often faster than the workaround (0.90x--0.95x).

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.

- Remove UTF-8 BOM from RegexParser.cs and Regex.Match.Tests.cs
- Remove extra blank line in RegexParser.cs (line 24)
- Add blank line between AnyNewLine_Dollar_TestData and AnyNewLine_EndZ
- Add missing \u2028 (Line Separator) test case for \Z
- Add RegexOptionAnyNewLine assertion in test helpers
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings March 10, 2026 01:51

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

@danmoseley
danmoseley merged commit fc52927 into dotnet:mainMar 17, 2026
94 checks passed
@danmoseley

Copy link
Copy Markdown
ContributorAuthor

Cross-engine survey: Singleline/DOTALL precedence over newline-mode settings

Validated that .NET's design ("if both Singleline and AnyNewLine are specified, Singleline takes precedence and . matches every character") is consistent with all comparable engines.

EngineEquivalent to AnyNewLineEquivalent to SinglelineDOTALL takes precedence?
PCRE2PCRE2_NEWLINE_ANY (newline convention) — makes . exclude all Unicode newlinesPCRE2_DOTALL (/s)Yes. DOTALL makes . match everything regardless of newline convention
Rust regexCRLF mode — affects anchors only, not .dot_matches_new_lineYes.dot_matches_new_line overrides; CRLF mode only affects anchors
ICUDefault behavior (. already excludes all Unicode line terminators)UREGEX_DOTALLYes. DOTALL makes . match all chars including all line terminators
JavaDefault behavior (. already excludes \r, \n, \u0085, \u2028, \u2029)Pattern.DOTALL ((?s))Yes. DOTALL overrides
PerlNo direct equivalent; uses \R for Unicode newlines/sYes./s makes . match \n; no "any newline" mode exists for .
RE2/GoNo Unicode newline modedot_nl / (?s)Yes.dot_nl makes . match \n
PythonNo Unicode newline mode for .re.DOTALLYes. DOTALL wins

Notable: Java and ICU already exclude all Unicode newlines from . by default (no flag needed), which is essentially AnyNewLine behavior baked in. PCRE2's PCRE2_NEWLINE_ANY is the closest direct analogue to AnyNewLine, and DOTALL explicitly takes full precedence over it.

@danmoseley
danmoseley deleted the anynewline-lower-v2 branch March 19, 2026 22:18
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Apr 19, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regex Match, Split and Matches should support RegexOptions.AnyNewLine as (?=\r\z|\n\z|\r\n\z|\z)

5 participants

@danmoseley@MihuBot@jzabroski@stephentoub