Skip to content

feat!: url pattern compatibility#178

Open
pi0 wants to merge 12 commits intomainfrom
feat/url-pattern-compat
Open

feat!: url pattern compatibility#178
pi0 wants to merge 12 commits intomainfrom
feat/url-pattern-compat

Conversation

@pi0
Copy link
Member

@pi0 pi0 commented Feb 27, 2026

Summary

Add URLPattern-inspired pathname syntax to route definitions:

  • Named regex constraints:id(\d+), :ext(png|jpg|gif), :version(v\d+)
  • Unnamed regex groups(\d+), (png|jpg|gif)
  • Wildcard * unnamed captures/*.png, /file-*-*.png (including mid-segment wildcards)
  • Modifiers:name? (optional), :name+ (one-or-more), :name* (zero-or-more)
  • Regex + modifier combo:id(\d+)?, :id(\d+)+, :id(\d+)*
  • Coexistence — regex-constrained and unconstrained params on the same path node (e.g. /users/:id(\d+) + /users/:slug)
  • Group delimiters{...} and {...}? (e.g. /book{s}?, /blog/:id(\d+){-:title}?)

⚠️ Breaking change

Unnamed captures now use URLPattern-style numeric keys in matcher results:

  • before: _0, _1, ...
  • now: "0", "1", ...

Applies to both unnamed regex groups and unnamed * captures in findRoute() / findAllRoutes() / compiled matchers.

Type-level inference (InferRouteParams) now also uses numeric keys for unnamed * params.

Note: routeToRegExp() still emits regex-compatible named groups (_0, _1, ...) because JavaScript RegExp group names cannot be numeric.

Implementation details

  • Modifier expansion: :name? expands into two routes (with/without), :name+/:name* expand into wildcard (**) routes. Trailing segments after +/* are preserved.
  • Group delimiter expansion: expandGroupDelimiters() runs before add/remove/regexp conversion.
    • {...} -> inline expansion
    • {...}? -> with/without expansions
    • {...}+ / {...}* -> regex fragment form (?:...)+ / (?:...)* (single-segment only)
  • Wildcard segment conversion: unescaped * at segment top-level is converted to unnamed captures in route insertion and routeToRegExp() conversion.
  • Shared wildcard parser: src/_segment-wildcards.ts is used by add/remove/regexp paths to keep wildcard semantics consistent.
  • Route removal parity: removeRoute() classifies wildcard-segment patterns as dynamic routes, so entries like /assets/*.png remove correctly.
  • Unnamed index tracking: getParamRegexp() accepts a shared unnamed counter so unnamed keys remain unique across mixed unnamed regex + wildcard captures across segments.
  • Unnamed key normalization: interpreter (getMatchParams) and compiler output normalize internal unnamed group keys to public numeric keys.

Current limitations

Feature URLPattern Status
Repeating group delimiters across segments {/foo/bar}+ ❌ (throws: unsupported group repetition across segments)
Modifier semantics on unnamed groups /(foo|bar)?, /(\d+)+ ⚠️ not expanded as segment modifiers
ignoreCase option { ignoreCase: true }
Full URL component matching protocol, hostname, etc. Out of scope (pathname router)
Trailing slash control /books/books/ rou3 allows optional trailing slash

Test plan

  • Named regex constraints match/reject correctly
  • Unnamed regex groups match/reject correctly
  • Wildcard * unnamed captures (/*.png, /file-*-*.png, cross-segment indexing)
  • URLPattern-style unnamed indexing in matcher outputs ("0", "1", ...)
  • Multi-unnamed groups across segments (/path/(\d+)/(\w+) -> "0", "1")
  • Modifier expansion (:name?, :name+, :name*)
  • Regex + modifier combos (:id(\d+)?, :id(\d+)+, :id(\d+)*)
  • Coexistence of constrained + unconstrained params
  • Non-capturing/group-delimiter routes (/book{s}?, /blog/:id(\d+){-:title}?, /foo{/bar}?)
  • routeToRegExp() updated for all new patterns
  • removeRoute() for wildcard-segment patterns (/assets/*.png)
  • Interpreter and compiled router produce identical results

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds group-delimiter expansion, regex-constrained and unnamed parenthesized captures, and URL modifiers (?, +, *) with expansion; updates path-escaping to include parentheses/braces, augments regexp generation, updates router/add/remove flows to expand group delimiters and modifiers, and extends tests and docs.

Changes

Cohort / File(s) Summary
Route registration & parsing
src/operations/add.ts, src/operations/remove.ts
Import and use expandGroupDelimiters to expand {...}-style group delimiters before register/remove; add modifier-expansion (:name?, :name+, :name*) via _expandModifiers; treat segments containing ( as regex parameters and record regex-param mappings; escape additional chars (parentheses and braces) in path escaping.
Group-delimiters helper
src/_group-delimiters.ts
New exported helper `expandGroupDelimiters(path: string): string[]
Regexp generation
src/regexp.ts
Split route->RegExp logic into routeToRegExp and internal _routeToRegExp; add support for modifier-aware segment expansion, inline-pattern handling, named and unnamed captures, and repeat/optional semantics for ?, +, * while preserving existing wildcard behavior.
Tests: regexp, router & bench
test/regexp.test.ts, test/router.test.ts, test/bench/bundle.test.ts
Expanded tests covering regex-constrained params, unnamed capture groups, optional/one-or-more/zero-or-more modifiers, nested/combined patterns, and precedence/coexistence; increased bundle size assertions (unminified and gzip).
Documentation & agents
README.md, AGENTS.md
Add "Route Patterns" section documenting new URLPattern-compatible syntax (named params, wildcards, regex constraints, unnamed groups, modifiers) and document src/_group-delimiters.ts behavior and limitations.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇
I hop through slashes, nibble on parens bright,
A question curls, a plus takes flight,
Braced groups unfold their private door,
Named or shy captures ask for more,
New routes bound out under moonlight ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat!: url pattern compatibility' is clear and directly related to the main objective of adding URLPattern-compatible pathname route syntax (named regex constraints, modifiers, unnamed groups). It accurately summarizes the primary change.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/url-pattern-compat

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pi0 pi0 mentioned this pull request Feb 27, 2026
URLPattern-compatible unnamed regex groups are auto-named `_0`, `_1`, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
test/router.test.ts (1)

480-536: Add regression tests for multi-unnamed groups and mid-path +/* modifiers.

Please add cases like /path/(\\d+)/(\\w+) (expect _0, _1) and /a/:id+/tail, /a/:id*/tail to lock in route-wide unnamed-group indexing and suffix-preserving modifier behavior.

