[libc-commits] [libc] [libc][regex] Add ERE {m, n} interval quantifiers (PR #236569)

Jeff Bailey via libc-commits libc-commits at lists.llvm.org
Sun Nov 1 00:35:21 PDT 2026


https://github.com/kaladron updated https://github.com/llvm/llvm-project/pull/236569

>From 871067bfb35fc615760856aa6a6b2dafb858e3a5 Mon Sep 17 00:00:00 2001
From: Jeff Bailey <jbailey at raspberryginger.com>
Date: Sat, 31 Oct 2026 20:49:23 +0000
Subject: [PATCH] [libc][regex] Add ERE {m,n} interval quantifiers (#236569)

POSIX Extended Regular Expressions support bounded and lower-bounded
interval quantifiers ({m}, {m,}, and {m,n}) with repetition counts up
to RE_DUP_MAX (255); although POSIX leaves an unescaped '{' not
followed by an ASCII digit undefined, real-world patterns rely on it
matching a literal '{'.  Unrolling counted repetitions at parse time
duplicates subexpressions up to 255 times per quantifier, while
unanchored derivative stepping across high-count intervals builds
quadratic alternation chains as active repetition states accumulate at
each input byte.

We represent interval quantifiers directly in Expr by moving ch into
the 8-byte payload union alongside named Binary and RepeatBounds
structs, freeing two bytes to cache a has_repeat bit and a saturating
16-bit rep_expansion factor within the 16-byte node size, and packing
hash_expr into a single 16-byte HashKey struct round.  During Repeat
derivative evaluation, next_min collapses to zero whenever the body is
nullable at the pre-byte anchor context so leading copies can match
empty at the start of a line, while ExprPool::make_alt coalesces
overlapping or adjacent intervals across bare nodes, shared-prefix or
shared-suffix Concat nodes, and full Alt spines in ascending ExprId
order.  We also push r and Star(r) as separate atom-stack entries for
'+' in parse_concat (follow-up to commit adb80317aa28) so right-to-left
folding builds r . (r* . rest) once without leaving an orphaned Concat
spine in the pool.

External compliance suites: Henry Spencer suite passes 268/456 (+50)
boolean/error cases and 84/456 (+11) full-position cases; AT&T testregex
passes 399/400 (+66) ERE boolean/error cases (100% of case-sensitive
ERE) and 401/473 (+66) overall boolean/error cases with 0 regressions.

Assisted-by: Automated tooling, human reviewed.
---
 libc/hdr/limits_macros.h                      |   8 +
 libc/include/limits.yaml                      |   4 +
 libc/include/llvm-libc-macros/limits-macros.h |   8 +
 libc/src/__support/regex/CMakeLists.txt       |   3 +
 libc/src/__support/regex/regex_ast.h          |  84 +++++--
 libc/src/__support/regex/regex_expr_pool.cpp  | 229 ++++++++++++++++--
 libc/src/__support/regex/regex_expr_pool.h    |   2 +
 libc/src/__support/regex/regex_matcher.cpp    |  24 +-
 libc/src/__support/regex/regex_parser.cpp     |  96 ++++++--
 libc/test/src/__support/regex/expr_test.cpp   |  51 ++++
 .../test/src/__support/regex/matcher_test.cpp | 196 ++++++++++++++-
 libc/test/src/regex/regex_basic_test.cpp      |  31 ++-
 12 files changed, 667 insertions(+), 69 deletions(-)

diff --git a/libc/hdr/limits_macros.h b/libc/hdr/limits_macros.h
index 7ed79685f7c3ff..fc63f35898f9b9 100644
--- a/libc/hdr/limits_macros.h
+++ b/libc/hdr/limits_macros.h
@@ -17,6 +17,14 @@
 
 #include <limits.h>
 
+#ifndef _POSIX2_RE_DUP_MAX
+#define _POSIX2_RE_DUP_MAX 255
+#endif // _POSIX2_RE_DUP_MAX
+
+#ifndef RE_DUP_MAX
+#define RE_DUP_MAX _POSIX2_RE_DUP_MAX
+#endif // RE_DUP_MAX
+
 #endif // LIBC_FULL_BUILD
 
 #endif // LLVM_LIBC_HDR_LIMITS_MACROS_H
diff --git a/libc/include/limits.yaml b/libc/include/limits.yaml
index 1d619984ff5c38..aa3677fa9432bf 100644
--- a/libc/include/limits.yaml
+++ b/libc/include/limits.yaml
@@ -92,6 +92,10 @@ macros:
     macro_header: limits-macros.h
   - macro_name: NZERO
     macro_header: limits-macros.h
+  - macro_name: _POSIX2_RE_DUP_MAX
+    macro_header: limits-macros.h
+  - macro_name: RE_DUP_MAX
+    macro_header: limits-macros.h
 types: []
 enums: []
 objects: []
diff --git a/libc/include/llvm-libc-macros/limits-macros.h b/libc/include/llvm-libc-macros/limits-macros.h
index 64f1b507ab3861..66db0746145c2e 100644
--- a/libc/include/llvm-libc-macros/limits-macros.h
+++ b/libc/include/llvm-libc-macros/limits-macros.h
@@ -298,4 +298,12 @@
 #define NZERO 20
 #endif // NZERO
 
+#ifndef _POSIX2_RE_DUP_MAX
+#define _POSIX2_RE_DUP_MAX 255
+#endif // _POSIX2_RE_DUP_MAX
+
+#ifndef RE_DUP_MAX
+#define RE_DUP_MAX _POSIX2_RE_DUP_MAX
+#endif // RE_DUP_MAX
+
 #endif // LLVM_LIBC_MACROS_LIMITS_MACROS_H
diff --git a/libc/src/__support/regex/CMakeLists.txt b/libc/src/__support/regex/CMakeLists.txt
index a9113005c08fe7..f66fe3efe4dc41 100644
--- a/libc/src/__support/regex/CMakeLists.txt
+++ b/libc/src/__support/regex/CMakeLists.txt
@@ -10,9 +10,11 @@ add_object_library(
     libc.hdr.regex_macros
     libc.hdr.stdint_proxy
     libc.src.__support.alloc_checker
+    libc.src.__support.CPP.algorithm
     libc.src.__support.CPP.array
     libc.src.__support.CPP.bitset
     libc.src.__support.CPP.new
+    libc.src.__support.CPP.optional
     libc.src.__support.CPP.scope
     libc.src.__support.CPP.span
     libc.src.__support.CPP.utility
@@ -58,6 +60,7 @@ add_object_library(
     regex_parser.h
   DEPENDS
     .regex_expr_pool
+    libc.hdr.limits_macros
     libc.hdr.regex_macros
     libc.src.__support.alloc_checker
     libc.src.__support.CPP.array
diff --git a/libc/src/__support/regex/regex_ast.h b/libc/src/__support/regex/regex_ast.h
index 5c5fa006bec76b..83af439fe97e1a 100644
--- a/libc/src/__support/regex/regex_ast.h
+++ b/libc/src/__support/regex/regex_ast.h
@@ -28,6 +28,14 @@ inline constexpr ExprId INVALID_EXPR_ID = 0;
 inline constexpr ExprId EMPTY_SET_ID = 1;
 inline constexpr ExprId EMPTY_STR_ID = 2;
 
+/// Sentinel upper bound representing unbounded repetition in Repeat(body, m,
+/// REPEAT_INF); never collides with parsed bounds (<= RE_DUP_MAX = 255).
+inline constexpr uint16_t REPEAT_INF = 0xFFFF;
+
+/// Compile-time guard against nested repetition bombs (e.g. (a{255}){255});
+/// ExprPool::MAX_NODE_LIMIT bounds lazy runtime state growth.
+inline constexpr uint16_t MAX_REPEAT_EXPANSION = 32768;
+
 /// One bit per possible byte value (2^CHAR_BIT = 256), allowing O(1)
 /// membership tests for wildcards and bracket expressions.
 inline constexpr size_t BYTE_SET_BITS = size_t(1) << CHAR_BIT;
@@ -57,6 +65,8 @@ enum class ExprKind : uint8_t {
   Alt,
   /// Zero or more repetitions of a sub-expression (body*).
   Star,
+  /// Bounded or lower-bounded repetitions of a sub-expression (body{min,max}).
+  Repeat,
 };
 
 /// A node in the Regular Expression Abstract Syntax Tree.
@@ -65,52 +75,75 @@ enum class ExprKind : uint8_t {
 /// derivative-based matching. This structure is intended to be managed by
 /// an ExprPool.
 struct Expr {
+  struct Binary {
+    ExprId left;
+    ExprId right;
+  };
+  struct RepeatBounds {
+    ExprId body;
+    uint16_t min;
+    uint16_t max;
+  };
+
   /// The type of this expression node.
   ExprKind kind = ExprKind::EmptySet;
   /// Whether this expression can match the empty string unconditionally.
   bool nullable : 1;
   /// Whether this expression or any child contains a context-sensitive Anchor.
   bool has_anchors : 1;
-  /// The character for Literal nodes.
-  char ch = '\0';
+  /// Whether this expression or any child contains a Repeat node.
+  bool has_repeat : 1;
+  /// Saturated cumulative repetition multiplier for O(1) DAG expansion checks.
+  uint16_t rep_expansion = 1;
   /// Precomputed 32-bit structural hash for O(1) lookup and rehashing.
   uint32_t hash = 0;
   union {
     /// Sub-expressions for Concat and Alt nodes.
-    struct {
-      ExprId left;
-      ExprId right;
-    } bin = {INVALID_EXPR_ID, INVALID_EXPR_ID};
+    Binary bin = {INVALID_EXPR_ID, INVALID_EXPR_ID};
+    /// Sub-expression and bounds for Repeat nodes.
+    RepeatBounds rep;
     /// Sub-expression for Star nodes.
     ExprId body;
     /// Index into ExprPool::byte_sets for ByteSet nodes.
     uint32_t byte_set_id;
     /// Bitmask of AnchorKind flags for Anchor nodes.
     uint8_t anchor_mask;
+    /// The character for Literal nodes.
+    char ch;
   };
 
   /// Default constructor creates an EmptySet node.
   /// Public to allow array allocation in ExprPool::Block.
-  constexpr Expr() : nullable(false), has_anchors(false) {}
+  constexpr Expr() : nullable(false), has_anchors(false), has_repeat(false) {}
 
 private:
   /// Create a leaf node with a specific nullability (and optional character).
   constexpr Expr(ExprKind k, bool is_null, char c = '\0')
-      : kind(k), nullable(is_null), has_anchors(false), ch(c) {}
+      : kind(k), nullable(is_null), has_anchors(false), has_repeat(false),
+        ch(c) {}
   /// Create an Anchor node.
   constexpr Expr(uint8_t mask)
       : kind(ExprKind::Anchor), nullable(false), has_anchors(true),
-        anchor_mask(mask) {}
+        has_repeat(false), anchor_mask(mask) {}
   /// Create a ByteSet node.
   constexpr Expr(uint32_t bs_id)
       : kind(ExprKind::ByteSet), nullable(false), has_anchors(false),
-        byte_set_id(bs_id) {}
+        has_repeat(false), byte_set_id(bs_id) {}
   /// Create a Star node.
-  constexpr Expr(ExprId b, bool anchors)
-      : kind(ExprKind::Star), nullable(true), has_anchors(anchors), body(b) {}
+  constexpr Expr(ExprId b, bool anchors, bool repeats, uint16_t exp)
+      : kind(ExprKind::Star), nullable(true), has_anchors(anchors),
+        has_repeat(repeats), rep_expansion(exp), body(b) {}
+  /// Create a Repeat node.
+  constexpr Expr(ExprId b, uint16_t min_rep, uint16_t max_rep, bool is_null,
+                 bool anchors, uint16_t exp)
+      : kind(ExprKind::Repeat), nullable((min_rep == 0) || is_null),
+        has_anchors(anchors), has_repeat(true), rep_expansion(exp),
+        rep{b, min_rep, max_rep} {}
   /// Create a binary node (Concat or Alt).
-  constexpr Expr(ExprKind k, bool is_null, bool anchors, ExprId l, ExprId r)
-      : kind(k), nullable(is_null), has_anchors(anchors), bin{l, r} {}
+  constexpr Expr(ExprKind k, bool is_null, bool anchors, bool repeats,
+                 uint16_t exp, ExprId l, ExprId r)
+      : kind(k), nullable(is_null), has_anchors(anchors), has_repeat(repeats),
+        rep_expansion(exp), bin{l, r} {}
 
 public:
   static constexpr Expr make_empty_set() {
@@ -124,16 +157,24 @@ struct Expr {
   }
   static constexpr Expr make_anchor(uint8_t mask) { return Expr(mask); }
   static constexpr Expr make_byte_set(uint32_t bs_id) { return Expr(bs_id); }
-  static constexpr Expr make_star(ExprId b, bool anchors = false) {
-    return Expr(b, anchors);
+  static constexpr Expr make_star(ExprId b, bool anchors = false,
+                                  bool repeats = false, uint16_t exp = 1) {
+    return Expr(b, anchors, repeats, exp);
+  }
+  static constexpr Expr make_repeat(ExprId b, uint16_t min_rep,
+                                    uint16_t max_rep, bool is_null = false,
+                                    bool anchors = false, uint16_t exp = 1) {
+    return Expr(b, min_rep, max_rep, is_null, anchors, exp);
   }
   static constexpr Expr make_concat(ExprId l, ExprId r, bool is_null = false,
-                                    bool anchors = false) {
-    return Expr(ExprKind::Concat, is_null, anchors, l, r);
+                                    bool anchors = false, bool repeats = false,
+                                    uint16_t exp = 1) {
+    return Expr(ExprKind::Concat, is_null, anchors, repeats, exp, l, r);
   }
   static constexpr Expr make_alt(ExprId l, ExprId r, bool is_null = false,
-                                 bool anchors = false) {
-    return Expr(ExprKind::Alt, is_null, anchors, l, r);
+                                 bool anchors = false, bool repeats = false,
+                                 uint16_t exp = 1) {
+    return Expr(ExprKind::Alt, is_null, anchors, repeats, exp, l, r);
   }
 
   /// Equivalence check for hash-consing.
@@ -152,6 +193,9 @@ struct Expr {
       return byte_set_id == other.byte_set_id;
     case ExprKind::Star:
       return body == other.body;
+    case ExprKind::Repeat:
+      return rep.body == other.rep.body && rep.min == other.rep.min &&
+             rep.max == other.rep.max;
     case ExprKind::Concat:
     case ExprKind::Alt:
       return bin.left == other.bin.left && bin.right == other.bin.right;
diff --git a/libc/src/__support/regex/regex_expr_pool.cpp b/libc/src/__support/regex/regex_expr_pool.cpp
index 7f68619c4b272a..6f40b6f721c099 100644
--- a/libc/src/__support/regex/regex_expr_pool.cpp
+++ b/libc/src/__support/regex/regex_expr_pool.cpp
@@ -13,7 +13,9 @@
 
 #include "src/__support/regex/regex_expr_pool.h"
 #include "hdr/regex_macros.h"
+#include "src/__support/CPP/algorithm.h"
 #include "src/__support/CPP/new.h"
+#include "src/__support/CPP/optional.h"
 #include "src/__support/CPP/scope.h"
 #include "src/__support/CPP/utility.h"
 #include "src/__support/alloc-checker.h"
@@ -33,29 +35,36 @@ uint32_t hash_expr(const Expr &e) {
   // high-entropy constants (derived from aHash) to produce a strong hash, while
   // the constant value guarantees deterministic hashing for hash-consing.
   internal::HashState hasher(0x12345678);
-  uint64_t kind = static_cast<uint64_t>(e.kind);
-  hasher.update(&kind, sizeof(kind));
+  struct HashKey {
+    uint64_t kind;
+    uint64_t payload;
+  } key = {static_cast<uint64_t>(e.kind), 0};
+  static_assert(sizeof(HashKey) == 2 * sizeof(uint64_t));
   switch (e.kind) {
   case ExprKind::Literal:
-    hasher.update(&e.ch, sizeof(e.ch));
+    key.payload = static_cast<uint8_t>(e.ch);
     break;
   case ExprKind::Anchor:
-    hasher.update(&e.anchor_mask, sizeof(e.anchor_mask));
+    key.payload = e.anchor_mask;
     break;
   case ExprKind::ByteSet:
-    hasher.update(&e.byte_set_id, sizeof(e.byte_set_id));
+    key.payload = e.byte_set_id;
     break;
   case ExprKind::Star:
-    hasher.update(&e.body, sizeof(e.body));
+    key.payload = e.body;
+    break;
+  case ExprKind::Repeat:
+    key.payload = (static_cast<uint64_t>(e.rep.body) << 32) |
+                  (static_cast<uint64_t>(e.rep.min) << 16) | e.rep.max;
     break;
   case ExprKind::Concat:
   case ExprKind::Alt:
-    hasher.update(&e.bin.left, sizeof(e.bin.left));
-    hasher.update(&e.bin.right, sizeof(e.bin.right));
+    key.payload = (static_cast<uint64_t>(e.bin.left) << 32) | e.bin.right;
     break;
   default:
     break;
   }
+  hasher.update(&key, sizeof(key));
   uint64_t h = hasher.finish();
   return static_cast<uint32_t>(h ^ (h >> 32));
 }
@@ -281,16 +290,61 @@ ErrorOr<ExprId> ExprPool::make_byte_set(const ByteSet &bs) {
   return id;
 }
 
+namespace {
+
+// Multiplies a subexpression's repeat expansion factor by an enclosing repeat
+// bound, saturating at MAX_REPEAT_EXPANSION + 1 so nested bounds checks never
+// overflow uint16_t.
+uint16_t scale_exp(uint16_t base, uint16_t factor) {
+  if (factor <= 1)
+    return base;
+  uint32_t prod = static_cast<uint32_t>(base) * static_cast<uint32_t>(factor);
+  return static_cast<uint16_t>(
+      cpp::min<uint32_t>(prod, MAX_REPEAT_EXPANSION + 1));
+}
+
+} // namespace
+
 ErrorOr<ExprId> ExprPool::make_star(ExprId body) {
   if (body == INVALID_EXPR_ID)
     return Error(REG_BADPAT);
   // Ø* = ε and ε* = ε
   if (body == EMPTY_SET_ID || body == EMPTY_STR_ID)
     return empty_str();
-  // (R*)* = R* (Idempotency)
-  if (get(body).kind == ExprKind::Star)
+  // (R*)* = R* and (R{m,n})* = R* iff m <= 1 (since R^1 is in R{m,n} iff
+  // m <= 1, whereas (a{2,3})* excludes "a").
+  const Expr be = get(body);
+  if (be.kind == ExprKind::Star)
     return body;
-  return intern(Expr::make_star(body, has_anchors(body)));
+  if (be.kind == ExprKind::Repeat && be.rep.min <= 1)
+    return make_star(be.rep.body);
+  return intern(
+      Expr::make_star(body, be.has_anchors, be.has_repeat, be.rep_expansion));
+}
+
+ErrorOr<ExprId> ExprPool::make_repeat(ExprId body, uint16_t min, uint16_t max) {
+  if (body == INVALID_EXPR_ID || min > max || min == REPEAT_INF)
+    return Error(REG_BADPAT);
+  // Normalize R{0,0} = ε, ε{m,n} = ε, Ø{m,n}, R{1,1} = R, and
+  // R{0,REPEAT_INF} = R*.
+  if (max == 0 || body == EMPTY_STR_ID)
+    return empty_str();
+  if (body == EMPTY_SET_ID)
+    return (min == 0) ? empty_str() : empty_set();
+  if (min == 1 && max == 1)
+    return body;
+  if (min == 0 && max == REPEAT_INF)
+    return make_star(body);
+  const Expr be = get(body);
+  if (be.kind == ExprKind::Star)
+    return body;
+  if (be.kind == ExprKind::Repeat && be.rep.min == 1 &&
+      be.rep.max == REPEAT_INF && min == 1 && max == REPEAT_INF)
+    return body;
+  uint16_t factor = (max == REPEAT_INF) ? min : max;
+  return intern(Expr::make_repeat(body, min, max, (min == 0) || be.nullable,
+                                  be.has_anchors,
+                                  scale_exp(be.rep_expansion, factor)));
 }
 
 ErrorOr<ExprId> ExprPool::make_concat(ExprId l, ExprId r) {
@@ -313,10 +367,115 @@ ErrorOr<ExprId> ExprPool::make_concat(ExprId l, ExprId r) {
       return tail;
     return make_concat(le.bin.left, *tail);
   }
-  return intern(Expr::make_concat(l, r, is_nullable(l) && is_nullable(r),
-                                  has_anchors(l) || has_anchors(r)));
+  const Expr re = get(r);
+  return intern(Expr::make_concat(
+      l, r, le.nullable && re.nullable, le.has_anchors || re.has_anchors,
+      le.has_repeat || re.has_repeat,
+      cpp::max(le.rep_expansion, re.rep_expansion)));
 }
 
+namespace {
+
+// Coalesces R{m1,n1} | R{m2,n2} (and shared-prefix/shared-suffix Concat nodes)
+// when their repetition intervals overlap or are adjacent.  Returns
+// INVALID_EXPR_ID when the pair cannot be coalesced.
+ErrorOr<ExprId> coalesce_repeat_pair(ExprId a, ExprId b, ExprPool &pool) {
+  const Expr ae = pool.get(a);
+  const Expr be = pool.get(b);
+  if (!ae.has_repeat && !be.has_repeat)
+    return INVALID_EXPR_ID;
+  if (ae.kind == ExprKind::Concat && be.kind == ExprKind::Concat) {
+    if (ae.bin.right == be.bin.right) {
+      ErrorOr<ExprId> left =
+          coalesce_repeat_pair(ae.bin.left, be.bin.left, pool);
+      if (!left.has_value() || *left == INVALID_EXPR_ID)
+        return left;
+      return pool.make_concat(*left, ae.bin.right);
+    }
+    if (ae.bin.left == be.bin.left) {
+      ErrorOr<ExprId> right =
+          coalesce_repeat_pair(ae.bin.right, be.bin.right, pool);
+      if (!right.has_value() || *right == INVALID_EXPR_ID)
+        return right;
+      return pool.make_concat(ae.bin.left, *right);
+    }
+  }
+  // Handle Concat(X, S) | S and Concat(P, X) | P where R{0,0} normalized to
+  // EmptyStr on one side.
+  if (ae.kind == ExprKind::Concat && ae.bin.right == b) {
+    ErrorOr<ExprId> left =
+        coalesce_repeat_pair(ae.bin.left, EMPTY_STR_ID, pool);
+    if (!left.has_value() || *left == INVALID_EXPR_ID)
+      return left;
+    return pool.make_concat(*left, b);
+  }
+  if (be.kind == ExprKind::Concat && be.bin.right == a) {
+    ErrorOr<ExprId> left =
+        coalesce_repeat_pair(EMPTY_STR_ID, be.bin.left, pool);
+    if (!left.has_value() || *left == INVALID_EXPR_ID)
+      return left;
+    return pool.make_concat(*left, a);
+  }
+  if (ae.kind != ExprKind::Repeat && be.kind != ExprKind::Repeat)
+    return INVALID_EXPR_ID;
+  ExprId body = (ae.kind == ExprKind::Repeat) ? ae.rep.body : be.rep.body;
+  struct RepeatInterval {
+    uint16_t min;
+    uint16_t max;
+  };
+  // Undo make_repeat normalizations: R -> [1,1], EmptyStr -> [0,0], and
+  // Star(R) -> [0,REPEAT_INF].
+  auto get_interval = [&](ExprId id,
+                          const Expr &e) -> cpp::optional<RepeatInterval> {
+    if (e.kind == ExprKind::Repeat && e.rep.body == body)
+      return RepeatInterval{e.rep.min, e.rep.max};
+    if (e.kind == ExprKind::Star && e.body == body)
+      return RepeatInterval{0, REPEAT_INF};
+    if (id == body)
+      return RepeatInterval{1, 1};
+    if (id == EMPTY_STR_ID)
+      return RepeatInterval{0, 0};
+    return cpp::nullopt;
+  };
+  cpp::optional<RepeatInterval> lhs = get_interval(a, ae);
+  cpp::optional<RepeatInterval> rhs = get_interval(b, be);
+  if (!lhs.has_value() || !rhs.has_value())
+    return INVALID_EXPR_ID;
+  // Integer intervals [lhs->min, lhs->max] and [rhs->min, rhs->max] coalesce
+  // iff they overlap or are adjacent (later_min <= earlier_max + 1, e.g.
+  // [2,3] U [4,5] = [2,5]).
+  uint16_t later_min = cpp::max(lhs->min, rhs->min);
+  uint16_t earlier_max = cpp::min(lhs->max, rhs->max);
+  if (earlier_max != REPEAT_INF && later_min > earlier_max + 1)
+    return INVALID_EXPR_ID;
+  return pool.make_repeat(body, cpp::min(lhs->min, rhs->min),
+                          cpp::max(lhs->max, rhs->max));
+}
+
+// Attempts to coalesce a single non-Alt branch `item` with any branch in
+// `spine`, returning INVALID_EXPR_ID if no branch coalesces.
+ErrorOr<ExprId> coalesce_item_into_spine(ExprId item, ExprId spine,
+                                         ExprPool &pool) {
+  const Expr item_e = pool.get(item);
+  const Expr spine_e = pool.get(spine);
+  if (!item_e.has_repeat && !spine_e.has_repeat)
+    return INVALID_EXPR_ID;
+  if (spine_e.kind != ExprKind::Alt)
+    return coalesce_repeat_pair(item, spine, pool);
+  ErrorOr<ExprId> head_c = coalesce_repeat_pair(item, spine_e.bin.left, pool);
+  if (!head_c.has_value())
+    return head_c;
+  if (*head_c != INVALID_EXPR_ID)
+    return pool.make_alt(*head_c, spine_e.bin.right);
+  ErrorOr<ExprId> tail_c =
+      coalesce_item_into_spine(item, spine_e.bin.right, pool);
+  if (!tail_c.has_value() || *tail_c == INVALID_EXPR_ID)
+    return tail_c;
+  return pool.make_alt(spine_e.bin.left, *tail_c);
+}
+
+} // namespace
+
 ErrorOr<ExprId> ExprPool::make_alt(ExprId l, ExprId r) {
   if (l == INVALID_EXPR_ID || r == INVALID_EXPR_ID)
     return Error(REG_BADPAT);
@@ -333,15 +492,38 @@ ErrorOr<ExprId> ExprPool::make_alt(ExprId l, ExprId r) {
   const Expr le = get(l);
   const Expr re = get(r);
   if (le.kind != ExprKind::Alt && re.kind != ExprKind::Alt) {
+    ErrorOr<ExprId> coalesced = coalesce_repeat_pair(l, r, *this);
+    if (!coalesced.has_value() || *coalesced != INVALID_EXPR_ID)
+      return coalesced;
     if (l > r)
       cpp::swap(l, r);
-    return intern(Expr::make_alt(l, r, is_nullable(l) || is_nullable(r),
-                                 has_anchors(l) || has_anchors(r)));
+    return intern(Expr::make_alt(l, r, le.nullable || re.nullable,
+                                 le.has_anchors || re.has_anchors,
+                                 le.has_repeat || re.has_repeat,
+                                 cpp::max(le.rep_expansion, re.rep_expansion)));
+  }
+
+  if (le.has_repeat || re.has_repeat) {
+    if (le.kind != ExprKind::Alt) {
+      ErrorOr<ExprId> c = coalesce_item_into_spine(l, r, *this);
+      if (!c.has_value() || *c != INVALID_EXPR_ID)
+        return c;
+    } else if (re.kind != ExprKind::Alt) {
+      ErrorOr<ExprId> c = coalesce_item_into_spine(r, l, *this);
+      if (!c.has_value() || *c != INVALID_EXPR_ID)
+        return c;
+    } else {
+      ErrorOr<ExprId> c = coalesce_item_into_spine(le.bin.left, r, *this);
+      if (!c.has_value())
+        return c;
+      if (*c != INVALID_EXPR_ID)
+        return make_alt(le.bin.right, *c);
+    }
   }
 
   // Merge two right-associated sorted Alt spines in ascending ExprId order so
   // multi-way alternations canonicalize to a single deduplicated tree and
-  // derivative state spaces under '*' stay finite.
+  // derivative state spaces under '*' and '{m,n}' stay finite.
   ExprId hl = (le.kind == ExprKind::Alt) ? le.bin.left : l;
   ExprId tl = (le.kind == ExprKind::Alt) ? le.bin.right : EMPTY_SET_ID;
   ExprId hr = (re.kind == ExprKind::Alt) ? re.bin.left : r;
@@ -358,9 +540,18 @@ ErrorOr<ExprId> ExprPool::make_alt(ExprId l, ExprId r) {
   }
   if (!rest.has_value())
     return rest;
-  return intern(Expr::make_alt(head, *rest,
-                               is_nullable(head) || is_nullable(*rest),
-                               has_anchors(head) || has_anchors(*rest)));
+  // Tail coalescing may produce an already-interned ExprId <= head; re-merge
+  // when needed to preserve ascending ExprId order along the Alt spine.
+  const Expr re_rest = get(*rest);
+  ExprId rest_head = (re_rest.kind == ExprKind::Alt) ? re_rest.bin.left : *rest;
+  if (head >= rest_head)
+    return make_alt(head, *rest);
+  const Expr he = get(head);
+  return intern(
+      Expr::make_alt(head, *rest, he.nullable || re_rest.nullable,
+                     he.has_anchors || re_rest.has_anchors,
+                     he.has_repeat || re_rest.has_repeat,
+                     cpp::max(he.rep_expansion, re_rest.rep_expansion)));
 }
 
 } // namespace regex
diff --git a/libc/src/__support/regex/regex_expr_pool.h b/libc/src/__support/regex/regex_expr_pool.h
index 37fc585e2e2387..cade66d14c3716 100644
--- a/libc/src/__support/regex/regex_expr_pool.h
+++ b/libc/src/__support/regex/regex_expr_pool.h
@@ -167,6 +167,8 @@ class ExprPool {
   ErrorOr<ExprId> make_byte_set(const ByteSet &bs);
   /// Creates or returns a normalized Kleene star node (body*).
   ErrorOr<ExprId> make_star(ExprId body);
+  /// Creates or returns a normalized Repeat node (body{min,max}).
+  ErrorOr<ExprId> make_repeat(ExprId body, uint16_t min, uint16_t max);
   /// Creates or returns a right-associated Concatenation node (l . r).
   ErrorOr<ExprId> make_concat(ExprId l, ExprId r);
   /// Creates or returns a canonical deduplicated Alternation node (l | r).
diff --git a/libc/src/__support/regex/regex_matcher.cpp b/libc/src/__support/regex/regex_matcher.cpp
index c58daf83294f60..1a7746c06f0238 100644
--- a/libc/src/__support/regex/regex_matcher.cpp
+++ b/libc/src/__support/regex/regex_matcher.cpp
@@ -44,7 +44,7 @@ uint8_t ctx_at(cpp::string_view input, size_t pos, int cflags, int eflags) {
 
 bool nullable(ExprId id, uint8_t anchor_ctx, const ExprPool &pool) {
   const Expr &e = pool.get(id);
-  if (!e.has_anchors)
+  if (e.nullable || !e.has_anchors)
     return e.nullable;
   switch (e.kind) {
   case ExprKind::Anchor:
@@ -55,6 +55,8 @@ bool nullable(ExprId id, uint8_t anchor_ctx, const ExprPool &pool) {
   case ExprKind::Alt:
     return nullable(e.bin.left, anchor_ctx, pool) ||
            nullable(e.bin.right, anchor_ctx, pool);
+  case ExprKind::Repeat:
+    return nullable(e.rep.body, anchor_ctx, pool);
   default:
     return e.nullable;
   }
@@ -107,6 +109,26 @@ ErrorOr<ExprId> derivative(ExprId id, char c, ExprPool &pool,
       return d_body;
     return pool.make_concat(*d_body, id);
   }
+  case ExprKind::Repeat: {
+    // Bounded Repetition Rule:
+    // D_c(R{m,n}) = D_c(R) . R{nullable(R, ctx_before) ? 0 : max(0,m-1), n-1}.
+    // When R is nullable at ctx_before (e.g. via '^'), up to m - 1 leading
+    // copies may match empty at ctx_before before the copy that consumes c.
+    ErrorOr<ExprId> d_body = derivative(e.rep.body, c, pool, ctx_before);
+    if (!d_body.has_value() || *d_body == EMPTY_SET_ID)
+      return d_body;
+    uint16_t next_min =
+        (e.rep.min > 0 && !nullable(e.rep.body, ctx_before, pool))
+            ? static_cast<uint16_t>(e.rep.min - 1)
+            : static_cast<uint16_t>(0);
+    uint16_t next_max = (e.rep.max == REPEAT_INF)
+                            ? REPEAT_INF
+                            : static_cast<uint16_t>(e.rep.max - 1);
+    ErrorOr<ExprId> tail = pool.make_repeat(e.rep.body, next_min, next_max);
+    if (!tail.has_value())
+      return tail;
+    return pool.make_concat(*d_body, *tail);
+  }
   }
   __builtin_unreachable();
 }
diff --git a/libc/src/__support/regex/regex_parser.cpp b/libc/src/__support/regex/regex_parser.cpp
index f1864afd41171a..34257646a4134a 100644
--- a/libc/src/__support/regex/regex_parser.cpp
+++ b/libc/src/__support/regex/regex_parser.cpp
@@ -13,13 +13,16 @@
 
 #include "src/__support/regex/regex_parser.h"
 
+#include "hdr/limits_macros.h"
 #include "hdr/regex_macros.h"
 #include "src/__support/CPP/array.h"
 #include "src/__support/CPP/new.h"
+#include "src/__support/CPP/optional.h"
 #include "src/__support/CPP/scope.h"
 #include "src/__support/CPP/span.h"
 #include "src/__support/CPP/string_view.h"
 #include "src/__support/alloc-checker.h"
+#include "src/__support/ctype_utils.h"
 #include "src/__support/error_or.h"
 #include "src/__support/macros/config.h"
 #include "src/__support/regex/regex_ast.h"
@@ -165,17 +168,69 @@ ErrorOr<ExprId> parse_bracket_expr(cpp::string_view &rem, int cflags,
 // Maximum parenthesized group nesting depth to prevent parser stack overflow.
 constexpr size_t MAX_GROUP_DEPTH = 128;
 
+// Parses a decimal repetition bound in [0, RE_DUP_MAX] from the prefix of spec.
+cpp::optional<uint16_t> consume_bound(cpp::string_view &spec) {
+  if (spec.empty() || !internal::isdigit(spec.front()))
+    return cpp::nullopt;
+  uint32_t val = 0;
+  do {
+    val = val * 10 + static_cast<uint32_t>(spec.front() - '0');
+    if (val > RE_DUP_MAX)
+      return cpp::nullopt;
+    spec.remove_prefix(1);
+  } while (!spec.empty() && internal::isdigit(spec.front()));
+  return static_cast<uint16_t>(val);
+}
+
+// Parses a trailing '{m}', '{m,}', or '{m,n}' interval quantifier after '{' has
+// been matched with a leading digit.
+ErrorOr<ExprId> parse_repeat_bounds(cpp::string_view &rem, ExprId atom,
+                                    ExprPool &pool) {
+  rem.remove_prefix(1);
+  // Scan for '}' before parsing bounds so unclosed "a{1,x" reports REG_EBRACE.
+  size_t close_pos = rem.find_first_of('}');
+  if (close_pos == cpp::string_view::npos)
+    return Error(REG_EBRACE);
+  cpp::string_view spec = rem.substr(0, close_pos);
+  rem.remove_prefix(close_pos + 1);
+
+  cpp::optional<uint16_t> min_opt = consume_bound(spec);
+  if (!min_opt.has_value())
+    return Error(REG_BADBR);
+  uint16_t min_val = *min_opt;
+  uint16_t max_val = min_val;
+  if (spec.starts_with(',')) {
+    spec.remove_prefix(1);
+    if (spec.empty()) {
+      max_val = REPEAT_INF;
+    } else {
+      cpp::optional<uint16_t> max_opt = consume_bound(spec);
+      if (!max_opt.has_value() || !spec.empty() || min_val > *max_opt)
+        return Error(REG_BADBR);
+      max_val = *max_opt;
+    }
+  } else if (!spec.empty()) {
+    return Error(REG_BADBR);
+  }
+
+  uint16_t bound = (max_val == REPEAT_INF) ? min_val : max_val;
+  if (bound > 1 && pool.get(atom).rep_expansion > MAX_REPEAT_EXPANSION / bound)
+    return Error(REG_ESPACE);
+  return pool.make_repeat(atom, min_val, max_val);
+}
+
 struct ParsedAtomStack;
 
 ErrorOr<ExprId> parse_alt(cpp::string_view &rem, int cflags, ExprPool &pool,
                           size_t depth, size_t &re_nsub,
                           ParsedAtomStack &stack);
 
-// Parses a single ERE atom and an optional trailing '*', '+', or '?'
+// Parses a single ERE atom and an optional trailing '*', '+', '?', or '{m,n}'
 // quantifier.
 ErrorOr<ExprId> parse_quantified_atom(cpp::string_view &rem, int cflags,
                                       ExprPool &pool, size_t depth,
-                                      size_t &re_nsub, ParsedAtomStack &stack) {
+                                      size_t &re_nsub, ParsedAtomStack &stack,
+                                      ExprId &prefix_atom) {
   char c = rem.front();
   rem.remove_prefix(1);
   ErrorOr<ExprId> atom = INVALID_EXPR_ID;
@@ -236,17 +291,18 @@ ErrorOr<ExprId> parse_quantified_atom(cpp::string_view &rem, int cflags,
     atom = pool.make_anchor(ANCHOR_EOL);
     can_repeat = false;
     break;
-  // Valid postfix '*', '+', or '?' is consumed after a repeatable atom below;
-  // reaching a quantifier at the start of an atom means it has no preceding
-  // repeatable atom (e.g. "*a", "^*", or doubled "a**").
+  // Valid postfix '*', '+', '?', or '{m,n}' is consumed after a repeatable
+  // atom below; reaching a quantifier at the start of an atom means it has no
+  // preceding repeatable atom (e.g. "*a", "^*", or doubled "a**").
   case '*':
   case '+':
   case '?':
     return Error(REG_BADRPT);
   case '{':
-    if (!rem.empty() && rem.front() >= '0' && rem.front() <= '9')
+    if (!rem.empty() && internal::isdigit(rem.front()))
       return Error(REG_BADRPT);
-    return Error(REG_BADPAT);
+    atom = pool.make_lit('{');
+    break;
   case '[':
     atom = parse_bracket_expr(rem, cflags, pool);
     break;
@@ -280,19 +336,22 @@ ErrorOr<ExprId> parse_quantified_atom(cpp::string_view &rem, int cflags,
     rem.remove_prefix(1);
     return pool.make_star(*atom);
   }
+  // Push r and Star(r) separately rather than Repeat(r, 1, REPEAT_INF) so
+  // right-to-left folding builds r . (r* . rest) once and regexec peels off
+  // long Concat spines without re-associating r on each repetition.
   if (rem.starts_with('+')) {
     rem.remove_prefix(1);
     ErrorOr<ExprId> star = pool.make_star(*atom);
-    if (!star.has_value())
-      return star;
-    return pool.make_concat(*atom, *star);
+    if (star.has_value())
+      prefix_atom = *atom;
+    return star;
   }
   if (rem.starts_with('?')) {
     rem.remove_prefix(1);
     return pool.make_alt(EMPTY_STR_ID, *atom);
   }
-  if (rem.starts_with('{'))
-    return Error(REG_BADPAT);
+  if (rem.size() >= 2 && rem[0] == '{' && internal::isdigit(rem[1]))
+    return parse_repeat_bounds(rem, *atom, pool);
   return atom;
 }
 
@@ -342,15 +401,20 @@ ErrorOr<ExprId> parse_concat(cpp::string_view &rem, int cflags, ExprPool &pool,
                              size_t depth, size_t &re_nsub,
                              ParsedAtomStack &stack) {
   // Collect quantified atoms for this branch on the shared stack, then fold
-  // right-to-left so right-associative make_concat stays O(1) without orphaned
-  // prefixes.
+  // right-to-left so non-Concat atoms link in O(1) without re-association.
   size_t atom_count = 0;
   while (!rem.empty() && !rem.starts_with('|') &&
          !(depth > 0 && rem.starts_with(')'))) {
-    ErrorOr<ExprId> atom =
-        parse_quantified_atom(rem, cflags, pool, depth, re_nsub, stack);
+    ExprId prefix_atom = INVALID_EXPR_ID;
+    ErrorOr<ExprId> atom = parse_quantified_atom(rem, cflags, pool, depth,
+                                                 re_nsub, stack, prefix_atom);
     if (!atom.has_value())
       return atom;
+    if (prefix_atom != INVALID_EXPR_ID) {
+      if (!stack.push(prefix_atom))
+        return Error(REG_ESPACE);
+      ++atom_count;
+    }
     if (!stack.push(*atom))
       return Error(REG_ESPACE);
     ++atom_count;
diff --git a/libc/test/src/__support/regex/expr_test.cpp b/libc/test/src/__support/regex/expr_test.cpp
index 990a67d0cc87c3..7f8d46291599ea 100644
--- a/libc/test/src/__support/regex/expr_test.cpp
+++ b/libc/test/src/__support/regex/expr_test.cpp
@@ -24,6 +24,7 @@ using LIBC_NAMESPACE::regex::ExprId;
 using LIBC_NAMESPACE::regex::ExprKind;
 using LIBC_NAMESPACE::regex::ExprPool;
 using LIBC_NAMESPACE::regex::INVALID_EXPR_ID;
+using LIBC_NAMESPACE::regex::REPEAT_INF;
 
 TEST(LlvmLibcRegexExprTest, Interning) {
   ExprPool pool;
@@ -223,6 +224,56 @@ TEST(LlvmLibcRegexExprTest, AlgebraicIdentitiesAltAndStar) {
   EXPECT_TRUE(pool.has_anchors(*pool.make_star(*pool.make_anchor(ANCHOR_BOL))));
   EXPECT_TRUE(Expr::make_star(lit_a) == Expr::make_star(lit_a));
   EXPECT_FALSE(Expr::make_star(lit_a) == Expr::make_star(lit_b));
+
+  // Repeat identities and interval coalescing in make_alt.
+  const ExprId plus_a = *pool.make_repeat(lit_a, 1, REPEAT_INF);
+  auto bad_rep_id = pool.make_repeat(INVALID_EXPR_ID, 1, 2);
+  auto bad_rep_range = pool.make_repeat(lit_a, 3, 2);
+  auto bad_rep_inf = pool.make_repeat(lit_a, REPEAT_INF, REPEAT_INF);
+  ASSERT_FALSE(bad_rep_id.has_value());
+  ASSERT_FALSE(bad_rep_range.has_value());
+  ASSERT_FALSE(bad_rep_inf.has_value());
+  EXPECT_EQ(bad_rep_id.error(), REG_BADPAT);
+  EXPECT_EQ(bad_rep_range.error(), REG_BADPAT);
+  EXPECT_EQ(bad_rep_inf.error(), REG_BADPAT);
+  EXPECT_EQ(*pool.make_repeat(lit_a, 0, 0), empty_str);
+  EXPECT_EQ(*pool.make_repeat(empty_str, 2, 4), empty_str);
+  EXPECT_EQ(*pool.make_repeat(empty_set, 0, 3), empty_str);
+  EXPECT_EQ(*pool.make_repeat(empty_set, 1, 3), empty_set);
+  EXPECT_EQ(*pool.make_repeat(lit_a, 1, 1), lit_a);
+  EXPECT_EQ(*pool.make_repeat(lit_a, 0, REPEAT_INF), star_a);
+  EXPECT_EQ(*pool.make_repeat(star_a, 2, 4), star_a);
+  EXPECT_EQ(*pool.make_star(plus_a), star_a);
+  EXPECT_EQ(*pool.make_repeat(plus_a, 1, REPEAT_INF), plus_a);
+
+  const ExprId rep_1_3 = *pool.make_repeat(lit_a, 1, 3);
+  const ExprId rep_2_3 = *pool.make_repeat(lit_a, 2, 3);
+  const ExprId rep_4_5 = *pool.make_repeat(lit_a, 4, 5);
+  const ExprId rep_1_2 = *pool.make_repeat(lit_a, 1, 2);
+  const ExprId rep_2_5 = *pool.make_repeat(lit_a, 2, 5);
+  EXPECT_EQ(*pool.make_alt(rep_2_3, rep_4_5), rep_2_5);
+  EXPECT_EQ(*pool.make_alt(lit_a, rep_2_3), rep_1_3);
+  EXPECT_EQ(*pool.make_alt(empty_str, plus_a), star_a);
+  EXPECT_EQ(*pool.make_alt(*pool.make_repeat(lit_a, 2, REPEAT_INF), star_a),
+            star_a);
+  EXPECT_EQ(*pool.make_alt(*pool.make_concat(rep_2_3, lit_b),
+                           *pool.make_concat(rep_4_5, lit_b)),
+            *pool.make_concat(rep_2_5, lit_b));
+  EXPECT_EQ(*pool.make_alt(*pool.make_concat(lit_b, rep_2_3),
+                           *pool.make_concat(lit_b, rep_4_5)),
+            *pool.make_concat(lit_b, rep_2_5));
+  EXPECT_EQ(*pool.make_alt(*pool.make_alt(rep_2_3, lit_c), rep_4_5),
+            *pool.make_alt(rep_2_3, *pool.make_alt(lit_c, rep_4_5)));
+  const ExprId merged = *pool.make_alt(*pool.make_alt(lit_c, rep_2_3), rep_1_2);
+  EXPECT_EQ(merged, *pool.make_alt(lit_c, rep_1_3));
+  EXPECT_LT(pool.get(merged).bin.left, pool.get(merged).bin.right);
+  EXPECT_TRUE(pool.is_nullable(*pool.make_repeat(lit_a, 0, 2)));
+  EXPECT_FALSE(pool.is_nullable(rep_2_3));
+  EXPECT_TRUE(
+      pool.has_anchors(*pool.make_repeat(*pool.make_anchor(ANCHOR_BOL), 2, 3)));
+  EXPECT_TRUE(Expr::make_repeat(lit_a, 2, 3) == Expr::make_repeat(lit_a, 2, 3));
+  EXPECT_FALSE(Expr::make_repeat(lit_a, 2, 3) ==
+               Expr::make_repeat(lit_a, 2, 4));
 }
 
 TEST(LlvmLibcRegexExprTest, HermeticHundredPools) {
diff --git a/libc/test/src/__support/regex/matcher_test.cpp b/libc/test/src/__support/regex/matcher_test.cpp
index d0d3c4c39c4739..d35fa207178d9f 100644
--- a/libc/test/src/__support/regex/matcher_test.cpp
+++ b/libc/test/src/__support/regex/matcher_test.cpp
@@ -252,15 +252,32 @@ TEST(LlvmLibcRegexMatcherTest, ParseEreEscapesAndErrors) {
   ASSERT_FALSE(trailing_escape.has_value());
   EXPECT_EQ(trailing_escape.error(), REG_EESCAPE);
 
-  const string_view bad_rpt_patterns[] = {"*",   "+",   "?",    "{1}", "^*",
-                                          "^+",  "^?",  "^{1}", "$*",  "a**",
-                                          "a*+", "a*?", "a*{1}"};
+  const string_view bad_rpt_patterns[] = {
+      "*",     "+",     "?",     "{1}",     "{1",    "^*",    "^+",
+      "^?",    "^{1}",  "$*",    "${1}",    "a**",   "a*+",   "a*?",
+      "a*{1}", "a+{1}", "a?{1}", "a{2}{2}", "a{2}*", "a{2}+", "a{2}?"};
   for (string_view bad_rpt : bad_rpt_patterns) {
     ErrorOr<ExprId> err = parse_ere(bad_rpt, REG_EXTENDED, pool);
     ASSERT_FALSE(err.has_value());
     EXPECT_EQ(err.error(), REG_BADRPT);
   }
 
+  const string_view ebrace_patterns[] = {"a{1", "a{1a", "a{1,2", "a{1,x"};
+  for (string_view pat : ebrace_patterns) {
+    ErrorOr<ExprId> err = parse_ere(pat, REG_EXTENDED, pool);
+    ASSERT_FALSE(err.has_value());
+    EXPECT_EQ(err.error(), REG_EBRACE);
+  }
+
+  const string_view badbr_patterns[] = {"a{1a}",    "a{1,x}",       "a{1,0}",
+                                        "a{5,2}",   "a{256}",       "a{1,256}",
+                                        "a{1,2,3}", "a{9876543210}"};
+  for (string_view pat : badbr_patterns) {
+    ErrorOr<ExprId> err = parse_ere(pat, REG_EXTENDED, pool);
+    ASSERT_FALSE(err.has_value());
+    EXPECT_EQ(err.error(), REG_BADBR);
+  }
+
   const string_view ebrack_patterns[] = {
       "[", "[^", "[abc", "[]", "a[[.x", "a[[.x.", "a[[:digit:", "a[[:digit"};
   for (string_view pat : ebrack_patterns) {
@@ -294,8 +311,6 @@ TEST(LlvmLibcRegexMatcherTest, ParseEreEscapesAndErrors) {
   }
 
   const string_view bad_patterns[] = {
-      // Unimplemented quantifiers.
-      "a{1}", "{",
       // Unimplemented word boundaries.
       "[[:<:]]", "[[:>:]]",
       // Unimplemented backreferences.
@@ -448,6 +463,7 @@ TEST(LlvmLibcRegexMatcherTest, PoolExhaustionPropagation) {
   // D_a((a . b) | (a . c)) = b | c, and D_a((a . b)*) = b . (a . b)*.
   const ExprId need_bc = *pool.make_alt(ab, ac);
   const ExprId star_ab = *pool.make_star(ab);
+  const ExprId rep_ab = *pool.make_repeat(ab, 2, 3);
   const ExprId concat_left_fail = *pool.make_concat(need_bc, lit_c);
   const ExprId concat_right_fail = *pool.make_concat(opt_a, need_bc);
   const ExprId alt_right_fail = *pool.make_alt(lit_a, need_bc);
@@ -459,9 +475,9 @@ TEST(LlvmLibcRegexMatcherTest, PoolExhaustionPropagation) {
   while (pool.size() < 10000)
     chain = *pool.make_concat(lit_a, chain);
 
-  const ExprId exhausted_exprs[] = {need_bc,          star_ab,
-                                    concat_left_fail, concat_right_fail,
-                                    alt_left_fail,    alt_right_fail};
+  const ExprId exhausted_exprs[] = {
+      need_bc,           star_ab,       rep_ab,        concat_left_fail,
+      concat_right_fail, alt_left_fail, alt_right_fail};
   for (ExprId expr : exhausted_exprs) {
     ErrorOr<ExprId> err = derivative(expr, 'a', pool);
     ASSERT_FALSE(err.has_value());
@@ -521,8 +537,9 @@ TEST(LlvmLibcRegexMatcherTest, AlternationAndGroupingStabilization) {
 
 TEST(LlvmLibcRegexMatcherTest, PlusAndQuestionDesugaringAndStabilization) {
   ExprPool pool;
-  // r+ desugars to Concat(r, Star(r)) and r? desugars to Alt(EmptyStr, r),
-  // interning to identical ExprId handles.
+  // r+ pushes r and Star(r) separately onto ParsedAtomStack and r? lowers to
+  // Alt(EmptyStr, r), interning to identical ExprId handles without orphaned
+  // Concat spines.
   ErrorOr<ExprId> plus_a = parse_ere("a+", REG_EXTENDED, pool);
   ErrorOr<ExprId> concat_star_a = parse_ere("aa*", REG_EXTENDED, pool);
   ASSERT_TRUE(plus_a.has_value());
@@ -541,4 +558,163 @@ TEST(LlvmLibcRegexMatcherTest, PlusAndQuestionDesugaringAndStabilization) {
   size_t warm_nodes = pool.size();
   EXPECT_EQ(match(pool, *ere, "aaabc"), MatchResult::Match);
   EXPECT_EQ(pool.size(), warm_nodes);
+
+  // Pushing r and Star(r) as separate stack entries builds r . (r* . rest)
+  // once, giving (ab)+c and (ab)(ab)*c identical 10-node pools and allowing
+  // ^(a...200_as...)+$ to compile in 407 nodes (2 * 200 + 7, rather than
+  // 3 * 200 + 8 = 608 with an orphaned Concat spine) while keeping the stack
+  // buffer <= 256 B for GPU targets.
+  ExprPool plus_pool;
+  ExprPool star_pool;
+  ErrorOr<ExprId> group_plus = parse_ere("(ab)+c", REG_EXTENDED, plus_pool);
+  ErrorOr<ExprId> group_star = parse_ere("(ab)(ab)*c", REG_EXTENDED, star_pool);
+  ASSERT_TRUE(group_plus.has_value());
+  ASSERT_TRUE(group_star.has_value());
+  EXPECT_EQ(plus_pool.size(), size_t(10));
+  EXPECT_EQ(plus_pool.size(), star_pool.size());
+
+  char pat200[206];
+  pat200[0] = '^';
+  pat200[1] = '(';
+  for (size_t i = 0; i < 200; ++i)
+    pat200[2 + i] = 'a';
+  pat200[202] = ')';
+  pat200[203] = '+';
+  pat200[204] = '$';
+  pat200[205] = '\0';
+  ExprPool long_plus_pool;
+  ErrorOr<ExprId> long_plus =
+      parse_ere(string_view(pat200, 205), REG_EXTENDED, long_plus_pool);
+  ASSERT_TRUE(long_plus.has_value());
+  EXPECT_EQ(long_plus_pool.size(), size_t(407));
+  EXPECT_EQ(match(long_plus_pool, *long_plus, string_view(pat200 + 2, 200)),
+            MatchResult::Match);
+}
+
+TEST(LlvmLibcRegexMatcherTest, RepeatStabilizationAndRfc2822Grammar) {
+  // Native Repeat(body, 255, 255) compiles into 4 nodes inside 1 KiB; both
+  // unanchored a{255} / a{255}b (via make_alt interval coalescing) and anchored
+  // ^a{255}$ match and stabilize on warm runs.
+  ExprPool rep_pool;
+  ErrorOr<ExprId> a255 = parse_ere("a{255}", REG_EXTENDED, rep_pool);
+  ASSERT_TRUE(a255.has_value());
+  EXPECT_EQ(rep_pool.size(), size_t(4));
+  EXPECT_LE(rep_pool.allocated_bytes(), size_t(1024));
+
+  ErrorOr<ExprId> a255b = parse_ere("a{255}b", REG_EXTENDED, rep_pool);
+  ErrorOr<ExprId> anchored_a255 = parse_ere("^a{255}$", REG_EXTENDED, rep_pool);
+  ASSERT_TRUE(a255b.has_value());
+  ASSERT_TRUE(anchored_a255.has_value());
+  char buf256[256];
+  for (char &ch : buf256)
+    ch = 'a';
+  EXPECT_EQ(match(rep_pool, *a255, string_view(buf256, 255)),
+            MatchResult::Match);
+  buf256[255] = 'b';
+  EXPECT_EQ(match(rep_pool, *a255b, string_view(buf256, 256)),
+            MatchResult::Match);
+  buf256[255] = 'a';
+  EXPECT_EQ(match(rep_pool, *anchored_a255, string_view(buf256, 255)),
+            MatchResult::Match);
+  size_t warm_nodes = rep_pool.size();
+  EXPECT_EQ(match(rep_pool, *a255, string_view(buf256, 255)),
+            MatchResult::Match);
+  EXPECT_EQ(match(rep_pool, *anchored_a255, string_view(buf256, 255)),
+            MatchResult::Match);
+  EXPECT_EQ(rep_pool.size(), warm_nodes);
+  EXPECT_EQ(match(rep_pool, *anchored_a255, string_view(buf256, 254)),
+            MatchResult::NoMatch);
+  EXPECT_EQ(match(rep_pool, *anchored_a255, string_view(buf256, 256)),
+            MatchResult::NoMatch);
+
+  // Shared-prefix Concat derivatives (^(a+){255}$, (ab?){255}b) and interleaved
+  // multi-branch spines (x?.{255}a|.{255}b) coalesce without hitting
+  // MAX_NODE_LIMIT, and cached Expr::rep_expansion parses 30-deep nested '+'
+  // inside '{2}' in O(1) per node.
+  ExprPool prefix_pool;
+  ErrorOr<ExprId> plus_255 =
+      parse_ere("^(a+){255}$", REG_EXTENDED, prefix_pool);
+  ErrorOr<ExprId> opt_255b =
+      parse_ere("(ab?){255}b", REG_EXTENDED, prefix_pool);
+  ErrorOr<ExprId> inter_255 =
+      parse_ere("x?.{255}a|.{255}b", REG_EXTENDED, prefix_pool);
+  ASSERT_TRUE(plus_255.has_value());
+  ASSERT_TRUE(opt_255b.has_value());
+  ASSERT_TRUE(inter_255.has_value());
+  EXPECT_EQ(match(prefix_pool, *plus_255, string_view(buf256, 255)),
+            MatchResult::Match);
+  buf256[255] = 'b';
+  EXPECT_EQ(match(prefix_pool, *opt_255b, string_view(buf256, 256)),
+            MatchResult::Match);
+  EXPECT_EQ(match(prefix_pool, *inter_255, string_view(buf256, 256)),
+            MatchResult::Match);
+
+  ExprPool dag_exp_pool;
+  ErrorOr<ExprId> deep_plus_rep = parse_ere(
+      "(((((((((((((((((((((((((((((((a)+)+)+)+)+)+)+)+)+)+)+)+)+)+)+)+"
+      ")+)+)+)+)+)+)+)+)+)+)+)+)+)+){2}",
+      REG_EXTENDED, dag_exp_pool);
+  ASSERT_TRUE(deep_plus_rep.has_value());
+  EXPECT_EQ(match(dag_exp_pool, *deep_plus_rep, "aa"), MatchResult::Match);
+
+  // Build a >7 KiB ERE from RFC-2822 Section 3.4.1 addr-spec EBNF productions
+  // (F = FWS, C = CFWS, T = atext, W = word, S = addr-spec) at compile time in
+  // .rodata (avoiding GPU stack pressure) and verify hash-consed DAG
+  // deduplication keeps the pool under 32 KiB.
+  struct Rfc2822Pattern {
+    char buf[8192]{};
+    size_t len = 0;
+
+    constexpr void expand(string_view str) {
+      for (char ch : str) {
+        if (ch == 'F') {
+          expand("(([ \t]*\r\n)?[ \t]+)");
+        } else if (ch == 'C') {
+          expand(R"(((F?\((F?([!-'*-[^-~]|\\([ -~]|[ )"
+                 "\t"
+                 R"(])))*F?\))+F?|F)?)");
+        } else if (ch == 'T') {
+          expand(R"([[:alnum:]!#$%&'*+/=?^_`{|}~-])");
+        } else if (ch == 'W') {
+          expand(R"(((CT+C)|(C"(F?([!#-[^-~]|\\([ -~]|[ )"
+                 "\t"
+                 R"(])))*F?"C)))");
+        } else if (ch == 'S') {
+          expand(R"((((CT+(\.T+)*C)|W|(W(\.W{1,1}){0,31}))@)"
+                 R"(((CT+(\.T+)*C)|(C\[(([0-9]{1,3}\.){3}[0-9]{1,3}|)"
+                 R"((F?([!-Z^-~]|\\([ -~]|[ )"
+                 "\t"
+                 R"(])))*F?)\]C)|)"
+                 R"(((CT+C)(\.(CT+C){1,1}){1,31}))))");
+        } else {
+          buf[len] = ch;
+          ++len;
+        }
+      }
+    }
+
+    constexpr Rfc2822Pattern() { expand("^(S|W{0,8}<S>)$"); }
+  };
+  static constexpr Rfc2822Pattern RFC2822_PATTERN{};
+  static_assert(RFC2822_PATTERN.len > 7000);
+  string_view rfc2822_ere(RFC2822_PATTERN.buf, RFC2822_PATTERN.len);
+  EXPECT_GT(rfc2822_ere.size(), size_t(7000));
+
+  ExprPool rfc_pool;
+  ErrorOr<ExprId> addr_spec = parse_ere(rfc2822_ere, REG_EXTENDED, rfc_pool);
+  ASSERT_TRUE(addr_spec.has_value());
+  EXPECT_LE(rfc_pool.allocated_bytes(), size_t(32768));
+
+  const string_view good_addrs[] = {"user.name+tag at example.com",
+                                    R"("quoted..user"@[192.168.1.1])",
+                                    "(comment) alice at sub.domain.org (ok)",
+                                    "(\r\n folded comment )alice at example.com",
+                                    "Alice Smith <alice at example.com>"};
+  for (string_view addr : good_addrs)
+    EXPECT_EQ(match(rfc_pool, *addr_spec, addr), MatchResult::Match);
+  const string_view bad_addrs[] = {"user..name at example.com", "@example.com",
+                                   "user@", "user at .example.com",
+                                   "user at example..com"};
+  for (string_view addr : bad_addrs)
+    EXPECT_EQ(match(rfc_pool, *addr_spec, addr), MatchResult::NoMatch);
 }
diff --git a/libc/test/src/regex/regex_basic_test.cpp b/libc/test/src/regex/regex_basic_test.cpp
index d30c957ba316ac..6eeb79f75bcab3 100644
--- a/libc/test/src/regex/regex_basic_test.cpp
+++ b/libc/test/src/regex/regex_basic_test.cpp
@@ -333,9 +333,9 @@ TEST(LlvmLibcRegexTest, UnimplementedSyntaxAndFlags) {
             REG_BADPAT);
   LIBC_NAMESPACE::regfree(&preg);
 
-  // Unimplemented ERE metacharacters and special escapes return REG_BADPAT.
-  const char *const bad_patterns[] = {"a{1}", R"(\1)", R"(\b)", "[[:<:]]",
-                                      "[[:>:]]"};
+  // Unimplemented ERE backreferences and word-boundary extensions return
+  // REG_BADPAT.
+  const char *const bad_patterns[] = {R"(\1)", R"(\b)", "[[:<:]]", "[[:>:]]"};
   for (const char *pat : bad_patterns) {
     EXPECT_EQ(LIBC_NAMESPACE::regcomp(&preg, pat, REG_EXTENDED), REG_BADPAT);
     LIBC_NAMESPACE::regfree(&preg);
@@ -589,6 +589,21 @@ TEST(LlvmLibcRegexTest, PlusAndQuestionQuantifiers) {
   check(R"(^\d+-\w?$)", "123-a", "7-", "-a", "7-ab");
   check(R"(a\+b\?c)", "a+b?c", "xx a+b?c yy", "abc", "aabbc");
 
+  // Framed near-miss boundary count checks ({n}, {n,m}, {n,}, {0}), anchored
+  // nullable bound, adjacent repeats, and literal '{' fallbacks.
+  check("xa{3}y", "xaaay", "xx xaaay yy", "xaay", "xaaaay");
+  check("xa{2,4}y", "xaay", "xaaaay", "xay", "xaaaaay");
+  check("xa{2,}y", "xaay", "xaaaaay", "xy", "xay");
+  check("xa{0}y", "xy", "xx xy yy", "xay", "xaay");
+  check("^a{0,3}$", "", "aaa", "aaaa", "b");
+  check("^a{2,3}b{1,2}$", "aab", "aaabb", "ab", "aaaabb");
+  check("^(a*$){2}$", "", "aaa", "b", "ab");
+  check("^(^|b){2}$", "b", "bb", "bbb", "a");
+  check("^(a{1,2}|a{4,5})$", "aa", "aaaa", "aaa", "aaaaaa");
+  check("^(a{2})*$", "", "aaaa", "a", "aaa");
+  check("((a{1,10}){1,10}){1,10}", "a", "aaaa", "b", "");
+  check("^{abc|a{b|a{,2}$", "{abc", "a{,2}", "abc", "aa");
+
   // Invalid repetition errors (REG_BADRPT).
   const char *const bad_rpt[] = {"^+",  "$+",  "^?",  "$?",   "a++", "a??",
                                  "a*+", "a+?", "a?*", "(+a)", "(?a)"};
@@ -597,6 +612,16 @@ TEST(LlvmLibcRegexTest, PlusAndQuestionQuantifiers) {
     EXPECT_EQ(LIBC_NAMESPACE::regcomp(&preg, pat, REG_EXTENDED), REG_BADRPT);
     LIBC_NAMESPACE::regfree(&preg);
   }
+  auto check_err = [](const char *pat, int expected_err) {
+    regex_t preg{};
+    EXPECT_EQ(LIBC_NAMESPACE::regcomp(&preg, pat, REG_EXTENDED), expected_err);
+    LIBC_NAMESPACE::regfree(&preg);
+  };
+  check_err("a{2}{2}", REG_BADRPT);
+  check_err("a{1,2", REG_EBRACE);
+  check_err("a{5,2}", REG_BADBR);
+  check_err("a{256}", REG_BADBR);
+  check_err("(a{255}){255}", REG_ESPACE);
 }
 
 #if defined(LIBC_ADD_NULL_CHECKS)



More information about the libc-commits mailing list