├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── spec ├── recursion-spec.js └── support │ └── jasmine.json └── src └── index.js /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 20 14 | - 18 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - uses: pnpm/action-setup@v4 21 | - run: pnpm install 22 | - run: pnpm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | /types 4 | /temp 5 | 6 | # Folder view configuration/cache files 7 | .DS_Store 8 | desktop.ini 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Steven Levithan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # regex-recursion 🪆 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![bundle][bundle-src]][bundle-href] 6 | 7 | This is an official plugin for [Regex+](https://github.com/slevithan/regex) (it can also be used standalone) that adds support for recursive matching up to a specified max depth *N*, where *N* can be between 2 and 100. Generated regexes are native JavaScript `RegExp` instances. 8 | 9 | > [!NOTE] 10 | > Regex flavors vary on whether they offer infinite or fixed-depth recursion. For example, recursion in Oniguruma uses a default depth limit of 20. 11 | 12 | Recursive matching is added to a regex via the following syntax. The recursion depth limit is provided in place of *N*. 13 | 14 | - `(?R=N)` — Recursively match the entire regex at this position. 15 | - `\g` or `\g` — Recursively match the contents of the group referenced by name or number at this position. The `\g<…>` subroutine must be *within* the referenced group. 16 | 17 | Details: 18 | 19 | - Multiple uses of recursion within the same pattern are supported if they're non-overlapping. 20 | - Named captures and backreferences are supported within recursion and are independent per depth level. A match result's `groups.name` property holds the value captured by group `name` at the top level of the recursion stack. Subpatterns `groups.name_$2`, etc. are available for each level of nested subpattern matches. 21 | 22 | ## 📜 Contents 23 | 24 | - [Install and use](#️-install-and-use) 25 | - [Examples](#-examples) 26 | - [Standalone use](#️-standalone-use) 27 | 28 | ## 🕹️ Install and use 29 | 30 | ```sh 31 | npm install regex regex-recursion 32 | ``` 33 | 34 | ```js 35 | import {regex} from 'regex'; 36 | import {recursion} from 'regex-recursion'; 37 | 38 | const re = regex({plugins: [recursion]})`…`; 39 | ``` 40 | 41 |
42 | Using CommonJS require 43 | 44 | ```js 45 | const {regex} = require('regex'); 46 | const {recursion} = require('regex-recursion-cjs'); 47 | 48 | const re = regex({plugins: [recursion]})`…`; 49 | ``` 50 | 51 | > **Note:** [*regex-recursion-cjs*](https://www.npmjs.com/package/regex-recursion-cjs) is a third-party CommonJS wrapper for this library. It might not always be up to date with the latest version. 52 |
53 | 54 |
55 | Using a global name in browsers 56 | 57 | ```html 58 | 59 | 60 | 66 | ``` 67 |
68 | 69 | ## 🪧 Examples 70 | 71 | ### Match an equal number of two different subpatterns 72 | 73 | #### Anywhere within a string 74 | 75 | ```js 76 | // Matches sequences of up to 20 'a' chars followed by the same number of 'b' 77 | const re = regex({plugins: [recursion]})`a(?R=20)?b`; 78 | re.exec('test aaaaaabbb')[0]; 79 | // → 'aaabbb' 80 | ``` 81 | 82 | #### As the entire string 83 | 84 | Use `\g` to recursively match just the specified group. 85 | 86 | ```js 87 | const re = regex({plugins: [recursion]})` 88 | ^ (? a \g? b) $ 89 | `; 90 | re.test('aaabbb'); // → true 91 | re.test('aaabb'); // → false 92 | ``` 93 | 94 | ### Match balanced parentheses 95 | 96 | ```js 97 | // Matches all balanced parentheses up to depth 20 98 | const parens = regex({flags: 'g', plugins: [recursion]})` 99 | \( ([^\(\)] | (?R=20))* \) 100 | `; 101 | 102 | 'test ) (balanced ((parens))) () ((a)) ( (b)'.match(parens); 103 | /* → [ 104 | '(balanced ((parens)))', 105 | '()', 106 | '((a))', 107 | '(b)' 108 | ] */ 109 | ``` 110 | 111 | Following is an alternative that matches the same strings, but adds a nested quantifier. It then uses an atomic group to prevent the nested quantifier from creating the potential for [catastrophic backtracking](https://www.regular-expressions.info/catastrophic.html). Since the example above doesn't *need* a nested quantifier, this isn't an improvement but merely an alternative that shows how to deal with the general problem of nested quantifiers that create multiple ways to divide matches of the same strings. 112 | 113 | ```js 114 | // With an atomic group 115 | const parens = regex({flags: 'g', plugins: [recursion]})` 116 | \( ((?> [^\(\)]+) | (?R=20))* \) 117 | `; 118 | 119 | // Same thing, but with a possessive quantifier 120 | const parens = regex({flags: 'g', plugins: [recursion]})` 121 | \( ([^\(\)]++ | (?R=20))* \) 122 | `; 123 | ``` 124 | 125 | The first example above matches sequences of non-parentheses in one step with the nested `+` quantifier, and avoids backtracking into these sequences by wrapping it with an atomic group `(?>…)`. Given that what the nested quantifier `+` matches overlaps with what the outer group can match with its `*` quantifier, the atomic group is important here. It avoids exponential backtracking when matching long strings with unbalanced parentheses. 126 | 127 | In cases where you're repeating a single token within an atomic group, possessive quantifiers (in this case, `++`) provide syntax sugar for the same behavior. 128 | 129 | Atomic groups and possessive quantifiers are [provided](https://github.com/slevithan/regex#atomic-groups) by the base Regex+ library. 130 | 131 | ### Match palindromes 132 | 133 | #### Match palindromes anywhere within a string 134 | 135 | ```js 136 | const palindromes = regex({flags: 'gi', plugins: [recursion]})` 137 | (? \w) 138 | # Recurse, or match a lone unbalanced char in the middle 139 | ((?R=15) | \w?) 140 | \k 141 | `; 142 | 143 | 'Racecar, ABBA, and redivided'.match(palindromes); 144 | // → ['Racecar', 'ABBA', 'edivide'] 145 | ``` 146 | 147 | Palindromes are sequences that read the same backwards as forwards. In the example above, the max length of matched palindromes is 31. That's because it sets the max recursion depth to 15 with `(?R=15)`. So, depth 15 × 2 chars (left + right) for each depth level + 1 optional unbalanced char in the middle = 31. To match longer palindromes, the max recursion depth can be increased to a max of 100, which would enable matching palindromes up to 201 characters long. 148 | 149 | #### Match palindromes as complete words 150 | 151 | ```js 152 | const palindromeWords = regex({flags: 'gi', plugins: [recursion]})` 153 | \b 154 | (? 155 | (? \w) 156 | (\g | \w?) 157 | \k 158 | ) 159 | \b 160 | `; 161 | 162 | 'Racecar, ABBA, and redivided'.match(palindromeWords); 163 | // → ['Racecar', 'ABBA'] 164 | ``` 165 | 166 | ## ⛓️‍💥 Standalone use 167 | 168 | Following is an example of using this library standalone, without Regex+. 169 | 170 | ```js 171 | import {recursion} from 'regex-recursion'; 172 | 173 | // Create a pattern that matches balanced parentheses 174 | const pattern = String.raw`\(([^\(\)]|(?R=20))*\)`; 175 | const processed = recursion(pattern); 176 | 177 | // The processed pattern can be used as a standard RegExp 178 | const re = new RegExp(processed.pattern); 179 | re.exec('foo (bar (baz) blah) end')[0]; 180 | // → '(bar (baz) blah)' 181 | ``` 182 | 183 | All ES2025 regex syntax is supported, but because the generated pattern is used without Regex+, you can't include Regex+'s extended syntax like insignificant whitespace, atomic groups, possessive quantifiers, and non-recursive subroutines. 184 | 185 | ## 🏷️ About 186 | 187 | Created by [Steven Levithan](https://github.com/slevithan). 188 | 189 | ### Sponsors and backers 190 | 191 | [](https://github.com/brc-dd) 192 | [](https://github.com/roboflow) 193 | 194 | ### Past sponsors 195 | 196 | [](https://github.com/antfu) 197 | 198 | If you want to support this project, I'd love your help by contributing improvements, sharing it with others, or [sponsoring](https://github.com/sponsors/slevithan) ongoing development. 199 | 200 | © 2024–present. MIT License. 201 | 202 | 203 | 204 | [npm-version-src]: https://img.shields.io/npm/v/regex-recursion?color=78C372 205 | [npm-version-href]: https://npmjs.com/package/regex-recursion 206 | [npm-downloads-src]: https://img.shields.io/npm/dm/regex-recursion?color=78C372 207 | [npm-downloads-href]: https://npmjs.com/package/regex-recursion 208 | [bundle-src]: https://img.shields.io/bundlejs/size/regex-recursion?color=78C372&label=minzip 209 | [bundle-href]: https://bundlejs.com/?q=regex-recursion&treeshake=[*] 210 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "regex-recursion", 3 | "version": "6.0.2", 4 | "description": "Recursive matching plugin for Regex+", 5 | "author": "Steven Levithan", 6 | "license": "MIT", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "types": "./types/index.d.ts", 11 | "import": "./src/index.js" 12 | } 13 | }, 14 | "browser": "./dist/regex-recursion.min.js", 15 | "types": "./types/index.d.ts", 16 | "scripts": { 17 | "bundle:global": "esbuild src/index.js --global-name=Regex.plugins --bundle --minify --sourcemap --outfile=dist/regex-recursion.min.js", 18 | "types": "tsc src/index.js --rootDir src --declaration --allowJs --emitDeclarationOnly --outDir types", 19 | "prebuild": "rm -rf dist/* types/*", 20 | "build": "pnpm run bundle:global && pnpm run types", 21 | "pretest": "pnpm run build", 22 | "test": "jasmine", 23 | "prepare": "pnpm test" 24 | }, 25 | "files": [ 26 | "dist", 27 | "src", 28 | "types" 29 | ], 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/slevithan/regex-recursion.git" 33 | }, 34 | "keywords": [ 35 | "recursion", 36 | "regex", 37 | "regexp" 38 | ], 39 | "dependencies": { 40 | "regex-utilities": "^2.3.0" 41 | }, 42 | "devDependencies": { 43 | "esbuild": "^0.24.2", 44 | "jasmine": "^5.5.0", 45 | "regex": "^6.0.1", 46 | "typescript": "^5.7.3" 47 | }, 48 | "packageManager": "pnpm@9.15.4" 49 | } 50 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | regex-utilities: 12 | specifier: ^2.3.0 13 | version: 2.3.0 14 | devDependencies: 15 | esbuild: 16 | specifier: ^0.24.2 17 | version: 0.24.2 18 | jasmine: 19 | specifier: ^5.5.0 20 | version: 5.5.0 21 | regex: 22 | specifier: ^6.0.1 23 | version: 6.0.1 24 | typescript: 25 | specifier: ^5.7.3 26 | version: 5.7.3 27 | 28 | packages: 29 | 30 | '@esbuild/aix-ppc64@0.24.2': 31 | resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} 32 | engines: {node: '>=18'} 33 | cpu: [ppc64] 34 | os: [aix] 35 | 36 | '@esbuild/android-arm64@0.24.2': 37 | resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} 38 | engines: {node: '>=18'} 39 | cpu: [arm64] 40 | os: [android] 41 | 42 | '@esbuild/android-arm@0.24.2': 43 | resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} 44 | engines: {node: '>=18'} 45 | cpu: [arm] 46 | os: [android] 47 | 48 | '@esbuild/android-x64@0.24.2': 49 | resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} 50 | engines: {node: '>=18'} 51 | cpu: [x64] 52 | os: [android] 53 | 54 | '@esbuild/darwin-arm64@0.24.2': 55 | resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} 56 | engines: {node: '>=18'} 57 | cpu: [arm64] 58 | os: [darwin] 59 | 60 | '@esbuild/darwin-x64@0.24.2': 61 | resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} 62 | engines: {node: '>=18'} 63 | cpu: [x64] 64 | os: [darwin] 65 | 66 | '@esbuild/freebsd-arm64@0.24.2': 67 | resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} 68 | engines: {node: '>=18'} 69 | cpu: [arm64] 70 | os: [freebsd] 71 | 72 | '@esbuild/freebsd-x64@0.24.2': 73 | resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} 74 | engines: {node: '>=18'} 75 | cpu: [x64] 76 | os: [freebsd] 77 | 78 | '@esbuild/linux-arm64@0.24.2': 79 | resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} 80 | engines: {node: '>=18'} 81 | cpu: [arm64] 82 | os: [linux] 83 | 84 | '@esbuild/linux-arm@0.24.2': 85 | resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} 86 | engines: {node: '>=18'} 87 | cpu: [arm] 88 | os: [linux] 89 | 90 | '@esbuild/linux-ia32@0.24.2': 91 | resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} 92 | engines: {node: '>=18'} 93 | cpu: [ia32] 94 | os: [linux] 95 | 96 | '@esbuild/linux-loong64@0.24.2': 97 | resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} 98 | engines: {node: '>=18'} 99 | cpu: [loong64] 100 | os: [linux] 101 | 102 | '@esbuild/linux-mips64el@0.24.2': 103 | resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} 104 | engines: {node: '>=18'} 105 | cpu: [mips64el] 106 | os: [linux] 107 | 108 | '@esbuild/linux-ppc64@0.24.2': 109 | resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} 110 | engines: {node: '>=18'} 111 | cpu: [ppc64] 112 | os: [linux] 113 | 114 | '@esbuild/linux-riscv64@0.24.2': 115 | resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} 116 | engines: {node: '>=18'} 117 | cpu: [riscv64] 118 | os: [linux] 119 | 120 | '@esbuild/linux-s390x@0.24.2': 121 | resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} 122 | engines: {node: '>=18'} 123 | cpu: [s390x] 124 | os: [linux] 125 | 126 | '@esbuild/linux-x64@0.24.2': 127 | resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} 128 | engines: {node: '>=18'} 129 | cpu: [x64] 130 | os: [linux] 131 | 132 | '@esbuild/netbsd-arm64@0.24.2': 133 | resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} 134 | engines: {node: '>=18'} 135 | cpu: [arm64] 136 | os: [netbsd] 137 | 138 | '@esbuild/netbsd-x64@0.24.2': 139 | resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} 140 | engines: {node: '>=18'} 141 | cpu: [x64] 142 | os: [netbsd] 143 | 144 | '@esbuild/openbsd-arm64@0.24.2': 145 | resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} 146 | engines: {node: '>=18'} 147 | cpu: [arm64] 148 | os: [openbsd] 149 | 150 | '@esbuild/openbsd-x64@0.24.2': 151 | resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} 152 | engines: {node: '>=18'} 153 | cpu: [x64] 154 | os: [openbsd] 155 | 156 | '@esbuild/sunos-x64@0.24.2': 157 | resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} 158 | engines: {node: '>=18'} 159 | cpu: [x64] 160 | os: [sunos] 161 | 162 | '@esbuild/win32-arm64@0.24.2': 163 | resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} 164 | engines: {node: '>=18'} 165 | cpu: [arm64] 166 | os: [win32] 167 | 168 | '@esbuild/win32-ia32@0.24.2': 169 | resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} 170 | engines: {node: '>=18'} 171 | cpu: [ia32] 172 | os: [win32] 173 | 174 | '@esbuild/win32-x64@0.24.2': 175 | resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} 176 | engines: {node: '>=18'} 177 | cpu: [x64] 178 | os: [win32] 179 | 180 | '@isaacs/cliui@8.0.2': 181 | resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} 182 | engines: {node: '>=12'} 183 | 184 | '@pkgjs/parseargs@0.11.0': 185 | resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} 186 | engines: {node: '>=14'} 187 | 188 | ansi-regex@5.0.1: 189 | resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 190 | engines: {node: '>=8'} 191 | 192 | ansi-regex@6.1.0: 193 | resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} 194 | engines: {node: '>=12'} 195 | 196 | ansi-styles@4.3.0: 197 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 198 | engines: {node: '>=8'} 199 | 200 | ansi-styles@6.2.1: 201 | resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} 202 | engines: {node: '>=12'} 203 | 204 | balanced-match@1.0.2: 205 | resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 206 | 207 | brace-expansion@2.0.1: 208 | resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} 209 | 210 | color-convert@2.0.1: 211 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} 212 | engines: {node: '>=7.0.0'} 213 | 214 | color-name@1.1.4: 215 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 216 | 217 | cross-spawn@7.0.6: 218 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 219 | engines: {node: '>= 8'} 220 | 221 | eastasianwidth@0.2.0: 222 | resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} 223 | 224 | emoji-regex@8.0.0: 225 | resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 226 | 227 | emoji-regex@9.2.2: 228 | resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} 229 | 230 | esbuild@0.24.2: 231 | resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} 232 | engines: {node: '>=18'} 233 | hasBin: true 234 | 235 | foreground-child@3.3.0: 236 | resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} 237 | engines: {node: '>=14'} 238 | 239 | glob@10.4.5: 240 | resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} 241 | hasBin: true 242 | 243 | is-fullwidth-code-point@3.0.0: 244 | resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 245 | engines: {node: '>=8'} 246 | 247 | isexe@2.0.0: 248 | resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 249 | 250 | jackspeak@3.4.3: 251 | resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} 252 | 253 | jasmine-core@5.5.0: 254 | resolution: {integrity: sha512-NHOvoPO6o9gVR6pwqEACTEpbgcH+JJ6QDypyymGbSUIFIFsMMbBJ/xsFNud8MSClfnWclXd7RQlAZBz7yVo5TQ==} 255 | 256 | jasmine@5.5.0: 257 | resolution: {integrity: sha512-JKlEVCVD5QBPYLsg/VE+IUtjyseDCrW8rMBu8la+9ysYashDgavMLM9Kotls1FhI6dCJLJ40dBCIfQjGLPZI1Q==} 258 | hasBin: true 259 | 260 | lru-cache@10.4.3: 261 | resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 262 | 263 | minimatch@9.0.5: 264 | resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} 265 | engines: {node: '>=16 || 14 >=14.17'} 266 | 267 | minipass@7.1.2: 268 | resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} 269 | engines: {node: '>=16 || 14 >=14.17'} 270 | 271 | package-json-from-dist@1.0.1: 272 | resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} 273 | 274 | path-key@3.1.1: 275 | resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} 276 | engines: {node: '>=8'} 277 | 278 | path-scurry@1.11.1: 279 | resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} 280 | engines: {node: '>=16 || 14 >=14.18'} 281 | 282 | regex-utilities@2.3.0: 283 | resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} 284 | 285 | regex@6.0.1: 286 | resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} 287 | 288 | shebang-command@2.0.0: 289 | resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 290 | engines: {node: '>=8'} 291 | 292 | shebang-regex@3.0.0: 293 | resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 294 | engines: {node: '>=8'} 295 | 296 | signal-exit@4.1.0: 297 | resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} 298 | engines: {node: '>=14'} 299 | 300 | string-width@4.2.3: 301 | resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} 302 | engines: {node: '>=8'} 303 | 304 | string-width@5.1.2: 305 | resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} 306 | engines: {node: '>=12'} 307 | 308 | strip-ansi@6.0.1: 309 | resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 310 | engines: {node: '>=8'} 311 | 312 | strip-ansi@7.1.0: 313 | resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} 314 | engines: {node: '>=12'} 315 | 316 | typescript@5.7.3: 317 | resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} 318 | engines: {node: '>=14.17'} 319 | hasBin: true 320 | 321 | which@2.0.2: 322 | resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 323 | engines: {node: '>= 8'} 324 | hasBin: true 325 | 326 | wrap-ansi@7.0.0: 327 | resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} 328 | engines: {node: '>=10'} 329 | 330 | wrap-ansi@8.1.0: 331 | resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} 332 | engines: {node: '>=12'} 333 | 334 | snapshots: 335 | 336 | '@esbuild/aix-ppc64@0.24.2': 337 | optional: true 338 | 339 | '@esbuild/android-arm64@0.24.2': 340 | optional: true 341 | 342 | '@esbuild/android-arm@0.24.2': 343 | optional: true 344 | 345 | '@esbuild/android-x64@0.24.2': 346 | optional: true 347 | 348 | '@esbuild/darwin-arm64@0.24.2': 349 | optional: true 350 | 351 | '@esbuild/darwin-x64@0.24.2': 352 | optional: true 353 | 354 | '@esbuild/freebsd-arm64@0.24.2': 355 | optional: true 356 | 357 | '@esbuild/freebsd-x64@0.24.2': 358 | optional: true 359 | 360 | '@esbuild/linux-arm64@0.24.2': 361 | optional: true 362 | 363 | '@esbuild/linux-arm@0.24.2': 364 | optional: true 365 | 366 | '@esbuild/linux-ia32@0.24.2': 367 | optional: true 368 | 369 | '@esbuild/linux-loong64@0.24.2': 370 | optional: true 371 | 372 | '@esbuild/linux-mips64el@0.24.2': 373 | optional: true 374 | 375 | '@esbuild/linux-ppc64@0.24.2': 376 | optional: true 377 | 378 | '@esbuild/linux-riscv64@0.24.2': 379 | optional: true 380 | 381 | '@esbuild/linux-s390x@0.24.2': 382 | optional: true 383 | 384 | '@esbuild/linux-x64@0.24.2': 385 | optional: true 386 | 387 | '@esbuild/netbsd-arm64@0.24.2': 388 | optional: true 389 | 390 | '@esbuild/netbsd-x64@0.24.2': 391 | optional: true 392 | 393 | '@esbuild/openbsd-arm64@0.24.2': 394 | optional: true 395 | 396 | '@esbuild/openbsd-x64@0.24.2': 397 | optional: true 398 | 399 | '@esbuild/sunos-x64@0.24.2': 400 | optional: true 401 | 402 | '@esbuild/win32-arm64@0.24.2': 403 | optional: true 404 | 405 | '@esbuild/win32-ia32@0.24.2': 406 | optional: true 407 | 408 | '@esbuild/win32-x64@0.24.2': 409 | optional: true 410 | 411 | '@isaacs/cliui@8.0.2': 412 | dependencies: 413 | string-width: 5.1.2 414 | string-width-cjs: string-width@4.2.3 415 | strip-ansi: 7.1.0 416 | strip-ansi-cjs: strip-ansi@6.0.1 417 | wrap-ansi: 8.1.0 418 | wrap-ansi-cjs: wrap-ansi@7.0.0 419 | 420 | '@pkgjs/parseargs@0.11.0': 421 | optional: true 422 | 423 | ansi-regex@5.0.1: {} 424 | 425 | ansi-regex@6.1.0: {} 426 | 427 | ansi-styles@4.3.0: 428 | dependencies: 429 | color-convert: 2.0.1 430 | 431 | ansi-styles@6.2.1: {} 432 | 433 | balanced-match@1.0.2: {} 434 | 435 | brace-expansion@2.0.1: 436 | dependencies: 437 | balanced-match: 1.0.2 438 | 439 | color-convert@2.0.1: 440 | dependencies: 441 | color-name: 1.1.4 442 | 443 | color-name@1.1.4: {} 444 | 445 | cross-spawn@7.0.6: 446 | dependencies: 447 | path-key: 3.1.1 448 | shebang-command: 2.0.0 449 | which: 2.0.2 450 | 451 | eastasianwidth@0.2.0: {} 452 | 453 | emoji-regex@8.0.0: {} 454 | 455 | emoji-regex@9.2.2: {} 456 | 457 | esbuild@0.24.2: 458 | optionalDependencies: 459 | '@esbuild/aix-ppc64': 0.24.2 460 | '@esbuild/android-arm': 0.24.2 461 | '@esbuild/android-arm64': 0.24.2 462 | '@esbuild/android-x64': 0.24.2 463 | '@esbuild/darwin-arm64': 0.24.2 464 | '@esbuild/darwin-x64': 0.24.2 465 | '@esbuild/freebsd-arm64': 0.24.2 466 | '@esbuild/freebsd-x64': 0.24.2 467 | '@esbuild/linux-arm': 0.24.2 468 | '@esbuild/linux-arm64': 0.24.2 469 | '@esbuild/linux-ia32': 0.24.2 470 | '@esbuild/linux-loong64': 0.24.2 471 | '@esbuild/linux-mips64el': 0.24.2 472 | '@esbuild/linux-ppc64': 0.24.2 473 | '@esbuild/linux-riscv64': 0.24.2 474 | '@esbuild/linux-s390x': 0.24.2 475 | '@esbuild/linux-x64': 0.24.2 476 | '@esbuild/netbsd-arm64': 0.24.2 477 | '@esbuild/netbsd-x64': 0.24.2 478 | '@esbuild/openbsd-arm64': 0.24.2 479 | '@esbuild/openbsd-x64': 0.24.2 480 | '@esbuild/sunos-x64': 0.24.2 481 | '@esbuild/win32-arm64': 0.24.2 482 | '@esbuild/win32-ia32': 0.24.2 483 | '@esbuild/win32-x64': 0.24.2 484 | 485 | foreground-child@3.3.0: 486 | dependencies: 487 | cross-spawn: 7.0.6 488 | signal-exit: 4.1.0 489 | 490 | glob@10.4.5: 491 | dependencies: 492 | foreground-child: 3.3.0 493 | jackspeak: 3.4.3 494 | minimatch: 9.0.5 495 | minipass: 7.1.2 496 | package-json-from-dist: 1.0.1 497 | path-scurry: 1.11.1 498 | 499 | is-fullwidth-code-point@3.0.0: {} 500 | 501 | isexe@2.0.0: {} 502 | 503 | jackspeak@3.4.3: 504 | dependencies: 505 | '@isaacs/cliui': 8.0.2 506 | optionalDependencies: 507 | '@pkgjs/parseargs': 0.11.0 508 | 509 | jasmine-core@5.5.0: {} 510 | 511 | jasmine@5.5.0: 512 | dependencies: 513 | glob: 10.4.5 514 | jasmine-core: 5.5.0 515 | 516 | lru-cache@10.4.3: {} 517 | 518 | minimatch@9.0.5: 519 | dependencies: 520 | brace-expansion: 2.0.1 521 | 522 | minipass@7.1.2: {} 523 | 524 | package-json-from-dist@1.0.1: {} 525 | 526 | path-key@3.1.1: {} 527 | 528 | path-scurry@1.11.1: 529 | dependencies: 530 | lru-cache: 10.4.3 531 | minipass: 7.1.2 532 | 533 | regex-utilities@2.3.0: {} 534 | 535 | regex@6.0.1: 536 | dependencies: 537 | regex-utilities: 2.3.0 538 | 539 | shebang-command@2.0.0: 540 | dependencies: 541 | shebang-regex: 3.0.0 542 | 543 | shebang-regex@3.0.0: {} 544 | 545 | signal-exit@4.1.0: {} 546 | 547 | string-width@4.2.3: 548 | dependencies: 549 | emoji-regex: 8.0.0 550 | is-fullwidth-code-point: 3.0.0 551 | strip-ansi: 6.0.1 552 | 553 | string-width@5.1.2: 554 | dependencies: 555 | eastasianwidth: 0.2.0 556 | emoji-regex: 9.2.2 557 | strip-ansi: 7.1.0 558 | 559 | strip-ansi@6.0.1: 560 | dependencies: 561 | ansi-regex: 5.0.1 562 | 563 | strip-ansi@7.1.0: 564 | dependencies: 565 | ansi-regex: 6.1.0 566 | 567 | typescript@5.7.3: {} 568 | 569 | which@2.0.2: 570 | dependencies: 571 | isexe: 2.0.0 572 | 573 | wrap-ansi@7.0.0: 574 | dependencies: 575 | ansi-styles: 4.3.0 576 | string-width: 4.2.3 577 | strip-ansi: 6.0.1 578 | 579 | wrap-ansi@8.1.0: 580 | dependencies: 581 | ansi-styles: 6.2.1 582 | string-width: 5.1.2 583 | strip-ansi: 7.1.0 584 | -------------------------------------------------------------------------------- /spec/recursion-spec.js: -------------------------------------------------------------------------------- 1 | import {recursion} from '../src/index.js'; 2 | import {regex} from 'regex'; 3 | 4 | const r = String.raw; 5 | 6 | describe('recursion', () => { 7 | it('should allow recursion depths 2-100', () => { 8 | const values = ['2', '100']; 9 | for (const value of values) { 10 | expect(() => regex({plugins: [recursion]})({raw: [`a(?R=${value})?b`]})).not.toThrow(); 11 | expect(() => regex({plugins: [recursion]})({raw: [`(?a\\g?b)`]})).not.toThrow(); 12 | } 13 | }); 14 | 15 | it('should throw for invalid and unsupported recursion depths', () => { 16 | const values = ['-2', '0', '1', '02', '+2', '2.5', '101', 'a', 'null']; 17 | for (const value of values) { 18 | expect(() => regex({plugins: [recursion]})({raw: [`a(?R=${value})?b`]})).toThrow(); 19 | expect(() => regex({plugins: [recursion]})({raw: [`(?a\\g?b)`]})).toThrow(); 20 | } 21 | }); 22 | 23 | // Documenting current behavior 24 | it('should throw for numbered backrefs if the recursed subpattern contains captures', () => { 25 | expect(() => regex({plugins: [recursion]})`a(?R=2)?b${/()\1/}`).toThrow(); 26 | expect(() => regex({plugins: [recursion]})`(?a|\g${/()\1/})`).toThrow(); 27 | expect(() => regex({plugins: [recursion]})`${/()\1/}a(?R=2)?b`).toThrow(); 28 | expect(() => regex({plugins: [recursion]})`(?${/()\1/}a|\g)`).toThrow(); 29 | }); 30 | 31 | it('should allow numbered backrefs if the recursed subpattern contains no captures', () => { 32 | expect(() => regex({plugins: [recursion]})`(?a|\g)${/()\1/}`).not.toThrow(); 33 | expect(() => regex({plugins: [recursion]})`${/()\1/}(?a|\g)`).not.toThrow(); 34 | }); 35 | 36 | it('should throw for subroutine definition groups when using recursion', () => { 37 | expect(() => regex({plugins: [recursion]})`a(?R=2)?b(?(DEFINE))`).toThrow(); 38 | expect(() => regex({plugins: [recursion]})`(?a|\g)(?(DEFINE))`).toThrow(); 39 | }); 40 | 41 | it('should not modify escaped recursion operators', () => { 42 | expect(() => regex({plugins: [recursion]})`a\(?R=2)?b`).toThrow(); 43 | expect('a\\gb').toMatch(regex({plugins: [recursion]})`^(?a\\g?b)$`); 44 | expect('a\\a\\bb').toMatch(regex({plugins: [recursion]})`^(?a\\\g?b)$`); 45 | }); 46 | 47 | it('should not modify recursion-like syntax in character classes', () => { 48 | expect(() => regex({plugins: [recursion]})`a[(?R=2)]b`).toThrow(); 49 | expect(() => regex({plugins: [recursion]})`(?a[\g]b)`).toThrow(); 50 | }); 51 | 52 | describe('global', () => { 53 | it('should match global recursion', () => { 54 | expect(regex({plugins: [recursion]})`a(?R=2)?b`.exec('aabb')?.[0]).toBe('aabb'); 55 | }); 56 | 57 | it('should throw for overlapping global recursions', () => { 58 | expect(() => regex({plugins: [recursion]})`a(?R=2)?b(?R=2)?`).toThrow(); 59 | expect(() => regex({plugins: [recursion]})`(a(?R=2)?)(b(?R=2)?)`).toThrow(); 60 | }); 61 | 62 | it('should have backrefs refer to their own recursion depth', () => { 63 | expect(regex({plugins: [recursion]})`(?\w)0(?R=2)?1\k`.exec('a0b01b1a')?.[0]).toBe('a0b01b1a'); 64 | expect(regex({plugins: [recursion]})`(?\w)0(?R=2)?1\k`.test('a0b01a1b')).toBeFalse(); 65 | }); 66 | }); 67 | 68 | describe('subpattern by name', () => { 69 | it('should match direct recursion', () => { 70 | expect('aabb').toMatch(regex({plugins: [recursion]})`^(?a\g?b)$`); 71 | expect('aab').not.toMatch(regex({plugins: [recursion]})`^(?a\g?b)$`); 72 | }); 73 | 74 | it('should match multiple direct, nonoverlapping recursions', () => { 75 | expect('aabbcddee').toMatch(regex({plugins: [recursion]})`^(?a\g?b)c(?d\g?e)$`); 76 | expect('aabbcddee').toMatch(regex({plugins: [recursion]})`^(?(?a\g?b)c(?d\g?e))$`); 77 | expect('aabbcddee').toMatch(regex({plugins: [recursion]})`^(?(?a\g?b))c(?d\g?e)$`); 78 | }); 79 | 80 | it('should throw for multiple direct, overlapping recursions', () => { 81 | expect(() => regex({plugins: [recursion]})`a(?R=2)?(?a\g?)`).toThrow(); 82 | expect(() => regex({plugins: [recursion]})`(?a\g?\g?)`).toThrow(); 83 | expect(() => regex({plugins: [recursion]})`(?(?a\g?)\g)`).toThrow(); 84 | }); 85 | 86 | it('should throw for indirect recursion', () => { 87 | expect(() => regex({plugins: [recursion]})`(?\g)(?a\g?)`).toThrow(); 88 | expect(() => regex({plugins: [recursion]})`\g(?\g)(?a\g?)`).toThrow(); 89 | expect(() => regex({plugins: [recursion]})`(?\g)(?\g)(?a\g?)`).toThrow(); 90 | expect(() => regex({plugins: [recursion]})`(?(?a\g?)\g)`).toThrow(); 91 | expect(() => regex({plugins: [recursion]})`(?(?a\g?)\g)`).toThrow(); 92 | expect(() => regex({plugins: [recursion]})`(?\g(?a\g?))`).toThrow(); 93 | }); 94 | 95 | it('should have backrefs refer to their own recursion depth', () => { 96 | expect(regex({plugins: [recursion]})`<(?(?\w)0\g?1\k)>`.exec('')?.[0]).toBe(''); 97 | expect(regex({plugins: [recursion]})`<(?(?\w)0\g?1\k)>`.test('')).toBeFalse(); 98 | }); 99 | 100 | it('should not adjust named backrefs referring outside of the recursed subpattern', () => { 101 | expect('aababbabcc').toMatch(regex({plugins: [recursion]})`^(?a)\k(?(?b)\k\k\k\g?)(?c)\k$`); 102 | }); 103 | 104 | it('should throw if referencing a non-ancestor group', () => { 105 | expect(() => regex({plugins: [recursion]})`(?)\g?`).toThrow(); 106 | expect(() => regex({plugins: [recursion]})`\g?(?)`).toThrow(); 107 | expect(() => regex({plugins: [recursion]})`(?)(?\g?)`).toThrow(); 108 | expect(() => regex({plugins: [recursion]})`(?\g?)(?)`).toThrow(); 109 | }); 110 | }); 111 | 112 | describe('subpattern by number', () => { 113 | it('should match direct recursion', () => { 114 | expect('aabb').toMatch(regex({plugins: [recursion]})`^(?a\g<1&R=2>?b)$`); 115 | expect('aab').not.toMatch(regex({plugins: [recursion]})`^(?a\g<1&R=2>?b)$`); 116 | expect(() => regex({plugins: [recursion]})`^(a\g<1&R=2>?b)$`).toThrow(); 117 | expect('aabb').toMatch(regex({plugins: [recursion], disable: {n: true}})`^(a\g<1&R=2>?b)$`); 118 | expect('aab').not.toMatch(regex({plugins: [recursion], disable: {n: true}})`^(a\g<1&R=2>?b)$`); 119 | }); 120 | 121 | it('should throw if referencing a non-ancestor group', () => { 122 | expect(() => regex({plugins: [recursion]})`(?)\g<1&R=2>?`).toThrow(); 123 | expect(() => regex({plugins: [recursion]})`\g<1&R=2>?(?)`).toThrow(); 124 | expect(() => regex({plugins: [recursion]})`(?)(?\g<1&R=2>?)`).toThrow(); 125 | expect(() => regex({plugins: [recursion]})`(?\g<2&R=2>?)(?)`).toThrow(); 126 | }); 127 | }); 128 | 129 | describe('subclass option', () => { 130 | it('should exclude duplicated numbered captures from result subpatterns', () => { 131 | // Subpattern recursion 132 | expect(regex({plugins: [recursion], subclass: false, disable: {n: true}})`((a)\g<1&R=2>?)`.exec('aa')).toHaveSize(4); 133 | expect(regex({plugins: [recursion], subclass: true, disable: {n: true}})`((a)\g<1&R=2>?)`.exec('aa')).toHaveSize(3); 134 | expect(regex({plugins: [recursion], subclass: true, disable: {n: true}})`((a)\g<1&R=2>?)(b)`.exec('aab')[3]).toBe('b'); 135 | // Global recursion 136 | expect(regex({plugins: [recursion], subclass: false, disable: {n: true}})`(a)(?R=2)?`.exec('aa')).toHaveSize(3); 137 | expect(regex({plugins: [recursion], subclass: true, disable: {n: true}})`(a)(?R=2)?`.exec('aa')).toHaveSize(2); 138 | expect(regex({plugins: [recursion], subclass: true, disable: {n: true}})`(?R=2)?(.)`.exec('ab')[1]).toBe('b'); 139 | }); 140 | 141 | it('should exclude duplicated named captures from result subpatterns', () => { 142 | // Subpattern recursion 143 | expect(regex({plugins: [recursion], subclass: false})`(?(?a)\g?)`.exec('aa')).toHaveSize(4); 144 | expect(regex({plugins: [recursion], subclass: true})`(?(?a)\g?)`.exec('aa')).toHaveSize(3); 145 | // Global recursion 146 | expect(regex({plugins: [recursion], subclass: false})`(?a)(?R=2)?`.exec('aa')).toHaveSize(3); 147 | expect(regex({plugins: [recursion], subclass: true})`(?a)(?R=2)?`.exec('aa')).toHaveSize(2); 148 | }); 149 | 150 | it('should handle recursion that contains hidden captures', () => { 151 | expect(recursion(r`^((a)\g<1&R=2>?b)$`, { 152 | hiddenCaptures: [2], 153 | })).toEqual({ 154 | pattern: '^((a)(?:(a)(?:)?b)?b)$', 155 | captureTransfers: new Map(), 156 | hiddenCaptures: [2, 3], 157 | }); 158 | // Atomic groups are handled by Regex+ *after* external plugins like recursion, so this is 159 | // actually testing Regex+'s ability to preserve and add to hidden captures across plugins 160 | expect(regex({plugins: [recursion], subclass: true, disable: {n: true}})`^(((?>a))\g<1&R=2>?b)$`.exec('aabb')).toHaveSize(3); 161 | expect(regex({plugins: [recursion], subclass: true, disable: {n: true}})`^(((?>a)(?>x))\g<1&R=2>?b)$`.exec('axaxbb')).toHaveSize(3); 162 | }); 163 | 164 | // Capture transfer is used by 165 | describe('with capture transfers', () => { 166 | it('should transfer with global recursion', () => { 167 | expect(recursion('(a)(?R=2)?(b)', { 168 | captureTransfers: new Map([[1, [2]]]), 169 | })).toEqual({ 170 | pattern: '(a)(?:(a)(?:)?(b))?(b)', 171 | captureTransfers: new Map([[1, [3, 4]]]), 172 | hiddenCaptures: [2, 3], 173 | }); 174 | }); 175 | 176 | it('should transfer to capture that precedes the recursion', () => { 177 | expect(recursion(r`()(()(a)()\g<2&R=2>?b)`, { 178 | captureTransfers: new Map([[1, [4]]]), 179 | hiddenCaptures: [4], 180 | })).toEqual({ 181 | pattern: '()(()(a)()(?:()(a)()(?:)?b)?b)', 182 | captureTransfers: new Map([[1, [4, 7]]]), 183 | hiddenCaptures: [4, 6, 7, 8], 184 | }); 185 | expect(recursion(r`()(a\g<2&R=2>?()(b)())`, { 186 | captureTransfers: new Map([[1, [4]]]), 187 | hiddenCaptures: [4], 188 | })).toEqual({ 189 | pattern: '()(a(?:a(?:)?()(b)())?()(b)())', 190 | captureTransfers: new Map([[1, [4, 7]]]), 191 | hiddenCaptures: jasmine.arrayWithExactContents([3, 4, 5, 7]), 192 | }); 193 | }); 194 | 195 | it('should transfer to capture of the recursed group', () => { 196 | expect(recursion(r`((a)\g<1&R=2>?(b))`, { 197 | captureTransfers: new Map([[1, [3]]]), 198 | })).toEqual({ 199 | pattern: '((a)(?:(a)(?:)?(b))?(b))', 200 | captureTransfers: new Map([[1, [4, 5]]]), 201 | hiddenCaptures: [3, 4], 202 | }); 203 | }); 204 | 205 | it('should transfer across multiple recursions', () => { 206 | // ## Capture in left contents of recursions 207 | expect(recursion(r`(?(a)\g?b) ((a)\g<3&R=2>?b)`, { 208 | captureTransfers: new Map([[1, [3]], [2, [4]]]), 209 | })).toEqual({ 210 | pattern: '(?(a)(?:(a)(?:)?b)?b) ((a)(?:(a)(?:)?b)?b)', 211 | captureTransfers: new Map([[1, [4]], [2, [5, 6]]]), 212 | hiddenCaptures: [3, 6], 213 | }); 214 | 215 | // ## Capture in right contents of recursions 216 | expect(recursion(r`(?a\g?(b)) (a\g<3&R=2>?(b))`, { 217 | captureTransfers: new Map([[1, [3]], [2, [4]]]), 218 | })).toEqual({ 219 | pattern: '(?a(?:a(?:)?(b))?(b)) (a(?:a(?:)?(b))?(b))', 220 | captureTransfers: new Map([[1, [4]], [3, [5, 6]]]), 221 | hiddenCaptures: [2, 5], 222 | }); 223 | // Oniguruma: `(?\g?(.)) \g` 224 | expect(recursion(r`(?\g?(.)) (\g<3&R=3>?(.))`, { 225 | captureTransfers: new Map([[1, [3]], [2, [4]]]), 226 | hiddenCaptures: [3, 4], 227 | })).toEqual({ 228 | pattern: '(?(?:(?:(?:)?(.))?(.))?(.)) ((?:(?:(?:)?(.))?(.))?(.))', 229 | captureTransfers: new Map([[1, [5]], [4, [6, 7, 8]]]), 230 | hiddenCaptures: jasmine.arrayWithExactContents([2, 3, 5, 6, 7, 8]), 231 | }); 232 | 233 | // ## Capture in left and right contents of recursions 234 | expect(recursion(r`(?(a)\g?(b)) ((a)\g<4&R=2>?(b))`, { 235 | captureTransfers: new Map([[1, [4]], [2, [5]], [3, [6]]]), 236 | })).toEqual({ 237 | pattern: '(?(a)(?:(a)(?:)?(b))?(b)) ((a)(?:(a)(?:)?(b))?(b))', 238 | captureTransfers: new Map([[1, [6]], [2, [7, 8]], [5, [9, 10]]]), 239 | hiddenCaptures: [3, 4, 8, 9], 240 | }); 241 | 242 | // ## Triple recursion with capture transfer to middle 243 | // Oniguruma: `\g (?a\g?b) (?c\g?d)` 244 | expect(recursion(r`(a(c\g<1&R=2>?d)?b) (?a(c\g<3&R=2>?d)?b) (?c(a\g<5&R=2>?b)?d)`, { 245 | captureTransfers: new Map([[3, [6]]]), 246 | hiddenCaptures: [1, 2, 4, 6], 247 | })).toEqual({ 248 | pattern: '(a(c(?:a(c(?:)?d)?b)?d)?b) (?a(c(?:a(c(?:)?d)?b)?d)?b) (?c(a(?:c(a(?:)?b)?d)?b)?d)', 249 | captureTransfers: new Map([[4, [8, 9]]]), 250 | hiddenCaptures: jasmine.arrayWithExactContents([1, 2, 3, 5, 6, 8, 9]), 251 | }); 252 | // Same as above but with depth 3 253 | expect(recursion(r`(a(c\g<1&R=3>?d)?b) (?a(c\g<3&R=3>?d)?b) (?c(a\g<5&R=3>?b)?d)`, { 254 | captureTransfers: new Map([[3, [6]]]), 255 | hiddenCaptures: [1, 2, 4, 6], 256 | })).toEqual({ 257 | pattern: '(a(c(?:a(c(?:a(c(?:)?d)?b)?d)?b)?d)?b) (?a(c(?:a(c(?:a(c(?:)?d)?b)?d)?b)?d)?b) (?c(a(?:c(a(?:c(a(?:)?b)?d)?b)?d)?b)?d)', 258 | captureTransfers: new Map([[5, [10, 11, 12]]]), 259 | hiddenCaptures: jasmine.arrayWithExactContents([1, 2, 3, 4, 6, 7, 8, 10, 11, 12]), 260 | }); 261 | }); 262 | 263 | it('should transfer between captures following recursion', () => { 264 | expect(recursion(r`((2)\g<1&R=2>?) (3) (4)`, { 265 | captureTransfers: new Map([[3, [4]]]), 266 | })).toEqual({ 267 | pattern: '((2)(?:(2)(?:)?)?) (3) (4)', 268 | captureTransfers: new Map([[4, [5]]]), 269 | hiddenCaptures: [3], 270 | }); 271 | }); 272 | }); 273 | }); 274 | }); 275 | 276 | describe('readme examples', () => { 277 | it('should match an equal number of two different subpatterns', () => { 278 | const re = regex({plugins: [recursion]})`a(?R=20)?b`; 279 | expect(re.exec('test aaaaaabbb')[0]).toBe('aaabbb'); 280 | }); 281 | 282 | it('should match an equal number of two different subpatterns, as the entire string', () => { 283 | const re = regex({plugins: [recursion]})` 284 | ^ (? a \g? b) $ 285 | `; 286 | expect(re.test('aaabbb')).toBeTrue(); 287 | expect(re.test('aaabb')).toBeFalse(); 288 | }); 289 | 290 | it('should match balanced parentheses', () => { 291 | const parens = regex({flags: 'g', plugins: [recursion]})` 292 | \( ([^\(\)] | (?R=20))* \) 293 | `; 294 | expect('test ) (balanced ((parens))) () ((a)) ( (b)'.match(parens)).toEqual(['(balanced ((parens)))', '()', '((a))', '(b)']); 295 | }); 296 | 297 | it('should match balanced parentheses using an atomic group', () => { 298 | const parens = regex({flags: 'g', plugins: [recursion]})` 299 | \( ((?> [^\(\)]+) | (?R=20))* \) 300 | `; 301 | expect('test ) (balanced ((parens))) () ((a)) ( (b)'.match(parens)).toEqual(['(balanced ((parens)))', '()', '((a))', '(b)']); 302 | }); 303 | 304 | it('should match balanced parentheses using a possessive quantifier', () => { 305 | const parens = regex({flags: 'g', plugins: [recursion]})` 306 | \( ([^\(\)]++ | (?R=20))* \) 307 | `; 308 | expect('test ) (balanced ((parens))) () ((a)) ( (b)'.match(parens)).toEqual(['(balanced ((parens)))', '()', '((a))', '(b)']); 309 | }); 310 | 311 | it('should match palindromes', () => { 312 | const palindromes = regex({flags: 'gi', plugins: [recursion]})` 313 | (? \w) 314 | # Recurse, or match a lone unbalanced char in the middle 315 | ((?R=15) | \w?) 316 | \k 317 | `; 318 | expect('Racecar, ABBA, and redivided'.match(palindromes)).toEqual(['Racecar', 'ABBA', 'edivide']); 319 | }); 320 | 321 | it('should match palindromes as complete words', () => { 322 | const palindromeWords = regex({flags: 'gi', plugins: [recursion]})` 323 | \b 324 | (? 325 | (? \w ) 326 | # Recurse, or match a lone unbalanced char in the center 327 | ( \g | \w? ) 328 | \k 329 | ) 330 | \b 331 | `; 332 | expect('Racecar, ABBA, and redivided'.match(palindromeWords)).toEqual(['Racecar', 'ABBA']); 333 | }); 334 | }); 335 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "*-spec.js" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.?(m)js" 8 | ], 9 | "env": { 10 | "stopSpecOnExpectationFailure": false, 11 | "random": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {Context, forEachUnescaped, getGroupContents, hasUnescaped, replaceUnescaped} from 'regex-utilities'; 2 | 3 | const r = String.raw; 4 | const gRToken = r`\\g<(?[^>&]+)&R=(?[^>]+)>`; 5 | const recursiveToken = r`\(\?R=(?[^\)]+)\)|${gRToken}`; 6 | const namedCaptureDelim = r`\(\?<(?![=!])(?[^>]+)>`; 7 | const captureDelim = r`${namedCaptureDelim}|(?\()(?!\?)`; 8 | const token = new RegExp(r`${namedCaptureDelim}|${recursiveToken}|\(\?|\\?.`, 'gsu'); 9 | const overlappingRecursionMsg = 'Cannot use multiple overlapping recursions'; 10 | 11 | /** 12 | @param {string} pattern 13 | @param {{ 14 | flags?: string; 15 | captureTransfers?: Map>; 16 | hiddenCaptures?: Array; 17 | mode?: 'plugin' | 'external'; 18 | }} [data] 19 | @returns {{ 20 | pattern: string; 21 | captureTransfers: Map>; 22 | hiddenCaptures: Array; 23 | }} 24 | */ 25 | function recursion(pattern, data) { 26 | const {hiddenCaptures, mode} = { 27 | hiddenCaptures: [], 28 | mode: 'plugin', 29 | ...data, 30 | }; 31 | // Capture transfer is used by 32 | let captureTransfers = data?.captureTransfers ?? new Map(); 33 | // Keep the initial fail-check (which avoids unneeded processing) as fast as possible by testing 34 | // without the accuracy improvement of using `hasUnescaped` with `Context.DEFAULT` 35 | if (!(new RegExp(recursiveToken, 'su').test(pattern))) { 36 | return { 37 | pattern, 38 | captureTransfers, 39 | hiddenCaptures, 40 | }; 41 | } 42 | if (mode === 'plugin' && hasUnescaped(pattern, r`\(\?\(DEFINE\)`, Context.DEFAULT)) { 43 | throw new Error('DEFINE groups cannot be used with recursion'); 44 | } 45 | 46 | const addedHiddenCaptures = []; 47 | const hasNumberedBackref = hasUnescaped(pattern, r`\\[1-9]`, Context.DEFAULT); 48 | const groupContentsStartPos = new Map(); 49 | const openGroups = []; 50 | let hasRecursed = false; 51 | let numCharClassesOpen = 0; 52 | let numCapturesPassed = 0; 53 | let match; 54 | token.lastIndex = 0; 55 | while ((match = token.exec(pattern))) { 56 | const {0: m, groups: {captureName, rDepth, gRNameOrNum, gRDepth}} = match; 57 | if (m === '[') { 58 | numCharClassesOpen++; 59 | } else if (!numCharClassesOpen) { 60 | 61 | // `(?R=N)` 62 | if (rDepth) { 63 | assertMaxInBounds(rDepth); 64 | if (hasRecursed) { 65 | throw new Error(overlappingRecursionMsg); 66 | } 67 | if (hasNumberedBackref) { 68 | // Could add support for numbered backrefs with extra effort, but it's probably not worth 69 | // it. To trigger this error, the regex must include recursion and one of the following: 70 | // - An interpolated regex that contains a numbered backref (since other numbered 71 | // backrefs are prevented by implicit flag n). 72 | // - A numbered backref, when flag n is explicitly disabled. 73 | // Note that Regex+'s extended syntax (atomic groups and sometimes subroutines) can also 74 | // add numbered backrefs, but those work fine because external plugins like this one run 75 | // *before* the transformation of built-in syntax extensions 76 | throw new Error( 77 | // When used in `external` mode by transpilers other than Regex+, backrefs might have 78 | // gone through conversion from named to numbered, so avoid a misleading error 79 | `${mode === 'external' ? 'Backrefs' : 'Numbered backrefs'} cannot be used with global recursion` 80 | ); 81 | } 82 | const left = pattern.slice(0, match.index); 83 | const right = pattern.slice(token.lastIndex); 84 | if (hasUnescaped(right, recursiveToken, Context.DEFAULT)) { 85 | throw new Error(overlappingRecursionMsg); 86 | } 87 | const reps = +rDepth - 1; 88 | pattern = makeRecursive( 89 | left, 90 | right, 91 | reps, 92 | false, 93 | hiddenCaptures, 94 | addedHiddenCaptures, 95 | numCapturesPassed 96 | ); 97 | captureTransfers = mapCaptureTransfers( 98 | captureTransfers, 99 | left, 100 | reps, 101 | addedHiddenCaptures.length, 102 | 0, 103 | numCapturesPassed 104 | ); 105 | // No need to parse further 106 | break; 107 | // `\g`, `\g` 108 | } else if (gRNameOrNum) { 109 | assertMaxInBounds(gRDepth); 110 | let isWithinReffedGroup = false; 111 | for (const g of openGroups) { 112 | if (g.name === gRNameOrNum || g.num === +gRNameOrNum) { 113 | isWithinReffedGroup = true; 114 | if (g.hasRecursedWithin) { 115 | throw new Error(overlappingRecursionMsg); 116 | } 117 | break; 118 | } 119 | } 120 | if (!isWithinReffedGroup) { 121 | throw new Error(r`Recursive \g cannot be used outside the referenced group "${ 122 | mode === 'external' ? gRNameOrNum : r`\g<${gRNameOrNum}&R=${gRDepth}>` 123 | }"`); 124 | } 125 | const startPos = groupContentsStartPos.get(gRNameOrNum); 126 | const groupContents = getGroupContents(pattern, startPos); 127 | if ( 128 | hasNumberedBackref && 129 | hasUnescaped(groupContents, r`${namedCaptureDelim}|\((?!\?)`, Context.DEFAULT) 130 | ) { 131 | throw new Error( 132 | // When used in `external` mode by transpilers other than Regex+, backrefs might have 133 | // gone through conversion from named to numbered, so avoid a misleading error 134 | `${mode === 'external' ? 'Backrefs' : 'Numbered backrefs'} cannot be used with recursion of capturing groups` 135 | ); 136 | } 137 | const groupContentsLeft = pattern.slice(startPos, match.index); 138 | const groupContentsRight = groupContents.slice(groupContentsLeft.length + m.length); 139 | const numAddedHiddenCapturesPreExpansion = addedHiddenCaptures.length; 140 | const reps = +gRDepth - 1; 141 | const expansion = makeRecursive( 142 | groupContentsLeft, 143 | groupContentsRight, 144 | reps, 145 | true, 146 | hiddenCaptures, 147 | addedHiddenCaptures, 148 | numCapturesPassed 149 | ); 150 | captureTransfers = mapCaptureTransfers( 151 | captureTransfers, 152 | groupContentsLeft, 153 | reps, 154 | addedHiddenCaptures.length - numAddedHiddenCapturesPreExpansion, 155 | numAddedHiddenCapturesPreExpansion, 156 | numCapturesPassed 157 | ); 158 | const pre = pattern.slice(0, startPos); 159 | const post = pattern.slice(startPos + groupContents.length); 160 | // Modify the string we're looping over 161 | pattern = `${pre}${expansion}${post}`; 162 | // Step forward for the next loop iteration 163 | token.lastIndex += expansion.length - m.length - groupContentsLeft.length - groupContentsRight.length; 164 | openGroups.forEach(g => g.hasRecursedWithin = true); 165 | hasRecursed = true; 166 | } else if (captureName) { 167 | numCapturesPassed++; 168 | groupContentsStartPos.set(String(numCapturesPassed), token.lastIndex); 169 | groupContentsStartPos.set(captureName, token.lastIndex); 170 | openGroups.push({ 171 | num: numCapturesPassed, 172 | name: captureName, 173 | }); 174 | } else if (m[0] === '(') { 175 | const isUnnamedCapture = m === '('; 176 | if (isUnnamedCapture) { 177 | numCapturesPassed++; 178 | groupContentsStartPos.set(String(numCapturesPassed), token.lastIndex); 179 | } 180 | openGroups.push(isUnnamedCapture ? {num: numCapturesPassed} : {}); 181 | } else if (m === ')') { 182 | openGroups.pop(); 183 | } 184 | 185 | } else if (m === ']') { 186 | numCharClassesOpen--; 187 | } 188 | } 189 | 190 | hiddenCaptures.push(...addedHiddenCaptures); 191 | 192 | return { 193 | pattern, 194 | captureTransfers, 195 | hiddenCaptures, 196 | }; 197 | } 198 | 199 | /** 200 | @param {string} max 201 | */ 202 | function assertMaxInBounds(max) { 203 | const errMsg = `Max depth must be integer between 2 and 100; used ${max}`; 204 | if (!/^[1-9]\d*$/.test(max)) { 205 | throw new Error(errMsg); 206 | } 207 | max = +max; 208 | if (max < 2 || max > 100) { 209 | throw new Error(errMsg); 210 | } 211 | } 212 | 213 | /** 214 | @param {string} left 215 | @param {string} right 216 | @param {number} reps 217 | @param {boolean} isSubpattern 218 | @param {Array} hiddenCaptures 219 | @param {Array} addedHiddenCaptures 220 | @param {number} numCapturesPassed 221 | @returns {string} 222 | */ 223 | function makeRecursive( 224 | left, 225 | right, 226 | reps, 227 | isSubpattern, 228 | hiddenCaptures, 229 | addedHiddenCaptures, 230 | numCapturesPassed 231 | ) { 232 | const namesInRecursed = new Set(); 233 | // Can skip this work if not needed 234 | if (isSubpattern) { 235 | forEachUnescaped(left + right, namedCaptureDelim, ({groups: {captureName}}) => { 236 | namesInRecursed.add(captureName); 237 | }, Context.DEFAULT); 238 | } 239 | const rest = [ 240 | reps, 241 | isSubpattern ? namesInRecursed : null, 242 | hiddenCaptures, 243 | addedHiddenCaptures, 244 | numCapturesPassed, 245 | ]; 246 | // Depth 2: 'left(?:left(?:)right)right' 247 | // Depth 3: 'left(?:left(?:left(?:)right)right)right' 248 | // Empty group in the middle separates tokens and absorbs a following quantifier if present 249 | return `${left}${ 250 | repeatWithDepth(`(?:${left}`, 'forward', ...rest) 251 | }(?:)${ 252 | repeatWithDepth(`${right})`, 'backward', ...rest) 253 | }${right}`; 254 | } 255 | 256 | /** 257 | @param {string} pattern 258 | @param {'forward' | 'backward'} direction 259 | @param {number} reps 260 | @param {Set | null} namesInRecursed 261 | @param {Array} hiddenCaptures 262 | @param {Array} addedHiddenCaptures 263 | @param {number} numCapturesPassed 264 | @returns {string} 265 | */ 266 | function repeatWithDepth( 267 | pattern, 268 | direction, 269 | reps, 270 | namesInRecursed, 271 | hiddenCaptures, 272 | addedHiddenCaptures, 273 | numCapturesPassed 274 | ) { 275 | const startNum = 2; 276 | const getDepthNum = i => direction === 'forward' ? (i + startNum) : (reps - i + startNum - 1); 277 | let result = ''; 278 | for (let i = 0; i < reps; i++) { 279 | const depthNum = getDepthNum(i); 280 | result += replaceUnescaped( 281 | pattern, 282 | r`${captureDelim}|\\k<(?[^>]+)>`, 283 | ({0: m, groups: {captureName, unnamed, backref}}) => { 284 | if (backref && namesInRecursed && !namesInRecursed.has(backref)) { 285 | // Don't alter backrefs to groups outside the recursed subpattern 286 | return m; 287 | } 288 | const suffix = `_$${depthNum}`; 289 | if (unnamed || captureName) { 290 | const addedCaptureNum = numCapturesPassed + addedHiddenCaptures.length + 1; 291 | addedHiddenCaptures.push(addedCaptureNum); 292 | incrementIfAtLeast(hiddenCaptures, addedCaptureNum); 293 | return unnamed ? m : `(?<${captureName}${suffix}>`; 294 | } 295 | return r`\k<${backref}${suffix}>`; 296 | }, 297 | Context.DEFAULT 298 | ); 299 | } 300 | return result; 301 | } 302 | 303 | /** 304 | Updates the array in place by incrementing each value greater than or equal to the threshold. 305 | @param {Array} arr 306 | @param {number} threshold 307 | */ 308 | function incrementIfAtLeast(arr, threshold) { 309 | for (let i = 0; i < arr.length; i++) { 310 | if (arr[i] >= threshold) { 311 | arr[i]++; 312 | } 313 | } 314 | } 315 | 316 | /** 317 | @param {Map>} captureTransfers 318 | @param {string} left 319 | @param {number} reps 320 | @param {number} numCapturesAddedInExpansion 321 | @param {number} numAddedHiddenCapturesPreExpansion 322 | @param {number} numCapturesPassed 323 | @returns {Map>} 324 | */ 325 | function mapCaptureTransfers(captureTransfers, left, reps, numCapturesAddedInExpansion, numAddedHiddenCapturesPreExpansion, numCapturesPassed) { 326 | if (captureTransfers.size && numCapturesAddedInExpansion) { 327 | let numCapturesInLeft = 0; 328 | forEachUnescaped(left, captureDelim, () => numCapturesInLeft++, Context.DEFAULT); 329 | // Is 0 for global recursion 330 | const recursionDelimCaptureNum = numCapturesPassed - numCapturesInLeft + numAddedHiddenCapturesPreExpansion; 331 | const newCaptureTransfers = new Map(); 332 | captureTransfers.forEach((from, to) => { 333 | const numCapturesInRight = (numCapturesAddedInExpansion - (numCapturesInLeft * reps)) / reps; 334 | const numCapturesAddedInLeft = numCapturesInLeft * reps; 335 | const newTo = to > (recursionDelimCaptureNum + numCapturesInLeft) ? to + numCapturesAddedInExpansion : to; 336 | const newFrom = []; 337 | for (const f of from) { 338 | // Before the recursed subpattern 339 | if (f <= recursionDelimCaptureNum) { 340 | newFrom.push(f); 341 | // After the recursed subpattern 342 | } else if (f > (recursionDelimCaptureNum + numCapturesInLeft + numCapturesInRight)) { 343 | newFrom.push(f + numCapturesAddedInExpansion); 344 | // Within the recursed subpattern, on the left of the recursion token 345 | } else if (f <= (recursionDelimCaptureNum + numCapturesInLeft)) { 346 | for (let i = 0; i <= reps; i++) { 347 | newFrom.push(f + (numCapturesInLeft * i)); 348 | } 349 | // Within the recursed subpattern, on the right of the recursion token 350 | } else { 351 | for (let i = 0; i <= reps; i++) { 352 | newFrom.push(f + numCapturesAddedInLeft + (numCapturesInRight * i)); 353 | } 354 | } 355 | } 356 | newCaptureTransfers.set(newTo, newFrom); 357 | }); 358 | return newCaptureTransfers; 359 | } 360 | return captureTransfers; 361 | } 362 | 363 | export { 364 | recursion, 365 | }; 366 | --------------------------------------------------------------------------------