Also applies to: 538-620

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/router.test.ts` around lines 480 - 536, Add regression tests to cover
multi-unnamed regex groups and mid-path modifiers: add testRouter cases for a
route like "/path/(\\d+)/(\\w+)" asserting params { _0, _1 } capture both
groups, and add cases for modifier-preserving suffixes such as "/a/:id+/tail"
and "/a/:id*/tail" verifying the route selection and that the "tail" suffix
remains matched; update the existing unnamed regex group tests (the testRouter
invocations) to include these new patterns and corresponding expected
data/params so unnamed-group indexing and suffix-preserving modifier behavior
are asserted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/operations/add.ts`:
- Around line 65-66: getParamRegexp currently resets unnamed capture naming per
segment, causing duplicate capture names like "_0" when multiple segments are
combined; update the call site where paramsRegexp[i] = regexp (and the similar
block at lines 131-138) to ensure unnamed captures are globally unique by
passing a segment-unique prefix or global counter into getParamRegexp (e.g.,
include the segment index or a route-level counter) and have getParamRegexp
incorporate that prefix into generated unnamed names; modify getParamRegexp
signature to accept that prefix and adjust callers (the place assigning
paramsRegexp[i] and the other block) so that assembled param names cannot
collide across segments.
- Around line 124-126: The expansion currently throws away suf and any inline
constraint because it only uses the param name (const name =
m[1].match(/:(\w+)/)?.[1]) and builds wc from pre.concat(`**:${name}`); fix by
extracting the full parameter token (including its constraint/regex) from m[1]
rather than just the name, and include suf when composing wc and the fallback
path; i.e., use the original matched segment (the full ":param(...)" or
":param+" token) in the wc construction and append the suf segments (if any) to
the returned paths so patterns like `/a/:id+/tail` and `:id(\d+)+/*` preserve
trailing segments and inline constraints.

