Repository: microsoft/STL
Pull request #5494: `<regex>`: Reject empty repetitions when required by regex grammars
Author: login: muellerj2
Labels: name: bug
description: Something isn't working
name: regex
description: meow is a substring of homeowner
Created: 2025-05-11T18:19:28Z | Merged: 2025-05-17T04:26:16Z

Fixes #5490.

## Standards

ECMA-262 15.10.2.5 "Term" specifies in the internal continuation of the internal helper function "RepeatMatcher" for production `Term :: Atom Quantifier`:

> If min is zero and y's endIndex is equal to x's endIndex, then return failure.

Here, min is decremented for each successful repeat match until it reaches zero, so the match is supposed to fail if an additional repetition yields an empty match and the specified minimum number of repetititions have been reached.

Similary, POSIX says (here for basic regexes, but the standard contains similar language for extended regexes):

> A subexpression repeated by an <asterisk> ('*') or an interval expression shall not match a null expression unless this is the only match for the repetition or it is necessary to satisfy the exact or minimum number of occurrences for the interval expression.

Note the subtle difference that POSIX allows an empty match if there is only one repetition.

## Changes

### `_Matcher::_Do_rep()`

The logic has been restructured:
* First, we check if the minimum number of repetitions have been reached. If not and there is no prior match or the last match wasn't empty (`_Progress` is true), we do another repetition. If the last match was empty, then we can speed this up by doing only one more match, because the repetitions are internally independent, so the difference can't be observed if we reorder when empty matches appear when repeating the loop. (This is the same change I already mentioned in https://github.com/microsoft/STL/pull/5218#issuecomment-2569520793, but I wasn't aware at that time that ECMAScript requires this change as well.)
* If the minimum has just been reached, we have yet to perform a single repetition, or the last match made progress, we try to do another repetition according to maximum repetition limits, the matching mode (longest, depth-first) and the greediness of the quantifier/loop.
* If the last match is empty, we accept in POSIX mode if this is the only repetition.
* Otherwise, we reject (`_Matched0` stays false).

### `_Matcher::_Do_rep0()`

The logic changes are similar, but one difference is exploited: Since the repeated subexpression must be branchless, all repetitions must either be non-empty or empty. Therefore, we only have to check at the first repetition if the match is non-empty, and we also don't have to try one last match to achieve minimum requirements, because the match will be empty again anyway.

However, the logic changes can't be observed with current NFAs, because capture groups currently can't appear in loops that the simple loop optimization is enabled for. This is because the parser generates unnecessary `_N_if` nodes.

By applying the following diff, these unnecessary nodes are no longer generated and we can check that the tests still pass when the updated logic for `_Do_rep0()` is actually exerted:

```diff
diff --git a/stl/inc/regex b/stl/inc/regex
index 89e8a509..85adbb41 100644
--- a/stl/inc/regex
+++ b/stl/inc/regex
@@ -4913,15 +4913,17 @@ void _Parser<_FwdIt, _Elem, _RxTraits>::_Disjunction() { // check for valid disj
         _Nfa._End_group(_Pos3);
     }
 
-    _Node_base* _Pos2 = _Nfa._Begin_if(_Pos1);
-    while (_Mchar == _Meta_bar) { // append terms as long as we keep finding | characters
-        _Next();
-        if (!_Alternative()) { // zero-length trailing alternative
-            _Node_base* _Pos3 = _Nfa._Begin_group();
-            _Nfa._End_group(_Pos3);
-        }
+    if (_Mchar == _Meta_bar) { // append terms as long as we keep finding | characters
+        _Node_base* _Pos2 = _Nfa._Begin_if(_Pos1);
+        do {
+            _Next();
+            if (!_Alternative()) { // zero-length trailing alternative
+                _Node_base* _Pos3 = _Nfa._Begin_group();
+                _Nfa._End_group(_Pos3);
+            }
 
-        _Nfa._Else_if(_Pos1, _Pos2);
+            _Nfa._Else_if(_Pos1, _Pos2);
+        } while (_Mchar == _Meta_bar);
     }
 }
 

```

(I haven't proposed this change yet because I'm not sure yet what to do about its side effects and what is most appropriate for a non-recursive matcher, but I do want to make sure that this change doesn't become impossible due to some erroneous change in `_Do_rep0()`.)

### `_Builder::_Add_rep2()`

In this function, quantifiers `?`, `??`, `{0,1}` and `{0,1}?` were not represented using `_N_rep` nodes, but instead emitted as two branches of an `_N_if` node. But this will accept an empty match, so if `(a?)?` is matched to the empty string, the capture group will be matched to the empty string as well. However, it should actually be unmatched according to the ECMAScript rule.

For this reason, this PR disables this alternative representation when the subexpression contains a capture group or a non-capturing group (for simplicity, because otherwise we would have to keep searching in the non-capturing group for capture groups).

### `_Parser::_Calculate_loop_simplicity()`

Disabling the alternative representation would have the consequence that some loops would no longer be marked as simple. This is undesirable because it can make regexes containing them newly susceptible to stack overflow.

To avoid this, the loop simplicity calculation now also marks loops as simple if they are branchless *and* all outer loops can be repeated at most once (so in particular, if they were all generated by `?`quantifiers).

Note that we must mark a loop is non-simple if some outer loop can be repeated at least twice (and contains a capture group somewhere) because `_Do_rep0()` does not reset the positions of capture groups when recursive `_Match_pat()` calls return. If an outer loop were repeated twice, i.e., another repetition of the outer loop could happen in one of `_Do_rep0()`'s `_Match_pat()`, the positions of capture groups in the outer loop would change. Since `_Do_rep0()` does not reset the capture groups, the starting positions of any of these capture groups would be corrupted during the remaining processing in `_Do_rep0()`.

### Miscellaneous
* I centralized the definition of `_Any_posix` because there was a need for it in `_Do_rep()` and `_Do_rep0()` now as well.
* `_Do_rep()` and `_Do_rep0()` no longer reset `_Tgt_state` when they didn't match. The correctness argument for these changes is the same as for the corresponding change to `_Do_if()` in #5405.

Linked issue #5490: `<regex>`: Optional empty repetitions are illegal
# Describe the bug
> NOTE 4
> Step 1 of the RepeatMatcher's d closure states that, once the minimum number of repetitions has been satisfied, any more expansions of Atom that match the empty String are not considered for further repetitions.

~JS spec, https://262.ecma-international.org/5.1/#sec-15.10.2.5

MS-STL currently does not comply with that clause (and neither do libstdc++ or libc++).

# Command-line test case
```c++
#include <regex>
#include <stdio.h>

int main() {
    try {
        std::string s{"b"};
        std::regex r{"(a*)*"};
        std::smatch m;
        bool result = std::regex_search(s, m, r);
        printf("regex_search: %d\n", result);
        for (unsigned i = 0; i < m.size(); ++i) {
            printf("m[%d]", i);
            if (m[i].matched) {
                printf(".str(): \"%s\"\n", m[i].str().c_str());
            } else {
                puts(".matched: false");
            }
        }
    } catch (const std::exception& e) {
        printf("Exception: %s\n", e.what());
    }
}
```

This also affects whether `a(b?)+c\1d` matches `abcd` (it shouldn't), though various recently-fixed regex bugs make it hard to demonstrate on Godbolt.

# Expected behavior
Group 0 - empty string
Group 1 - no match

# STL version
@muellerj2 says it's still present on main as of today

# Additional context
https://godbolt.org/z/s7ejf7GKv

https://github.com/llvm/llvm-project/issues/133314
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=120212
Linked issue comments: user: login: muellerj2
created_at: 2025-05-11T11:00:58Z
body: This also affects `(a?)?`, or potentially any expression containing capture groups quantified with `?`. In such cases, the natural fix also involves disabling the rewriting of `?` in `_Parser::_Quantifier()`.

Unfortunately, this has a bad side effect: It can cause the simple loop optimization to be disabled, which means some additional regexes potentially become susceptible to stack overflow (as in #997, #1528). Specifically, this makes `test_VSO_208146_regex_should_special_case_optional_as_non_quantifier()` in `VSO_0000000_regex_use` run into a stack overflow.

I can come up with one alternative: Adding a new flag to the `_Node_if` node, which means that a match should be rejected if this branch matched an empty string. But this complicates the matcher a lot more because it has to handle one more special case and maintain more state to determine whether the match for this branch was empty.

Obviously, the true long-term fix is to make the matcher non-recursive, but that solution isn't available to us now. (And even then, there would still be this side effect if an old matcher were to be mixed with a new parser.)
author_association: CONTRIBUTOR
user: login: Alcaro
created_at: 2025-05-11T11:35:36Z
body: In my regex engine, I implemented that rule by piggybacking on capture groups. If the current position is equal to the relevant saved pointer, backtrack. (Then discard the extra capture groups when matching is done.)

Your choice if that's a good solution, or even possible in your architecture and under your ABI constraints, of course.
author_association: CONTRIBUTOR
user: login: muellerj2
created_at: 2025-05-11T13:03:37Z
body: I found a solution: I can actually enable the simple loop optimization even if the loop logic is exerted in the specific case when the subexpression is branchless and all outer loops are allowed to match at most once (so when all outer loops were defined with `?`, `{0}`, `{0,0}` `{0,1}`, `{1}` or `{1,1}` quantifiers). 

I can't enable it for branchless loops when the outer loops match more than once because this makes the simple loop optimization go haywire: The boundaries of capture groups aren't reset at all when control is returned to `_Do_rep0()`, so the starting positions of capture groups in surrounding loops get corrupted when the outer loop is repeated one more time. I could fix this in the current matcher, but that still wouldn't allow us to enable the simple loop optimization because this wouldn't fix past matchers.

> In my regex engine, I implemented that rule by piggybacking on capture groups. If the current position is equal to the relevant saved pointer, backtrack. (Then discard the extra capture groups when matching is done.)

This is kind of the alternative I mentioned above, but it's not straightforward because the parser is not allowed to emit these capture groups explicitly as capture groups (ABI). These artificial capture groups would either require an extension to `_N_if` NFA nodes to store a unique identifier or would have to be a matcher-only concept, requiring some map from NFA node pointers to the starting positions of the capture groups.

For loops specifically, there already exist such artifical capture groups (see `_Loop_vals_v2_t`), but the original point of this optimization was to avoid the loop logic in the first place.
author_association: CONTRIBUTOR
user: login: StephanTLavavej
created_at: 2025-05-15T09:15:27Z
body: > I could fix this in the current matcher, but that still wouldn't allow us to enable the simple loop optimization because this wouldn't fix past matchers.

In #5456 you renamed the matcher to `_Matcher2`, so (until that ships) I don't think you need to worry about current/past mixing. The class name affects the mangled names of all member functions, preventing mixing.
author_association: MEMBER
user: login: muellerj2
created_at: 2025-05-15T18:43:43Z
body: > In https://github.com/microsoft/STL/pull/5456 you renamed the matcher to _Matcher2, so (until that ships) I don't think you need to worry about current/past mixing

No, this doesn't help because this specific problem is about mixing a new parser with an old matcher. The new parser would enable the single loop optimization by setting a flag on the NFA and the old matcher would obey the setting and produce crazy results.

I could set a new flag on the NFA nodes that is unknown to past matchers, but I would like to avoid the introduction of new flags for performance-related optimizations until the matcher has been changed to be non-recursive and we have a clearer perspective what optimizations will help in the long term.
author_association: CONTRIBUTOR

Diff:
diff --git a/stl/inc/regex b/stl/inc/regex
index 3366345c261..e3110d4207b 100644
--- a/stl/inc/regex
+++ b/stl/inc/regex
@@ -119,6 +119,7 @@ namespace regex_constants {
         grep       = 0x10,
         egrep      = 0x20,
         _Gmask     = 0x3F,
+        _Any_posix = basic | extended | grep | egrep | awk,
 
         icase    = 0x0100,
         nosubs   = 0x0200,
@@ -3199,10 +3200,14 @@ void _Builder<_FwdIt, _Elem, _RxTraits>::_Add_rep2(int _Min, int _Max, bool _Gre
     _Node_base* _Pos = _Current;
     if (_Pos->_Kind == _N_end_group || _Pos->_Kind == _N_end_capture) {
         _Pos = static_cast<_Node_end_group*>(_Pos)->_Back;
-    }
-
-    if (_Min == 0 && _Max == 1) { // rewrite zero-or-one quantifiers as alternations to make the
-                                  // "simple loop" optimization more likely to engage
+    } else if (_Min == 0 && _Max == 1) {
+        // Rewrite zero-or-one quantifiers as alternations to make the
+        // "simple loop" optimization more likely to engage.
+        //
+        // GH-5490: This rewrite becomes observably incorrect
+        // if the subexpression contains capture groups,
+        // so we don't apply it if the subexpression is surrounded
+        // by a capturing or non-capturing group.
         _Node_endif* _End       = new _Node_endif;
         _Node_if* _If_expr      = new _Node_if(_End);
         _Node_if* _If_empty_str = new _Node_if(_End);
@@ -3226,13 +3231,14 @@ void _Builder<_FwdIt, _Elem, _RxTraits>::_Add_rep2(int _Min, int _Max, bool _Gre
             swap(_If_expr->_Next->_Prev, _If_empty_str->_Next->_Prev); // intentional ADL
             swap(_If_expr->_Next, _If_empty_str->_Next); // intentional ADL
         }
-    } else {
-        _Node_end_rep* _Node0 = new _Node_end_rep();
-        _Node_rep* _Nx        = new _Node_rep(_Greedy, _Min, _Max, _Node0, _Root->_Loops++);
-        _Node0->_Begin_rep    = _Nx;
-        _Link_node(_Node0);
-        _Insert_node(_Pos, _Nx);
+        return;
     }
+
+    _Node_end_rep* _Node0 = new _Node_end_rep();
+    _Node_rep* _Nx        = new _Node_rep(_Greedy, _Min, _Max, _Node0, _Root->_Loops++);
+    _Node0->_Begin_rep    = _Nx;
+    _Link_node(_Node0);
+    _Insert_node(_Pos, _Nx);
 }
 
 template <class _FwdIt, class _Elem, class _RxTraits>
@@ -3311,26 +3317,30 @@ bool _Matcher2<_BidIt, _Elem, _RxTraits, _It, _Alloc>::_Do_rep0(_Node_rep* _Node
     int _Ix               = 0;
     _Tgt_state_t<_It> _St = _Tgt_state;
 
-    for (; _Ix < _Node->_Min; ++_Ix) { // do minimum number of reps
-        // GH-5365: We have to reset the capture groups from the second iteration on.
-        // We can avoid the reset for the first iteration
-        // because we know that a simple repetition was not encountered before.
-        if (_Ix > 0) {
-            _Tgt_state._Grp_valid = _St._Grp_valid;
-        }
-
-        _It _Cur = _Tgt_state._Cur;
+    if (0 < _Node->_Min) {
+        // GH-5365: We can avoid resetting capture groups for the first iteration
+        // because we know that a simple repetition of this loop was not encountered before.
         if (!_Match_pat(_Node->_Next)) { // didn't match minimum number of reps, fail
-            _Tgt_state = _St;
             return false;
-        } else if (_Cur == _Tgt_state._Cur) {
-            _Ix = _Node->_Min - 1; // skip matches that don't change state
+        } else if (_Tgt_state._Cur == _St._Cur) { // matches empty string
+            // loop is branchless, so it will only ever match empty strings
+            // -> skip all other matches as they don't change state and immediately try tail
+            return _Match_pat(_Node->_End_rep->_Next);
+        } else { // loop never matches the empty string
+            for (_Ix = 1; _Ix < _Node->_Min; ++_Ix) { // do minimum number of reps
+                // GH-5365: We have to reset the capture groups from the second iteration on.
+                _Tgt_state._Grp_valid = _St._Grp_valid;
+                if (!_Match_pat(_Node->_Next)) { // didn't match minimum number of reps, fail
+                    return false;
+                }
+            }
         }
     }
 
-    _Tgt_state_t<_It> _Final = _Tgt_state;
-    bool _Matched0           = false;
-    _It _Saved_pos           = _Tgt_state._Cur;
+    _Tgt_state_t<_It> _Final;
+    bool _Matched0 = false;
+    _It _Saved_pos = _Tgt_state._Cur;
+    bool _Done     = false;
 
     if (_Match_pat(_Node->_End_rep->_Next)) {
         if (!_Greedy) {
@@ -3342,32 +3352,58 @@ bool _Matcher2<_BidIt, _Elem, _RxTraits, _It, _Alloc>::_Do_rep0(_Node_rep* _Node
         _Matched0 = true;
     }
 
-    while (_Node->_Max == -1 || _Ix++ < _Node->_Max) { // try another rep/tail match
+    if (_Ix == 0 && _Node->_Max != 0) {
         _Tgt_state._Cur       = _Saved_pos;
         _Tgt_state._Grp_valid = _St._Grp_valid;
-        if (!_Match_pat(_Node->_Next)) {
-            break; // rep match failed, quit loop
-        }
 
-        _It _Mid = _Tgt_state._Cur;
-        if (_Match_pat(_Node->_End_rep->_Next)) {
-            if (!_Greedy) {
+        if (!_Match_pat(_Node->_Next)) { // rep match failed, we are done
+            _Done = true;
+        } else if (_Saved_pos == _Tgt_state._Cur) { // match empty, try no more repetitions
+            _Done = true;
+            // we only potentially accept/try tail for POSIX
+            if ((_Sflags & regex_constants::_Any_posix) && _Match_pat(_Node->_End_rep->_Next)) {
                 return true; // go with current match
             }
+        } else {
+            _Saved_pos = _Tgt_state._Cur;
+            if (_Match_pat(_Node->_End_rep->_Next)) {
+                if (!_Greedy) {
+                    return true; // go with current match
+                }
 
-            // record match and continue
-            _Final    = _Tgt_state;
-            _Matched0 = true;
+                // record match and continue
+                _Final    = _Tgt_state;
+                _Matched0 = true;
+            }
         }
+        _Ix = 1;
+    }
 
-        if (_Saved_pos == _Mid) {
-            break; // rep match ate no additional elements, quit loop
-        }
+    if (!_Done) {
+        while (_Node->_Max == -1 || _Ix++ < _Node->_Max) { // try another rep/tail match
+            _Tgt_state._Cur       = _Saved_pos;
+            _Tgt_state._Grp_valid = _St._Grp_valid;
+            if (!_Match_pat(_Node->_Next) || _Tgt_state._Cur == _Saved_pos) {
+                break; // rep match failed, quit loop
+            }
 
-        _Saved_pos = _Mid;
+            // since loop is branchless, empty rep match is not possible at this point
+            _Saved_pos = _Tgt_state._Cur;
+            if (_Match_pat(_Node->_End_rep->_Next)) {
+                if (!_Greedy) {
+                    return true; // go with current match
+                }
+
+                // record match and continue
+                _Final    = _Tgt_state;
+                _Matched0 = true;
+            }
+        }
     }
 
-    _Tgt_state = _Matched0 ? _Final : _St;
+    if (_Matched0) { // record final match
+        _Tgt_state = _Final;
+    }
     return _Matched0;
 }
 
@@ -3381,61 +3417,56 @@ bool _Matcher2<_BidIt, _Elem, _RxTraits, _It, _Alloc>::_Do_rep(_Node_rep* _Node,
     _It* _Loop_iter_sav    = static_cast<_It*>(_Psav->_Loop_iter);
     bool _Progress         = _Init_idx == 0 || *_Loop_iter_sav != _St._Cur;
 
-    if (0 <= _Node->_Max && _Node->_Max <= _Init_idx) {
-        _Matched0 = _Match_pat(_Node->_End_rep->_Next); // reps done, try tail
-    } else if (_Init_idx < _Node->_Min) { // try a required rep
-        if (!_Progress) {
-            _Matched0 = _Match_pat(_Node->_End_rep->_Next); // empty, try tail
-        } else { // try another required match
-            _Psav->_Loop_idx  = _Init_idx + 1;
-            _Psav->_Loop_iter = _STD addressof(_St._Cur);
-            _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
-                _Tgt_state._Grp_valid.end(), false);
-            _Matched0 = _Match_pat(_Node->_Next);
-        }
-    } else if (_Longest) { // longest, try any number of repetitions
-
-        // match with no further repetition
-        _Matched0 = _Match_pat(_Node->_End_rep->_Next);
-        // match with at least one more repetition if last repetition made progress
-        if (_Progress) {
+    if (_Init_idx < _Node->_Min) { // try another required match
+        _Psav->_Loop_iter = _STD addressof(_St._Cur);
+        _Psav->_Loop_idx  = _Progress ? _Init_idx + 1 : _Node->_Min; // try only one more match after an empty match
+        _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
+            _Tgt_state._Grp_valid.end(), false);
+        _Matched0 = _Match_pat(_Node->_Next);
+    } else if (_Init_idx == _Node->_Min || _Progress) {
+        if (0 <= _Node->_Max && _Node->_Max <= _Init_idx) {
+            _Matched0 = _Match_pat(_Node->_End_rep->_Next); // reps done, try tail
+        } else if (_Longest) { // longest, try any number of repetitions
+
+            // match with no further repetition
+            _Matched0 = _Match_pat(_Node->_End_rep->_Next);
+
+            // try to match with one more repetition
             _Tgt_state        = _St;
             _Psav->_Loop_idx  = _Init_idx + 1;
             _Psav->_Loop_iter = _STD addressof(_St._Cur);
-
             if (_Match_pat(_Node->_Next)) { // always call _Match_pat, even when _Matched0 is already true
                 _Matched0 = true;
             }
-        }
-    } else if (!_Greedy) { // not greedy, favor minimum number of reps
-        _Matched0 = _Match_pat(_Node->_End_rep->_Next);
-        if (!_Matched0 && _Progress) { // tail failed, try another rep
-            _Tgt_state        = _St;
-            _Psav->_Loop_idx  = _Init_idx + 1;
-            _Psav->_Loop_iter = _STD addressof(_St._Cur);
-            _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
-                _Tgt_state._Grp_valid.end(), false);
-            _Matched0 = _Match_pat(_Node->_Next);
-        }
-    } else { // greedy, favor maximum number of reps
-        if (_Progress) { // try another rep
+        } else if (!_Greedy) { // not greedy, favor minimum number of reps
+            _Matched0 = _Match_pat(_Node->_End_rep->_Next);
+            if (!_Matched0) { // tail failed, try another rep
+                _Tgt_state        = _St;
+                _Psav->_Loop_idx  = _Init_idx + 1;
+                _Psav->_Loop_iter = _STD addressof(_St._Cur);
+                _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
+                    _Tgt_state._Grp_valid.end(), false);
+                _Matched0 = _Match_pat(_Node->_Next);
+            }
+        } else { // greedy, favor maximum number of reps,
+            // so try another rep
             _Psav->_Loop_idx  = _Init_idx + 1;
             _Psav->_Loop_iter = _STD addressof(_St._Cur);
             _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
                 _Tgt_state._Grp_valid.end(), false);
             _Matched0 = _Match_pat(_Node->_Next);
-        }
 
-        if ((_Progress || 1 >= _Init_idx) && !_Matched0) { // rep failed, try tail
-            _Psav->_Loop_idx  = _Loop_idx_sav;
-            _Psav->_Loop_iter = _Loop_iter_sav;
-            _Tgt_state        = _St;
-            _Matched0         = _Match_pat(_Node->_End_rep->_Next);
+            if (!_Matched0) { // rep failed, try tail
+                _Psav->_Loop_idx  = _Loop_idx_sav;
+                _Psav->_Loop_iter = _Loop_iter_sav;
+                _Tgt_state        = _St;
+                _Matched0         = _Match_pat(_Node->_End_rep->_Next);
+            }
         }
-    }
-
-    if (!_Matched0) {
-        _Tgt_state = _St;
+    } else if (_Init_idx == 1 && (_Sflags & regex_constants::_Any_posix)) {
+        // POSIX allows an empty repetition if the subexpression is matched only once,
+        // so try tail
+        _Matched0 = _Match_pat(_Node->_End_rep->_Next);
     }
 
     _Psav->_Loop_idx  = _Loop_idx_sav;
@@ -3456,9 +3487,7 @@ bool _Matcher2<_BidIt, _Elem, _RxTraits, _It, _Alloc>::_Do_rep_first(_Node_rep*
     // No capture group reset is performed for POSIX regexes,
     // so we prevent any reset by setting the first capture group to the number of capture groups _Ncap.
     if (_Psav->_Group_first == 0) {
-        constexpr auto _Any_posix = regex_constants::basic | regex_constants::extended | regex_constants::grep
-                                  | regex_constants::egrep | regex_constants::awk;
-        if ((_Sflags & _Any_posix) || !_Find_first_inner_capture_group(_Node->_Next, _Psav)) {
+        if ((_Sflags & regex_constants::_Any_posix) || !_Find_first_inner_capture_group(_Node->_Next, _Psav)) {
             _Psav->_Group_first = _Ncap;
         }
     }
@@ -3833,10 +3862,8 @@ bool _Matcher2<_BidIt, _Elem, _RxTraits, _It, _Alloc>::_Match_pat(_Node_base* _N
             if (_Tgt_state._Cur == _End) {
                 _Failed = true;
             } else {
-                constexpr auto _Any_posix = regex_constants::basic | regex_constants::extended | regex_constants::grep
-                                          | regex_constants::egrep | regex_constants::awk;
                 const _Elem _Ch = *_Tgt_state._Cur;
-                if (_Sflags & _Any_posix) {
+                if (_Sflags & regex_constants::_Any_posix) {
                     if (_Ch == _Elem()) {
                         _Failed = true;
                     }
@@ -4923,10 +4950,18 @@ void _Parser<_FwdIt, _Elem, _RxTraits>::_Calculate_loop_simplicity(
             _Calculate_loop_simplicity(static_cast<_Node_assert*>(_Nx)->_Child, nullptr, nullptr);
             break;
         case _N_rep:
-            // _Node_rep inside another _Node_rep makes both not simple
+            // _Node_rep inside another _Node_rep makes both not simple if _Outer_rep can be repeated more than once
+            // because _Matcher2::_Do_rep0() does not reset capture group boundaries when control is returned to it.
+            // If _Outer_rep can repeat at most once, we have to analyze the structure of the inner loop.
             if (_Outer_rep) {
-                _Outer_rep->_Simple_loop                   = 0;
-                static_cast<_Node_rep*>(_Nx)->_Simple_loop = 0;
+                _Outer_rep->_Simple_loop = 0;
+                auto _Inner_rep          = static_cast<_Node_rep*>(_Nx);
+                if (_Outer_rep->_Max >= 0 && _Outer_rep->_Max <= 1) {
+                    _Calculate_loop_simplicity(_Inner_rep->_Next, _Inner_rep->_End_rep->_Next, _Inner_rep);
+                    _Nx = _Inner_rep->_End_rep;
+                } else {
+                    _Inner_rep->_Simple_loop = 0;
+                }
             } else {
                 _Outer_rep = static_cast<_Node_rep*>(_Nx);
             }
diff --git a/tests/std/tests/VSO_0000000_regex_use/test.cpp b/tests/std/tests/VSO_0000000_regex_use/test.cpp
index 36c8ef1b8ac..59c4f14cc99 100644
--- a/tests/std/tests/VSO_0000000_regex_use/test.cpp
+++ b/tests/std/tests/VSO_0000000_regex_use/test.cpp
@@ -1698,6 +1698,197 @@ void test_gh_5377() {
     }
 }
 
+void test_gh_5490() {
+    // GH-5490: Optional empty repetitions are illegal
+
+    // ECMA-262 15.10.2.5 "Term":
+    // "If min is zero and y's endIndex is equal to x's endIndex, then return failure."
+    // So if no additional repetition is required due to minimum requirements, the match should be rejected.
+
+    // Similarly, POSIX 9.3.6 and 9.4.6 state that a null expression can only be matched if this is the only match or it
+    // is necessary to satisfy the minimum number of repetitions.
+    // Note the subtle difference that the empty match is allowed if it is the only match.
+    for (string pattern : {"()*", "()?", "()*?", "()??", "(){0,}", "(){0,}?", "(){0,1}", "(){0,1}?"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (string pattern : {"()+", "()+?", "(){2,}?", "(){1}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex quantified_empty_regex_bre(&g_regexTester, R"(\(\)*)", option);
+        quantified_empty_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+    }
+
+    for (auto option : {ECMAScript, extended, egrep, awk}) {
+        test_regex simple_sequence_regex_ecma_or_ere(&g_regexTester, "(ab)*", option);
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex simple_sequence_regex_bre(&g_regexTester, R"(\(ab\)*)", option);
+        simple_sequence_regex_bre.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(ab(?=ab))*", "(ab(?!cc))*"}) {
+        test_regex nested_assertion_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_assertion_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("ababcc", "ab", match_default, {{0, 2}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("abababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(a*)*", "(a?)*", "(a?)?"}) {
+        test_regex nested_quantifier_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex nested_quantifier_regex_ere(&g_regexTester, pattern, option);
+            nested_quantifier_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {R"(\(a*\)*)", R"(\(a\{0,1\}\)*)", R"(\(a\{0,1\}\)\{0,1\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex nested_quantifier_regex_bre(&g_regexTester, pattern, option);
+            nested_quantifier_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            nested_quantifier_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            nested_quantifier_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {"(a*)+", "(a?)+"}) {
+        for (auto option : {ECMAScript, extended, egrep, awk}) {
+            test_regex plus_quantifier_regex_ecma_or_ere(&g_regexTester, pattern, option);
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {R"(\(a*\)\{1,\})", R"(\(a\{0,1\}\)\{1,\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex plus_quantifier_regex_bre(&g_regexTester, pattern, option);
+            plus_quantifier_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            plus_quantifier_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            plus_quantifier_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {"(a*){1}", "(a?){1}"}) {
+        for (auto option : {ECMAScript, extended, egrep, awk}) {
+            test_regex repeat_once_regex_ecma_or_ere(&g_regexTester, pattern, option);
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {R"(\(a*\)\{1\})", R"(\(a\{0,1\}\)\{1\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex repeat_once_regex_bre(&g_regexTester, pattern, option);
+            repeat_once_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_once_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeat_once_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {"(a*){2}", "(a?){2}"}) {
+        test_regex repeat_twice_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        repeat_twice_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        repeat_twice_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        repeat_twice_regex_ecma.should_search_match_capture_groups("a", "a", match_default, {{1, 1}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex repeat_twice_regex_ere(&g_regexTester, pattern, option);
+            repeat_twice_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_twice_regex_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeat_twice_regex_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {R"(\(a*\)\{2\})", R"(\(a\{0,1\}\)\{2\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex repeat_twice_regex_bre(&g_regexTester, pattern, option);
+            repeat_twice_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_twice_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeat_twice_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {"(a?a?){2}", "(a?a?)+"}) {
+        test_regex repeated_double_question_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        repeated_double_question_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        repeated_double_question_regex_ecma.should_search_match_capture_groups("bbb", "", match_default, {{0, 0}});
+        repeated_double_question_regex_ecma.should_search_match_capture_groups("aaa", "aaa", match_default, {{2, 3}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex repeated_double_question_regex_ere(&g_regexTester, pattern, option);
+            repeated_double_question_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeated_double_question_regex_ere.should_search_match_capture_groups("bbb", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeated_double_question_regex_ere.should_search_match_capture_groups(
+                "aaa", "aaa", match_default, {{1, 3}});
+        }
+    }
+
+    for (string pattern : {R"(\(a\{0,1\}a\{0,1\}\)\{2\})", R"(\(a\{0,1\}a\{0,1\}\)\{1,\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex repeated_double_question_regex_bre(&g_regexTester, pattern, option);
+            repeated_double_question_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeated_double_question_regex_bre.should_search_match_capture_groups("bbb", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeated_double_question_regex_bre.should_search_match_capture_groups(
+                "aaa", "aaa", match_default, {{1, 3}});
+        }
+    }
+
+    {
+        test_regex backref_ecma(&g_regexTester, R"(a(b?)+c\1d)", ECMAScript);
+        backref_ecma.should_search_fail("abcd");
+        backref_ecma.should_search_match_capture_groups("acd", "acd", match_default, {{1, 1}});
+        backref_ecma.should_search_match_capture_groups("abcbd", "abcbd", match_default, {{1, 2}});
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex backref_bre(&g_regexTester, R"(a\(b\{0,1\}\)\{1,\}c\1d)", option);
+        backref_bre.should_search_fail("abcd");
+        backref_bre.should_search_match_capture_groups("acd", "acd", match_default, {{1, 1}});
+        backref_bre.should_search_match_capture_groups("abcbd", "abcbd", match_default, {{1, 2}});
+    }
+
+    {
+        test_regex backref_min_repeat_ecma(&g_regexTester, R"((a?){3,4}b\1c)", ECMAScript);
+        backref_min_repeat_ecma.should_search_match_capture_groups("aabc", "aabc", match_default, {{2, 2}});
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex backref_min_repeat_bre(&g_regexTester, R"(\(a\{0,1\}\)\{3,4\}b\1c)", option);
+        backref_min_repeat_bre.should_search_match_capture_groups("aabc", "aabc", match_default, {{2, 2}});
+    }
+}
+
 int main() {
     test_dev10_449367_case_insensitivity_should_work();
     test_dev11_462743_regex_collate_should_not_disable_regex_icase();
@@ -1744,6 +1935,7 @@ int main() {
     test_gh_5371();
     test_gh_5374();
     test_gh_5377();
+    test_gh_5490();
 
     return g_regexTester.result();
 }
diff --git a/tests/tr1/tests/regex2/test.cpp b/tests/tr1/tests/regex2/test.cpp
index d52371192de..83706bb66bc 100644
--- a/tests/tr1/tests/regex2/test.cpp
+++ b/tests/tr1/tests/regex2/test.cpp
@@ -741,7 +741,8 @@ static const regex_test tests[] = {
     {__LINE__, T("a(bbb+|bb+|b)b"), T("abbb"), "2 0 4 1 3", NOT_BG},
     {__LINE__, T("a(bbb+|bb+|b)bb"), T("abbb"), "2 0 4 1 2", NOT_BG},
     {__LINE__, T("(.*).*"), T("abcdef"), "2 0 6 0 6", NOT_BG},
-    {__LINE__, T("(a*)*"), T("bc"), "2 0 0 0 0", NOT_BG},
+    {__LINE__, T("(a*)*"), T("bc"), "2 0 0 -1 -1", ECMA},
+    {__LINE__, T("(a*)*"), T("bc"), "2 0 0 0 0", EEA},
     {__LINE__, T("a(b|c)*d"), T("ad"), "2 0 2 -1 -1", NOT_BG},
     {__LINE__, T("a(b|c)*d"), T("abcd"), "2 0 4 2 3", NOT_BG},
     {__LINE__, T("a(b|c)+d"), T("abd"), "2 0 3 1 2", NOT_BG},


Reviews:
user: login: StephanTLavavej
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T11:39:15Z
user: login: StephanTLavavej
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T11:41:32Z

Review comments:
diff_hunk: @@ -3381,61 +3417,56 @@ bool _Matcher2<_BidIt, _Elem, _RxTraits, _It, _Alloc>::_Do_rep(_Node_rep* _Node,
     _It* _Loop_iter_sav    = static_cast<_It*>(_Psav->_Loop_iter);
     bool _Progress         = _Init_idx == 0 || *_Loop_iter_sav != _St._Cur;
 
-    if (0 <= _Node->_Max && _Node->_Max <= _Init_idx) {
-        _Matched0 = _Match_pat(_Node->_End_rep->_Next); // reps done, try tail
-    } else if (_Init_idx < _Node->_Min) { // try a required rep
-        if (!_Progress) {
-            _Matched0 = _Match_pat(_Node->_End_rep->_Next); // empty, try tail
-        } else { // try another required match
-            _Psav->_Loop_idx  = _Init_idx + 1;
-            _Psav->_Loop_iter = _STD addressof(_St._Cur);
-            _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
-                _Tgt_state._Grp_valid.end(), false);
-            _Matched0 = _Match_pat(_Node->_Next);
-        }
-    } else if (_Longest) { // longest, try any number of repetitions
-
-        // match with no further repetition
-        _Matched0 = _Match_pat(_Node->_End_rep->_Next);
-        // match with at least one more repetition if last repetition made progress
-        if (_Progress) {
+    if (_Init_idx < _Node->_Min) { // try another required match
+        _Psav->_Loop_iter = _STD addressof(_St._Cur);
+        _Psav->_Loop_idx  = _Progress ? _Init_idx + 1 : _Node->_Min; // try only one more match after an empty match
+        _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
+            _Tgt_state._Grp_valid.end(), false);
+        _Matched0 = _Match_pat(_Node->_Next);
+    } else if (_Init_idx == _Node->_Min || _Progress) {
+        if (0 <= _Node->_Max && _Node->_Max <= _Init_idx) {
+            _Matched0 = _Match_pat(_Node->_End_rep->_Next); // reps done, try tail
+        } else if (_Longest) { // longest, try any number of repetitions
+
+            // match with no further repetition
+            _Matched0 = _Match_pat(_Node->_End_rep->_Next);
+
+            // try to match with one more repetition
             _Tgt_state        = _St;
             _Psav->_Loop_idx  = _Init_idx + 1;
             _Psav->_Loop_iter = _STD addressof(_St._Cur);
-
             if (_Match_pat(_Node->_Next)) { // always call _Match_pat, even when _Matched0 is already true
                 _Matched0 = true;
             }
-        }
-    } else if (!_Greedy) { // not greedy, favor minimum number of reps
-        _Matched0 = _Match_pat(_Node->_End_rep->_Next);
-        if (!_Matched0 && _Progress) { // tail failed, try another rep
-            _Tgt_state        = _St;
-            _Psav->_Loop_idx  = _Init_idx + 1;
-            _Psav->_Loop_iter = _STD addressof(_St._Cur);
-            _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
-                _Tgt_state._Grp_valid.end(), false);
-            _Matched0 = _Match_pat(_Node->_Next);
-        }
-    } else { // greedy, favor maximum number of reps
-        if (_Progress) { // try another rep
+        } else if (!_Greedy) { // not greedy, favor minimum number of reps
+            _Matched0 = _Match_pat(_Node->_End_rep->_Next);
+            if (!_Matched0) { // tail failed, try another rep
+                _Tgt_state        = _St;
+                _Psav->_Loop_idx  = _Init_idx + 1;
+                _Psav->_Loop_iter = _STD addressof(_St._Cur);
+                _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
+                    _Tgt_state._Grp_valid.end(), false);
+                _Matched0 = _Match_pat(_Node->_Next);
+            }
+        } else { // greedy, favor maximum number of reps,
+            // so try another rep
             _Psav->_Loop_idx  = _Init_idx + 1;
             _Psav->_Loop_iter = _STD addressof(_St._Cur);
             _STD fill(_Tgt_state._Grp_valid.begin() + static_cast<ptrdiff_t>(_Psav->_Group_first),
                 _Tgt_state._Grp_valid.end(), false);
             _Matched0 = _Match_pat(_Node->_Next);
-        }
 
-        if ((_Progress || 1 >= _Init_idx) && !_Matched0) { // rep failed, try tail
-            _Psav->_Loop_idx  = _Loop_idx_sav;
-            _Psav->_Loop_iter = _Loop_iter_sav;
-            _Tgt_state        = _St;
-            _Matched0         = _Match_pat(_Node->_End_rep->_Next);
+            if (!_Matched0) { // rep failed, try tail
+                _Psav->_Loop_idx  = _Loop_idx_sav;
+                _Psav->_Loop_iter = _Loop_iter_sav;
+                _Tgt_state        = _St;
+                _Matched0         = _Match_pat(_Node->_End_rep->_Next);
+            }
         }
-    }
-
-    if (!_Matched0) {
-        _Tgt_state = _St;
+    } else if (_Init_idx == 1 && (_Sflags & regex_constants::_Any_posix)) {
+        // POSIX allows an empty repetition if the subexpression is matched only once,
+        // so try trail
path: stl/inc/regex
user: login: StephanTLavavej
body: Nitpick: Everywhere else says "try tail" although the meaning is basically the same.
created_at: 2025-05-15T09:23:14Z
author_association: MEMBER
diff_hunk: @@ -4923,10 +4950,18 @@ void _Parser<_FwdIt, _Elem, _RxTraits>::_Calculate_loop_simplicity(
             _Calculate_loop_simplicity(static_cast<_Node_assert*>(_Nx)->_Child, nullptr, nullptr);
             break;
         case _N_rep:
-            // _Node_rep inside another _Node_rep makes both not simple
+            // _Node_rep inside another _Node_rep makes both not simple if _Outer_rep can be repeated more than once
+            // because _Matcher::_Do_rep0() does not reset capture group boundaries when control is returned to it.
path: stl/inc/regex
user: login: StephanTLavavej
body: `_Matcher` has been renamed.
created_at: 2025-05-15T10:03:17Z
author_association: MEMBER
diff_hunk: @@ -1698,6 +1698,190 @@ void test_gh_5377() {
     }
 }
 
+void test_gh_5490() {
+    // GH-5490: Optional empty repetitions are illegal
+
+    // ECMA-262 15.10.2.5 "Term":
+    // "If min is zero and y's endIndex is equal to x's endIndex, then return failure."
+    // So if no additional repetition is required due to minimum requirements, the match should be rejected.
+
+    // Similarly, POSIX 9.3.6 and 9.4.6 state that a null expression can only be matched if this is the only match or it
+    // is necessary to satisfy the minimum number of repetitions.
+    // Note the subtle difference that the empty match is allowed if it is the only match.
+    for (string pattern : {"()*", "()?", "()??", "()*?", "(){0,}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (string pattern : {"()+", "()+?", "(){2,}?", "(){1}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex quantified_empty_regex_bre(&g_regexTester, R"(\(\)*)", option);
+        quantified_empty_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+    }
+
+
path: tests/std/tests/VSO_0000000_regex_use/test.cpp
user: login: StephanTLavavej
body: Mega nitpick: Extra newline.
created_at: 2025-05-15T10:19:16Z
author_association: MEMBER
diff_hunk: @@ -1698,6 +1698,190 @@ void test_gh_5377() {
     }
 }
 
+void test_gh_5490() {
+    // GH-5490: Optional empty repetitions are illegal
+
+    // ECMA-262 15.10.2.5 "Term":
+    // "If min is zero and y's endIndex is equal to x's endIndex, then return failure."
+    // So if no additional repetition is required due to minimum requirements, the match should be rejected.
+
+    // Similarly, POSIX 9.3.6 and 9.4.6 state that a null expression can only be matched if this is the only match or it
+    // is necessary to satisfy the minimum number of repetitions.
+    // Note the subtle difference that the empty match is allowed if it is the only match.
+    for (string pattern : {"()*", "()?", "()??", "()*?", "(){0,}"}) {
path: tests/std/tests/VSO_0000000_regex_use/test.cpp
user: login: StephanTLavavej
body: We could test a few more patterns here to be comprehensive. Since `{0,}` is like `*`, we could test `{0,1}` to be like `?`, and we could test both followed by `?`.

(Also mega-nitpick, it would be more consistent if `"()*?"` appeared before `"()??"`.)
created_at: 2025-05-15T10:25:48Z
author_association: MEMBER
diff_hunk: @@ -1698,6 +1698,190 @@ void test_gh_5377() {
     }
 }
 
+void test_gh_5490() {
+    // GH-5490: Optional empty repetitions are illegal
+
+    // ECMA-262 15.10.2.5 "Term":
+    // "If min is zero and y's endIndex is equal to x's endIndex, then return failure."
+    // So if no additional repetition is required due to minimum requirements, the match should be rejected.
+
+    // Similarly, POSIX 9.3.6 and 9.4.6 state that a null expression can only be matched if this is the only match or it
+    // is necessary to satisfy the minimum number of repetitions.
+    // Note the subtle difference that the empty match is allowed if it is the only match.
+    for (string pattern : {"()*", "()?", "()??", "()*?", "(){0,}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (string pattern : {"()+", "()+?", "(){2,}?", "(){1}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex quantified_empty_regex_bre(&g_regexTester, R"(\(\)*)", option);
+        quantified_empty_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+    }
+
+
+    for (auto option : {ECMAScript, extended, egrep, awk}) {
+        test_regex simple_sequence_regex_ecma_or_ere(&g_regexTester, "(ab)*", option);
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex simple_sequence_regex_bre(&g_regexTester, R"(\(ab\)*)", option);
+        simple_sequence_regex_bre.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(ab(?=ab))*", "(ab(?!cc))*"}) {
+        test_regex nested_assertion_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_assertion_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("ababcc", "ab", match_default, {{0, 2}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("abababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(a*)*", "(a?)*", "(a?)?"}) {
+        test_regex nested_quantifier_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex nested_quantifier_regex_ere(&g_regexTester, pattern, option);
+            nested_quantifier_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex doublestar_regex_bre(&g_regexTester, R"(\(a*\)*)", option);
+        doublestar_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        doublestar_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        doublestar_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+    }
+
+    for (string pattern : {"(a*)+", "(a?)+"}) {
+        for (auto option : {ECMAScript, extended, egrep, awk}) {
+            test_regex plus_quantifier_regex_ecma_or_ere(&g_regexTester, pattern, option);
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex plus_quantifier_regex_bre(&g_regexTester, R"(\(a*\)\{1,\})", option);
+        plus_quantifier_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        plus_quantifier_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        plus_quantifier_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+    }
+
+    for (string pattern : {"(a*){1}", "(a?){1}"}) {
+        for (auto option : {ECMAScript, extended, egrep, awk}) {
+            test_regex repeat_once_regex_ecma_or_ere(&g_regexTester, pattern, option);
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {R"(\(a*\)\{1\})", R"(\(a\{0,1\}\)\{1\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex repeat_once_regex_bre(&g_regexTester, R"(\(a*\)\{1\})", option);
path: tests/std/tests/VSO_0000000_regex_use/test.cpp
user: login: StephanTLavavej
body: `pattern` is dead here, with the first element of the initializer list always being used. I believe this is a refactoring relic.
created_at: 2025-05-15T10:33:37Z
author_association: MEMBER
diff_hunk: @@ -1698,6 +1698,190 @@ void test_gh_5377() {
     }
 }
 
+void test_gh_5490() {
+    // GH-5490: Optional empty repetitions are illegal
+
+    // ECMA-262 15.10.2.5 "Term":
+    // "If min is zero and y's endIndex is equal to x's endIndex, then return failure."
+    // So if no additional repetition is required due to minimum requirements, the match should be rejected.
+
+    // Similarly, POSIX 9.3.6 and 9.4.6 state that a null expression can only be matched if this is the only match or it
+    // is necessary to satisfy the minimum number of repetitions.
+    // Note the subtle difference that the empty match is allowed if it is the only match.
+    for (string pattern : {"()*", "()?", "()??", "()*?", "(){0,}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (string pattern : {"()+", "()+?", "(){2,}?", "(){1}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex quantified_empty_regex_bre(&g_regexTester, R"(\(\)*)", option);
+        quantified_empty_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+    }
+
+
+    for (auto option : {ECMAScript, extended, egrep, awk}) {
+        test_regex simple_sequence_regex_ecma_or_ere(&g_regexTester, "(ab)*", option);
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex simple_sequence_regex_bre(&g_regexTester, R"(\(ab\)*)", option);
+        simple_sequence_regex_bre.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(ab(?=ab))*", "(ab(?!cc))*"}) {
+        test_regex nested_assertion_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_assertion_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("ababcc", "ab", match_default, {{0, 2}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("abababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(a*)*", "(a?)*", "(a?)?"}) {
+        test_regex nested_quantifier_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex nested_quantifier_regex_ere(&g_regexTester, pattern, option);
+            nested_quantifier_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex doublestar_regex_bre(&g_regexTester, R"(\(a*\)*)", option);
+        doublestar_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        doublestar_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        doublestar_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+    }
+
+    for (string pattern : {"(a*)+", "(a?)+"}) {
+        for (auto option : {ECMAScript, extended, egrep, awk}) {
+            test_regex plus_quantifier_regex_ecma_or_ere(&g_regexTester, pattern, option);
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex plus_quantifier_regex_bre(&g_regexTester, R"(\(a*\)\{1,\})", option);
path: tests/std/tests/VSO_0000000_regex_use/test.cpp
user: login: StephanTLavavej
body: Below, `?` for ECMA-or-ERE was translated into `\{0,1\}` for BRE. We could do the same here for `"(a?)+"` above.
created_at: 2025-05-15T10:43:49Z
author_association: MEMBER
diff_hunk: @@ -1698,6 +1698,190 @@ void test_gh_5377() {
     }
 }
 
+void test_gh_5490() {
+    // GH-5490: Optional empty repetitions are illegal
+
+    // ECMA-262 15.10.2.5 "Term":
+    // "If min is zero and y's endIndex is equal to x's endIndex, then return failure."
+    // So if no additional repetition is required due to minimum requirements, the match should be rejected.
+
+    // Similarly, POSIX 9.3.6 and 9.4.6 state that a null expression can only be matched if this is the only match or it
+    // is necessary to satisfy the minimum number of repetitions.
+    // Note the subtle difference that the empty match is allowed if it is the only match.
+    for (string pattern : {"()*", "()?", "()??", "()*?", "(){0,}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (string pattern : {"()+", "()+?", "(){2,}?", "(){1}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex quantified_empty_regex_bre(&g_regexTester, R"(\(\)*)", option);
+        quantified_empty_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+    }
+
+
+    for (auto option : {ECMAScript, extended, egrep, awk}) {
+        test_regex simple_sequence_regex_ecma_or_ere(&g_regexTester, "(ab)*", option);
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex simple_sequence_regex_bre(&g_regexTester, R"(\(ab\)*)", option);
+        simple_sequence_regex_bre.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(ab(?=ab))*", "(ab(?!cc))*"}) {
+        test_regex nested_assertion_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_assertion_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("ababcc", "ab", match_default, {{0, 2}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("abababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(a*)*", "(a?)*", "(a?)?"}) {
+        test_regex nested_quantifier_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex nested_quantifier_regex_ere(&g_regexTester, pattern, option);
+            nested_quantifier_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex doublestar_regex_bre(&g_regexTester, R"(\(a*\)*)", option);
path: tests/std/tests/VSO_0000000_regex_use/test.cpp
user: login: StephanTLavavej
body: Similarly, we could translate the other `nested_quantifier` test cases to BRE, which would also address my nitpick that `doublestar` inconsistently lacks an underscore to separate the words.
created_at: 2025-05-15T11:02:42Z
author_association: MEMBER
diff_hunk: @@ -1698,6 +1698,190 @@ void test_gh_5377() {
     }
 }
 
+void test_gh_5490() {
+    // GH-5490: Optional empty repetitions are illegal
+
+    // ECMA-262 15.10.2.5 "Term":
+    // "If min is zero and y's endIndex is equal to x's endIndex, then return failure."
+    // So if no additional repetition is required due to minimum requirements, the match should be rejected.
+
+    // Similarly, POSIX 9.3.6 and 9.4.6 state that a null expression can only be matched if this is the only match or it
+    // is necessary to satisfy the minimum number of repetitions.
+    // Note the subtle difference that the empty match is allowed if it is the only match.
+    for (string pattern : {"()*", "()?", "()??", "()*?", "(){0,}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (string pattern : {"()+", "()+?", "(){2,}?", "(){1}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex quantified_empty_regex_bre(&g_regexTester, R"(\(\)*)", option);
+        quantified_empty_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+    }
+
+
+    for (auto option : {ECMAScript, extended, egrep, awk}) {
+        test_regex simple_sequence_regex_ecma_or_ere(&g_regexTester, "(ab)*", option);
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex simple_sequence_regex_bre(&g_regexTester, R"(\(ab\)*)", option);
+        simple_sequence_regex_bre.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(ab(?=ab))*", "(ab(?!cc))*"}) {
+        test_regex nested_assertion_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_assertion_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("ababcc", "ab", match_default, {{0, 2}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("abababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(a*)*", "(a?)*", "(a?)?"}) {
+        test_regex nested_quantifier_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex nested_quantifier_regex_ere(&g_regexTester, pattern, option);
+            nested_quantifier_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex doublestar_regex_bre(&g_regexTester, R"(\(a*\)*)", option);
+        doublestar_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        doublestar_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        doublestar_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+    }
+
+    for (string pattern : {"(a*)+", "(a?)+"}) {
+        for (auto option : {ECMAScript, extended, egrep, awk}) {
+            test_regex plus_quantifier_regex_ecma_or_ere(&g_regexTester, pattern, option);
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex plus_quantifier_regex_bre(&g_regexTester, R"(\(a*\)\{1,\})", option);
+        plus_quantifier_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        plus_quantifier_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        plus_quantifier_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+    }
+
+    for (string pattern : {"(a*){1}", "(a?){1}"}) {
+        for (auto option : {ECMAScript, extended, egrep, awk}) {
+            test_regex repeat_once_regex_ecma_or_ere(&g_regexTester, pattern, option);
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {R"(\(a*\)\{1\})", R"(\(a\{0,1\}\)\{1\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex repeat_once_regex_bre(&g_regexTester, R"(\(a*\)\{1\})", option);
+            repeat_once_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_once_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeat_once_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {"(a*){2}", "(a?){2}"}) {
+        test_regex repeat_twice_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        repeat_twice_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        repeat_twice_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        repeat_twice_regex_ecma.should_search_match_capture_groups("a", "a", match_default, {{1, 1}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex repeat_twice_regex_ere(&g_regexTester, pattern, option);
+            repeat_twice_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_twice_regex_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeat_twice_regex_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {R"(\(a\{0,1\}\)\{2\})", R"(\(a*\)\{2\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex repeat_twice_regex_bre(&g_regexTester, pattern, option);
+            repeat_twice_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_twice_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeat_twice_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {"(a?a?){2}", "(a?a?)+"}) {
+        test_regex repeated_double_question_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        repeated_double_question_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        repeated_double_question_regex_ecma.should_search_match_capture_groups("bbb", "", match_default, {{0, 0}});
+        repeated_double_question_regex_ecma.should_search_match_capture_groups("aaa", "aaa", match_default, {{2, 3}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex repeated_double_question_regex_ere(&g_regexTester, pattern, option);
+            repeated_double_question_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeated_double_question_regex_ere.should_search_match_capture_groups("bbb", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeated_double_question_regex_ere.should_search_match_capture_groups(
+                "aaa", "aaa", match_default, {{1, 3}});
+        }
+    }
+
+    for (string pattern : {R"(\(a\{0,1\}a\{0,1\}\)\{2\})", R"(\(a\{0,1\}a\{0,1\}\)\{1,\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex repeated_double_question_regex_bre(&g_regexTester, pattern, option);
+            repeated_double_question_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeated_double_question_regex_bre.should_search_match_capture_groups("bbb", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeated_double_question_regex_bre.should_search_match_capture_groups(
+                "aaa", "aaa", match_default, {{1, 3}});
+        }
+    }
+
+    {
+        test_regex backref_ecma(&g_regexTester, R"(a(b?)+c\1d)", ECMAScript);
+        backref_ecma.should_search_fail("abcd");
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex backref_bre(&g_regexTester, R"(a\(b?\)\{1,\}c\1d)", option);
+        backref_bre.should_search_fail("abcd");
path: tests/std/tests/VSO_0000000_regex_use/test.cpp
user: login: StephanTLavavej
body: Bug: `?` isn't special for BREs, so this literal character will always cause the search to fail. This would have been noticed if there were both positive and negative tests.
created_at: 2025-05-15T11:11:32Z
author_association: MEMBER
diff_hunk: @@ -1698,6 +1698,190 @@ void test_gh_5377() {
     }
 }
 
+void test_gh_5490() {
+    // GH-5490: Optional empty repetitions are illegal
+
+    // ECMA-262 15.10.2.5 "Term":
+    // "If min is zero and y's endIndex is equal to x's endIndex, then return failure."
+    // So if no additional repetition is required due to minimum requirements, the match should be rejected.
+
+    // Similarly, POSIX 9.3.6 and 9.4.6 state that a null expression can only be matched if this is the only match or it
+    // is necessary to satisfy the minimum number of repetitions.
+    // Note the subtle difference that the empty match is allowed if it is the only match.
+    for (string pattern : {"()*", "()?", "()??", "()*?", "(){0,}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (string pattern : {"()+", "()+?", "(){2,}?", "(){1}"}) {
+        test_regex quantified_empty_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        quantified_empty_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+
+        // empty groups are not allowed in EREs
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex quantified_empty_regex_bre(&g_regexTester, R"(\(\)*)", option);
+        quantified_empty_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        quantified_empty_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+    }
+
+
+    for (auto option : {ECMAScript, extended, egrep, awk}) {
+        test_regex simple_sequence_regex_ecma_or_ere(&g_regexTester, "(ab)*", option);
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_ecma_or_ere.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex simple_sequence_regex_bre(&g_regexTester, R"(\(ab\)*)", option);
+        simple_sequence_regex_bre.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        simple_sequence_regex_bre.should_search_match_capture_groups("ababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(ab(?=ab))*", "(ab(?!cc))*"}) {
+        test_regex nested_assertion_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_assertion_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("ababcc", "ab", match_default, {{0, 2}});
+        nested_assertion_regex_ecma.should_search_match_capture_groups("abababcc", "abab", match_default, {{2, 4}});
+    }
+
+    for (string pattern : {"(a*)*", "(a?)*", "(a?)?"}) {
+        test_regex nested_quantifier_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{-1, -1}});
+        nested_quantifier_regex_ecma.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex nested_quantifier_regex_ere(&g_regexTester, pattern, option);
+            nested_quantifier_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            nested_quantifier_regex_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex doublestar_regex_bre(&g_regexTester, R"(\(a*\)*)", option);
+        doublestar_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        doublestar_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        doublestar_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+    }
+
+    for (string pattern : {"(a*)+", "(a?)+"}) {
+        for (auto option : {ECMAScript, extended, egrep, awk}) {
+            test_regex plus_quantifier_regex_ecma_or_ere(&g_regexTester, pattern, option);
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            plus_quantifier_regex_ecma_or_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (auto option : {basic, grep}) {
+        test_regex plus_quantifier_regex_bre(&g_regexTester, R"(\(a*\)\{1,\})", option);
+        plus_quantifier_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        plus_quantifier_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        plus_quantifier_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+    }
+
+    for (string pattern : {"(a*){1}", "(a?){1}"}) {
+        for (auto option : {ECMAScript, extended, egrep, awk}) {
+            test_regex repeat_once_regex_ecma_or_ere(&g_regexTester, pattern, option);
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            repeat_once_regex_ecma_or_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {R"(\(a*\)\{1\})", R"(\(a\{0,1\}\)\{1\})"}) {
+        for (auto option : {basic, grep}) {
+            test_regex repeat_once_regex_bre(&g_regexTester, R"(\(a*\)\{1\})", option);
+            repeat_once_regex_bre.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_once_regex_bre.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeat_once_regex_bre.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {"(a*){2}", "(a?){2}"}) {
+        test_regex repeat_twice_regex_ecma(&g_regexTester, pattern, ECMAScript);
+        repeat_twice_regex_ecma.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+        repeat_twice_regex_ecma.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+        repeat_twice_regex_ecma.should_search_match_capture_groups("a", "a", match_default, {{1, 1}});
+
+        for (auto option : {extended, egrep, awk}) {
+            test_regex repeat_twice_regex_ere(&g_regexTester, pattern, option);
+            repeat_twice_regex_ere.should_search_match_capture_groups("", "", match_default, {{0, 0}});
+            repeat_twice_regex_ere.should_search_match_capture_groups("b", "", match_default, {{0, 0}});
+            // leftmost-longest rule according to Boost semantics
+            repeat_twice_regex_ere.should_search_match_capture_groups("a", "a", match_default, {{0, 1}});
+        }
+    }
+
+    for (string pattern : {R"(\(a\{0,1\}\)\{2\})", R"(\(a*\)\{2\})"}) {
path: tests/std/tests/VSO_0000000_regex_use/test.cpp
user: login: StephanTLavavej
body: Nitpick: When "translating" ECMA/ERE syntax to BRE, let's list the patterns in the same order so the parallel structure is clearer.
created_at: 2025-05-15T11:30:47Z
author_association: MEMBER

PR comments:
user: login: StephanTLavavej
created_at: 2025-05-15T11:40:28Z
body: Thanks! :heart_eyes_cat: I expanded the test coverage slightly and fixed a couple of test bugs.
author_association: MEMBER
user: login: StephanTLavavej
created_at: 2025-05-16T16:03:26Z
body: I'm mirroring this to the MSVC-internal repo - please notify me if any further changes are pushed.
author_association: MEMBER
user: login: StephanTLavavej
created_at: 2025-05-17T04:26:43Z
body: :empty_nest: :low_battery: :low_brightness:
===
author_association: MEMBER