In `@src/regexp.ts`:
- Around line 12-13: The current branch that routes segments into the
regex-parameter path triggers on any "(" or ":" even when the "(" is escaped
(e.g., "\("), causing literal parentheses to be treated as captures; update the
condition and pattern matching to only consider unescaped "(" characters —
replace segment.includes("(") with a check for an unescaped parenthesis (use a
negative-lookbehind or equivalent check for a preceding backslash) and update
the modMatch logic (the pattern matching anchored in the code that currently
uses /^(.*:\w+(?:\([^)]*\))?)([?+*])$/) to require unescaped "(" when detecting
parameter captures; apply the same change at the other occurrence referenced
(around line 37) so escaped "\(" remain literal and are not rewritten as named
captures.
- Around line 27-29: The current branch in src/regexp.ts that pushes into
reSegments replaces any inline constraint with .+ / .*, which ignores user regex
like :id(\d+)+; instead, preserve the original inner pattern when mod is '+' or
'*' by applying the quantifier to the inner pattern rather than swapping it for
`.+`/`.*`: construct the named capture using the original constraint (e.g., the
extracted pattern variable) and apply the modifier outside it (for example wrap
as a non-capturing group if needed) so you emit a capture like
`(?<name>(?:<originalPattern>)+)` or `(?<name>(?:<originalPattern>)* )` rather
than `(?<name>.+)`/`(?<name>.*)`; update the push to reSegments to reference the
pattern variable instead of hardcoded .+/.* while keeping existing name and mod
variables.

---

Nitpick comments:
In `@test/router.test.ts`:
- Around line 480-536: Add regression tests to cover multi-unnamed regex groups
and mid-path modifiers: add testRouter cases for a route like
"/path/(\\d+)/(\\w+)" asserting params { _0, _1 } capture both groups, and add
cases for modifier-preserving suffixes such as "/a/:id+/tail" and "/a/:id*/tail"
verifying the route selection and that the "tail" suffix remains matched; update
the existing unnamed regex group tests (the testRouter invocations) to include
these new patterns and corresponding expected data/params so unnamed-group
indexing and suffix-preserving modifier behavior are asserted.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2ec1bd8 and c14d31a.

📒 Files selected for processing (5)
  • src/operations/add.ts
  • src/regexp.ts
  • test/bench/bundle.test.ts
  • test/regexp.test.ts
  • test/router.test.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (4)
src/operations/add.ts (2)

127-129: ⚠️ Potential issue | 🔴 Critical

+/* expansion drops trailing segments and widens constrained params.

Lines [128]-[129] only compose from pre, so /a/:id+/tail loses /tail. This branch also strips :id(\d+)+ down to **:id, removing its constraint.

💡 Proposed fix
-    const name = m[1].match(/:(\w+)/)?.[1] || "_";
-    const wc = "/" + pre.concat(`**:${name}`).join("/");
-    return m[2] === "+" ? [wc] : [wc, "/" + pre.join("/")];
+    const [, base, mod] = m;
+    const name = base.match(/:(\w+)/)?.[1] || "_";
+    const hasInlinePattern = /\([^)]*\)/.test(base);
+    if (hasInlinePattern && (mod === "+" || mod === "*")) {
+      throw new Error(`Unsupported modifier with inline pattern: "${segments[i]}"`);
+    }
+    const withWildcard = "/" + [...pre, `**:${name}`, ...suf].join("/");
+    const withoutWildcard = "/" + [...pre, ...suf].join("/");
+    return mod === "+" ? [withWildcard] : [withWildcard, withoutWildcard];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/add.ts` around lines 127 - 129, The '+' expansion branch
currently builds wc from only pre and a simplified `**:${name}`, which drops any
trailing segments and strips parameter constraints; update the m[2] === "+"
branch so wc is composed from pre + the wildcard-replaced current segment + any
original following segments (preserve the tail after the matched param) and
preserve the original param constraint/text (use the full matched token from
m[1] rather than only the captured name) when forming the wildcard segment;
adjust the code that sets `name`, `wc`, and the return for the `m[2] === "+"`
case accordingly so `/a/:id+(\\d+)/tail` becomes `/a/**:id(\\d+)/tail` (and
similar) instead of losing `/tail` or the constraint.

68-71: ⚠️ Potential issue | 🟠 Major

Unnamed regex capture names can collide across segments.

Line [134] resets unnamed capture indexing per segment. Routes with multiple unnamed regex segments can reuse _0 and overwrite params.

💡 Proposed fix
-      } else if (segment.includes(":", 1) || segment.includes("(")) {
-        const regexp = getParamRegexp(segment);
+      } else if (segment.includes(":", 1) || segment.includes("(")) {
+        const [regexp, nextUnnamedIndex] = getParamRegexp(
+          segment,
+          _unnamedParamIndex,
+        );
+        _unnamedParamIndex = nextUnnamedIndex;
         paramsRegexp[i] = regexp;
         node.hasRegexParam = true;
         paramsMap.push([i, regexp, false]);
       } else {
         paramsMap.push([i, segment.slice(1), false]);
       }
@@
-function getParamRegexp(segment: string): RegExp {
-  let _i = 0;
+function getParamRegexp(
+  segment: string,
+  unnamedStart = 0,
+): [RegExp, number] {
+  let _i = unnamedStart;
@@
-  return new RegExp(`^${regex}$`);
+  return [new RegExp(`^${regex}$`), _i];
}

Also applies to: 133-143

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/add.ts` around lines 68 - 71, The unnamed regex capture names
are being reset per segment causing collisions when a route has multiple unnamed
regex segments; update the logic around getParamRegexp/paramsRegexp/paramsMap so
unnamed capture names are globally unique across the entire route: introduce and
pass a shared unnamedIndex (or use the segment index as part of the name) into
getParamRegexp instead of resetting it per segment, increment the shared counter
as each unnamed capture is created, and store that unique capture name in
paramsMap (the entry pushed to paramsMap for each param should reference the
generated unique name); ensure node.hasRegexParam remains set but do not reset
the unnamed counter per segment.
src/regexp.ts (2)

27-29: ⚠️ Potential issue | 🟠 Major

+ / * modifiers discard inline regex constraints.

Line [28] emits .+ / .*, so constrained routes like :id(\d+)+ and :id(\d+)* accept non-matching values.

💡 Proposed fix
-        // + or *
-        reSegments.push(mod === "+" ? `?(?<${name}>.+)` : `?(?<${name}>.*)`);
+        // + or * (preserve inline constraint when present)
+        const pattern = base.match(/:(\w+)(?:\(([^)]*)\))?/)?.[2] || "[^/]+";
+        const repeated = `${pattern}(?:/${pattern})*`;
+        reSegments.push(
+          mod === "+"
+            ? `?(?<${name}>${repeated})`
+            : `?(?<${name}>${repeated})?`,
+        );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/regexp.ts` around lines 27 - 29, The current branch that handles +/*
modifiers always emits .+/.*, discarding any inline constraint; update the
reSegments push to reuse the parsed constraint (e.g., a variable like
pattern/constraint) when present by wrapping it in a non-capturing group and
applying the quantifier: for "+" emit ?(?<name>(?:<constraint>)+) and for "*"
emit ?(?<name>(?:<constraint>)*); if no constraint exists, fall back to the
existing .+/.* behavior. Locate the code building reSegments (the block that
checks mod === "+" / mod === "*" and references name) and replace the string
templates so they inject the constraint variable instead of hard-coded .+/.*
while preserving the existing optional prefix and named capture.

12-13: ⚠️ Potential issue | 🟠 Major

Escaped literal parentheses are still parsed as capture groups.

Line [12] routes any ( to regex-parameter logic, and Line [37] rewrites escaped \( too. That breaks literal-parenthesis routes.

💡 Proposed fix
-    } else if (segment.includes(":") || segment.includes("(")) {
+    } else if (segment.includes(":") || /(^|[^\\])\(/.test(segment)) {
@@
-          .replace(/\((?![?<])/g, () => `(?<_${idCtr++}>`)
+          .replace(/(^|[^\\])\((?![?<])/g, (_, p) => `${p}(?<_${idCtr++}>`)

Also applies to: 37-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/regexp.ts` around lines 12 - 13, The branch `if (segment.includes(":") ||
segment.includes("("))` incorrectly treats escaped literals like "\(" or "\:" as
regex parameters; change the guard to detect unescaped characters only (e.g.
test for an unescaped "(" and ":" using something like /(^|[^\\])\(/ and
/(^|[^\\]):/), and ensure the subsequent `modMatch` logic (the `const modMatch =
segment.match(/^(.*:\w+(?:\([^)]*\))?)([?+*])$/);` handling) only runs for
segments with unescaped colons/parentheses so escaped `\(` and `\:` remain
literal and are not parsed as capture groups.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/operations/add.ts`:
- Around line 127-129: The '+' expansion branch currently builds wc from only
pre and a simplified `**:${name}`, which drops any trailing segments and strips
parameter constraints; update the m[2] === "+" branch so wc is composed from pre
+ the wildcard-replaced current segment + any original following segments
(preserve the tail after the matched param) and preserve the original param
constraint/text (use the full matched token from m[1] rather than only the
captured name) when forming the wildcard segment; adjust the code that sets
`name`, `wc`, and the return for the `m[2] === "+"` case accordingly so
`/a/:id+(\\d+)/tail` becomes `/a/**:id(\\d+)/tail` (and similar) instead of
losing `/tail` or the constraint.
- Around line 68-71: The unnamed regex capture names are being reset per segment
causing collisions when a route has multiple unnamed regex segments; update the
logic around getParamRegexp/paramsRegexp/paramsMap so unnamed capture names are
globally unique across the entire route: introduce and pass a shared
unnamedIndex (or use the segment index as part of the name) into getParamRegexp
instead of resetting it per segment, increment the shared counter as each
unnamed capture is created, and store that unique capture name in paramsMap (the
entry pushed to paramsMap for each param should reference the generated unique
name); ensure node.hasRegexParam remains set but do not reset the unnamed
counter per segment.

In `@src/regexp.ts`:
- Around line 27-29: The current branch that handles +/* modifiers always emits
.+/.*, discarding any inline constraint; update the reSegments push to reuse the
parsed constraint (e.g., a variable like pattern/constraint) when present by
wrapping it in a non-capturing group and applying the quantifier: for "+" emit
?(?<name>(?:<constraint>)+) and for "*" emit ?(?<name>(?:<constraint>)*); if no
constraint exists, fall back to the existing .+/.* behavior. Locate the code
building reSegments (the block that checks mod === "+" / mod === "*" and
references name) and replace the string templates so they inject the constraint
variable instead of hard-coded .+/.* while preserving the existing optional
prefix and named capture.
- Around line 12-13: The branch `if (segment.includes(":") ||
segment.includes("("))` incorrectly treats escaped literals like "\(" or "\:" as
regex parameters; change the guard to detect unescaped characters only (e.g.
test for an unescaped "(" and ":" using something like /(^|[^\\])\(/ and
/(^|[^\\]):/), and ensure the subsequent `modMatch` logic (the `const modMatch =
segment.match(/^(.*:\w+(?:\([^)]*\))?)([?+*])$/);` handling) only runs for
segments with unescaped colons/parentheses so escaped `\(` and `\:` remain
literal and are not parsed as capture groups.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c14d31a and cb06fbc.

📒 Files selected for processing (4)
  • src/operations/add.ts
  • src/regexp.ts
  • test/regexp.test.ts
  • test/router.test.ts

Document all supported route patterns including static, named params,
wildcards, regex constraints, unnamed groups, and modifiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Line 103: Replace each unlabeled fenced code block in README.md (the example
blocks showing routes and arrow outputs) with a labeled block by changing the
opening ``` to ```text so Markdown lint MD040 is satisfied; specifically update
the unlabeled fences wrapping the route examples (the blocks containing lines
like "/path/to/resource", "/users/:name        →  /users/foo        → { name:
\"foo\" }", "/path/**            →  /path/foo/bar     → {}", the regex and
optional/greedy param examples, and all similar example fences) to use ```text.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cb06fbc and 16cfb44.

📒 Files selected for processing (1)
  • README.md

@codecov
Copy link

codecov bot commented Feb 27, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/operations/add.ts (1)

46-57: ⚠️ Potential issue | 🔴 Critical

+/* expansion is still incorrect for constrained or non-terminal routes.

Line 129 rewrites +/* into **:${name}, which strips inline constraints (e.g. :id(\d+)+). Also, wildcard matching is terminal (Line 56 break), so suffixes added in Line 129 are never enforced (/a/:id+/tail effectively degrades to /a/**:id).

💡 Safe short-term guard (prevents silent misrouting until full support exists)
 function _expandModifiers(segments: string[]): string[] | undefined {
   for (let i = 0; i < segments.length; i++) {
     const m = segments[i].match(/^(.*:\w+(?:\([^)]*\))?)([?+*])$/);
     if (!m) continue;
     const pre = segments.slice(0, i);
     const suf = segments.slice(i + 1);
     if (m[2] === "?") {
       return [
         "/" + pre.concat(m[1]).concat(suf).join("/"),
         "/" + pre.concat(suf).join("/"),
       ];
     }
+    const hasInlineConstraint = /\([^)]*\)/.test(m[1]);
+    const hasTrailingSegments = suf.length > 0;
+    if (hasInlineConstraint || hasTrailingSegments) {
+      throw new Error(`Unsupported modifier pattern: "${segments[i]}"`);
+    }
     const name = m[1].match(/:(\w+)/)?.[1] || "_";
-    const wc = "/" + [...pre, `**:${name}`, ...suf].join("/");
-    const without = "/" + [...pre, ...suf].join("/");
+    const wc = "/" + [...pre, `**:${name}`].join("/");
+    const without = "/" + pre.join("/");
     return m[2] === "+" ? [wc] : [wc, without];
   }
 }

Also applies to: 128-131

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/add.ts` around lines 46 - 57, The current handling of "**"
rewrites "+/*" into a terminal wildcard that strips inline constraints and stops
processing suffixes; change the logic in src/operations/add.ts where
segment.startsWith("**") is handled so that you (1) preserve the entire inline
parameter token (everything after ":" including constraint regex and quantifier)
when pushing into paramsMap instead of truncating to just the name, and (2) do
not unconditionally break out of the segment-processing loop so that
non-terminal wildcard expansions can still allow subsequent segments to be
enforced. Update the block that sets node.wildcard and the paramsMap push
(referencing node.wildcard, paramsMap, and the segment.startsWith("**") branch)
to keep the original parameter string and to continue processing remaining
segments when the wildcard is non-terminal.
🧹 Nitpick comments (1)
test/router.test.ts (1)

520-582: Add tests for constrained/non-terminal +/* to lock behavior.

Current modifier tests cover terminal unconstrained :name+ and :name*, but not cases like /:id(\d+)+ or /a/:id+/tail. Those are high-risk paths for expansion logic regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/router.test.ts` around lines 520 - 582, Add tests to cover constrained
and non-terminal +/* modifiers to prevent regressions: inside the "url pattern
modifiers" describe block add testRouter cases using patterns like
"/:id(\\d+)+", "/:id(\\d+)*", "/a/:id+/tail" and "/a/:id*/tail" (i.e., pass
those pattern arrays into testRouter) and assert expected matches and
non-matches — for constrained "+/*" ensure numeric regex-only matches (e.g.,
"/123/456" matches "/:id(\\d+)+", "/abc" does not), and for non-terminal
variants ensure trailing segments are required/optional appropriately (e.g.,
"/a/1/2/tail" matches "/a/:id+/tail", "/a/tail" does not for + but does for *);
put these alongside the existing tests so testRouter and the existing pattern
strings (e.g., "/files/:path+", "/files/:path*") are referenced for consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/operations/add.ts`:
- Around line 46-57: The current handling of "**" rewrites "+/*" into a terminal
wildcard that strips inline constraints and stops processing suffixes; change
the logic in src/operations/add.ts where segment.startsWith("**") is handled so
that you (1) preserve the entire inline parameter token (everything after ":"
including constraint regex and quantifier) when pushing into paramsMap instead
of truncating to just the name, and (2) do not unconditionally break out of the
segment-processing loop so that non-terminal wildcard expansions can still allow
subsequent segments to be enforced. Update the block that sets node.wildcard and
the paramsMap push (referencing node.wildcard, paramsMap, and the
segment.startsWith("**") branch) to keep the original parameter string and to
continue processing remaining segments when the wildcard is non-terminal.

---

Nitpick comments:
In `@test/router.test.ts`:
- Around line 520-582: Add tests to cover constrained and non-terminal +/*
modifiers to prevent regressions: inside the "url pattern modifiers" describe
block add testRouter cases using patterns like "/:id(\\d+)+", "/:id(\\d+)*",
"/a/:id+/tail" and "/a/:id*/tail" (i.e., pass those pattern arrays into
testRouter) and assert expected matches and non-matches — for constrained "+/*"
ensure numeric regex-only matches (e.g., "/123/456" matches "/:id(\\d+)+",
"/abc" does not), and for non-terminal variants ensure trailing segments are
required/optional appropriately (e.g., "/a/1/2/tail" matches "/a/:id+/tail",
"/a/tail" does not for + but does for *); put these alongside the existing tests
so testRouter and the existing pattern strings (e.g., "/files/:path+",
"/files/:path*") are referenced for consistency.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 16cfb44 and bf78005.

📒 Files selected for processing (5)
  • README.md
  • src/operations/add.ts
  • src/regexp.ts
  • test/regexp.test.ts
  • test/router.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • README.md

expand {…} routes across add/remove/regexp with tests and docs
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/operations/add.ts (1)

139-143: ⚠️ Potential issue | 🟠 Major

Constrained +/* routes lose their inline regex during expansion.

_expandModifiers reduces :id(\d+)+ / :id(\d+)* to **:id, so constraints are dropped and non-matching paths are accepted.

💡 Proposed fix (safe behavior until constrained wildcard validation exists)
-    const name = m[1].match(/:(\w+)/)?.[1] || "_";
+    const base = m[1];
+    const name = base.match(/:(\w+)/)?.[1] || "_";
+    const hasInlinePattern = /:\w+\([^)]*\)/.test(base);
+    if (hasInlinePattern) {
+      throw new Error(
+        `unsupported constrained repeat modifier in radix matcher: "${segments[i]}"`,
+      );
+    }
     const wc = "/" + [...pre, `**:${name}`, ...suf].join("/");
     const without = "/" + [...pre, ...suf].join("/");
     return m[2] === "+" ? [wc] : [wc, without];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/add.ts` around lines 139 - 143, _expandModifiers currently
extracts only the parameter name with m[1].match(/:(\w+)/) which drops any
inline regex, so when building wc it emits `**:name` and loses constraints;
update the extraction to capture the optional inline constraint (e.g. use
something like m[1].match(/:(\w+)(\([^)]+\))?/) to get both name and regex) and
build the wildcard segment as `**:${name}${regex || ''}` (i.e. preserve the
captured `(regex)` when constructing wc in the _expandModifiers logic) so
constrained modifiers keep their inline regex.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/operations/remove.ts`:
- Around line 13-23: removeRoute currently only expands group delimiters (via
expandGroupDelimiters(path)) and can miss routes that were stored after modifier
expansion (e.g., :id?, :path+, :path*); update removeRoute to mirror addRoute by
also expanding modifier delimiters before proceeding: call the same
modifier-expansion helper used by addRoute (the function that returns expanded
modifier paths), iterate over each expanded modifier path and call
removeRoute(ctx, method, expandedModifierPath) just like you do for group
expansions, then fall back to splitPath and _remove(ctx.root, method || "",
segments, 0) when no expansions apply.

---

Duplicate comments:
In `@src/operations/add.ts`:
- Around line 139-143: _expandModifiers currently extracts only the parameter
name with m[1].match(/:(\w+)/) which drops any inline regex, so when building wc
it emits `**:name` and loses constraints; update the extraction to capture the
optional inline constraint (e.g. use something like
m[1].match(/:(\w+)(\([^)]+\))?/) to get both name and regex) and build the
wildcard segment as `**:${name}${regex || ''}` (i.e. preserve the captured
`(regex)` when constructing wc in the _expandModifiers logic) so constrained
modifiers keep their inline regex.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bf78005 and 411d907.

📒 Files selected for processing (9)
  • AGENTS.md
  • README.md
  • src/_group-delimiters.ts
  • src/operations/add.ts
  • src/operations/remove.ts
  • src/regexp.ts
  • test/bench/bundle.test.ts
  • test/regexp.test.ts
  • test/router.test.ts
✅ Files skipped from review due to trivial changes (1)
  • README.md

Comment on lines +13 to 23
const groupExpanded = expandGroupDelimiters(path);
if (groupExpanded) {
for (const expandedPath of groupExpanded) {
removeRoute(ctx, method, expandedPath);
}
return;
}

const segments = splitPath(path);
return _remove(ctx.root, method || "", segments, 0);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make removeRoute mirror modifier expansion used by addRoute.

addRoute expands modifier routes (:id?, :path+, :path*) before insertion, but this function only expands {...} groups. Removing by original modifier path can silently fail.

💡 Proposed fix
 export function removeRoute<T>(
   ctx: RouterContext<T>,
   method: string,
   path: string,
 ): void {
   const groupExpanded = expandGroupDelimiters(path);
   if (groupExpanded) {
     for (const expandedPath of groupExpanded) {
       removeRoute(ctx, method, expandedPath);
     }
     return;
   }

-  const segments = splitPath(path);
+  const segments = splitPath(path);
+  const modifierExpanded = _expandModifiers(segments);
+  if (modifierExpanded) {
+    for (const expandedPath of modifierExpanded) {
+      removeRoute(ctx, method, expandedPath);
+    }
+    return;
+  }
   return _remove(ctx.root, method || "", segments, 0);
 }
+
+function _expandModifiers(segments: string[]): string[] | undefined {
+  for (let i = 0; i < segments.length; i++) {
+    const m = segments[i].match(/^(.*:\w+(?:\([^)]*\))?)([?+*])$/);
+    if (!m) continue;
+    const pre = segments.slice(0, i);
+    const suf = segments.slice(i + 1);
+    if (m[2] === "?") {
+      return [
+        "/" + pre.concat(m[1]).concat(suf).join("/"),
+        "/" + pre.concat(suf).join("/"),
+      ];
+    }
+    const name = m[1].match(/:(\w+)/)?.[1] || "_";
+    const wc = "/" + [...pre, `**:${name}`, ...suf].join("/");
+    const without = "/" + [...pre, ...suf].join("/");
+    return m[2] === "+" ? [wc] : [wc, without];
+  }
+}
🧰 Tools
🪛 Biome (2.4.4)

[error] 22-22: The function should not return a value because its return type is void.

(lint/correctness/noVoidTypeReturn)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/remove.ts` around lines 13 - 23, removeRoute currently only
expands group delimiters (via expandGroupDelimiters(path)) and can miss routes
that were stored after modifier expansion (e.g., :id?, :path+, :path*); update
removeRoute to mirror addRoute by also expanding modifier delimiters before
proceeding: call the same modifier-expansion helper used by addRoute (the
function that returns expanded modifier paths), iterate over each expanded
modifier path and call removeRoute(ctx, method, expandedModifierPath) just like
you do for group expansions, then fall back to splitPath and _remove(ctx.root,
method || "", segments, 0) when no expansions apply.

Comment on lines +4 to +10
const groupExpanded = expandGroupDelimiters(route);
if (groupExpanded) {
const sources = groupExpanded.map((expandedRoute) =>
routeToRegExp(expandedRoute).source.slice(1, -1),
);
return new RegExp(`^(?:${sources.join("|")})$`);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

JavaScript RegExp duplicate named capture groups alternation (?<name>pattern)|(?<name>pattern)

💡 Result:

In JavaScript, duplicate named capture groups are now allowed only when they are in different alternation branches and can’t both participate in the same match—e.g. (?<name>a)|(?<name>b) is valid in engines that implemented the newer behavior. If the duplicates could be matched “at the same time” (i.e., ambiguous), it’s still a SyntaxError. [1]

Example (valid where supported)

const re = /(?<year>\d{4})-\d{2}|\d{2}-(?<year>\d{4})/;
re.exec("02-1999").groups.year; // "1999"

This is the canonical “same concept, two formats” use case. [1]

Support (important)

This works only in newer runtimes (roughly Chrome/Edge 125+, Firefox 129+, Safari 17+). Older browsers throw “Duplicate capture group name”. [2]

If you need it to work everywhere

  • Use distinct names and coalesce:
    const re = /(?<year1>\d{4})-\d{2}|\d{2}-(?<year2>\d{4})/;
    const m = re.exec("02-1999");
    const year = m.groups.year1 ?? m.groups.year2;
  • Or transpile regex literals with Babel’s transform for duplicate named groups. [3]

Background: this started as a TC39 proposal to permit reusing names (primarily for alternation-style patterns). [4]

Sources: MDN error doc (and rule/exception) [1], Can I use support table [2], Babel plugin docs [3], TC39 proposal repo [4].


Address runtime compatibility for duplicate named capture groups in alternations.

Line 9 joins expanded regex sources with | (alternation), which can create patterns like (?<id>a)|(?<id>b) with duplicate named capture groups. While this is valid in modern JavaScript engines (Chrome 125+, Firefox 129+, Safari 17+), it will throw SyntaxError: Invalid regular expression: Duplicate capture group name in older runtimes.

If older browser support is required, either use distinct group names and coalesce them from groups, or transpile with Babel's regex duplicate-name-groups plugin. Confirm the target runtime and add appropriate handling.

🧰 Tools
🪛 ast-grep (0.41.0)

[warning] 8-8: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(^(?:${sources.join("|")})$)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

@pi0 pi0 changed the title feat: url pattern compatibility feat!: url pattern compatibility Feb 27, 2026
pi0 and others added 5 commits February 28, 2026 00:05
handle mid-segment * patterns in matching, removal, regexp, docs, and tests
align matcher output with urlpattern-style unnamed group keys
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant