├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── ---bug-report.yml │ ├── ---documentation.yml │ └── ---feature-suggestion.yml └── workflows │ ├── bench.yml │ ├── ci.yml │ ├── codeql.yml │ ├── performance.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── LICENCE ├── README.md ├── build.config.ts ├── docs ├── .gitignore ├── .vscode │ └── extensions.json ├── app.config.ts ├── assets │ └── css │ │ └── tailwind.css ├── content │ ├── 1.guide │ │ ├── 1.index.md │ │ ├── 2.usage.md │ │ ├── 3.examples.md │ │ ├── 4.transform.md │ │ └── 5.converter.md │ ├── 2.contribution.md │ └── index.md ├── nuxt.config.ts ├── package.json ├── public │ ├── favicon.ico │ └── logo.svg ├── tailwind.config.js └── tsconfig.json ├── eslint.config.mjs ├── examples ├── date.ts ├── email.ts ├── semver.ts ├── time.ts └── url.ts ├── further-magic.d.ts ├── nuxt.mjs ├── package.json ├── playground ├── index.mjs └── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json ├── src ├── converter.ts ├── core │ ├── flags.ts │ ├── inputs.ts │ ├── internal.ts │ ├── types │ │ ├── escape.ts │ │ ├── join.ts │ │ ├── magic-regexp.ts │ │ └── sources.ts │ └── wrap.ts ├── further-magic.ts ├── index.ts └── transform.ts ├── tea.yaml ├── test ├── augments.test.ts ├── converter.test.ts ├── experimental.test.ts ├── flags.test.ts ├── index.test.ts ├── inputs.test.ts ├── transform.bench.ts ├── transform.test.ts └── utils │ └── index.ts ├── transform.d.ts ├── tsconfig.json └── vitest.config.mts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [daniel@roe.dev](mailto:daniel@roe.dev). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 44 | 45 | [homepage]: https://www.contributor-covenant.org 46 | 47 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq 48 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [danielroe] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Something's not working 3 | labels: [bug] 4 | assignees: danielroe 5 | body: 6 | - type: textarea 7 | validations: 8 | required: true 9 | attributes: 10 | label: 🐛 The bug 11 | description: What isn't working? Describe what the bug is. 12 | - type: input 13 | validations: 14 | required: true 15 | attributes: 16 | label: 🛠️ To reproduce 17 | description: A reproduction of the bug via https://stackblitz.com/github/danielroe/magic-regexp/tree/main/playground 18 | placeholder: https://stackblitz.com/[...] 19 | - type: textarea 20 | validations: 21 | required: true 22 | attributes: 23 | label: 🌈 Expected behaviour 24 | description: What did you expect to happen? Is there a section in the docs about this? 25 | - type: textarea 26 | attributes: 27 | label: ℹ️ Additional context 28 | description: Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---documentation.yml: -------------------------------------------------------------------------------- 1 | name: 📚 Documentation 2 | description: How do I ... ? 3 | labels: [documentation] 4 | assignees: danielroe 5 | body: 6 | - type: textarea 7 | validations: 8 | required: true 9 | attributes: 10 | label: 📚 Is your documentation request related to a problem? 11 | description: A clear and concise description of what the problem is. 12 | placeholder: I feel I should be able to [...] but I can't see how to do it from the docs. 13 | - type: textarea 14 | attributes: 15 | label: 🔍 Where should you find it? 16 | description: What page of the docs do you expect this information to be found on? 17 | - type: textarea 18 | attributes: 19 | label: ℹ️ Additional context 20 | description: Add any other context or information. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-suggestion.yml: -------------------------------------------------------------------------------- 1 | name: 🆕 Feature suggestion 2 | description: Suggest an idea 3 | labels: [enhancement] 4 | assignees: danielroe 5 | body: 6 | - type: textarea 7 | validations: 8 | required: true 9 | attributes: 10 | label: 🆒 Your use case 11 | description: Add a description of your use case, and how this feature would help you. 12 | placeholder: When I do [...] I would expect to be able to do [...] 13 | - type: textarea 14 | attributes: 15 | label: 🔍 Alternatives you've considered 16 | description: Have you considered any alternative solutions or features? 17 | - type: textarea 18 | attributes: 19 | label: ℹ️ Additional info 20 | description: Is there any other context you think would be helpful to know? 21 | -------------------------------------------------------------------------------- /.github/workflows/bench.yml: -------------------------------------------------------------------------------- 1 | name: benchmark 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - renovate/* 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | jobs: 12 | run: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: pnpm/action-setup@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | cache: pnpm 20 | - name: Install dependencies 21 | run: pnpm install 22 | - name: Run benchmarks 23 | uses: CodSpeedHQ/action@v3 24 | with: 25 | run: pnpm vitest bench 26 | # token retrieved from the CodSpeed app at the previous step 27 | token: ${{ secrets.CODSPEED_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | - renovate/* 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 18 | - run: npm i -g --force corepack && corepack enable 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 18 22 | cache: pnpm 23 | 24 | - name: 📦 Install dependencies 25 | run: pnpm install --frozen-lockfile 26 | 27 | - name: 🔠 Lint project 28 | run: pnpm lint 29 | 30 | test: 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 35 | - run: npm i -g --force corepack && corepack enable 36 | - uses: actions/setup-node@v4 37 | with: 38 | node-version: 18 39 | cache: pnpm 40 | 41 | - name: 📦 Install dependencies 42 | run: pnpm install --frozen-lockfile 43 | 44 | - name: 🛠 Build project 45 | run: pnpm build 46 | 47 | - name: 💪 Test types 48 | run: pnpm test:types 49 | 50 | - name: 🧪 Test project 51 | run: pnpm test -- --coverage 52 | 53 | - name: 🟩 Coverage 54 | uses: codecov/codecov-action@v5 55 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '2 2 * * 0' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [javascript] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v3 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v3 40 | with: 41 | category: '/language:${{ matrix.language }}' 42 | -------------------------------------------------------------------------------- /.github/workflows/performance.yml: -------------------------------------------------------------------------------- 1 | name: performance 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | check-performance: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 11 | - run: npm i -g --force corepack && corepack enable 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 18 15 | cache: pnpm 16 | 17 | - name: 📦 Install dependencies 18 | run: pnpm install --frozen-lockfile 19 | 20 | - name: TSC diagnostics diff 21 | uses: beerose/tsc-diagnostics-diff-action@49bf67cb35cae2ba688d1e7dffe5f14acbb62406 22 | with: 23 | github-token: ${{ secrets.GITHUB_TOKEN }} 24 | leave-comment: true 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 18 23 | 24 | - run: npx changelogithub 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | .vercel 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @danielroe 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [daniel@roe.dev](mailto:daniel@roe.dev). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 44 | 45 | [homepage]: https://www.contributor-covenant.org 46 | 47 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq 48 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Daniel Roe 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 | # 🦄 magic-regexp 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![Github Actions][github-actions-src]][github-actions-href] 6 | [![Codecov][codecov-src]][codecov-href] 7 | [![Bundlephobia][bundlephobia-src]][bundlephobia-href] 8 | 9 | > A compiled-away, type-safe, readable RegExp alternative 10 | 11 | - [✨  Changelog](https://github.com/danielroe/magic-regexp/blob/main/CHANGELOG.md) 12 | - [📖  Documentation](https://regexp.dev) 13 | - [▶️  Online playground](https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbzgYwBYEMoBoVQKbox4BKeA5gKIAeYOAJsGcDDnlesjADYCeOI6HgCM8OCADs8AeSgBZaKLgB3aHQDCGKHAC+cAGZQIIOAHIBTZAFp8ZNmBMAoBwHoAVK4dxXKDONtwYCE9vUEhYRAA6KN0DI1NzYCsbO2c9AFdYVDwoSwTkRy8AiACoHgCeMDw4LjwANzwuOFJKGjgBGDQ4fABnNK4YbrgACjts0DxxGHQuAEpg5ydnZzgAFQq8OhR0MBgMqrJDNLBuh2QJbvgASQARAH1iCjgAXlwCImbqMBH2Tl4hk2AdEsJhmEXQ4joQwYTBgERg426QwArKCDhAjhsAIKIgF0EEzOZncQXOBoo6DF4mbpGKqAywAJgAzAAWACcAAY4Fl8Mp9hATBF2mghjd7hQZgB+CJk44uZZwBVwAB6EsWywAimlEgBrSzgoEMWBlbp4ED1KCnc7wADKFFkADUKMQxc9XoQSORPkNPHAJNI5AooYxmKjDpU6Nj-gIAFbQEFYH0CkwJhV+mTyfBBmGh9HhyNmYDiOMzFNtQQif4RZO+yTpwNoTA5jERnFgQhofEJuYOW0Op1iuXK1WW4nwHp9eAvZD4d0fGjfDjcHj-PQQCDOIgXCLR7ogsF6IhQf5CTDOfFwvAXY+n1frzewncgtVNPB6bITZCXopwMD4WrAdFul4LYdj2TYZUGNJukLMgAiyUkwzgcR0BAPARxJFYKAAOQoFYXSnGd3k9ecfTYRc-hUKB1E0aUwyxHE9GAKALg0TB42UVRWKgWjc3o-4TSJai2JLGt-QzPAhgbKACR9BUwQhCJ8DffBxE-FYIH4vBBK4p9FTgeS6EU1931UvB1JXJiWM0eMHG7IkSXHfp6VdTCcLwh4LyvExXIAHieJ4pH8gA+NyQSAA) 14 | 15 | ## Features 16 | 17 | - Runtime is zero-dependency and ultra-minimal 18 | - Ships with transform to compile to pure RegExp 19 | - Automatically typed capture groups 20 | - Natural language syntax 21 | - Generated RegExp displays on hover 22 | 23 | [📖  Read more](https://regexp.dev) 24 | 25 | ## 💻 Development 26 | 27 | - Clone this repository 28 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` (use `npm i -g corepack` for Node.js < 16.10) 29 | - Install dependencies using `pnpm install` 30 | - Run interactive tests using `pnpm dev` 31 | 32 | ## Similar packages 33 | 34 | - [verbal-expressions](http://verbalexpressions.github.io/) 35 | - [typed-regex](https://github.com/phenax/typed-regex/) 36 | 37 | ## License 38 | 39 | Made with ❤️ 40 | 41 | Published under [MIT License](./LICENCE). 42 | 43 | 44 | 45 | [npm-version-src]: https://img.shields.io/npm/v/magic-regexp?style=flat-square 46 | [npm-version-href]: https://npmjs.com/package/magic-regexp 47 | [npm-downloads-src]: https://img.shields.io/npm/dm/magic-regexp?style=flat-square 48 | [npm-downloads-href]: https://npmjs.com/package/magic-regexp 49 | [github-actions-src]: https://img.shields.io/github/actions/workflow/status/danielroe/magic-regexp/ci.yml?branch=main&style=flat-square 50 | [github-actions-href]: https://github.com/danielroe/magic-regexp/actions?query=workflow%3Aci 51 | [codecov-src]: https://img.shields.io/codecov/c/gh/danielroe/magic-regexp/main?style=flat-square 52 | [codecov-href]: https://codecov.io/gh/danielroe/magic-regexp 53 | [bundlephobia-src]: https://img.shields.io/bundlephobia/minzip/magic-regexp?style=flat-square 54 | [bundlephobia-href]: https://bundlephobia.com/package/magic-regexp 55 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | declaration: true, 5 | rollup: { emitCJS: true }, 6 | entries: ['./src/index', './src/transform', './src/converter', './src/further-magic'], 7 | externals: ['magic-regexp', 'type-level-regexp'], 8 | }) 9 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /docs/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nuxt.mdc" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /docs/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | shadcnDocs: { 3 | site: { 4 | name: 'magic-regexp', 5 | description: 'Beautifully designed Nuxt Content template built with shadcn-vue. Customizable. Compatible. Open Source.', 6 | ogImageComponent: 'ShadcnDocs', 7 | ogImageColor: 'dark', 8 | }, 9 | theme: { 10 | customizable: false, 11 | color: '', 12 | radius: 0.5, 13 | }, 14 | header: { 15 | title: 'magic-regexp', 16 | showTitle: true, 17 | darkModeToggle: true, 18 | logo: { 19 | light: '/logo.svg', 20 | dark: '/logo.svg', 21 | }, 22 | nav: [], 23 | links: [{ 24 | icon: 'lucide:github', 25 | to: 'https://github.com/unjs/magic-regexp', 26 | target: '_blank', 27 | }], 28 | }, 29 | aside: { 30 | useLevel: true, 31 | collapse: false, 32 | }, 33 | main: { 34 | breadCrumb: true, 35 | showTitle: true, 36 | }, 37 | footer: { 38 | credits: 'Made with ❤️. Copyright © 2025 Daniel Roe', 39 | links: [{ 40 | icon: 'lucide:github', 41 | to: 'https://github.com/unjs/magic-regexp', 42 | target: '_blank', 43 | }], 44 | }, 45 | toc: { 46 | enable: true, 47 | title: 'On This Page', 48 | links: [{ 49 | title: 'Star on GitHub', 50 | icon: 'lucide:star', 51 | to: 'https://github.com/unjs/magic-regexp', 52 | target: '_blank', 53 | }, { 54 | title: 'Create Issues', 55 | icon: 'lucide:circle-dot', 56 | to: 'https://github.com/unjs/magic-regexp/issues', 57 | target: '_blank', 58 | }], 59 | }, 60 | search: { 61 | enable: true, 62 | inAside: false, 63 | }, 64 | }, 65 | }) 66 | -------------------------------------------------------------------------------- /docs/assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @config '../../tailwind.config.js'; 4 | @source '../../node_modules/shadcn-docs-nuxt'; 5 | 6 | @layer base { 7 | *, 8 | ::after, 9 | ::before, 10 | ::backdrop, 11 | ::file-selector-button { 12 | border-color: var(--color-gray-200, currentcolor); 13 | } 14 | } 15 | 16 | @layer base { 17 | :root { 18 | --background: 0 0% 100%; 19 | --foreground: 240 10% 3.9%; 20 | 21 | --card: 0 0% 100%; 22 | --card-foreground: 240 10% 3.9%; 23 | 24 | --popover: 0 0% 100%; 25 | --popover-foreground: 240 10% 3.9%; 26 | 27 | --primary: 320 90% 60%; 28 | --primary-foreground: 0 0% 100%; 29 | 30 | --secondary: 240 4.8% 95.9%; 31 | --secondary-foreground: 240 5.9% 10%; 32 | 33 | --muted: 240 4.8% 95.9%; 34 | --muted-foreground: 240 3.8% 46.1%; 35 | 36 | --accent: 240 4.8% 95.9%; 37 | --accent-foreground: 240 5.9% 10%; 38 | 39 | --destructive: 0 84.2% 60.2%; 40 | --destructive-foreground: 0 0% 98%; 41 | 42 | --border:240 5.9% 90%; 43 | --input:240 5.9% 90%; 44 | --ring:240 5.9% 10%; 45 | --radius: 0.5rem; 46 | } 47 | 48 | .dark { 49 | --background:240 10% 3.9%; 50 | --foreground:0 0% 98%; 51 | 52 | --card:240 10% 3.9%; 53 | --card-foreground:0 0% 98%; 54 | 55 | --popover:240 10% 3.9%; 56 | --popover-foreground:0 0% 98%; 57 | 58 | --primary: 320 90% 60%; 59 | --primary-foreground: 0 0% 100%; 60 | 61 | --secondary:240 3.7% 15.9%; 62 | --secondary-foreground:0 0% 98%; 63 | 64 | --muted:240 3.7% 15.9%; 65 | --muted-foreground:240 5% 64.9%; 66 | 67 | --accent:240 3.7% 15.9%; 68 | --accent-foreground:0 0% 98%; 69 | 70 | --destructive:0 62.8% 30.6%; 71 | --destructive-foreground:0 0% 98%; 72 | 73 | --border:240 3.7% 15.9%; 74 | --input:240 3.7% 15.9%; 75 | --ring:240 4.9% 83.9%; 76 | } 77 | } 78 | 79 | @utility step { 80 | counter-increment: step; 81 | 82 | &:before { 83 | @apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background; 84 | @apply -ml-[50px] -mt-1; 85 | content: counter(step); 86 | } 87 | } 88 | 89 | @layer base { 90 | * { 91 | @apply border-border outline-ring/50; 92 | } 93 | 94 | body { 95 | @apply bg-background text-foreground; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /docs/content/1.guide/1.index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Setup 3 | description: Install magic-regexp from npm and (optionally) enable the build-time transform via a plugin. 4 | --- 5 | 6 | First, install `magic-regexp`: 7 | 8 | :pm-install{name="magic-regexp"} 9 | 10 | --- 11 | 12 | Second, optionally, you can enable the included transform, [which enables zero-runtime usage](/guide/usage#build-time-transform). 13 | 14 | ::code-group 15 | 16 | ```js [nuxt.config.ts] 17 | // Nuxt 3 18 | import { defineNuxtConfig } from 'nuxt' 19 | 20 | export default defineNuxtConfig({ 21 | // This will also enable auto-imports of magic-regexp helpers 22 | modules: ['magic-regexp/nuxt'], 23 | }) 24 | ``` 25 | 26 | ```js [vite.config.ts] 27 | import { MagicRegExpTransformPlugin } from 'magic-regexp/transform' 28 | import { defineConfig } from 'vite' 29 | 30 | export default defineConfig({ 31 | plugins: [MagicRegExpTransformPlugin.vite()], 32 | }) 33 | ``` 34 | 35 | ```js [next.config.mjs] 36 | // or, if using next.config.js 37 | // const { MagicRegExpTransformPlugin } = require('magic-regexp/transform') 38 | import { MagicRegExpTransformPlugin } from 'magic-regexp/transform' 39 | 40 | export default { 41 | webpack(config) { 42 | config.plugins = config.plugins || [] 43 | config.plugins.push(MagicRegExpTransformPlugin.webpack()) 44 | return config 45 | }, 46 | } 47 | ``` 48 | 49 | ```js [build.config.ts ] 50 | import { MagicRegExpTransformPlugin } from 'magic-regexp/transform' 51 | // unbuild 52 | import { defineBuildConfig } from 'unbuild' 53 | 54 | export default defineBuildConfig({ 55 | hooks: { 56 | 'rollup:options': (options, config) => { 57 | config.plugins.push(MagicRegExpTransformPlugin.rollup()) 58 | }, 59 | }, 60 | }) 61 | ``` 62 | 63 | :: 64 | -------------------------------------------------------------------------------- /docs/content/1.guide/2.usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Usage 3 | --- 4 | 5 | ```js 6 | import { createRegExp, exactly } from 'magic-regexp' 7 | 8 | const regExp = createRegExp(exactly('foo/test.js').after('bar/')) 9 | console.log(regExp) 10 | 11 | // /(?<=bar\/)foo\/test\.js/ 12 | ``` 13 | 14 | ## createRegExp 15 | 16 | Every pattern you create with the library should be wrapped in `createRegExp`, which enables the build-time transform. 17 | 18 | `createRegExp` accepts an arbitrary number of arguments of type `string` or `Input` (built up using helpers from `magic-regexp`), and an optional final argument of an array of flags or a flags string. It creates a `MagicRegExp`, which concatenates all the patterns from the arguments that were passed in. 19 | 20 | ```js 21 | import { createRegExp, exactly, global, maybe, multiline } from 'magic-regexp' 22 | 23 | createRegExp(exactly('foo').or('bar')) 24 | 25 | createRegExp('string-to-match', [global, multiline]) 26 | // you can also pass flags directly as strings or Sets 27 | createRegExp('string-to-match', ['g', 'm']) 28 | 29 | // or pass in multiple `string` and `input patterns`, 30 | // all inputs will be concatenated to one RegExp pattern 31 | createRegExp( 32 | 'foo', 33 | maybe('bar').groupedAs('g1'), 34 | 'baz', 35 | [global, multiline] 36 | ) 37 | // equivalent to /foo(?(?:bar)?)baz/gm 38 | ``` 39 | 40 | ::alert 41 | By default, all helpers from `magic-regexp` assume that input that is passed should be escaped - so no special RegExp characters apply. So `createRegExp('foo.\d')` will not match `food3` but only `foo.\d` exactly. 42 | :: 43 | 44 | ## Creating inputs 45 | 46 | There are a range of helpers that can be used to activate pattern matching, and they can be chained. Each one of these returns an object of type `Input` that can be passed directly to `new RegExp`, `createRegExp`, to another helper or chained to produce more complex patterns. 47 | 48 | | | | 49 | | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 50 | | `charIn`, `charNotIn` | this matches or doesn't match any character in the string provided. | 51 | | `anyOf` | this takes a variable number of inputs and matches any of them. | 52 | | `char`, `word`, `wordChar`, `wordBoundary`, `digit`, `whitespace`, `letter`, `letter.lowercase`, `letter.uppercase`, `tab`, `linefeed` and `carriageReturn` | these are helpers for specific RegExp characters. | 53 | | `not` | this can prefix `word`, `wordChar`, `wordBoundary`, `digit`, `whitespace`, `letter`, `letter.lowercase`, `letter.uppercase`, `tab`, `linefeed` or `carriageReturn`. For example `createRegExp(not.letter)`. | 54 | | `maybe` | equivalent to `?` - this takes a variable number of inputs and marks them as optional. | 55 | | `oneOrMore` | Equivalent to `+` - this takes a variable number of inputs and marks them as repeatable, any number of times but at least once. | 56 | | `exactly` | This takes a variable number of inputs and concatenate their patterns, and escapes string inputs to match it exactly. | 57 | 58 | ::alert 59 | All helpers that takes `string` and `Input` are variadic functions, so you can pass in one or multiple arguments of `string` or `Input` to them and they will be concatenated to one pattern. for example, `exactly('foo', maybe('bar'))` is equivalent to `exactly('foo').and(maybe('bar'))`. 60 | :: 61 | 62 | ## Chaining inputs 63 | 64 | All of the helpers above return an object of type `Input` that can be chained with the following helpers: 65 | 66 | | | | 67 | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 68 | | `and` | this takes a variable number of inputs and adds them as new pattern to the current input, or you can use `and.referenceTo(groupName)` to adds a new pattern referencing to a named group. | 69 | | `or` | this takes a variable number of inputs and provides as an alternative to the current input. | 70 | | `after`, `before`, `notAfter` and `notBefore` | these takes a variable number of inputs and activate positive/negative lookahead/lookbehinds. Make sure to check [browser support](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#browser_compatibility) as not all browsers support lookbehinds (notably Safari). | 71 | | `times` | this is a function you can call directly to repeat the previous pattern an exact number of times, or you can use `times.between(min, max)` to specify a range, `times.atLeast(x)` to indicate it must repeat at least x times, `times.atMost(x)` to indicate it must repeat at most x times or `times.any()` to indicate it can repeat any number of times, _including none_. | 72 | | `optionally` | this is a function you can call to mark the current input as optional. | 73 | | `as` | alias for `groupedAs` | 74 | | `groupedAs` | this defines the entire input so far as a named capture group. You will get type safety when using the resulting RegExp with `String.match()`. | 75 | | `grouped` | this defines the entire input so far as an anonymous group. | 76 | | `at` | this allows you to match beginning/ends of lines with `at.lineStart()` and `at.lineEnd()`. | 77 | 78 | ::alert 79 | By default, for better regex performance, creation input helpers such as `anyOf`, `maybe`, `oneOrMore`, and chaining input helpers such as `or`, `times(.between/atLeast/any)`, or `optionally` will wrap the input in a non-capturing group with `(?:)`. You can use chaining input helper `grouped` after any `Input` type to capture it as an anonymous group. 80 | :: 81 | 82 | ## Debugging 83 | 84 | When using `magic-regexp`, a TypeScript generic is generated for you that should show the RegExp that you are constructing, as you go. 85 | 86 | This is true not just for the final RegExp, but also for the pieces you create along the way. 87 | 88 | So, for example: 89 | 90 | ```ts 91 | import { exactly } from 'magic-regexp' 92 | 93 | exactly('test.mjs') 94 | // (alias) exactly<"test.mjs">(input: "test.mjs"): Input<"test\\.mjs", never> 95 | 96 | exactly('test.mjs').or('something.else') 97 | // (property) Input<"test\\.mjs", never>.or: <"something.else">(input: "something.else") => Input<"(?:test\\.mjs|something\\.else)", never> 98 | ``` 99 | 100 | Each function, if you hover over it, shows what's going in, and what's coming out by way of regular expression 101 | 102 | You can also call `.toString()` on any input to see the same information at runtime. 103 | 104 | ## Type-Level match result (experimental) 105 | We also provide an experimental feature that allows you to obtain the type-level results of a RegExp match or replace in string literals. To try this feature, please import all helpers from a subpath export `magic-regexp/further-magic` instead of `magic-regexp`. 106 | 107 | ```ts 108 | import { createRegExp, digit, exactly } from 'magic-regexp/further-magic' 109 | ``` 110 | 111 | This feature is especially useful when you want to obtain the type of the matched groups or test if your RegExp matches and captures from a given string as expected. 112 | 113 | This feature works best for matching literal strings such as 114 | ```ts 115 | 'foo'.match(createRegExp(exactly('foo').groupedAs('g1'))) 116 | ``` 117 | which will return a matched result of type `['foo', 'foo']`. `result.groups` of type `{ g1: 'foo' }`, `result.index` of type `0` and `result.length` of type `2`. 118 | 119 | If matching with dynamic string, such as 120 | ```ts 121 | myString.match(createRegExp(exactly('foo').or('bar').groupedAs('g1'))) 122 | ``` 123 | the type of the matched result will be `null`, or array of union of possible matches `["bar", "bar"] | ["foo", "foo"]` and `result.groups` will be type `{ g1: "bar" } | { g1: "foo" }`. 124 | 125 | ::alert 126 | For more usage details please see the [usage examples](3.examples.md#type-level-regexp-match-and-replace-result-experimental) or [test](https://github.com/danielroe/magic-regexp/blob/main/test/further-magic.test.ts). For type-related issues, please report them to [type-level-regexp](https://github.com/didavid61202/type-level-regexp). 127 | :: 128 | -------------------------------------------------------------------------------- /docs/content/1.guide/3.examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | --- 4 | 5 | ### Quick-and-dirty semver 6 | 7 | ```js 8 | import { char, createRegExp, digit, maybe, oneOrMore } from 'magic-regexp' 9 | 10 | createRegExp( 11 | oneOrMore(digit).groupedAs('major'), 12 | '.', 13 | oneOrMore(digit).groupedAs('minor'), 14 | maybe('.', oneOrMore(char).groupedAs('patch')) 15 | ) 16 | // /(?\d+)\.(?\d+)(?:\.(?.+))?/ 17 | ``` 18 | 19 | ### References to previously captured groups using the group name 20 | 21 | ```js 22 | import assert from 'node:assert' 23 | import { char, createRegExp, oneOrMore, wordChar } from 'magic-regexp' 24 | 25 | const TENET_RE = createRegExp( 26 | wordChar 27 | .groupedAs('firstChar') 28 | .and(wordChar.groupedAs('secondChar')) 29 | .and(oneOrMore(char)) 30 | .and.referenceTo('secondChar') 31 | .and.referenceTo('firstChar') 32 | ) 33 | // /(?\w)(?\w).+\k\k/ 34 | 35 | assert.equal(TENET_RE.test('TEN<==O==>NET'), true) 36 | ``` 37 | 38 | ### Type-level RegExp match and replace result (experimental) 39 | 40 | ::alert 41 | This feature is still experimental, to try it please import `createRegExp ` and all `Input` helpers from `magic-regexp/further-magic` instead of `magic-regexp`. 42 | :: 43 | 44 | When matching or replacing with literal string such as `magic-regexp v3.2.5.beta.1 just release!` 45 | 46 | ```ts 47 | import { 48 | anyOf, 49 | createRegExp, 50 | digit, 51 | exactly, 52 | oneOrMore, 53 | wordChar 54 | } from 'magic-regexp/further-magic' 55 | 56 | const literalString = 'magic-regexp 3.2.5.beta.1 just release!' 57 | 58 | const semverRegExp = createRegExp( 59 | oneOrMore(digit) 60 | .as('major') 61 | .and('.') 62 | .and(oneOrMore(digit).as('minor')) 63 | .and( 64 | exactly('.') 65 | .and(oneOrMore(anyOf(wordChar, '.')).groupedAs('patch')) 66 | .optionally() 67 | ) 68 | ) 69 | 70 | // `String.match()` example 71 | const matchResult = literalString.match(semverRegExp) 72 | matchResult[0] // "3.2.5.beta.1" 73 | matchResult[3] // "5.beta.1" 74 | matchResult.length // 4 75 | matchResult.index // 14 76 | matchResult.groups 77 | // groups: { 78 | // major: "3"; 79 | // minor: "2"; 80 | // patch: "5.beta.1"; 81 | // } 82 | 83 | // `String.replace()` example 84 | const replaceResult = literalString.replace( 85 | semverRegExp, 86 | `minor version "$2" brings many great DX improvements, while patch "$" fix some bugs and it's` 87 | ) 88 | 89 | replaceResult // "magic-regexp minor version \"2\" brings many great DX improvements, while patch \"5.beta.1\" fix some bugs and it's just release!" 90 | ``` 91 | 92 | When matching dynamic string, the result will be union of possible matches 93 | 94 | ```ts 95 | const myString = 'dynamic' 96 | 97 | const RegExp = createRegExp(exactly('foo').or('bar').groupedAs('g1')) 98 | const matchAllResult = myString.match(RegExp) 99 | 100 | matchAllResult 101 | // null | RegExpMatchResult<{ 102 | // matched: ["bar", "bar"] | ["foo", "foo"]; 103 | // namedCaptures: ["g1", "bar"] | ["g1", "foo"]; 104 | // input: string; 105 | // restInput: undefined; 106 | // }> 107 | matchAllResult?.[0] // ['foo', 'foo'] | ['bar', 'bar'] 108 | matchAllResult?.length // 2 | undefined 109 | matchAllResult?.groups 110 | // groups: { 111 | // g1: "foo" | "bar"; 112 | // } | undefined 113 | ``` 114 | -------------------------------------------------------------------------------- /docs/content/1.guide/4.transform.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Transform 3 | --- 4 | 5 | The best way to use `magic-regexp` is by making use of the included build-time transform. 6 | 7 | ```js 8 | const beforeTransform = createRegExp(exactly('foo/test.js').after('bar/')) 9 | // => gets _compiled_ to 10 | const afterTransform = /(?<=bar\/)foo\/test\.js/ 11 | ``` 12 | 13 | Of course, this only works with non-dynamic regexps. Within the `createRegExp` block you have to include all the helpers you are using from `magic-regexp` - and not rely on any external variables. This, for example, will not statically compile into a RegExp, although it will still continue to work with a minimal runtime: 14 | 15 | ```js 16 | const someString = 'test' 17 | const regExp = createRegExp(exactly(someString)) 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/content/1.guide/5.converter.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Converter (experimental) 3 | --- 4 | 5 | It is also possible to convert existing regular expressions to `magic-regexp` syntax. 6 | 7 | ```ts 8 | import { convert } from 'magic-regexp/converter' 9 | 10 | convert(/[abc]/) 11 | // createRegExp(exactly('a').or('b').or('c')) 12 | 13 | convert(/(foo)bar\d+/) 14 | // createRegExp(exactly('foo').grouped(), 'bar', oneOrMore(digit)) 15 | ``` 16 | 17 | ### Options 18 | 19 | - `argsOnly` (boolean) 20 | _Default: `false`_ 21 | Only show arguments without `createRegExp` 22 | 23 | ```ts 24 | convert(/\w+@\w\.com/, { argsOnly: true }) 25 | // oneOrMore(wordChar), '@', wordChar, '.com' 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/content/2.contribution.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contribution 3 | --- 4 | 5 | ## Roadmap 6 | 7 | **Future ideas** 8 | 9 | ::list{type=info} 10 | 11 | - More TypeScript guard-rails 12 | - More complex RegExp features/syntax 13 | - Instrumentation for accurately getting coverage on RegExps 14 | - Hybrid/partially-compiled RegExps for better dynamic support 15 | 16 | :: 17 | 18 | ## Development 19 | 20 | - Clone this repository 21 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` 22 | - Install dependencies using `pnpm install` 23 | - Run interactive tests using `pnpm dev` 24 | -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | navigation: false 4 | --- 5 | 6 | ::hero 7 | --- 8 | actions: 9 | - name: Documentation 10 | leftIcon: 'lucide:rocket' 11 | to: /guide 12 | - name: Try it out 13 | leftIcon: 'lucide:play' 14 | variant: outline 15 | to: https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbzgYwBYEMoBoVQKbox4BKeA5gKIAeYOAJsGcDDnlesjADYCeOI6HgCM8OCADs8AeSgBZaKLgB3aHQDCGKHAC+cAGZQIIOAHIBTZAFp8ZNmBMAoBwHoAVK4dxXKDONtwYCE9vUEhYRAA6KN0DI1NzYCsbO2c9AFdYVDwoSwTkRy8AiACoHgCeMDw4LjwANzwuOFJKGjgBGDQ4fABnNK4YbrgACjts0DxxGHQuAEpg5ydnZzgAFQq8OhR0MBgMqrJDNLBuh2QJbvgASQARAH1iCjgAXlwCImbqMBH2Tl4hk2AdEsJhmEXQ4joQwYTBgERg426QwArKCDhAjhsAIKIgF0EEzOZncQXOBoo6DF4mbpGKqAywAJgAzAAWACcAAY4Fl8Mp9hATBF2mghjd7hQZgB+CJk44uZZwBVwAB6EsWywAimlEgBrSzgoEMWBlbp4ED1KCnc7wADKFFkADUKMQxc9XoQSORPkNPHAJNI5AooYxmKjDpU6Nj-gIAFbQEFYH0CkwJhV+mTyfBBmGh9HhyNmYDiOMzFNtQQif4RZO+yTpwNoTA5jERnFgQhofEJuYOW0Op1iuXK1WW4nwHp9eAvZD4d0fGjfDjcHj-PQQCDOIgXCLR7ogsF6IhQf5CTDOfFwvAXY+n1frzewncgtVNPB6bITZCXopwMD4WrAdFul4LYdj2TYZUGNJukLMgAiyUkwzgcR0BAPARxJFYKAAOQoFYXSnGd3k9ecfTYRc-hUKB1E0aUwyxHE9GAKALg0TB42UVRWKgWjc3o-4TSJai2JLGt-QzPAhgbKACR9BUwQhCJ8DffBxE-FYIH4vBBK4p9FTgeS6EU1931UvB1JXJiWM0eMHG7IkSXHfp6VdTCcLwh4LyvExXIAHieJ4pH8gA+NyQSAA 16 | --- 17 | 18 | #title 19 | magic-regexp 20 | 21 | #description 22 | A compiled-away, type-safe, readable RegExp alternative. 23 | :: 24 | 25 | ::card-group 26 | ::card 27 | --- 28 | icon: 'heroicons-cube-transparent' 29 | icon-size: 26 30 | --- 31 | 32 | #title 33 | Lightweight runtime 34 | 35 | #description 36 | Zero-dependency, minimal runtime if no transform is used. 37 | :: 38 | 39 | ::card 40 | --- 41 | icon: 'heroicons-wrench' 42 | icon-size: 26 43 | --- 44 | 45 | #title 46 | ... or pure RegExp 47 | 48 | #description 49 | Ships with transform to compile to pure regular expression. 50 | :: 51 | 52 | ::card 53 | --- 54 | icon: 'heroicons-shield-check' 55 | icon-size: 26 56 | --- 57 | 58 | #title 59 | Type-safe 60 | 61 | #description 62 | Automatically typed capture groups, with generated RegExp displaying on hover. 63 | :: 64 | 65 | ::card 66 | --- 67 | icon: 'heroicons-book-open' 68 | icon-size: 26 69 | --- 70 | 71 | #title 72 | Intuitive syntax 73 | 74 | #description 75 | Natural language syntax regular expression builder. 76 | :: 77 | :: 78 | -------------------------------------------------------------------------------- /docs/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | devtools: { enabled: true }, 4 | extends: ['shadcn-docs-nuxt'], 5 | modules: [ 6 | '@nuxtjs/plausible', 7 | ], 8 | i18n: { 9 | defaultLocale: 'en', 10 | locales: [ 11 | { 12 | code: 'en', 13 | name: 'English', 14 | language: 'en-US', 15 | }, 16 | ], 17 | }, 18 | compatibilityDate: '2024-07-06', 19 | plausible: { 20 | domain: 'regexp.dev', 21 | apiHost: 'https://v.roe.dev', 22 | }, 23 | }) 24 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "type": "module", 4 | "private": true, 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "cd .. && pnpm build --stub && cd docs && pnpm nuxt prepare" 11 | }, 12 | "dependencies": { 13 | "@nuxtjs/plausible": "^1.2.0", 14 | "nuxt": "^3.17.4", 15 | "shadcn-docs-nuxt": "^1.0.1", 16 | "vue": "^3.5.16", 17 | "vue-router": "^4.5.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unjs/magic-regexp/339b8d9c2a1b2b4044f0bc9d75df2fc52a393fdf/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import animate from 'tailwindcss-animate' 2 | 3 | export default { 4 | darkMode: 'class', 5 | safelist: ['dark'], 6 | prefix: '', 7 | content: [ 8 | './content/**/*', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: '2rem', 14 | screens: { 15 | '2xl': '1400px', 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: 'hsl(var(--border))', 21 | input: 'hsl(var(--input))', 22 | ring: 'hsl(var(--ring))', 23 | background: 'hsl(var(--background))', 24 | foreground: 'hsl(var(--foreground))', 25 | primary: { 26 | DEFAULT: 'hsl(var(--primary))', 27 | foreground: 'hsl(var(--primary-foreground))', 28 | }, 29 | secondary: { 30 | DEFAULT: 'hsl(var(--secondary))', 31 | foreground: 'hsl(var(--secondary-foreground))', 32 | }, 33 | destructive: { 34 | DEFAULT: 'hsl(var(--destructive))', 35 | foreground: 'hsl(var(--destructive-foreground))', 36 | }, 37 | muted: { 38 | DEFAULT: 'hsl(var(--muted))', 39 | foreground: 'hsl(var(--muted-foreground))', 40 | }, 41 | accent: { 42 | DEFAULT: 'hsl(var(--accent))', 43 | foreground: 'hsl(var(--accent-foreground))', 44 | }, 45 | popover: { 46 | DEFAULT: 'hsl(var(--popover))', 47 | foreground: 'hsl(var(--popover-foreground))', 48 | }, 49 | card: { 50 | DEFAULT: 'hsl(var(--card))', 51 | foreground: 'hsl(var(--card-foreground))', 52 | }, 53 | }, 54 | borderRadius: { 55 | xl: 'calc(var(--radius) + 4px)', 56 | lg: 'var(--radius)', 57 | md: 'calc(var(--radius) - 2px)', 58 | sm: 'calc(var(--radius) - 4px)', 59 | }, 60 | keyframes: { 61 | 'accordion-down': { 62 | from: { height: '0' }, 63 | to: { height: 'var(--radix-accordion-content-height)' }, 64 | }, 65 | 'accordion-up': { 66 | from: { height: 'var(--radix-accordion-content-height)' }, 67 | to: { height: '0' }, 68 | }, 69 | 'collapsible-down': { 70 | from: { height: '0' }, 71 | to: { height: 'var(--radix-collapsible-content-height)' }, 72 | }, 73 | 'collapsible-up': { 74 | from: { height: 'var(--radix-collapsible-content-height)' }, 75 | to: { height: '0' }, 76 | }, 77 | }, 78 | animation: { 79 | 'accordion-down': 'accordion-down 0.2s ease-out', 80 | 'accordion-up': 'accordion-up 0.2s ease-out', 81 | 'collapsible-down': 'collapsible-down 0.2s ease-in-out', 82 | 'collapsible-up': 'collapsible-up 0.2s ease-in-out', 83 | }, 84 | }, 85 | }, 86 | 87 | plugins: [animate], 88 | } 89 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | stylistic: true, 5 | rules: { 6 | 'ts/method-signature-style': 'off', 7 | }, 8 | }).append({ 9 | files: ['test/**/*.bench.ts'], 10 | rules: { 11 | 'test/consistent-test-it': 'off', 12 | }, 13 | }, { 14 | files: ['test/**/*.test.ts'], 15 | rules: { 16 | 'regexp/no-dupe-disjunctions': 'off', 17 | 'regexp/strict': 'off', 18 | 'regexp/no-useless-assertions': 'off', 19 | 'regexp/no-useless-backreference': 'off', 20 | 'regexp/no-empty-group': 'off', 21 | 'regexp/no-empty-capturing-group': 'off', 22 | 'regexp/no-useless-non-capturing-group': 'off', 23 | 'regexp/no-useless-character-class': 'off', 24 | 'regexp/prefer-d': 'off', 25 | 'regexp/prefer-character-class': 'off', 26 | 'regexp/use-ignore-case': 'off', 27 | 'regexp/optimal-quantifier-concatenation': 'off', 28 | 'regexp/no-useless-quantifier': 'off', 29 | 'regexp/prefer-plus-quantifier': 'off', 30 | }, 31 | }, { 32 | files: ['docs/**/*'], 33 | rules: { 34 | 'regexp/prefer-character-class': 'off', 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /examples/date.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { anyOf, createRegExp, digit, exactly } from 'magic-regexp' 3 | 4 | // YYYY-MM-DD 5 | const year = digit.times(4).groupedAs('year') 6 | // From 1 to 12 7 | const month = anyOf(exactly('0').and(digit), '10', '11', '12').groupedAs('month') 8 | const day = anyOf( 9 | exactly('0').and(digit), 10 | exactly('1').and(digit), 11 | exactly('2').and(digit), 12 | '30', 13 | '31', 14 | ).groupedAs('day') 15 | 16 | const date = createRegExp(exactly(year, '-', month, '-', day)) 17 | 18 | /** 19 | * Valid dates 20 | */ 21 | console.log(date.exec('2020-01-01')) 22 | console.log(date.exec('2020-12-31')) 23 | console.log(date.exec('2020-02-29')) 24 | -------------------------------------------------------------------------------- /examples/email.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { anyOf, createRegExp, digit, letter, oneOrMore } from 'magic-regexp' 3 | 4 | const email = createRegExp( 5 | // Local part 6 | oneOrMore(anyOf(letter, digit, '-', '.', '_', '+')).groupedAs('localPart'), 7 | '@', 8 | // Domain 9 | oneOrMore(anyOf(letter, digit, '-', '.')).groupedAs('domain'), 10 | '.', 11 | // TLD 12 | oneOrMore(letter).groupedAs('tld'), 13 | ) 14 | 15 | /** 16 | * Valid emails 17 | */ 18 | console.log(email.exec('foo@bar.com')) 19 | console.log(email.exec('another.foo@maybe-bar.fr')) 20 | -------------------------------------------------------------------------------- /examples/semver.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { anyOf, createRegExp, digit, letter, maybe, oneOrMore } from 'magic-regexp' 3 | 4 | // https://semver.org/ 5 | const major = oneOrMore(digit).groupedAs('major') 6 | const minor = oneOrMore(digit).groupedAs('minor') 7 | const patch = oneOrMore(digit).groupedAs('patch') 8 | 9 | const nonDigit = anyOf(letter, '-', '.') 10 | 11 | const prerelease = oneOrMore(anyOf(digit, nonDigit)).groupedAs('prerelease') 12 | const build = oneOrMore(anyOf(digit, nonDigit)).groupedAs('build') 13 | 14 | const semver = createRegExp( 15 | major, 16 | '.', 17 | minor, 18 | '.', 19 | patch, 20 | maybe('-'), 21 | maybe(prerelease), 22 | maybe('+'), 23 | maybe(build), 24 | ) 25 | 26 | /** 27 | * Valid semver 28 | * @see https://semver.org/#semantic-versioning-specification-semver 29 | */ 30 | console.log(semver.exec('1.2.3')) 31 | console.log(semver.exec('1.2.3-alpha')) 32 | console.log(semver.exec('1.0.0-x.7.z.92')) 33 | console.log(semver.exec('1.0.0-alpha+001')) 34 | console.log(semver.exec('1.0.0+20130313144700')) 35 | console.log(semver.exec('1.0.0-beta+exp.sha.5114f85')) 36 | console.log(semver.exec('1.0.0+21AF26D3----117B344092BD')) 37 | -------------------------------------------------------------------------------- /examples/time.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { anyOf, createRegExp, digit, exactly } from 'magic-regexp' 3 | 4 | const hour = anyOf(exactly('0').and(digit), exactly('1').and(anyOf('0', '1', '2'))).groupedAs( 5 | 'hour', 6 | ) 7 | 8 | const minute = anyOf( 9 | exactly('0').and(digit), 10 | exactly('1').and(digit), 11 | exactly('2').and(digit), 12 | exactly('3').and(digit), 13 | exactly('4').and(digit), 14 | exactly('5').and(digit), 15 | ).groupedAs('minute') 16 | 17 | const dayPart = anyOf(exactly('AM'), exactly('PM')).groupedAs('dayPart') 18 | 19 | const time = createRegExp(exactly(hour, ':', minute, ' ', dayPart)) 20 | 21 | /** 22 | * Valid times 23 | */ 24 | console.log(time.exec('12:00 AM')) 25 | console.log(time.exec('12:00 PM')) 26 | console.log(time.exec('01:46 AM')) 27 | -------------------------------------------------------------------------------- /examples/url.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { anyOf, createRegExp, digit, exactly, letter, maybe, oneOrMore } from 'magic-regexp' 3 | 4 | // Inspired by https://www.rfc-editor.org/rfc/rfc3986#section-3 but not fully compliant. 5 | const schema = exactly(letter) 6 | .and(oneOrMore(anyOf(letter, digit, '+', '-', '.'))) 7 | .groupedAs('schema') 8 | 9 | const userinfo = oneOrMore( 10 | anyOf(letter, digit, '-', '.', '_', '~', '!', '$', '&', '"', '*', '+', ',', ';', '=', ':', '%'), 11 | ).groupedAs('userinfo') 12 | const host = oneOrMore(anyOf(letter, digit, '-', '.')).groupedAs('host') // This is simplified from the RFC. We consider using a registered host. 13 | const port = oneOrMore(digit).groupedAs('port') 14 | const authority = maybe(userinfo, '@').and(host, maybe(':', port)).groupedAs('authority') 15 | 16 | const path = oneOrMore( 17 | anyOf( 18 | letter, 19 | digit, 20 | '-', 21 | '.', 22 | '_', 23 | '~', 24 | '!', 25 | '$', 26 | '&', 27 | '\'', 28 | '(', 29 | ')', 30 | '*', 31 | '+', 32 | ',', 33 | ';', 34 | '=', 35 | ':', 36 | '@', 37 | '%', 38 | '/', 39 | ), 40 | ).groupedAs('path') 41 | 42 | const query = oneOrMore( 43 | anyOf( 44 | letter, 45 | digit, 46 | '-', 47 | '.', 48 | '_', 49 | '~', 50 | '!', 51 | '$', 52 | '&', 53 | '\'', 54 | '(', 55 | ')', 56 | '*', 57 | '+', 58 | ',', 59 | ';', 60 | '=', 61 | ':', 62 | '@', 63 | '%', 64 | '/', 65 | '?', 66 | ), 67 | ).groupedAs('query') 68 | 69 | const fragment = oneOrMore( 70 | anyOf( 71 | letter, 72 | digit, 73 | '-', 74 | '.', 75 | '_', 76 | '~', 77 | '!', 78 | '$', 79 | '&', 80 | '\'', 81 | '(', 82 | ')', 83 | '*', 84 | '+', 85 | ',', 86 | ';', 87 | '=', 88 | ':', 89 | '@', 90 | '%', 91 | '/', 92 | '?', 93 | ), 94 | ).groupedAs('fragment') 95 | 96 | const url = createRegExp( 97 | schema.and('://').and(authority).and(maybe(path), maybe('?', query), maybe('#', fragment)), 98 | ) 99 | 100 | console.log(url.exec('https://www.example.com/')) 101 | console.log(url.exec('https://www.example.com:8080/')) 102 | console.log(url.exec('https://user:pass@www.example.com')) 103 | console.log(url.exec('https://www.example.com/path/to/resource')) 104 | console.log(url.exec('https://www.example.com/path/to/resource?query=string')) 105 | console.log(url.exec('https://www.example.com/path/to/resource?query=string#fragment')) 106 | -------------------------------------------------------------------------------- /further-magic.d.ts: -------------------------------------------------------------------------------- 1 | // Legacy stub for previous TS versions 2 | 3 | export * from './dist/further-magic' 4 | -------------------------------------------------------------------------------- /nuxt.mjs: -------------------------------------------------------------------------------- 1 | import { addImportsSources, addVitePlugin, addWebpackPlugin, defineNuxtModule } from '@nuxt/kit' 2 | import * as magicRegexp from 'magic-regexp' 3 | import { MagicRegExpTransformPlugin } from 'magic-regexp/transform' 4 | 5 | export default defineNuxtModule({ 6 | setup(_options, nuxt) { 7 | addImportsSources({ 8 | from: 'magic-regexp', 9 | imports: Object.keys(magicRegexp), 10 | }) 11 | 12 | // Disable RegExp code transformation in development mode 13 | if (nuxt.options.dev) 14 | return 15 | 16 | addWebpackPlugin(MagicRegExpTransformPlugin.webpack()) 17 | addVitePlugin(MagicRegExpTransformPlugin.vite()) 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "magic-regexp", 3 | "version": "0.10.0", 4 | "packageManager": "pnpm@10.11.0", 5 | "description": "A compiled-away, type-safe, readable RegExp alternative", 6 | "license": "MIT", 7 | "homepage": "https://regexp.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/unjs/magic-regexp.git" 11 | }, 12 | "sideEffects": false, 13 | "exports": { 14 | ".": { 15 | "import": "./dist/index.mjs", 16 | "require": "./dist/index.cjs" 17 | }, 18 | "./converter": { 19 | "import": "./dist/converter.mjs", 20 | "require": "./dist/converter.cjs" 21 | }, 22 | "./transform": { 23 | "import": "./dist/transform.mjs", 24 | "require": "./dist/transform.cjs" 25 | }, 26 | "./further-magic": { 27 | "import": "./dist/further-magic.mjs", 28 | "require": "./dist/further-magic.cjs" 29 | }, 30 | "./nuxt": "./nuxt.mjs" 31 | }, 32 | "main": "./dist/index.cjs", 33 | "module": "./dist/index.mjs", 34 | "types": "./dist/index.d.ts", 35 | "files": [ 36 | "dist", 37 | "further-magic.d.ts", 38 | "nuxt.mjs", 39 | "transform.d.ts" 40 | ], 41 | "scripts": { 42 | "build": "unbuild", 43 | "dev": "vitest dev", 44 | "docs:generate": "nuxt generate docs", 45 | "lint": "eslint .", 46 | "prepare": "npx simple-git-hooks && pnpm build", 47 | "prepublishOnly": "pnpm lint && pnpm test", 48 | "release": "bumpp && npm publish", 49 | "test": "vitest run", 50 | "test:types": "tsc --noEmit" 51 | }, 52 | "dependencies": { 53 | "estree-walker": "^3.0.3", 54 | "magic-string": "^0.30.12", 55 | "mlly": "^1.7.2", 56 | "regexp-tree": "^0.1.27", 57 | "type-level-regexp": "~0.1.17", 58 | "ufo": "^1.5.4", 59 | "unplugin": "^2.0.0" 60 | }, 61 | "devDependencies": { 62 | "@antfu/eslint-config": "4.13.2", 63 | "@codspeed/vitest-plugin": "4.0.1", 64 | "@nuxt/kit": "3.17.4", 65 | "@types/estree": "1.0.7", 66 | "@types/node": "22.15.29", 67 | "@vitest/coverage-v8": "3.1.4", 68 | "acorn": "8.14.1", 69 | "bumpp": "10.1.1", 70 | "eslint": "9.28.0", 71 | "expect-type": "1.2.1", 72 | "lint-staged": "latest", 73 | "rollup": "4.41.1", 74 | "simple-git-hooks": "2.13.0", 75 | "typescript": "5.8.3", 76 | "unbuild": "3.5.0", 77 | "vite": "6.3.5", 78 | "vitest": "3.1.4" 79 | }, 80 | "resolutions": { 81 | "magic-regexp": "link:.", 82 | "nuxt": "3.17.4", 83 | "vite": "6.3.5", 84 | "vue": "3.5.16" 85 | }, 86 | "simple-git-hooks": { 87 | "pre-commit": "npx lint-staged" 88 | }, 89 | "lint-staged": { 90 | "*": [ 91 | "npx eslint --fix" 92 | ] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /playground/index.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import assert from 'node:assert' 3 | import { char, createRegExp, digit, exactly, maybe, oneOrMore, wordChar } from 'magic-regexp' 4 | 5 | /** 6 | * change to 7 | * import {...} from 'magic-regexp/further-magic' 8 | * to try type level RegExp match results (experimental) 9 | */ 10 | 11 | // Typed capture groups 12 | const ID_RE = createRegExp(exactly('id-').and(digit.times(5).groupedAs('id'))) 13 | const groups = 'some id-23490 here we go'.match(ID_RE)?.groups 14 | console.log(ID_RE, groups?.id) 15 | 16 | // Quick-and-dirty semver 17 | const SEMVER_RE = createRegExp( 18 | oneOrMore(digit).groupedAs('major'), 19 | '.', 20 | oneOrMore(digit).groupedAs('minor'), 21 | maybe('.', oneOrMore(char).groupedAs('patch')), 22 | ) 23 | console.log(SEMVER_RE) 24 | 25 | assert.equal(createRegExp(exactly('foo/test.js').after('bar/')).test('bar/foo/test.js'), true) 26 | 27 | // References to previously captured groups using the group name 28 | const TENET_RE = createRegExp( 29 | exactly(wordChar.groupedAs('firstChar'), wordChar.groupedAs('secondChar'), oneOrMore(char)) 30 | .and.referenceTo('secondChar') 31 | .and.referenceTo('firstChar'), 32 | ) 33 | 34 | assert.equal(TENET_RE.test('TEN<==O==>NET'), true) 35 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "node index.mjs" 5 | }, 6 | "dependencies": { 7 | "magic-regexp": "latest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | - docs 4 | ignoredBuiltDependencies: 5 | - '@parcel/watcher' 6 | - esbuild 7 | - shadcn-docs-nuxt 8 | - unrs-resolver 9 | - vue-demi 10 | onlyBuiltDependencies: 11 | - '@tailwindcss/oxide' 12 | - sharp 13 | - simple-git-hooks 14 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>danielroe/renovate"] 4 | } 5 | -------------------------------------------------------------------------------- /src/converter.ts: -------------------------------------------------------------------------------- 1 | import type { Char, ClassRange, Expression } from 'regexp-tree/ast' 2 | import regexpTree from 'regexp-tree' 3 | 4 | function build(node: Expression | null): string { 5 | if (node === null) 6 | return '' 7 | 8 | switch (node.type) { 9 | case 'CharacterClass': { 10 | const exprs = combineContinuousSimpleChars(node.expressions) 11 | 12 | // TODO: hard coded cases, need to be improved for multi char class 13 | if (exprs.length === 1) { 14 | const first = exprs[0] 15 | if (typeof first === 'string') { 16 | return node.negative ? `charNotIn(${first})` : `charIn(${first})` 17 | } 18 | else if (first.type === 'Char' && first.kind === 'meta' && node.negative) { 19 | if (first.value === '\\t') 20 | return `not.tab` 21 | if (first.value === '\\n') 22 | return `not.linefeed` 23 | if (first.value === '\\r') 24 | return `not.carriageReturn` 25 | } 26 | else { 27 | const range = normalizeClassRange(first) 28 | if (range === 'A-Z') 29 | return node.negative ? `not.letter.uppercase` : `letter.uppercase` 30 | else if (range === 'a-z') 31 | return node.negative ? `not.letter.lowercase` : `letter.lowercase` 32 | } 33 | } 34 | else if (exprs.length === 2) { 35 | if (typeof exprs[0] !== 'string' && typeof exprs[1] !== 'string') { 36 | const range1 = normalizeClassRange(exprs[0]) 37 | const range2 = normalizeClassRange(exprs[1]) 38 | if ((range1 === 'A-Z' && range2 === 'a-z') || (range1 === 'a-z' && range2 === 'A-Z')) 39 | return node.negative ? `not.letter` : `letter` 40 | } 41 | } 42 | 43 | throw new Error('Unsupported for Complex charactor class') 44 | } 45 | 46 | case 'Disjunction': 47 | return chain(build(node.left), `or(${build(node.right)})`) 48 | 49 | case 'Assertion': 50 | switch (node.kind) { 51 | case '\\b': 52 | return 'wordBoundary' 53 | 54 | case '\\B': 55 | return 'not.wordBoundary' 56 | 57 | case '^': 58 | return chain('', 'at.lineStart()') 59 | 60 | case '$': 61 | return chain('', 'at.lineEnd()') 62 | 63 | case 'Lookbehind': 64 | return chain('', `${node.negative ? 'notAfter' : 'after'}(${build(node.assertion)})`) 65 | 66 | case 'Lookahead': 67 | return chain('', `${node.negative ? 'notBefore' : 'before'}(${build(node.assertion)})`) 68 | 69 | /* v8 ignore next 2 */ 70 | default: 71 | throw new TypeError(`Unknown Assertion kind: ${(node as any).kind}`) 72 | } 73 | case 'Char': 74 | if (node.kind === 'meta') { 75 | switch (node.value) { 76 | case '.': 77 | return 'char' 78 | 79 | case '\\w': 80 | return 'wordChar' 81 | case '\\d': 82 | return 'digit' 83 | case '\\s': 84 | return 'whitespace' 85 | case '\\t': 86 | return 'tab' 87 | case '\\n': 88 | return 'linefeed' 89 | case '\\r': 90 | return 'carriageReturn' 91 | 92 | case '\\W': 93 | return 'not.wordChar' 94 | case '\\D': 95 | return 'not.digit' 96 | case '\\S': 97 | return 'not.whitespace' 98 | 99 | case '\f': 100 | case '\v': 101 | default: 102 | throw new Error(`Unsupported Meta Char: ${node.value}`) 103 | } 104 | } 105 | else { 106 | const char = getChar(node) 107 | if (char === null) 108 | throw new Error(`Unknown Char: ${node.value}`) 109 | return `'${char}'` 110 | } 111 | 112 | case 'Repetition': { 113 | const quantifier = node.quantifier 114 | const expr = build(node.expression) 115 | 116 | // TODO: support lazy quantifier 117 | const lazy = !quantifier.greedy 118 | if (lazy) 119 | throw new Error('Unsupported for lazy quantifier') 120 | 121 | switch (quantifier.kind) { 122 | case '+': 123 | return `oneOrMore(${expr})` 124 | case '?': 125 | return `maybe(${expr})` 126 | case '*': 127 | return chain(expr, 'times.any()') 128 | case 'Range': 129 | // {1} 130 | if (quantifier.from === quantifier.to) 131 | return chain(expr, `times(${quantifier.from})`) 132 | 133 | // {1,} 134 | else if (!quantifier.to) 135 | return chain(expr, `times.atLeast(${quantifier.from})`) 136 | 137 | // {0,3} 138 | else if (quantifier.from === 0) 139 | return chain(expr, `times.atMost(${quantifier.to})`) 140 | 141 | // {1,3} 142 | return chain(expr, `times.between(${quantifier.from}, ${quantifier.to})`) 143 | 144 | /* v8 ignore next 2 */ 145 | default: 146 | return '' as never 147 | } 148 | } 149 | 150 | case 'Alternative': { 151 | const alts = combineContinuousSimpleChars(node.expressions) 152 | const exprs: string[] = [] 153 | 154 | for (let i = 0; i < alts.length; i++) { 155 | const alt = alts[i] 156 | 157 | if (typeof alt === 'string') { 158 | exprs.push(alt) 159 | continue 160 | } 161 | 162 | if (alt.type === 'Assertion') { 163 | switch (alt.kind) { 164 | case '^': { 165 | const next = alts[++i] 166 | if (next === undefined) 167 | throw new Error(`Unexpected assertion: ${JSON.stringify(alt)}`) 168 | exprs.push(chain(next, 'at.lineStart()')) 169 | continue 170 | } 171 | 172 | case '$': { 173 | const prev = exprs.pop() 174 | if (prev === undefined) 175 | throw new Error(`Unexpected assertion: ${JSON.stringify(alt)}`) 176 | exprs.push(chain(prev, 'at.lineEnd()')) 177 | continue 178 | } 179 | 180 | case 'Lookbehind': { 181 | const next = alts[++i] 182 | if (next === undefined) 183 | throw new Error(`Unexpected assertion: ${JSON.stringify(alt)}`) 184 | const helper = alt.negative ? 'notAfter' : 'after' 185 | exprs.push(chain(next, `${helper}(${build(alt.assertion)})`)) 186 | continue 187 | } 188 | 189 | case 'Lookahead': { 190 | const prev = exprs.pop() 191 | if (prev === undefined) 192 | throw new Error(`Unexpected assertion: ${JSON.stringify(alt)}`) 193 | const helper = alt.negative ? 'notBefore' : 'before' 194 | exprs.push(chain(prev, `${helper}(${build(alt.assertion)})`)) 195 | continue 196 | } 197 | } 198 | } 199 | 200 | // TODO: currenly not support backreference for cross group 201 | if (alt.type === 'Backreference') { 202 | if (alt.kind !== 'name') 203 | throw new Error(`Unsupport for number reference`) 204 | 205 | const ref = chain(`exactly(${exprs.join(', ')})`, `and.referenceTo('${alt.reference}')`) 206 | exprs.length = 0 207 | exprs.push(ref) 208 | continue 209 | } 210 | 211 | exprs.push(build(alt)) 212 | } 213 | 214 | return exprs.join(', ') 215 | } 216 | case 'Group': 217 | if (node.capturing) 218 | return chain(build(node.expression), node.name ? `as('${node.name}')` : 'grouped()') 219 | else return chain(build(node.expression)) 220 | 221 | /* v8 ignore next 2 */ 222 | case 'Backreference': 223 | return chain('', `and.referenceTo('${node.reference}')`) 224 | } 225 | } 226 | 227 | function normalizeClassRange(node: Char | ClassRange): string | undefined { 228 | if (node.type === 'ClassRange') 229 | return `${node.from.value}-${node.to.value}` 230 | } 231 | 232 | function combineContinuousSimpleChars( 233 | expressions: T[], 234 | ): (T | string)[] { 235 | let simpleChars = '' 236 | const exprs = expressions.reduce( 237 | (acc, expr) => { 238 | const char = expr.type === 'Char' ? getChar(expr) : null 239 | if (char !== null) { 240 | simpleChars += char 241 | } 242 | else { 243 | if (simpleChars) { 244 | acc.push(`'${simpleChars}'`) 245 | simpleChars = '' 246 | } 247 | acc.push(expr) 248 | } 249 | return acc 250 | }, 251 | [] as Array, 252 | ) 253 | 254 | // Add the last accumulated string if it exists 255 | if (simpleChars) 256 | exprs.push(`'${simpleChars}'`) 257 | 258 | return exprs 259 | } 260 | 261 | function getChar(char: Char): string | null { 262 | function escapeSimpleChar(char: string): string { 263 | // for generator only because we will output createRegExp('...') 264 | return char === '\'' ? '\\\'' : char 265 | } 266 | 267 | switch (char.kind) { 268 | case 'simple': 269 | return escapeSimpleChar(char.value) 270 | 271 | case 'oct': 272 | case 'decimal': 273 | case 'hex': 274 | case 'unicode': 275 | if ('symbol' in char) 276 | return escapeSimpleChar((char as any).symbol) 277 | } 278 | 279 | return null 280 | } 281 | 282 | function chain(expr: Expression | string, helper?: string): string { 283 | let _expr = '' 284 | if (typeof expr === 'string') { 285 | if (expr === '') 286 | _expr = 'exactly(\'\')' 287 | else _expr = expr.startsWith('\'') && expr.endsWith('\'') ? `exactly(${expr})` : expr 288 | } 289 | else { 290 | _expr = build(expr) 291 | } 292 | return helper ? `${_expr}.${helper}` : _expr 293 | } 294 | 295 | function buildFlags(flags: string) { 296 | if (!flags) 297 | return '' 298 | 299 | const readableFlags = flags.split('').map((flag) => { 300 | return ( 301 | { 302 | d: 'withIndices', 303 | i: 'caseInsensitive', 304 | g: 'global', 305 | m: 'multiline', 306 | s: 'dotAll', 307 | u: 'unicode', 308 | y: 'sticky', 309 | }[flag] || `'${flag}'` 310 | ) 311 | }) 312 | 313 | return `[${readableFlags.join(', ')}]` 314 | } 315 | 316 | export function convert(regex: RegExp, { argsOnly = false } = {}) { 317 | const ast = regexpTree.parse(regex) 318 | 319 | if (ast.type !== 'RegExp') 320 | throw new TypeError(`Unexpected RegExp AST: ${ast.type}`) 321 | 322 | const flags = buildFlags(ast.flags) 323 | const args = build(ast.body) + (flags ? `, ${flags}` : '') 324 | return argsOnly ? args : `createRegExp(${args})` 325 | } 326 | -------------------------------------------------------------------------------- /src/core/flags.ts: -------------------------------------------------------------------------------- 1 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#advanced_searching_with_flags 2 | 3 | export type Flag = 'd' | 'g' | 'i' | 'm' | 's' | 'u' | 'y' 4 | 5 | /** Generate indices for substring matches */ 6 | export const withIndices = 'd' 7 | 8 | /** Case-insensitive search */ 9 | export const caseInsensitive = 'i' 10 | 11 | /** Global search */ 12 | export const global = 'g' 13 | 14 | /** Multi-line search */ 15 | export const multiline = 'm' 16 | 17 | /** Allows `.` to match newline characters */ 18 | export const dotAll = 's' 19 | 20 | /** Treat a pattern as a sequence of unicode code points */ 21 | export const unicode = 'u' 22 | 23 | /** Perform a "sticky" search that matches starting at the current position in the target string */ 24 | export const sticky = 'y' 25 | -------------------------------------------------------------------------------- /src/core/inputs.ts: -------------------------------------------------------------------------------- 1 | import type { CharInput, Input } from './internal' 2 | import type { EscapeChar } from './types/escape' 3 | import type { Join } from './types/join' 4 | import type { InputSource, MapToCapturedGroupsArr, MapToGroups, MapToValues } from './types/sources' 5 | import type { IfUnwrapped } from './wrap' 6 | 7 | import { createInput } from './internal' 8 | import { wrap } from './wrap' 9 | 10 | export type { Input } 11 | 12 | const ESCAPE_REPLACE_RE = /[.*+?^${}()|[\]\\/]/g 13 | 14 | function createCharInput(raw: T) { 15 | const input = createInput(`[${raw}]`) 16 | const from = (charFrom: From, charTo: To) => createCharInput(`${raw}${escapeCharInput(charFrom)}-${escapeCharInput(charTo)}`) 17 | const orChar = Object.assign((chars: T) => createCharInput(`${raw}${escapeCharInput(chars)}`), { from }) 18 | return Object.assign(input, { orChar, from }) as CharInput 19 | } 20 | 21 | function escapeCharInput(raw: T) { 22 | return raw.replace(/[-\\^\]]/g, '\\$&') as EscapeChar 23 | } 24 | 25 | /** This matches any character in the string provided */ 26 | export const charIn = Object.assign((chars: T) => { 27 | return createCharInput(escapeCharInput(chars)) 28 | }, createCharInput('')) 29 | 30 | /** This matches any character that is not in the string provided */ 31 | export const charNotIn = Object.assign((chars: T) => { 32 | return createCharInput(`^${escapeCharInput(chars)}`) 33 | }, createCharInput('^')) 34 | 35 | /** 36 | * This takes a variable number of inputs and matches any of them 37 | * @example 38 | * anyOf('foo', maybe('bar'), 'baz') // => /(?:foo|(?:bar)?|baz)/ 39 | * @argument inputs - arbitrary number of `string` or `Input`, where `string` will be escaped 40 | */ 41 | export function anyOf(...inputs: Inputs): Input<`(?:${Join>})`, MapToGroups, MapToCapturedGroupsArr> { 42 | return createInput(`(?:${inputs.map(a => exactly(a)).join('|')})`) 43 | } 44 | 45 | export const char = createInput('.') 46 | export const word = createInput('\\b\\w+\\b') 47 | export const wordChar = createInput('\\w') 48 | export const wordBoundary = createInput('\\b') 49 | export const digit = createInput('\\d') 50 | export const whitespace = createInput('\\s') 51 | export const letter = Object.assign(createInput('[a-zA-Z]'), { 52 | lowercase: createInput('[a-z]'), 53 | uppercase: createInput('[A-Z]'), 54 | }) 55 | export const tab = createInput('\\t') 56 | export const linefeed = createInput('\\n') 57 | export const carriageReturn = createInput('\\r') 58 | 59 | export const not = { 60 | word: createInput('\\W+'), 61 | wordChar: createInput('\\W'), 62 | wordBoundary: createInput('\\B'), 63 | digit: createInput('\\D'), 64 | whitespace: createInput('\\S'), 65 | letter: Object.assign(createInput('[^a-zA-Z]'), { 66 | lowercase: createInput('[^a-z]'), 67 | uppercase: createInput('[^A-Z]'), 68 | }), 69 | tab: createInput('[^\\t]'), 70 | linefeed: createInput('[^\\n]'), 71 | carriageReturn: createInput('[^\\r]'), 72 | } 73 | 74 | /** 75 | * Equivalent to `?` - takes a variable number of inputs and marks them as optional 76 | * @example 77 | * maybe('foo', exactly('ba?r')) // => /(?:fooba\?r)?/ 78 | * @argument inputs - arbitrary number of `string` or `Input`, where `string` will be escaped 79 | */ 80 | export function maybe< 81 | Inputs extends InputSource[], 82 | Value extends string = Join, '', ''>, 83 | >(...inputs: Inputs): Input< 84 | IfUnwrapped, 85 | MapToGroups, 86 | MapToCapturedGroupsArr 87 | > { 88 | return createInput(`${wrap(exactly(...inputs))}?`) 89 | } 90 | 91 | /** 92 | * This takes a variable number of inputs and concatenate their patterns, and escapes string inputs to match it exactly 93 | * @example 94 | * exactly('fo?o', maybe('bar')) // => /fo\?o(?:bar)?/ 95 | * @argument inputs - arbitrary number of `string` or `Input`, where `string` will be escaped 96 | */ 97 | export function exactly(...inputs: Inputs): Input, '', ''>, MapToGroups, MapToCapturedGroupsArr> { 98 | return createInput( 99 | inputs 100 | .map(input => (typeof input === 'string' ? input.replace(ESCAPE_REPLACE_RE, '\\$&') : input)) 101 | .join(''), 102 | ) 103 | } 104 | 105 | /** 106 | * Equivalent to `+` - this takes a variable number of inputs and marks them as repeatable, any number of times but at least once 107 | * @example 108 | * oneOrMore('foo', maybe('bar')) // => /(?:foo(?:bar)?)+/ 109 | * @argument inputs - arbitrary number of `string` or `Input`, where `string` will be escaped 110 | */ 111 | export function oneOrMore< 112 | Inputs extends InputSource[], 113 | Value extends string = Join, '', ''>, 114 | >(...inputs: Inputs): Input< 115 | IfUnwrapped, 116 | MapToGroups, 117 | MapToCapturedGroupsArr 118 | > { 119 | return createInput(`${wrap(exactly(...inputs))}+`) 120 | } 121 | -------------------------------------------------------------------------------- /src/core/internal.ts: -------------------------------------------------------------------------------- 1 | import type { EscapeChar } from './types/escape' 2 | import type { Join } from './types/join' 3 | import type { InputSource, MapToCapturedGroupsArr, MapToGroups, MapToValues } from './types/sources' 4 | import type { IfUnwrapped } from './wrap' 5 | 6 | import { exactly } from './inputs' 7 | import { wrap } from './wrap' 8 | 9 | const GROUPED_AS_REPLACE_RE = /^(?:\(\?:(.+)\)|(.+))$/ 10 | const GROUPED_REPLACE_RE = /^(?:\(\?:(.+)\)([?+*]|\{[\d,]+\})?|(.+))$/ 11 | 12 | export interface Input< 13 | V extends string, 14 | G extends string = never, 15 | C extends (string | undefined)[] = [], 16 | > { 17 | /** 18 | * this takes a variable number of inputs and adds them as new pattern to the current input, or you can use `and.referenceTo(groupName)` to adds a new pattern referencing to a named group 19 | * @example 20 | * exactly('foo').and('bar', maybe('baz')) // => /foobar(?:baz)?/ 21 | * @argument inputs - arbitrary number of `string` or `Input`, where `string` will be escaped 22 | */ 23 | and: { 24 | >( 25 | ...inputs: I 26 | ): Input<`${V}${Join, '', ''>}`, G | MapToGroups, [...C, ...CG]> 27 | /** this adds a new pattern to the current input, with the pattern reference to a named group. */ 28 | referenceTo: (groupName: N) => Input<`${V}\\k<${N}>`, G, C> 29 | } 30 | /** 31 | * this takes a variable number of inputs and provides as an alternative to the current input 32 | * @example 33 | * exactly('foo').or('bar', maybe('baz')) // => /foo|bar(?:baz)?/ 34 | * @argument inputs - arbitrary number of `string` or `Input`, where `string` will be escaped 35 | */ 36 | or: >( 37 | ...inputs: I 38 | ) => Input<`(?:${V}|${Join, '', ''>})`, G | MapToGroups, [...C, ...CG]> 39 | /** 40 | * this takes a variable number of inputs and activate a positive lookbehind. Make sure to check [browser support](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#browser_compatibility) as not all browsers support lookbehinds (notably Safari) 41 | * @example 42 | * exactly('foo').after('bar', maybe('baz')) // => /(?<=bar(?:baz)?)foo/ 43 | * @argument inputs - arbitrary number of `string` or `Input`, where `string` will be escaped 44 | */ 45 | after: >( 46 | ...inputs: I 47 | ) => Input<`(?<=${Join, '', ''>})${V}`, G | MapToGroups, [...CG, ...C]> 48 | /** 49 | * this takes a variable number of inputs and activate a positive lookahead 50 | * @example 51 | * exactly('foo').before('bar', maybe('baz')) // => /foo(?=bar(?:baz)?)/ 52 | * @argument inputs - arbitrary number of `string` or `Input`, where `string` will be escaped 53 | */ 54 | before: >( 55 | ...inputs: I 56 | ) => Input<`${V}(?=${Join, '', ''>})`, G, [...C, ...CG]> 57 | /** 58 | * these takes a variable number of inputs and activate a negative lookbehind. Make sure to check [browser support](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#browser_compatibility) as not all browsers support lookbehinds (notably Safari) 59 | * @example 60 | * exactly('foo').notAfter('bar', maybe('baz')) // => /(?>( 64 | ...inputs: I 65 | ) => Input<`(?, '', ''>})${V}`, G, [...CG, ...C]> 66 | /** 67 | * this takes a variable number of inputs and activate a negative lookahead 68 | * @example 69 | * exactly('foo').notBefore('bar', maybe('baz')) // => /foo(?!bar(?:baz)?)/ 70 | * @argument inputs - arbitrary number of `string` or `Input`, where `string` will be escaped 71 | */ 72 | notBefore: >( 73 | ...inputs: I 74 | ) => Input<`${V}(?!${Join, '', ''>})`, G, [...C, ...CG]> 75 | /** repeat the previous pattern an exact number of times */ 76 | times: { 77 | >( 78 | number: N 79 | ): Input 80 | /** specify that the expression can repeat any number of times, _including none_ */ 81 | any: >() => Input 82 | /** specify that the expression must occur at least `N` times */ 83 | atLeast: < 84 | N extends number, 85 | NV extends string = IfUnwrapped, 86 | >( 87 | number: N 88 | ) => Input 89 | /** specify that the expression must occur at most `N` times */ 90 | atMost: < 91 | N extends number, 92 | NV extends string = IfUnwrapped, 93 | >( 94 | number: N 95 | ) => Input 96 | /** specify a range of times to repeat the previous pattern */ 97 | between: < 98 | Min extends number, 99 | Max extends number, 100 | NV extends string = IfUnwrapped, 101 | >( 102 | min: Min, 103 | max: Max 104 | ) => Input 105 | } 106 | /** this defines the entire input so far as a named capture group. You will get type safety when using the resulting RegExp with `String.match()`. Alias for `groupedAs` */ 107 | as: ( 108 | key: K 109 | ) => Input< 110 | V extends `(?:${infer S})` ? `(?<${K}>${S})` : `(?<${K}>${V})`, 111 | G | K, 112 | [V extends `(?:${infer S})` ? `(?<${K}>${S})` : `(?<${K}>${V})`, ...C] 113 | > 114 | /** this defines the entire input so far as a named capture group. You will get type safety when using the resulting RegExp with `String.match()` */ 115 | groupedAs: ( 116 | key: K 117 | ) => Input< 118 | V extends `(?:${infer S})` ? `(?<${K}>${S})` : `(?<${K}>${V})`, 119 | G | K, 120 | [V extends `(?:${infer S})` ? `(?<${K}>${S})` : `(?<${K}>${V})`, ...C] 121 | > 122 | /** this capture the entire input so far as an anonymous group */ 123 | grouped: () => Input< 124 | V extends `(?:${infer S})${infer E}` ? `(${S})${E}` : `(${V})`, 125 | G, 126 | [V extends `(?:${infer S})${'' | '?' | '+' | '*' | `{${string}}`}` ? `(${S})` : `(${V})`, ...C] 127 | > 128 | /** this allows you to match beginning/ends of lines with `at.lineStart()` and `at.lineEnd()` */ 129 | at: { 130 | lineStart: () => Input<`^${V}`, G, C> 131 | lineEnd: () => Input<`${V}$`, G, C> 132 | } 133 | /** this allows you to mark the input so far as optional */ 134 | optionally: >() => Input 135 | 136 | toString: () => string 137 | } 138 | 139 | export interface CharInput extends Input<`[${T}]`> { 140 | orChar: ((chars: Or) => CharInput<`${T}${EscapeChar}`>) & CharInput 141 | from: (charFrom: From, charTo: To) => CharInput<`${T}${EscapeChar}-${EscapeChar}`> 142 | } 143 | 144 | export function createInput< 145 | Value extends string, 146 | Groups extends string = never, 147 | CaptureGroupsArr extends (string | undefined)[] = [], 148 | >(s: Value | Input): Input { 149 | const groupedAsFn = (key: string) => 150 | createInput(`(?<${key}>${`${s}`.replace(GROUPED_AS_REPLACE_RE, '$1$2')})`) 151 | 152 | return { 153 | toString: () => s.toString(), 154 | and: Object.assign((...inputs: InputSource[]) => createInput(`${s}${exactly(...inputs)}`), { 155 | referenceTo: (groupName: string) => createInput(`${s}\\k<${groupName}>`), 156 | }), 157 | or: (...inputs) => createInput(`(?:${s}|${inputs.map(v => exactly(v)).join('|')})`), 158 | after: (...input) => createInput(`(?<=${exactly(...input)})${s}`), 159 | before: (...input) => createInput(`${s}(?=${exactly(...input)})`), 160 | notAfter: (...input) => createInput(`(? createInput(`${s}(?!${exactly(...input)})`), 162 | times: Object.assign((number: number) => createInput(`${wrap(s)}{${number}}`), { 163 | any: () => createInput(`${wrap(s)}*`), 164 | atLeast: (min: number) => createInput(`${wrap(s)}{${min},}`), 165 | atMost: (max: number) => createInput(`${wrap(s)}{0,${max}}`), 166 | between: (min: number, max: number) => createInput(`${wrap(s)}{${min},${max}}`), 167 | }), 168 | optionally: () => createInput(`${wrap(s)}?`), 169 | as: groupedAsFn, 170 | groupedAs: groupedAsFn, 171 | grouped: () => createInput(`${s}`.replace(GROUPED_REPLACE_RE, '($1$3)$2')), 172 | at: { 173 | lineStart: () => createInput(`^${s}`), 174 | lineEnd: () => createInput(`${s}$`), 175 | }, 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/core/types/escape.ts: -------------------------------------------------------------------------------- 1 | import type { Input } from '../inputs' 2 | import type { InputSource } from './sources' 3 | 4 | export type Escape< 5 | T extends string, 6 | EscapeChar extends string, 7 | > = T extends `${infer Start}${EscapeChar}${string}` 8 | ? Start extends `${string}${EscapeChar}${string}` 9 | ? never 10 | : T extends `${Start}${infer Char}${string}` 11 | ? Char extends EscapeChar 12 | ? T extends `${Start}${Char}${infer Rest}` 13 | ? `${Start}\\${Char}${Escape}` 14 | : never 15 | : never 16 | : never 17 | : T 18 | 19 | export type EscapeChar = Escape 20 | export type StripEscapes = T extends `${infer A}\\${infer B}` ? `${A}${B}` : T 21 | 22 | // prettier-ignore 23 | export type ExactEscapeChar = '.' | '*' | '+' | '?' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '/' 24 | 25 | export type GetValue = T extends string 26 | ? Escape 27 | : T extends Input 28 | ? R 29 | : never 30 | -------------------------------------------------------------------------------- /src/core/types/join.ts: -------------------------------------------------------------------------------- 1 | export type Join< 2 | T extends string[], 3 | Prefix extends string = '', 4 | Joiner extends string = '|', 5 | > = T extends [infer F, ...infer R] 6 | ? F extends string 7 | ? `${Prefix}${F}${R extends string[] ? Join : ''}` 8 | : '' 9 | : '' 10 | 11 | type UnionToIntersection = (Union extends Union ? (a: Union) => any : never) extends ( 12 | a: infer I 13 | ) => any 14 | ? I 15 | : never 16 | 17 | export type UnionToTuple = UnionToIntersection< 18 | Union extends any ? () => Union : never 19 | > extends () => infer Item 20 | ? UnionToTuple, [...Tuple, Item]> 21 | : Tuple 22 | -------------------------------------------------------------------------------- /src/core/types/magic-regexp.ts: -------------------------------------------------------------------------------- 1 | const NamedGroupsS = Symbol('NamedGroups') 2 | const ValueS = Symbol('Value') 3 | const CapturedGroupsArrS = Symbol('CapturedGroupsArr') 4 | const FlagsS = Symbol('Flags') 5 | 6 | export type MagicRegExp< 7 | Value extends string, 8 | NamedGroups extends string | never = never, 9 | CapturedGroupsArr extends (string | undefined)[] = [], 10 | Flags extends string | never = never, 11 | > = RegExp & { 12 | [NamedGroupsS]: NamedGroups 13 | [CapturedGroupsArrS]: CapturedGroupsArr 14 | [ValueS]: Value 15 | [FlagsS]: Flags 16 | } 17 | 18 | type ExtractGroups> = 19 | T extends MagicRegExp ? V : never 20 | 21 | type StringWithHint = string & { 22 | _capturedBy: S 23 | } 24 | 25 | export type StringCapturedBy = StringWithHint 26 | 27 | export type MapToStringCapturedBy = { 28 | [K in keyof Ar]: Ar[K] extends string ? StringCapturedBy | undefined : undefined 29 | } 30 | 31 | export type MagicRegExpMatchArray> = Omit< 32 | RegExpMatchArray, 33 | 'groups' 34 | > & { 35 | groups: Record, string | undefined> 36 | } & { 37 | [index: number | string | symbol]: never 38 | } & (T extends MagicRegExp 39 | ? readonly [string | undefined, ...MapToStringCapturedBy] 40 | // eslint-disable-next-line ts/no-empty-object-type 41 | : {}) 42 | -------------------------------------------------------------------------------- /src/core/types/sources.ts: -------------------------------------------------------------------------------- 1 | import type { Input } from '../internal' 2 | import type { GetValue } from './escape' 3 | 4 | export type InputSource = S | Input 5 | 6 | export type MapToValues = T extends [ 7 | infer First, 8 | ...infer Rest extends InputSource[], 9 | ] 10 | ? First extends InputSource 11 | ? [GetValue, ...MapToValues] 12 | : [] 13 | : [] 14 | 15 | export type MapToGroups = T extends [ 16 | infer First, 17 | ...infer Rest extends InputSource[], 18 | ] 19 | ? First extends Input 20 | ? K | MapToGroups 21 | : MapToGroups 22 | : never 23 | 24 | export type MapToCapturedGroupsArr< 25 | Inputs extends any[], 26 | MapToUndefined extends boolean = false, 27 | CapturedGroupsArr extends any[] = [], 28 | Count extends any[] = [], 29 | > = Count['length'] extends Inputs['length'] 30 | ? CapturedGroupsArr 31 | : Inputs[Count['length']] extends Input 32 | ? [CaptureGroups] extends [never] 33 | ? MapToCapturedGroupsArr 34 | : MapToUndefined extends true 35 | ? MapToCapturedGroupsArr< 36 | Inputs, 37 | MapToUndefined, 38 | [...CapturedGroupsArr, undefined], 39 | [...Count, ''] 40 | > 41 | : MapToCapturedGroupsArr< 42 | Inputs, 43 | MapToUndefined, 44 | [...CapturedGroupsArr, ...CaptureGroups], 45 | [...Count, ''] 46 | > 47 | : MapToCapturedGroupsArr 48 | -------------------------------------------------------------------------------- /src/core/wrap.ts: -------------------------------------------------------------------------------- 1 | import type { Input } from './internal' 2 | import type { StripEscapes } from './types/escape' 3 | 4 | export type IfUnwrapped = Value extends `(${string})` 5 | ? No 6 | : StripEscapes extends `${infer A}${infer B}` 7 | ? A extends '' 8 | ? No 9 | : B extends '' 10 | ? No 11 | : Yes 12 | : never 13 | 14 | const NO_WRAP_RE = /^(?:\(.*\)|\\?.)$/ 15 | 16 | export function wrap(s: string | Input) { 17 | const v = s.toString() 18 | return NO_WRAP_RE.test(v) ? v : `(?:${v})` 19 | } 20 | -------------------------------------------------------------------------------- /src/further-magic.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | MatchAllRegExp, 3 | MatchRegExp, 4 | ParseRegExp, 5 | RegExpMatchResult, 6 | ReplaceWithRegExp, 7 | } from 'type-level-regexp/regexp' 8 | import type { Flag } from './core/flags' 9 | import type { Join, UnionToTuple } from './core/types/join' 10 | import type { InputSource, MapToGroups, MapToValues } from './core/types/sources' 11 | 12 | import { exactly } from './core/inputs' 13 | 14 | const NamedGroupsS = Symbol('NamedGroupsType') 15 | const ValueS = Symbol('Value') 16 | const FlagsS = Symbol('Flags') 17 | 18 | export type MagicRegExp< 19 | Value extends string, 20 | NamedGroups extends string | never = never, 21 | Flags extends Flag[] | never = never, 22 | > = RegExp & { 23 | [NamedGroupsS]: NamedGroups 24 | [ValueS]: Value 25 | [FlagsS]: Flags 26 | } 27 | 28 | export const createRegExp: { 29 | /** Create Magic RegExp from Input helpers and string (string will be sanitized) */ 30 | ( 31 | ...inputs: Inputs 32 | ): MagicRegExp<`/${Join, '', ''>}/`, MapToGroups, []> 33 | < 34 | Inputs extends InputSource[], 35 | FlagUnion extends Flag | undefined = undefined, 36 | CloneFlagUnion extends Flag | undefined = FlagUnion, 37 | Flags extends Flag[] = CloneFlagUnion extends undefined 38 | ? [] 39 | : UnionToTuple extends infer F extends Flag[] 40 | ? F 41 | : never, 42 | >( 43 | ...inputs: [...Inputs, [...Flags] | string | Set] 44 | ): MagicRegExp< 45 | `/${Join, '', ''>}/${Join}`, 46 | MapToGroups, 47 | Flags 48 | > 49 | } = (...inputs: any[]) => { 50 | const flags 51 | = inputs.length > 1 52 | && (Array.isArray(inputs[inputs.length - 1]) || inputs[inputs.length - 1] instanceof Set) 53 | ? inputs.pop() 54 | : undefined 55 | return new RegExp(exactly(...inputs).toString(), [...(flags || '')].join('')) as any 56 | } 57 | 58 | export * from './core/flags' 59 | export * from './core/inputs' 60 | export * from './core/types/magic-regexp' 61 | export { spreadRegExpIterator, spreadRegExpMatchArray } from 'type-level-regexp/regexp' 62 | 63 | // Add additional overload to global String object types to allow for typed capturing groups 64 | declare global { 65 | interface String { 66 | match( 67 | this: InputString, 68 | regexp: MagicRegExp<`/${RegExpPattern}/${Join}`, string, Flags> 69 | ): MatchRegExp< 70 | InputString, 71 | ParseRegExp, 72 | Flag[] extends Flags ? never : Flags[number] 73 | > 74 | 75 | /** @deprecated String.matchAll requires global flag to be set. */ 76 | matchAll[]>>(regexp: R): never 77 | 78 | matchAll( 79 | this: InputString, 80 | regexp: MagicRegExp<`/${RegExpPattern}/${Join}`, string, Flags> 81 | ): MatchAllRegExp< 82 | InputString, 83 | ParseRegExp, 84 | Flag[] extends Flags ? never : Flags[number] 85 | > 86 | 87 | /** @deprecated String.matchAll requires global flag to be set. */ 88 | matchAll>(regexp: R): never 89 | 90 | replace< 91 | InputString extends string, 92 | RegExpPattern extends string, 93 | Flags extends Flag[], 94 | ReplaceValue extends string, 95 | RegExpParsedAST extends any[] = string extends RegExpPattern 96 | ? never 97 | : ParseRegExp, 98 | MatchResult = MatchRegExp, 99 | Match extends any[] = MatchResult extends RegExpMatchResult< 100 | { 101 | matched: infer MatchArray extends any[] 102 | namedCaptures: [string, any] 103 | input: infer Input extends string 104 | restInput: string | undefined 105 | }, 106 | { 107 | index: infer Index extends number 108 | groups: infer Groups 109 | input: string 110 | keys: (...arg: any) => any 111 | } 112 | > 113 | ? [...MatchArray, Index, Input, Groups] 114 | : never, 115 | >( 116 | this: InputString, 117 | regexp: MagicRegExp<`/${RegExpPattern}/${Join}`, string, Flags>, 118 | replaceValue: ReplaceValue | ((...match: Match) => ReplaceValue) 119 | ): any[] extends RegExpParsedAST 120 | ? never 121 | : ReplaceWithRegExp 122 | 123 | /** @deprecated String.replaceAll requires global flag to be set. */ 124 | replaceAll>( 125 | searchValue: R, 126 | replaceValue: string | ((substring: string, ...args: any[]) => string) 127 | ): never 128 | /** @deprecated String.replaceAll requires global flag to be set. */ 129 | replaceAll[]>>( 130 | searchValue: R, 131 | replaceValue: string | ((substring: string, ...args: any[]) => string) 132 | ): never 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Flag } from './core/flags' 2 | import type { Join, UnionToTuple } from './core/types/join' 3 | import type { MagicRegExp, MagicRegExpMatchArray } from './core/types/magic-regexp' 4 | import type { InputSource, MapToCapturedGroupsArr, MapToGroups, MapToValues } from './core/types/sources' 5 | 6 | import { exactly } from './core/inputs' 7 | 8 | export const createRegExp: { 9 | /** Create Magic RegExp from Input helpers and string (string will be sanitized) */ 10 | ( 11 | ...inputs: Inputs 12 | ): MagicRegExp< 13 | `/${Join, '', ''>}/`, 14 | MapToGroups, 15 | MapToCapturedGroupsArr, 16 | never 17 | > 18 | ( 19 | ...inputs: [...Inputs, [...Flags]] 20 | ): MagicRegExp< 21 | `/${Join, '', ''>}/${Join}`, 22 | MapToGroups, 23 | MapToCapturedGroupsArr, 24 | Flags[number] 25 | > 26 | < 27 | Inputs extends InputSource[], 28 | FlagUnion extends Flag = never, 29 | Flags extends Flag[] = UnionToTuple extends infer F extends Flag[] ? F : never, 30 | >( 31 | ...inputs: [...Inputs, Set] 32 | ): MagicRegExp< 33 | `/${Join, '', ''>}/${Join}`, 34 | MapToGroups, 35 | MapToCapturedGroupsArr, 36 | Flags[number] 37 | > 38 | } = (...inputs: any[]) => { 39 | const flags 40 | = inputs.length > 1 41 | && (Array.isArray(inputs[inputs.length - 1]) || inputs[inputs.length - 1] instanceof Set) 42 | ? inputs.pop() 43 | : undefined 44 | return new RegExp(exactly(...inputs).toString(), [...(flags || '')].join('')) as any 45 | } 46 | 47 | export * from './core/flags' 48 | export * from './core/inputs' 49 | export * from './core/types/magic-regexp' 50 | 51 | // Add additional overload to global String object types to allow for typed capturing groups 52 | declare global { 53 | interface String { 54 | match>>( 55 | regexp: R 56 | ): MagicRegExpMatchArray | null 57 | match>( 58 | regexp: R 59 | ): string[] | null 60 | 61 | /** @deprecated String.matchAll requires global flag to be set. */ 62 | matchAll>(regexp: R): never 63 | /** @deprecated String.matchAll requires global flag to be set. */ 64 | matchAll>>( 65 | regexp: R 66 | ): never 67 | 68 | matchAll>( 69 | regexp: R 70 | ): IterableIterator> 71 | 72 | /** @deprecated String.replaceAll requires global flag to be set. */ 73 | replaceAll>( 74 | searchValue: R, 75 | replaceValue: string | ((substring: string, ...args: any[]) => string) 76 | ): never 77 | /** @deprecated String.replaceAll requires global flag to be set. */ 78 | replaceAll>>( 79 | searchValue: R, 80 | replaceValue: string | ((substring: string, ...args: any[]) => string) 81 | ): never 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/transform.ts: -------------------------------------------------------------------------------- 1 | import type { SimpleCallExpression } from 'estree' 2 | import type { Node } from 'estree-walker' 3 | import type { Context } from 'node:vm' 4 | import type { SourceMapInput } from 'rollup' 5 | 6 | import { pathToFileURL } from 'node:url' 7 | import { createContext, runInContext } from 'node:vm' 8 | import { walk } from 'estree-walker' 9 | import * as magicRegExp from 'magic-regexp' 10 | import MagicString from 'magic-string' 11 | import { findStaticImports, parseStaticImport } from 'mlly' 12 | import { parseQuery, parseURL } from 'ufo' 13 | import { createUnplugin } from 'unplugin' 14 | 15 | export const MagicRegExpTransformPlugin = createUnplugin(() => { 16 | return { 17 | name: 'MagicRegExpTransformPlugin', 18 | enforce: 'post', 19 | transformInclude(id) { 20 | const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) 21 | const { type } = parseQuery(search) 22 | 23 | // vue files 24 | if (pathname.endsWith('.vue') && (type === 'script' || !search)) 25 | return true 26 | 27 | // js files 28 | if (pathname.match(/\.((c|m)?j|t)sx?$/g)) 29 | return true 30 | 31 | return false 32 | }, 33 | transform(code, id) { 34 | if (!code.includes('magic-regexp')) 35 | return 36 | 37 | const statements = findStaticImports(code).filter( 38 | i => i.specifier === 'magic-regexp' || i.specifier === 'magic-regexp/further-magic', 39 | ) 40 | if (!statements.length) 41 | return 42 | 43 | const contextMap: Context = { ...magicRegExp } 44 | const wrapperNames: string[] = [] 45 | let namespace: string 46 | 47 | for (const i of statements.flatMap(i => parseStaticImport(i))) { 48 | if (i.namespacedImport) { 49 | namespace = i.namespacedImport 50 | contextMap[i.namespacedImport] = magicRegExp 51 | } 52 | if (i.namedImports) { 53 | for (const key in i.namedImports) 54 | contextMap[i.namedImports[key]] = magicRegExp[key as keyof typeof magicRegExp] 55 | 56 | if (i.namedImports.createRegExp) 57 | wrapperNames.push(i.namedImports.createRegExp) 58 | } 59 | } 60 | 61 | const context = createContext(contextMap) 62 | 63 | const s = new MagicString(code) 64 | 65 | walk(this.parse(code) as Node, { 66 | enter(_node) { 67 | if (_node.type !== 'CallExpression') 68 | return 69 | const node = _node as SimpleCallExpression 70 | if ( 71 | // Normal call 72 | !wrapperNames.includes((node.callee as any).name) 73 | // Namespaced call 74 | && (node.callee.type !== 'MemberExpression' 75 | || node.callee.object.type !== 'Identifier' 76 | || node.callee.object.name !== namespace 77 | || node.callee.property.type !== 'Identifier' 78 | || node.callee.property.name !== 'createRegExp') 79 | ) { 80 | return 81 | } 82 | 83 | const { start, end } = node as any as { start: number, end: number } 84 | 85 | try { 86 | const value = runInContext(code.slice(start, end), context) 87 | s.overwrite(start, end, value.toString()) 88 | } 89 | catch { 90 | // We silently ignore any code that relies on external context 91 | // as it can use runtime `magic-regexp` support 92 | } 93 | }, 94 | }) 95 | 96 | if (s.hasChanged()) { 97 | return { 98 | code: s.toString(), 99 | map: s.generateMap({ includeContent: true, source: id }) as SourceMapInput, 100 | } 101 | } 102 | }, 103 | } 104 | }) 105 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0xBb533C940878fdBa9E5434d659e05dAbEc4EC423' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /test/augments.test.ts: -------------------------------------------------------------------------------- 1 | import type { MagicRegExp, MagicRegExpMatchArray } from '../src' 2 | 3 | import { expectTypeOf } from 'expect-type' 4 | import { describe, expect, it } from 'vitest' 5 | 6 | import { char, createRegExp, global } from '../src' 7 | 8 | describe('string', () => { 9 | it('.match non-global', () => { 10 | const result = 'test'.match(createRegExp(char.groupedAs('foo'))) 11 | expect(Array.isArray(result)).toBeTruthy() 12 | expect(result?.groups?.foo).toEqual('t') 13 | expectTypeOf(result).toEqualTypeOf.)/', 'foo', ['(?.)'], never> 15 | > | null>() 16 | }) 17 | it('.match global', () => { 18 | const result = 'test'.match(createRegExp(char.groupedAs('foo'), [global])) 19 | expect(Array.isArray(result)).toBeTruthy() 20 | // @ts-expect-error there are no groups within the result 21 | expect(result?.groups).toBeUndefined() 22 | expectTypeOf(result).toEqualTypeOf() 23 | }) 24 | it.todo('.matchAll non-global', () => { 25 | // should be deprecated 26 | expectTypeOf('test'.matchAll(createRegExp(char.groupedAs('foo')))).toEqualTypeOf() 27 | expectTypeOf('test'.matchAll(createRegExp(char.groupedAs('foo'), ['m']))).toEqualTypeOf() 28 | }) 29 | it('.matchAll global', () => { 30 | const results = 'test'.matchAll(createRegExp(char.groupedAs('foo'), [global])) 31 | let count = 0 32 | for (const result of results) { 33 | count++ 34 | expect([...'test'].includes(result?.groups.foo || '')).toBeTruthy() 35 | expectTypeOf(result).toEqualTypeOf< 36 | MagicRegExpMatchArray.)/g', 'foo', ['(?.)'], 'g'>> 37 | >() 38 | } 39 | expect(count).toBe(4) 40 | }) 41 | it.todo('.replaceAll non-global', () => { 42 | // should be deprecated 43 | expectTypeOf('test'.replaceAll(createRegExp(char.groupedAs('foo')), '')).toEqualTypeOf() 44 | expectTypeOf( 45 | 'test'.replaceAll(createRegExp(char.groupedAs('foo'), ['m']), ''), 46 | ).toEqualTypeOf() 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/converter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { convert as _convert } from '../src/converter' 3 | 4 | const convert = (regex: RegExp) => _convert(regex, { argsOnly: true }) 5 | 6 | describe('basic', () => { 7 | it('charIn', () => { 8 | expect(convert(/[abc]/)).toMatchInlineSnapshot(`"charIn('abc')"`) 9 | expect(() => convert(/[abc\d]/)).toThrowErrorMatchingInlineSnapshot( 10 | `[Error: Unsupported for Complex charactor class]`, 11 | ) 12 | expect(() => convert(/[0-9]/)).toThrowErrorMatchingInlineSnapshot( 13 | `[Error: Unsupported for Complex charactor class]`, 14 | ) 15 | }) 16 | 17 | it('charNotIn', () => { 18 | expect(convert(/[^abc]/)).toMatchInlineSnapshot(`"charNotIn('abc')"`) 19 | expect(() => convert(/[^abc\d]/)).toThrowErrorMatchingInlineSnapshot( 20 | `[Error: Unsupported for Complex charactor class]`, 21 | ) 22 | expect(() => convert(/[^0-9]/)).toThrowErrorMatchingInlineSnapshot( 23 | `[Error: Unsupported for Complex charactor class]`, 24 | ) 25 | }) 26 | 27 | it('or', () => { 28 | expect(convert(/a|b|c/)).toMatchInlineSnapshot(`"exactly('a').or('b').or('c')"`) 29 | expect(convert(/a|b/)).toMatchInlineSnapshot(`"exactly('a').or('b')"`) 30 | expect(convert(/aba|abb|abc/)).toMatchInlineSnapshot(`"exactly('aba').or('abb').or('abc')"`) 31 | expect(convert(/[abc]|abb|abc/)).toMatchInlineSnapshot(`"charIn('abc').or('abb').or('abc')"`) 32 | expect(convert(/(a|b)|abb|abc/)).toMatchInlineSnapshot( 33 | `"exactly('a').or('b').grouped().or('abb').or('abc')"`, 34 | ) 35 | expect(convert(/(?:a[b]c|d)/)).toMatchInlineSnapshot(`"exactly('a', charIn('b'), 'c').or('d')"`) 36 | expect(convert(/(?:a[b]c|d[d])/)).toMatchInlineSnapshot( 37 | `"exactly('a', charIn('b'), 'c').or('d', charIn('d'))"`, 38 | ) 39 | }) 40 | 41 | it('and.referenceTo', () => { 42 | expect(convert(/(?abc)bcd\kefg/)).toMatchInlineSnapshot( 43 | `"exactly(exactly('abc').as('group'), 'bcd').and.referenceTo('group'), 'efg'"`, 44 | ) 45 | expect(() => convert(/(?)1\1/)).toThrowErrorMatchingInlineSnapshot( 46 | `[Error: Unsupport for number reference]`, 47 | ) 48 | }) 49 | 50 | it('regex helpers', () => { 51 | expect(convert(/\w/)).toMatchInlineSnapshot(`"wordChar"`) 52 | expect(convert(/\w\b\d\s\t\n\r/)).toMatchInlineSnapshot( 53 | `"wordChar, wordBoundary, digit, whitespace, tab, linefeed, carriageReturn"`, 54 | ) 55 | expect(convert(/[A-Z]/)).toMatchInlineSnapshot(`"letter.uppercase"`) 56 | expect(convert(/[a-z]/)).toMatchInlineSnapshot(`"letter.lowercase"`) 57 | expect(convert(/[A-Za-z]/)).toMatchInlineSnapshot(`"letter"`) 58 | expect(convert(/[a-zA-Z]/)).toMatchInlineSnapshot(`"letter"`) 59 | expect(convert(/./)).toMatchInlineSnapshot(`"char"`) 60 | 61 | // TODO: expected: "word" 62 | expect(convert(/\b\w+\b/)).toMatchInlineSnapshot( 63 | `"wordBoundary, oneOrMore(wordChar), wordBoundary"`, 64 | ) 65 | }) 66 | 67 | it('regex helpers (not)', () => { 68 | expect(convert(/\W\B\D\S/)).toMatchInlineSnapshot( 69 | `"not.wordChar, not.wordBoundary, not.digit, not.whitespace"`, 70 | ) 71 | expect(convert(/[^\t]/)).toMatchInlineSnapshot(`"not.tab"`) 72 | expect(convert(/[^\n]/)).toMatchInlineSnapshot(`"not.linefeed"`) 73 | expect(convert(/[^\r]/)).toMatchInlineSnapshot(`"not.carriageReturn"`) 74 | expect(convert(/[^A-Z]/)).toMatchInlineSnapshot(`"not.letter.uppercase"`) 75 | expect(convert(/[^a-z]/)).toMatchInlineSnapshot(`"not.letter.lowercase"`) 76 | expect(convert(/[^A-Za-z]/)).toMatchInlineSnapshot(`"not.letter"`) 77 | expect(convert(/[^a-zA-Z]/)).toMatchInlineSnapshot(`"not.letter"`) 78 | 79 | // TODO: expected: "not.word" 80 | expect(convert(/\W+/)).toMatchInlineSnapshot(`"oneOrMore(not.wordChar)"`) 81 | }) 82 | 83 | it('char', () => { 84 | expect(convert(/\x3B/)).toMatchInlineSnapshot(`"';'"`) 85 | // @ts-expect-error testing invalid input 86 | expect(convert(/\42/)).toMatchInlineSnapshot(`"'*'"`) 87 | // @ts-expect-error testing invalid input 88 | expect(convert(/\073/)).toMatchInlineSnapshot(`"';'"`) 89 | expect(convert(/\u003B/)).toMatchInlineSnapshot(`"';'"`) 90 | }) 91 | 92 | it('maybe (optionally)', () => { 93 | expect(convert(/a?/)).toMatchInlineSnapshot(`"maybe('a')"`) 94 | expect(convert(/ab?/)).toMatchInlineSnapshot(`"'a', maybe('b')"`) 95 | expect(convert(/a?b?/)).toMatchInlineSnapshot(`"maybe('a'), maybe('b')"`) 96 | expect(convert(/a|b?/)).toMatchInlineSnapshot(`"exactly('a').or(maybe('b'))"`) 97 | }) 98 | 99 | it('lazy', () => { 100 | expect(() => convert(/a+?/)).toThrowErrorMatchingInlineSnapshot( 101 | `[Error: Unsupported for lazy quantifier]`, 102 | ) 103 | }) 104 | 105 | it('unsupported meta characters', () => { 106 | expect(() => convert(/\f/)).toThrowErrorMatchingInlineSnapshot( 107 | `[Error: Unsupported Meta Char: \\f]`, 108 | ) 109 | }) 110 | 111 | it('oneOrMore', () => { 112 | expect(convert(/a+/)).toMatchInlineSnapshot(`"oneOrMore('a')"`) 113 | expect(convert(/ab+/)).toMatchInlineSnapshot(`"'a', oneOrMore('b')"`) 114 | expect(convert(/a+b+/)).toMatchInlineSnapshot(`"oneOrMore('a'), oneOrMore('b')"`) 115 | expect(convert(/a|b+/)).toMatchInlineSnapshot(`"exactly('a').or(oneOrMore('b'))"`) 116 | }) 117 | 118 | it('multiple inputs (alternative, and)', () => { 119 | expect(convert(/abc/)).toMatchInlineSnapshot(`"'abc'"`) 120 | expect(convert(/a+bc+de+/)).toMatchInlineSnapshot( 121 | `"oneOrMore('a'), 'b', oneOrMore('c'), 'd', oneOrMore('e')"`, 122 | ) 123 | expect(convert(/aaa+bbb+ccc+/)).toMatchInlineSnapshot( 124 | `"'aa', oneOrMore('a'), 'bb', oneOrMore('b'), 'cc', oneOrMore('c')"`, 125 | ) 126 | expect(convert(/a|bcd|a+bbb+ccc+/)).toMatchInlineSnapshot( 127 | `"exactly('a').or('bcd').or(oneOrMore('a'), 'bb', oneOrMore('b'), 'cc', oneOrMore('c'))"`, 128 | ) 129 | expect(convert(/a(?:b[cd]ef)ghi/)).toMatchInlineSnapshot( 130 | `"'a', exactly('b', charIn('cd'), 'ef'), 'ghi'"`, 131 | ) 132 | }) 133 | 134 | it('exactly (escaped)', () => { 135 | expect(convert(/1\.2\*3/)).toMatchInlineSnapshot(`"'1.2*3'"`) 136 | expect(convert(/1.23/)).toMatchInlineSnapshot(`"'1', char, '23'"`) 137 | }) 138 | 139 | // chaining inputs 140 | it('at', () => { 141 | expect(convert(/^abc$/)).toMatchInlineSnapshot(`"exactly('abc').at.lineStart().at.lineEnd()"`) 142 | expect(convert(/^abbc$/)).toMatchInlineSnapshot(`"exactly('abbc').at.lineStart().at.lineEnd()"`) 143 | expect(convert(/^a[b]c$/)).toMatchInlineSnapshot( 144 | `"exactly('a').at.lineStart(), charIn('b'), exactly('c').at.lineEnd()"`, 145 | ) 146 | expect(convert(/^\b$/)).toMatchInlineSnapshot(`"wordBoundary.at.lineStart().at.lineEnd()"`) 147 | 148 | // edge cases 149 | expect(convert(/^$/)).toMatchInlineSnapshot(`"exactly('').at.lineEnd().at.lineStart()"`) 150 | expect(convert(/^/)).toMatchInlineSnapshot(`"exactly('').at.lineStart()"`) 151 | expect(convert(/$/)).toMatchInlineSnapshot(`"exactly('').at.lineEnd()"`) 152 | }) 153 | 154 | it('times', () => { 155 | expect(convert(/a*/)).toMatchInlineSnapshot(`"exactly('a').times.any()"`) 156 | expect(convert(/abc*/)).toMatchInlineSnapshot(`"'ab', exactly('c').times.any()"`) 157 | expect(convert(/(abc)*/)).toMatchInlineSnapshot(`"exactly('abc').grouped().times.any()"`) 158 | expect(convert(/[abc]*/)).toMatchInlineSnapshot(`"charIn('abc').times.any()"`) 159 | 160 | expect(convert(/a{1}/)).toMatchInlineSnapshot(`"exactly('a').times(1)"`) 161 | expect(convert(/a{1,}/)).toMatchInlineSnapshot(`"exactly('a').times.atLeast(1)"`) 162 | expect(convert(/a{0,3}/)).toMatchInlineSnapshot(`"exactly('a').times.atMost(3)"`) 163 | expect(convert(/a{1,3}/)).toMatchInlineSnapshot(`"exactly('a').times.between(1, 3)"`) 164 | }) 165 | 166 | it('after, notAfter, before, notBefore', () => { 167 | expect(convert(/(?<=a)b/)).toMatchInlineSnapshot(`"exactly('b').after('a')"`) 168 | expect(convert(/(?<=a[a]bc)b/)).toMatchInlineSnapshot( 169 | `"exactly('b').after('a', charIn('a'), 'bc')"`, 170 | ) 171 | 172 | expect(convert(/(? { 195 | expect(convert(/(abc)/)).toMatchInlineSnapshot(`"exactly('abc').grouped()"`) 196 | expect(convert(/(?:abc)/)).toMatchInlineSnapshot(`"exactly('abc')"`) 197 | expect(convert(/(abc[c]abc)/)).toMatchInlineSnapshot( 198 | `"exactly('abc', charIn('c'), 'abc').grouped()"`, 199 | ) 200 | expect(convert(/1(abc[c]abc|a)2/)).toMatchInlineSnapshot( 201 | `"'1', exactly('abc', charIn('c'), 'abc').or('a').grouped(), '2'"`, 202 | ) 203 | expect(convert(/((?:abc[bcd]cde)|(?:bcdd))/)).toMatchInlineSnapshot( 204 | `"exactly('abc', charIn('bcd'), 'cde').or(exactly('bcdd')).grouped()"`, 205 | ) 206 | }) 207 | 208 | it('groupedAs', () => { 209 | expect(convert(/(?abc)/)).toMatchInlineSnapshot(`"exactly('abc').as('foo')"`) 210 | // @ts-expect-error needs a capturing group name 211 | expect(convert(/(?<\u{03C0}>x)/u)).toMatchInlineSnapshot(`"exactly('x').as('π'), [unicode]"`) 212 | }) 213 | }) 214 | -------------------------------------------------------------------------------- /test/experimental.test.ts: -------------------------------------------------------------------------------- 1 | import type { MagicRegExp } from '../src/further-magic' 2 | 3 | import { describe, expect, expectTypeOf, it } from 'vitest' 4 | import { anyOf, caseInsensitive, createRegExp, digit, exactly, global, maybe, oneOrMore, spreadRegExpIterator, spreadRegExpMatchArray, wordChar } from '../src/further-magic' 5 | 6 | describe('magic-regexp', () => { 7 | it('works as a normal regexp', () => { 8 | const regExp = createRegExp('in', [global]) 9 | expect('thing'.match(regExp)?.[0]).toMatchInlineSnapshot('"in"') 10 | expect(regExp.test('thing')).toBeTruthy() 11 | expect(regExp.lastIndex).toMatchInlineSnapshot('4') 12 | expectTypeOf(regExp).not.toEqualTypeOf(RegExp) 13 | 14 | const regExp2 = createRegExp(exactly('foo')) 15 | expect(regExp2).toMatchInlineSnapshot('/foo/') 16 | expectTypeOf(regExp2).toEqualTypeOf>() 17 | }) 18 | }) 19 | 20 | describe('experimental: type-level RegExp match for type safe match results', () => { 21 | it('`..match` returns type-safe match array, index, length and groups with string literal', () => { 22 | const regExp = createRegExp( 23 | exactly('bar').or('baz').groupedAs('g1'), 24 | exactly('qux', digit.times(2)).groupedAs('g2'), 25 | new Set([caseInsensitive] as const), 26 | ) 27 | 28 | expect(regExp).toMatchInlineSnapshot('/\\(\\?bar\\|baz\\)\\(\\?qux\\\\d\\{2\\}\\)/i') 29 | expectTypeOf(regExp).toEqualTypeOf< 30 | MagicRegExp<'/(?bar|baz)(?qux\\d{2})/i', 'g1' | 'g2', ['i']> 31 | >() 32 | 33 | const matchResult = 'prefix_babAzqUx42_suffix'.match(regExp) 34 | 35 | const spreadedResult = spreadRegExpMatchArray(matchResult) 36 | expect(spreadedResult).toMatchInlineSnapshot(` 37 | [ 38 | "bAzqUx42", 39 | "bAz", 40 | "qUx42", 41 | ] 42 | `) 43 | expectTypeOf(spreadedResult).toEqualTypeOf<['bAzqUx42', 'bAz', 'qUx42']>() 44 | 45 | expect(matchResult[0]).toMatchInlineSnapshot('"bAzqUx42"') 46 | expectTypeOf(matchResult[0]).toEqualTypeOf<'bAzqUx42'>() 47 | 48 | expect(matchResult[1]).toMatchInlineSnapshot('"bAz"') 49 | expectTypeOf(matchResult[1]).toEqualTypeOf<'bAz'>() 50 | 51 | expect(matchResult[2]).toMatchInlineSnapshot('"qUx42"') 52 | expectTypeOf(matchResult[2]).toEqualTypeOf<'qUx42'>() 53 | 54 | // @ts-expect-error - Element implicitly has an 'any' type because expression of type '3' can't be used to index 55 | expect(matchResult[3]).toMatchInlineSnapshot('undefined') 56 | 57 | expect(matchResult.index).toMatchInlineSnapshot('9') 58 | expectTypeOf(matchResult.index).toEqualTypeOf<9>() 59 | 60 | expect(matchResult.length).toMatchInlineSnapshot('3') 61 | expectTypeOf(matchResult.length).toEqualTypeOf<3>() 62 | 63 | expect(matchResult.groups).toMatchInlineSnapshot(` 64 | { 65 | "g1": "bAz", 66 | "g2": "qUx42", 67 | } 68 | `) 69 | expectTypeOf(matchResult.groups).toEqualTypeOf<{ 70 | g1: 'bAz' 71 | g2: 'qUx42' 72 | }>() 73 | }) 74 | 75 | it('`.replace()` with literal string as second arg returns exact replaced string literal', () => { 76 | const regExp = createRegExp( 77 | exactly('bar').or('baz').groupedAs('g1'), 78 | exactly('qux', digit.times(2)).groupedAs('g2'), 79 | new Set([caseInsensitive] as const), 80 | ) 81 | 82 | const replaceResult = 'prefix_babAzqUx42_suffix'.replace(regExp, '_g2:$_c1:$1_all:$&') 83 | expect(replaceResult).toMatchInlineSnapshot('"prefix_ba_g2:qUx42_c1:bAz_all:bAzqUx42_suffix"') 84 | expectTypeOf(replaceResult).toEqualTypeOf<'prefix_ba_g2:qUx42_c1:bAz_all:bAzqUx42_suffix'>() 85 | }) 86 | 87 | it('`.replace()` with type-safe function as second arg returns exact replaced string literal', () => { 88 | const regExp = createRegExp( 89 | exactly('bar').or('baz').groupedAs('g1'), 90 | exactly('qux', digit.times(2)).groupedAs('g2'), 91 | new Set([caseInsensitive] as const), 92 | ) 93 | 94 | const replaceFunctionResult = 'prefix_babAzqUx42_suffix'.replace( 95 | regExp, 96 | (match, c1, c2, offset, input, groups) => 97 | `capture 1 is: ${c1}, offset is: ${offset} groups:{ g1: ${groups.g1}, g2: ${groups.g2} }`, 98 | ) 99 | expect(replaceFunctionResult).toMatchInlineSnapshot( 100 | '"prefix_bacapture 1 is: bAz, offset is: 9 groups:{ g1: bAz, g2: qUx42 }_suffix"', 101 | ) 102 | expectTypeOf( 103 | replaceFunctionResult, 104 | ).toEqualTypeOf<'prefix_bacapture 1 is: bAz, offset is: 9 groups:{ g1: bAz, g2: qUx42 }_suffix'>() 105 | }) 106 | 107 | it('`.matchAll()` returns typed iterableIterator, spread with `spreadRegExpIterator` to get type-safe match results', () => { 108 | const semverRegExp = createRegExp( 109 | oneOrMore(digit).as('major'), 110 | '.', 111 | oneOrMore(digit).as('minor'), 112 | maybe('.', oneOrMore(anyOf(wordChar, '.')).groupedAs('patch')), 113 | ['g'], 114 | ) 115 | const semversIterator 116 | = 'magic-regexp v3.2.5.beta.1 just release, with the updated type-level-regexp v0.1.8 and nuxt 3.3!'.matchAll( 117 | semverRegExp, 118 | ) 119 | 120 | const semvers = spreadRegExpIterator(semversIterator) 121 | 122 | expect(semvers[0]).toMatchInlineSnapshot(` 123 | [ 124 | "3.2.5.beta.1", 125 | "3", 126 | "2", 127 | "5.beta.1", 128 | ] 129 | `) 130 | expectTypeOf(semvers[0]._matchArray).toEqualTypeOf<['3.2.5.beta.1', '3', '2', '5.beta.1']>() 131 | 132 | expect(semvers[1][0]).toMatchInlineSnapshot('"0.1.8"') 133 | expectTypeOf(semvers[1][0]).toEqualTypeOf<'0.1.8'>() 134 | 135 | expect(semvers[1].index).toMatchInlineSnapshot('77') 136 | expectTypeOf(semvers[1].index).toEqualTypeOf<77>() 137 | 138 | expect(semvers[1][3]).toMatchInlineSnapshot('"8"') 139 | expectTypeOf(semvers[1][3]).toEqualTypeOf<'8'>() 140 | 141 | expect(semvers[2].groups).toMatchInlineSnapshot(` 142 | { 143 | "major": "3", 144 | "minor": "3", 145 | "patch": undefined, 146 | } 147 | `) 148 | expectTypeOf(semvers[2].groups).toEqualTypeOf<{ 149 | major: '3' 150 | minor: '3' 151 | patch: undefined 152 | }>() 153 | }) 154 | 155 | it('`.match` returns type-safe match array, index, length and groups with union of possible string literals', () => { 156 | const regExp = createRegExp( 157 | exactly('bar').or('baz').groupedAs('g1').and(exactly('qux')).groupedAs('g2'), 158 | ) 159 | const dynamicString = '_barqux_' 160 | 161 | const matchResult = dynamicString.match(regExp) 162 | 163 | expect(matchResult).toMatchInlineSnapshot(` 164 | [ 165 | "barqux", 166 | "barqux", 167 | "bar", 168 | ] 169 | `) 170 | expectTypeOf(matchResult?._matchArray).toEqualTypeOf< 171 | ['barqux', 'barqux', 'bar'] 172 | >() 173 | 174 | expect(matchResult?.[0]).toMatchInlineSnapshot('"barqux"') 175 | expectTypeOf(matchResult?.[0]).toEqualTypeOf<'barqux'>() 176 | 177 | expect(matchResult?.[1]).toMatchInlineSnapshot('"barqux"') 178 | expectTypeOf(matchResult?.[1]).toEqualTypeOf<'barqux'>() 179 | 180 | expect(matchResult?.[2]).toMatchInlineSnapshot('"bar"') 181 | expectTypeOf(matchResult?.[2]).toEqualTypeOf<'bar'>() 182 | 183 | // @ts-expect-error - Element implicitly has an 'any' type because expression of type '3' can't be used to index 184 | expect(matchResult?.[3]).toMatchInlineSnapshot('undefined') 185 | 186 | expect(matchResult?.index).toMatchInlineSnapshot('1') 187 | expectTypeOf(matchResult?.index).toEqualTypeOf<1>() 188 | 189 | expect(matchResult?.length).toMatchInlineSnapshot('3') 190 | expectTypeOf(matchResult?.length).toEqualTypeOf<3>() 191 | 192 | expect(matchResult?.groups).toMatchInlineSnapshot(` 193 | { 194 | "g1": "bar", 195 | "g2": "barqux", 196 | } 197 | `) 198 | expectTypeOf(matchResult?.groups).toEqualTypeOf< 199 | { g1: 'bar', g2: 'barqux' } 200 | >() 201 | }) 202 | }) 203 | -------------------------------------------------------------------------------- /test/flags.test.ts: -------------------------------------------------------------------------------- 1 | import type * as flags from '../src/core/flags' 2 | 3 | import type { Flag } from '../src/core/flags' 4 | 5 | import { expectTypeOf } from 'expect-type' 6 | 7 | import { describe, it } from 'vitest' 8 | 9 | type ValueOf = T[keyof T] 10 | 11 | describe('flags', () => { 12 | it('are all present', () => { 13 | expectTypeOf().toMatchTypeOf>() 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable ts/no-unused-expressions */ 2 | 3 | import type { MagicRegExp, MagicRegExpMatchArray, StringCapturedBy } from '../src' 4 | import { expectTypeOf } from 'expect-type' 5 | import { describe, expect, it } from 'vitest' 6 | 7 | import { anyOf, caseInsensitive, char, createRegExp, digit, exactly, global, maybe, multiline, oneOrMore } from '../src' 8 | import { createInput } from '../src/core/internal' 9 | 10 | describe('magic-regexp', () => { 11 | it('works as a normal regexp', () => { 12 | const regExp = createRegExp('in', [global]) 13 | expect('thing'.match(regExp)?.[0]).toMatchInlineSnapshot('"in"') 14 | expect(regExp.test('thing')).toBeTruthy() 15 | expect(regExp.lastIndex).toMatchInlineSnapshot('4') 16 | expectTypeOf(regExp).not.toEqualTypeOf(RegExp) 17 | }) 18 | it('collects flag type', () => { 19 | const re_array_flag = createRegExp('.', [global, multiline]) 20 | expectTypeOf(re_array_flag).toEqualTypeOf>() 21 | 22 | const re_set_flag = createRegExp('.', new Set([global] as const)) 23 | expectTypeOf(re_set_flag).toEqualTypeOf>() 24 | }) 25 | it('sanitize string input', () => { 26 | const escapeChars = '.*+?^${}()[]/' 27 | const re = createRegExp(escapeChars) 28 | expect(String(re)).toMatchInlineSnapshot(`"/\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\[\\]\\//"`) 29 | expectTypeOf(re).toEqualTypeOf< 30 | MagicRegExp<'/\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\[\\]\\//', never, []> 31 | >() 32 | }) 33 | }) 34 | 35 | describe('inputs', () => { 36 | it('createInput serializes to string', () => { 37 | expect(`${createInput('\\s')}`).toEqual('\\s') 38 | }) 39 | it('type infer group names when nesting createInput', () => { 40 | expectTypeOf(createRegExp(createInput(exactly('\\s').groupedAs('groupName')))).toEqualTypeOf< 41 | MagicRegExp<'/(?\\s)/', 'groupName', ['(?\\s)'], never> 42 | >() 43 | }) 44 | it('takes variadic args and flags', () => { 45 | const regExp = createRegExp( 46 | oneOrMore(digit).as('major'), 47 | '.', 48 | oneOrMore(digit).as('minor'), 49 | maybe('.', oneOrMore(char).groupedAs('patch')), 50 | [caseInsensitive], 51 | ) 52 | const result = '3.4.1-beta'.match(regExp) 53 | expect(Array.isArray(result)).toBeTruthy() 54 | expect(result?.groups).toMatchInlineSnapshot(` 55 | { 56 | "major": "3", 57 | "minor": "4", 58 | "patch": "1-beta", 59 | } 60 | `) 61 | expectTypeOf(regExp).toEqualTypeOf< 62 | MagicRegExp< 63 | '/(?\\d+)\\.(?\\d+)(?:\\.(?.+))?/i', 64 | 'major' | 'minor' | 'patch', 65 | ['(?\\d+)', '(?\\d+)', '(?.+)'], 66 | 'i' 67 | > 68 | >() 69 | }) 70 | it('any', () => { 71 | const regExp = createRegExp(anyOf('foo', 'bar')) 72 | expect(regExp).toMatchInlineSnapshot('/\\(\\?:foo\\|bar\\)/') 73 | expect(regExp.test('foo')).toBeTruthy() 74 | expect(regExp.test('bar')).toBeTruthy() 75 | expect(regExp.test('baz')).toBeFalsy() 76 | }) 77 | it('before', () => { 78 | const regExp = createRegExp(char.before('foo')) 79 | expect(regExp).toMatchInlineSnapshot('/\\.\\(\\?=foo\\)/') 80 | expect('bafoo'.match(regExp)?.[0]).toMatchInlineSnapshot('"a"') 81 | expect(regExp.test('foo')).toBeFalsy() 82 | }) 83 | it('after', () => { 84 | const regExp = createRegExp(char.after('foo')) 85 | expect(regExp).toMatchInlineSnapshot('/\\(\\?<=foo\\)\\./') 86 | expect('fooafoo'.match(regExp)?.[0]).toMatchInlineSnapshot('"a"') 87 | expect(regExp.test('foo')).toBeFalsy() 88 | }) 89 | it('notBefore', () => { 90 | const regExp = createRegExp(exactly('bar').notBefore('foo')) 91 | expect(regExp).toMatchInlineSnapshot('/bar\\(\\?!foo\\)/') 92 | expect('barfoo'.match(regExp)).toBeFalsy() 93 | }) 94 | it('notAfter', () => { 95 | const regExp = createRegExp(exactly('bar').notAfter('foo')) 96 | expect(regExp).toMatchInlineSnapshot('/\\(\\? { 101 | const pattern = exactly('test/thing') 102 | expect(pattern.toString()).toMatchInlineSnapshot(`"test\\/thing"`) 103 | expect(createRegExp(pattern).test('test/thing')).toBeTruthy() 104 | }) 105 | it('times', () => { 106 | expect(exactly('test').times.between(1, 3).toString()).toMatchInlineSnapshot('"(?:test){1,3}"') 107 | expect(exactly('test').times.atLeast(3).toString()).toMatchInlineSnapshot('"(?:test){3,}"') 108 | expect(exactly('test').times.atMost(3).toString()).toMatchInlineSnapshot('"(?:test){0,3}"') 109 | expect(exactly('test').times(4).or('foo').toString()).toMatchInlineSnapshot( 110 | '"(?:(?:test){4}|foo)"', 111 | ) 112 | }) 113 | it('capture groups', () => { 114 | const pattern = anyOf(anyOf('foo', 'bar').groupedAs('test'), exactly('baz').groupedAs('test2')) 115 | const regexp = createRegExp(pattern) 116 | 117 | expect('football'.match(regexp)?.groups).toMatchInlineSnapshot(` 118 | { 119 | "test": "foo", 120 | "test2": undefined, 121 | } 122 | `) 123 | expect('fobazzer'.match(regexp)?.groups).toMatchInlineSnapshot(` 124 | { 125 | "test": undefined, 126 | "test2": "baz", 127 | } 128 | `) 129 | 130 | expectTypeOf('fobazzer'.match(regexp)).toEqualTypeOf | null>() 133 | expectTypeOf('fobazzer'.match(regexp)?.groups).toEqualTypeOf< 134 | Record<'test' | 'test2', string | undefined> | undefined 135 | >() 136 | 137 | // @ts-expect-error there should be no 'other' group 138 | 'fobazzer'.match(createRegExp(pattern))?.groups.other 139 | 140 | for (const match of 'fobazzer'.matchAll(createRegExp(pattern, [global]))) { 141 | expect(match.groups).toMatchInlineSnapshot(` 142 | { 143 | "test": undefined, 144 | "test2": "baz", 145 | } 146 | `) 147 | expectTypeOf(match.groups).toEqualTypeOf>() 148 | } 149 | 150 | ''.match( 151 | createRegExp( 152 | anyOf(anyOf('foo', 'bar').groupedAs('test'), exactly('baz').groupedAs('test2')).and( 153 | digit.times(5).groupedAs('id').optionally(), 154 | ), 155 | ), 156 | )?.groups?.id 157 | }) 158 | it('named backreference to capture groups', () => { 159 | const pattern = exactly('foo') 160 | .groupedAs('fooGroup') 161 | .and(exactly('bar').groupedAs('barGroup')) 162 | .and('baz') 163 | .and 164 | .referenceTo('barGroup') 165 | .and 166 | .referenceTo('fooGroup') 167 | .and 168 | .referenceTo('barGroup') 169 | 170 | expect('foobarbazbarfoobar'.match(createRegExp(pattern))).toMatchInlineSnapshot(` 171 | [ 172 | "foobarbazbarfoobar", 173 | "foo", 174 | "bar", 175 | ] 176 | `) 177 | expectTypeOf(pattern.and.referenceTo).toBeCallableWith('barGroup') 178 | // @ts-expect-error there is no 'bazgroup' capture group 179 | pattern.and.referenceTo('bazgroup') 180 | }) 181 | it('can type-safe access matched array with hint for corresponding capture group', () => { 182 | const pattern = anyOf( 183 | exactly('foo|?').grouped(), 184 | exactly('bar').and(maybe('baz').grouped()).groupedAs('groupName'), 185 | exactly('boo').times(2).grouped(), 186 | anyOf( 187 | exactly('a').times(3), 188 | exactly('b').and(maybe('c|d?')).times.any(), 189 | exactly('1') 190 | .and(maybe(exactly('2').and(maybe('3')).and('2'))) 191 | .and('1'), 192 | ).grouped(), 193 | ).grouped() 194 | 195 | const match = 'booboo'.match(createRegExp(pattern)) 196 | 197 | if (!match) 198 | return expect(match).toBeTruthy() 199 | expectTypeOf(match.length).toEqualTypeOf<7>() 200 | expectTypeOf(match[0]).toEqualTypeOf() 201 | expectTypeOf(match[1]).toEqualTypeOf< 202 | | StringCapturedBy<'((foo\\|\\?)|(?bar(baz)?)|(boo){2}|(a{3}|(?:b(?:c\\|d\\?)?)*|1(?:23?2)?1))'> 203 | | undefined 204 | >() 205 | // @ts-expect-error match result array marked as readonly and shouldn't be assigned to 206 | match[1] = 'match result array marked as readonly' 207 | let typedVar: string | undefined 208 | // eslint-disable-next-line unused-imports/no-unused-vars, prefer-const 209 | typedVar = match[1] // can be assign to typed variable 210 | expectTypeOf(match[2]).toEqualTypeOf | undefined>() 211 | expectTypeOf(match[2]?.concat(match[3] || '')).toEqualTypeOf() 212 | expectTypeOf(match[3]).toEqualTypeOfbar(baz)?)'> | undefined>() 213 | expectTypeOf(match[4]).toEqualTypeOf | undefined>() 214 | expectTypeOf(match[5]).toEqualTypeOf | undefined>() 215 | expectTypeOf(match[6]).toEqualTypeOf< 216 | StringCapturedBy<'(a{3}|(?:b(?:c\\|d\\?)?)*|1(?:23?2)?1)'> | undefined 217 | >() 218 | expectTypeOf(match[7]).toEqualTypeOf() 219 | }) 220 | }) 221 | -------------------------------------------------------------------------------- /test/inputs.test.ts: -------------------------------------------------------------------------------- 1 | import type { MagicRegExp } from '../src' 2 | 3 | import { expectTypeOf } from 'expect-type' 4 | import { describe, expect, it } from 'vitest' 5 | import { createRegExp } from '../src' 6 | import { anyOf, carriageReturn, char, charIn, charNotIn, digit, exactly, letter, linefeed, maybe, not, oneOrMore, tab, whitespace, word, wordBoundary, wordChar } from '../src/core/inputs' 7 | import { extractRegExp } from './utils' 8 | 9 | describe('inputs', () => { 10 | it('charIn', () => { 11 | const input = charIn('fo]^') 12 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[fo\\\\\\]\\\\\\^\\]/') 13 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[fo\\]\\^]'>() 14 | }) 15 | it('charIn.orChar', () => { 16 | const input = charIn('a').orChar('b') 17 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[ab\\]/') 18 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[ab]'>() 19 | }) 20 | it('charIn.orChar.from', () => { 21 | const input = charIn('a').orChar.from('a', 'b') 22 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[aa-b\\]/') 23 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[aa-b]'>() 24 | }) 25 | it('charIn.from', () => { 26 | const input = charIn.from('a', 'b') 27 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[a-b\\]/') 28 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[a-b]'>() 29 | }) 30 | it('charNotIn', () => { 31 | const input = charNotIn('fo^-') 32 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[\\^fo\\\\\\^\\\\-\\]/') 33 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[^fo\\^\\-]'>() 34 | }) 35 | it('charNotIn.orChar', () => { 36 | const input = charNotIn('a').orChar('b') 37 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[\\^ab\\]/') 38 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[^ab]'>() 39 | }) 40 | it('charNotIn.orChar.from', () => { 41 | const input = charNotIn('a').orChar.from('a', 'b') 42 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[\\^aa-b\\]/') 43 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[^aa-b]'>() 44 | }) 45 | it('charNotIn.from', () => { 46 | const input = charNotIn.from('a', 'b') 47 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[\\^a-b\\]/') 48 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[^a-b]'>() 49 | }) 50 | it('anyOf', () => { 51 | const values = ['fo/o', 'bar', 'baz', oneOrMore('this')] as const 52 | const input = anyOf(...values) 53 | const regexp = new RegExp(input as any) 54 | expect(regexp).toMatchInlineSnapshot('/\\(\\?:fo\\\\/o\\|bar\\|baz\\|\\(\\?:this\\)\\+\\)/') 55 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'(?:fo\\/o|bar|baz|(?:this)+)'>() 56 | for (const value of values.slice(0, -1) as string[]) { 57 | expect(regexp.test(value)).toBeTruthy() 58 | expect(regexp.exec(value)?.[1]).toBeUndefined() 59 | } 60 | expect(regexp.test('qux')).toBeFalsy() 61 | }) 62 | it('char', () => { 63 | const input = char 64 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\./') 65 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'.'>() 66 | }) 67 | it('maybe', () => { 68 | const input = maybe('foo') 69 | const regexp = new RegExp(input as any) 70 | expect(regexp).toMatchInlineSnapshot('/\\(\\?:foo\\)\\?/') 71 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'(?:foo)?'>() 72 | 73 | const nestedInputWithGroup = maybe(exactly('foo').groupedAs('groupName')) 74 | expectTypeOf(createRegExp(nestedInputWithGroup)).toEqualTypeOf< 75 | MagicRegExp<'/(?foo)?/', 'groupName', ['(?foo)'], never> 76 | >() 77 | 78 | const multi = maybe('foo', input.groupedAs('groupName'), 'bar') 79 | const regexp2 = new RegExp(multi as any) 80 | expect(regexp2).toMatchInlineSnapshot( 81 | '/\\(\\?:foo\\(\\?\\(\\?:foo\\)\\?\\)bar\\)\\?/', 82 | ) 83 | expectTypeOf(extractRegExp(multi)).toEqualTypeOf<'(?:foo(?(?:foo)?)bar)?'>() 84 | }) 85 | it('oneOrMore', () => { 86 | const input = oneOrMore('foo') 87 | const regexp = new RegExp(input as any) 88 | expect(regexp).toMatchInlineSnapshot('/\\(\\?:foo\\)\\+/') 89 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'(?:foo)+'>() 90 | 91 | const nestedInputWithGroup = oneOrMore(exactly('foo').groupedAs('groupName')) 92 | expectTypeOf(createRegExp(nestedInputWithGroup)).toEqualTypeOf< 93 | MagicRegExp<'/(?foo)+/', 'groupName', ['(?foo)'], never> 94 | >() 95 | 96 | const multi = oneOrMore('foo', input.groupedAs('groupName'), 'bar') 97 | const regexp2 = new RegExp(multi as any) 98 | expect(regexp2).toMatchInlineSnapshot( 99 | '/\\(\\?:foo\\(\\?\\(\\?:foo\\)\\+\\)bar\\)\\+/', 100 | ) 101 | expectTypeOf(extractRegExp(multi)).toEqualTypeOf<'(?:foo(?(?:foo)+)bar)+'>() 102 | }) 103 | it('exactly', () => { 104 | const input = exactly('fo?[a-z]{2}/o?') 105 | expect(new RegExp(input as any)).toMatchInlineSnapshot( 106 | '/fo\\\\\\?\\\\\\[a-z\\\\\\]\\\\\\{2\\\\\\}\\\\/o\\\\\\?/', 107 | ) 108 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'fo\\?\\[a-z\\]\\{2\\}\\/o\\?'>() 109 | 110 | const nestedInputWithGroup = exactly(maybe('foo').grouped().and('bar').groupedAs('groupName')) 111 | expectTypeOf(createRegExp(nestedInputWithGroup)).toEqualTypeOf< 112 | MagicRegExp< 113 | '/(?(foo)?bar)/', 114 | 'groupName', 115 | ['(?(foo)?bar)', '(foo)'], 116 | never 117 | > 118 | >() 119 | 120 | const multi = exactly('foo', input.groupedAs('groupName'), 'bar') 121 | const regexp2 = new RegExp(multi as any) 122 | expect(regexp2).toMatchInlineSnapshot( 123 | '/foo\\(\\?fo\\\\\\?\\\\\\[a-z\\\\\\]\\\\\\{2\\\\\\}\\\\/o\\\\\\?\\)bar/', 124 | ) 125 | expectTypeOf( 126 | extractRegExp(multi), 127 | ).toEqualTypeOf<'foo(?fo\\?\\[a-z\\]\\{2\\}\\/o\\?)bar'>() 128 | }) 129 | it('word', () => { 130 | const input = word 131 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\\\b\\\\w\\+\\\\b/') 132 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'\\b\\w+\\b'>() 133 | }) 134 | it('wordChar', () => { 135 | const input = wordChar 136 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\\\w/') 137 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'\\w'>() 138 | }) 139 | it('digit', () => { 140 | const input = digit 141 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\\\d/') 142 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'\\d'>() 143 | }) 144 | it('wordBoundary', () => { 145 | const input = wordBoundary 146 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\\\b/') 147 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'\\b'>() 148 | }) 149 | it('whitespace', () => { 150 | const input = whitespace 151 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\\\s/') 152 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'\\s'>() 153 | }) 154 | it('letter', () => { 155 | const input = letter 156 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[a-zA-Z\\]/') 157 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[a-zA-Z]'>() 158 | }) 159 | it('letter.lowercase', () => { 160 | const input = letter.lowercase 161 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[a-z\\]/') 162 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[a-z]'>() 163 | }) 164 | it('letter.uppercase', () => { 165 | const input = letter.uppercase 166 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\[A-Z\\]/') 167 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'[A-Z]'>() 168 | }) 169 | it('tab', () => { 170 | const input = tab 171 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\\\t/') 172 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'\\t'>() 173 | }) 174 | it('linefeed', () => { 175 | const input = linefeed 176 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\\\n/') 177 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'\\n'>() 178 | }) 179 | it('carriageReturn', () => { 180 | const input = carriageReturn 181 | expect(new RegExp(input as any)).toMatchInlineSnapshot('/\\\\r/') 182 | expectTypeOf(extractRegExp(input)).toEqualTypeOf<'\\r'>() 183 | }) 184 | it('not', () => { 185 | expect(not.word.toString()).toMatchInlineSnapshot(`"\\W+"`) 186 | expectTypeOf(extractRegExp(not.word)).toEqualTypeOf<'\\W+'>() 187 | expect(not.wordChar.toString()).toMatchInlineSnapshot(`"\\W"`) 188 | expectTypeOf(extractRegExp(not.wordChar)).toEqualTypeOf<'\\W'>() 189 | expect(not.wordBoundary.toString()).toMatchInlineSnapshot(`"\\B"`) 190 | expectTypeOf(extractRegExp(not.wordBoundary)).toEqualTypeOf<'\\B'>() 191 | expect(not.digit.toString()).toMatchInlineSnapshot(`"\\D"`) 192 | expectTypeOf(extractRegExp(not.digit)).toEqualTypeOf<'\\D'>() 193 | expect(not.whitespace.toString()).toMatchInlineSnapshot(`"\\S"`) 194 | expectTypeOf(extractRegExp(not.whitespace)).toEqualTypeOf<'\\S'>() 195 | expect(not.letter.toString()).toMatchInlineSnapshot('"[^a-zA-Z]"') 196 | expectTypeOf(extractRegExp(not.letter)).toEqualTypeOf<'[^a-zA-Z]'>() 197 | expect(not.letter.lowercase.toString()).toMatchInlineSnapshot('"[^a-z]"') 198 | expectTypeOf(extractRegExp(not.letter.lowercase)).toEqualTypeOf<'[^a-z]'>() 199 | expect(not.letter.uppercase.toString()).toMatchInlineSnapshot('"[^A-Z]"') 200 | expectTypeOf(extractRegExp(not.letter.uppercase)).toEqualTypeOf<'[^A-Z]'>() 201 | expect(not.tab.toString()).toMatchInlineSnapshot(`"[^\\t]"`) 202 | expectTypeOf(extractRegExp(not.tab)).toEqualTypeOf<'[^\\t]'>() 203 | expect(not.linefeed.toString()).toMatchInlineSnapshot(`"[^\\n]"`) 204 | expectTypeOf(extractRegExp(not.linefeed)).toEqualTypeOf<'[^\\n]'>() 205 | expect(not.carriageReturn.toString()).toMatchInlineSnapshot(`"[^\\r]"`) 206 | expectTypeOf(extractRegExp(not.carriageReturn)).toEqualTypeOf<'[^\\r]'>() 207 | }) 208 | it('no extra wrap by ()', () => { 209 | const input = oneOrMore( 210 | anyOf( 211 | anyOf('foo', '?').grouped().times(2), 212 | exactly('bar').groupedAs('groupName').times.between(3, 4), 213 | exactly('baz').or('boo').grouped().times.atLeast(5), 214 | ).grouped(), 215 | ) 216 | const regexp = new RegExp(input as any) 217 | expect(regexp).toMatchInlineSnapshot( 218 | '/\\(\\(foo\\|\\\\\\?\\)\\{2\\}\\|\\(\\?bar\\)\\{3,4\\}\\|\\(baz\\|boo\\)\\{5,\\}\\)\\+/', 219 | ) 220 | expectTypeOf( 221 | extractRegExp(input), 222 | ).toEqualTypeOf<'((foo|\\?){2}|(?bar){3,4}|(baz|boo){5,})+'>() 223 | }) 224 | }) 225 | 226 | describe('chained inputs', () => { 227 | const input = exactly('?') 228 | const multichar = exactly('ab') 229 | it('and', () => { 230 | const val = input.and('test.js') 231 | const regexp = new RegExp(val as any) 232 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?test\\\\\\.js/') 233 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\?test\\.js'>() 234 | 235 | const multi = multichar.and('foo', input.groupedAs('groupName'), 'bar') 236 | const regexp2 = new RegExp(multi as any) 237 | expect(regexp2).toMatchInlineSnapshot('/abfoo\\(\\?\\\\\\?\\)bar/') 238 | expectTypeOf(extractRegExp(multi)).toEqualTypeOf<'abfoo(?\\?)bar'>() 239 | }) 240 | it('and.referenceTo', () => { 241 | const val = input.groupedAs('namedGroup').and(exactly('any')).and.referenceTo('namedGroup') 242 | const regexp = new RegExp(val as any) 243 | expect(regexp).toMatchInlineSnapshot('/\\(\\?\\\\\\?\\)any\\\\k/') 244 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'(?\\?)any\\k'>() 245 | }) 246 | it('or', () => { 247 | const test = 'test.js' 248 | const val = input.or(test) 249 | const regexp = new RegExp(val as any) 250 | expect(regexp).toMatchInlineSnapshot('/\\(\\?:\\\\\\?\\|test\\\\\\.js\\)/') 251 | expect(regexp.test(test)).toBeTruthy() 252 | expect(regexp.exec(test)?.[1]).toBeUndefined() 253 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'(?:\\?|test\\.js)'>() 254 | 255 | const multi = multichar.or('foo', input.groupedAs('groupName'), exactly('bar').or(test)) 256 | const regexp2 = new RegExp(multi as any) 257 | expect(regexp2).toMatchInlineSnapshot( 258 | '/\\(\\?:ab\\|foo\\|\\(\\?\\\\\\?\\)\\|\\(\\?:bar\\|test\\\\\\.js\\)\\)/', 259 | ) 260 | expectTypeOf( 261 | extractRegExp(multi), 262 | ).toEqualTypeOf<'(?:ab|foo(?\\?)(?:bar|test\\.js))'>() 263 | }) 264 | it('after', () => { 265 | const val = input.after('test.js') 266 | const regexp = new RegExp(val as any) 267 | expect(regexp).toMatchInlineSnapshot('/\\(\\?<=test\\\\\\.js\\)\\\\\\?/') 268 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'(?<=test\\.js)\\?'>() 269 | 270 | const multi = multichar.after('foo', input.groupedAs('groupName'), 'bar') 271 | const regexp2 = new RegExp(multi as any) 272 | expect(regexp2).toMatchInlineSnapshot('/\\(\\?<=foo\\(\\?\\\\\\?\\)bar\\)ab/') 273 | expectTypeOf(extractRegExp(multi)).toEqualTypeOf<'(?<=foo(?\\?)bar)ab'>() 274 | }) 275 | it('before', () => { 276 | const val = input.before('test.js') 277 | const regexp = new RegExp(val as any) 278 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?\\(\\?=test\\\\\\.js\\)/') 279 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\?(?=test\\.js)'>() 280 | 281 | const multi = multichar.before('foo', input.groupedAs('groupName'), 'bar') 282 | const regexp2 = new RegExp(multi as any) 283 | expect(regexp2).toMatchInlineSnapshot('/ab\\(\\?=foo\\(\\?\\\\\\?\\)bar\\)/') 284 | expectTypeOf(extractRegExp(multi)).toEqualTypeOf<'ab(?=foo(?\\?)bar)'>() 285 | }) 286 | it('notAfter', () => { 287 | const val = input.notAfter('test.js') 288 | const regexp = new RegExp(val as any) 289 | expect(regexp).toMatchInlineSnapshot('/\\(\\?() 291 | 292 | const multi = multichar.notAfter('foo', input.groupedAs('groupName'), 'bar') 293 | const regexp2 = new RegExp(multi as any) 294 | expect(regexp2).toMatchInlineSnapshot('/\\(\\?\\\\\\?\\)bar\\)ab/') 295 | expectTypeOf(extractRegExp(multi)).toEqualTypeOf<'(?\\?)bar)ab'>() 296 | }) 297 | it('notBefore', () => { 298 | const val = input.notBefore('test.js') 299 | const regexp = new RegExp(val as any) 300 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?\\(\\?!test\\\\\\.js\\)/') 301 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\?(?!test\\.js)'>() 302 | 303 | const multi = multichar.notBefore('foo', input.groupedAs('groupName'), 'bar') 304 | const regexp2 = new RegExp(multi as any) 305 | expect(regexp2).toMatchInlineSnapshot('/ab\\(\\?!foo\\(\\?\\\\\\?\\)bar\\)/') 306 | expectTypeOf(extractRegExp(multi)).toEqualTypeOf<'ab(?!foo(?\\?)bar)'>() 307 | }) 308 | it('times', () => { 309 | const val = input.times(500) 310 | const regexp = new RegExp(val as any) 311 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?\\{500\\}/') 312 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\?{500}'>() 313 | 314 | const val2 = multichar.times(500) 315 | const regexp2 = new RegExp(val2 as any) 316 | expect(regexp2).toMatchInlineSnapshot('/\\(\\?:ab\\)\\{500\\}/') 317 | expectTypeOf(extractRegExp(val2)).toEqualTypeOf<'(?:ab){500}'>() 318 | }) 319 | it('times.any', () => { 320 | const val = input.times.any() 321 | const regexp = new RegExp(val as any) 322 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?\\*/') 323 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\?*'>() 324 | 325 | const val2 = multichar.times.any() 326 | const regexp2 = new RegExp(val2 as any) 327 | expect(regexp2).toMatchInlineSnapshot('/\\(\\?:ab\\)\\*/') 328 | expectTypeOf(extractRegExp(val2)).toEqualTypeOf<'(?:ab)*'>() 329 | }) 330 | it('times.atLeast', () => { 331 | const val = input.times.atLeast(2) 332 | const regexp = new RegExp(val as any) 333 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?\\{2,\\}/') 334 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\?{2,}'>() 335 | 336 | const val2 = multichar.times.atLeast(2) 337 | const regexp2 = new RegExp(val2 as any) 338 | expect(regexp2).toMatchInlineSnapshot('/\\(\\?:ab\\)\\{2,\\}/') 339 | expectTypeOf(extractRegExp(val2)).toEqualTypeOf<'(?:ab){2,}'>() 340 | }) 341 | it('times.atMost', () => { 342 | const val = input.times.atMost(2) 343 | const regexp = new RegExp(val as any) 344 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?\\{0,2\\}/') 345 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\?{0,2}'>() 346 | 347 | const val2 = multichar.times.atMost(2) 348 | const regexp2 = new RegExp(val2 as any) 349 | expect(regexp2).toMatchInlineSnapshot('/\\(\\?:ab\\)\\{0,2\\}/') 350 | expectTypeOf(extractRegExp(val2)).toEqualTypeOf<'(?:ab){0,2}'>() 351 | }) 352 | 353 | it('times.between', () => { 354 | const val = input.times.between(3, 5) 355 | const regexp = new RegExp(val as any) 356 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?\\{3,5\\}/') 357 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\?{3,5}'>() 358 | 359 | const val2 = multichar.times.between(3, 5) 360 | const regexp2 = new RegExp(val2 as any) 361 | expect(regexp2).toMatchInlineSnapshot('/\\(\\?:ab\\)\\{3,5\\}/') 362 | expectTypeOf(extractRegExp(val2)).toEqualTypeOf<'(?:ab){3,5}'>() 363 | }) 364 | it('optionally', () => { 365 | const val = input.optionally() 366 | const regexp = new RegExp(val as any) 367 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?\\?/') 368 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\??'>() 369 | 370 | const val2 = multichar.optionally() 371 | const regexp2 = new RegExp(val2 as any) 372 | expect(regexp2).toMatchInlineSnapshot('/\\(\\?:ab\\)\\?/') 373 | expectTypeOf(extractRegExp(val2)).toEqualTypeOf<'(?:ab)?'>() 374 | }) 375 | it('as', () => { 376 | const val = input.as('test') 377 | const regexp = new RegExp(val as any) 378 | expect(regexp).toMatchInlineSnapshot('/\\(\\?\\\\\\?\\)/') 379 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'(?\\?)'>() 380 | 381 | const retentEssentialWrap = oneOrMore('foo').as('groupName') 382 | expect(createRegExp(retentEssentialWrap)).toMatchInlineSnapshot( 383 | '/\\(\\?\\(\\?:foo\\)\\+\\)/', 384 | ) 385 | expectTypeOf(extractRegExp(retentEssentialWrap)).toEqualTypeOf<'(?(?:foo)+)'>() 386 | 387 | const removeExtraWrap = anyOf('foo', 'bar', 'baz').as('groupName') 388 | expect(createRegExp(removeExtraWrap)).toMatchInlineSnapshot( 389 | '/\\(\\?foo\\|bar\\|baz\\)/', 390 | ) 391 | expectTypeOf(extractRegExp(removeExtraWrap)).toEqualTypeOf<'(?foo|bar|baz)'>() 392 | }) 393 | it('groupedAs', () => { 394 | const val = input.groupedAs('test') 395 | const regexp = new RegExp(val as any) 396 | expect(regexp).toMatchInlineSnapshot('/\\(\\?\\\\\\?\\)/') 397 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'(?\\?)'>() 398 | 399 | const retentEssentialWrap = oneOrMore('foo').groupedAs('groupName') 400 | expect(createRegExp(retentEssentialWrap)).toMatchInlineSnapshot( 401 | '/\\(\\?\\(\\?:foo\\)\\+\\)/', 402 | ) 403 | expectTypeOf(extractRegExp(retentEssentialWrap)).toEqualTypeOf<'(?(?:foo)+)'>() 404 | 405 | const removeExtraWrap = anyOf('foo', 'bar', 'baz').groupedAs('groupName') 406 | expect(createRegExp(removeExtraWrap)).toMatchInlineSnapshot( 407 | '/\\(\\?foo\\|bar\\|baz\\)/', 408 | ) 409 | expectTypeOf(extractRegExp(removeExtraWrap)).toEqualTypeOf<'(?foo|bar|baz)'>() 410 | }) 411 | it('grouped', () => { 412 | const val = input.grouped() 413 | const regexp = new RegExp(val as any) 414 | expect(regexp).toMatchInlineSnapshot('/\\(\\\\\\?\\)/') 415 | expect(regexp.exec('?')?.[1]).toMatchInlineSnapshot('"?"') 416 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'(\\?)'>() 417 | 418 | const convertToCaptureGroups = anyOf( 419 | 'foo', 420 | maybe('baz').grouped(), 421 | exactly('bar').times(2).grouped(), 422 | oneOrMore('bar').grouped(), 423 | ).grouped() 424 | expect(createRegExp(convertToCaptureGroups)).toMatchInlineSnapshot( 425 | '/\\(foo\\|\\(baz\\)\\?\\|\\(bar\\)\\{2\\}\\|\\(bar\\)\\+\\)/', 426 | ) 427 | expectTypeOf( 428 | extractRegExp(convertToCaptureGroups), 429 | ).toEqualTypeOf<'(foo|(baz)?|(bar){2}|(bar)+)'>() 430 | 431 | const dontConvertInnerNonCapture = exactly('foo').and(oneOrMore('bar')).grouped() 432 | expect(createRegExp(dontConvertInnerNonCapture)).toMatchInlineSnapshot( 433 | '/\\(foo\\(\\?:bar\\)\\+\\)/', 434 | ) 435 | expectTypeOf(extractRegExp(dontConvertInnerNonCapture)).toEqualTypeOf<'(foo(?:bar)+)'>() 436 | }) 437 | it('at.lineStart', () => { 438 | const val = input.at.lineStart() 439 | const regexp = new RegExp(val as any) 440 | expect(regexp).toMatchInlineSnapshot('/\\^\\\\\\?/') 441 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'^\\?'>() 442 | }) 443 | it('at.lineEnd', () => { 444 | const val = input.at.lineEnd() 445 | const regexp = new RegExp(val as any) 446 | expect(regexp).toMatchInlineSnapshot('/\\\\\\?\\$/') 447 | expectTypeOf(extractRegExp(val)).toEqualTypeOf<'\\?$'>() 448 | }) 449 | }) 450 | -------------------------------------------------------------------------------- /test/transform.bench.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'acorn' 2 | import { bench, describe } from 'vitest' 3 | 4 | import { MagicRegExpTransformPlugin } from '../src/transform' 5 | 6 | describe(`transformer: magic-regexp`, () => { 7 | const couldTransform = [ 8 | `import { createRegExp, exactly, anyOf } from 'magic-regexp'`, 9 | 'const re1 = createRegExp(exactly(\'bar\').notBefore(\'foo\'))', 10 | ] 11 | 12 | bench('ignores non-JS files', () => { 13 | transform(couldTransform, 'test.css') 14 | }) 15 | 16 | bench('transforms vue script blocks', () => { 17 | transform(couldTransform, 'test.vue?type=script') 18 | transform(couldTransform, 'test.vue') 19 | transform(couldTransform, 'test.vue?type=template') 20 | }) 21 | 22 | bench(`ignores code without imports from magic-regexp`, () => { 23 | transform(couldTransform[1]) 24 | transform([`// magic-regexp`, couldTransform[1]]) 25 | }) 26 | 27 | bench('preserves context for dynamic regexps', () => { 28 | transform([ 29 | `import { createRegExp } from 'magic-regexp'`, 30 | `console.log(createRegExp(anyOf(keys)))`, 31 | ]) 32 | }) 33 | 34 | bench('statically replaces regexps where possible', () => { 35 | transform([ 36 | 'import { something } from \'other-module\'', 37 | `import { createRegExp, exactly, anyOf } from 'magic-regexp'`, 38 | '//', // this lets us tree-shake the import for use in our test-suite 39 | 'const re1 = createRegExp(exactly(\'bar\').notBefore(\'foo\'))', 40 | 'const re2 = createRegExp(anyOf(exactly(\'bar\'), \'foo\'))', 41 | 'const re3 = createRegExp(\'/foo/bar\')', 42 | // This line will be double-escaped in the snapshot 43 | 're3.test(\'/foo/bar\')', 44 | ]) 45 | }) 46 | 47 | bench('respects how users import library', () => { 48 | transform([ 49 | `import { createRegExp as cRE } from 'magic-regexp'`, 50 | `import { exactly as ext, createRegExp } from 'magic-regexp'`, 51 | `import * as magicRE from 'magic-regexp'`, 52 | 'const re1 = cRE(ext(\'bar\').notBefore(\'foo\'))', 53 | 'const re2 = magicRE.createRegExp(magicRE.anyOf(\'bar\', \'foo\'))', 54 | 'const re3 = createRegExp(\'test/value\')', 55 | ]) 56 | }) 57 | }) 58 | 59 | function transform(code: string | string[], id = 'some-id.js') { 60 | const plugin = MagicRegExpTransformPlugin.vite() as any 61 | return plugin.transform.call( 62 | { parse: (code: string) => parse(code, { ecmaVersion: 2022, sourceType: 'module' }) }, 63 | Array.isArray(code) ? code.join('\n') : code, 64 | id, 65 | )?.code 66 | } 67 | -------------------------------------------------------------------------------- /test/transform.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'acorn' 2 | import { describe, expect, it } from 'vitest' 3 | 4 | import { MagicRegExpTransformPlugin } from '../src/transform' 5 | 6 | for (const importSpecifier of ['magic-regexp', 'magic-regexp/further-magic']) { 7 | describe(`transformer: ${importSpecifier}`, () => { 8 | const couldTransform = [ 9 | `import { createRegExp, exactly, anyOf } from '${importSpecifier}'`, 10 | 'const re1 = createRegExp(exactly(\'bar\').notBefore(\'foo\'))', 11 | ] 12 | 13 | it('ignores non-JS files', () => { 14 | expect(transform(couldTransform, 'test.css')).toBeUndefined() 15 | }) 16 | 17 | it('transforms vue script blocks', () => { 18 | expect(transform(couldTransform, 'test.vue?type=script')).toBeDefined() 19 | expect(transform(couldTransform, 'test.vue')).toBeDefined() 20 | expect(transform(couldTransform, 'test.vue?type=template')).toBeUndefined() 21 | }) 22 | 23 | it(`ignores code without imports from ${importSpecifier}`, () => { 24 | expect(transform(couldTransform[1])).toBeUndefined() 25 | expect(transform([`// ${importSpecifier}`, couldTransform[1]])).toBeUndefined() 26 | }) 27 | 28 | it('preserves context for dynamic regexps', () => { 29 | expect( 30 | transform([ 31 | `import { createRegExp } from '${importSpecifier}'`, 32 | `console.log(createRegExp(anyOf(keys)))`, 33 | ]), 34 | ).not.toBeDefined() 35 | }) 36 | 37 | it('statically replaces regexps where possible', () => { 38 | const code = transform([ 39 | 'import { something } from \'other-module\'', 40 | `import { createRegExp, exactly, anyOf } from '${importSpecifier}'`, 41 | '//', // this lets us tree-shake the import for use in our test-suite 42 | 'const re1 = createRegExp(exactly(\'bar\').notBefore(\'foo\'))', 43 | 'const re2 = createRegExp(anyOf(exactly(\'bar\'), \'foo\'))', 44 | 'const re3 = createRegExp(\'/foo/bar\')', 45 | // This line will be double-escaped in the snapshot 46 | 're3.test(\'/foo/bar\')', 47 | ]) 48 | expect(code).toBe( 49 | `import { something } from 'other-module' 50 | import { createRegExp, exactly, anyOf } from '${importSpecifier}' 51 | // 52 | const re1 = /bar(?!foo)/ 53 | const re2 = /(?:bar|foo)/ 54 | const re3 = /\\/foo\\/bar/ 55 | re3.test('/foo/bar')`, 56 | ) 57 | // ... but we test it here. 58 | // eslint-disable-next-line no-eval 59 | expect(eval(code.split('//').pop())).toBe(true) 60 | }) 61 | 62 | it('respects how users import library', () => { 63 | const code = transform([ 64 | `import { createRegExp as cRE } from '${importSpecifier}'`, 65 | `import { exactly as ext, createRegExp } from '${importSpecifier}'`, 66 | `import * as magicRE from '${importSpecifier}'`, 67 | 'const re1 = cRE(ext(\'bar\').notBefore(\'foo\'))', 68 | 'const re2 = magicRE.createRegExp(magicRE.anyOf(\'bar\', \'foo\'))', 69 | 'const re3 = createRegExp(\'test/value\')', 70 | ]) 71 | expect(code).toBe( 72 | `import { createRegExp as cRE } from '${importSpecifier}' 73 | import { exactly as ext, createRegExp } from '${importSpecifier}' 74 | import * as magicRE from '${importSpecifier}' 75 | const re1 = /bar(?!foo)/ 76 | const re2 = /(?:bar|foo)/ 77 | const re3 = /test\\/value/`, 78 | ) 79 | }) 80 | }) 81 | } 82 | 83 | function transform(code: string | string[], id = 'some-id.js') { 84 | const plugin = MagicRegExpTransformPlugin.vite() as any 85 | return plugin.transform.call( 86 | { parse: (code: string) => parse(code, { ecmaVersion: 2022, sourceType: 'module' }) }, 87 | Array.isArray(code) ? code.join('\n') : code, 88 | id, 89 | )?.code 90 | } 91 | -------------------------------------------------------------------------------- /test/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { Input } from 'magic-regexp' 2 | 3 | export function extractRegExp(input: T) { 4 | return input as T extends Input ? RE : never 5 | } 6 | -------------------------------------------------------------------------------- /transform.d.ts: -------------------------------------------------------------------------------- 1 | // Legacy stub for previous TS versions 2 | 3 | export * from './dist/transform' 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "paths": { 7 | "magic-regexp/transform": ["./src/transform"], 8 | "magic-regexp/further-magic": ["./src/further-magic"], 9 | "magic-regexp": ["./src/index"] 10 | }, 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true 14 | }, 15 | "exclude": ["docs", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | 3 | import codspeedPlugin from '@codspeed/vitest-plugin' 4 | import { defineConfig } from 'vitest/config' 5 | 6 | export default defineConfig({ 7 | plugins: [codspeedPlugin()], 8 | resolve: { 9 | alias: { 10 | 'magic-regexp': fileURLToPath(new URL('./src/index.ts', import.meta.url).href), 11 | }, 12 | }, 13 | test: { 14 | coverage: { 15 | exclude: ['src/core/types', 'src/converter.ts'], 16 | thresholds: { 17 | 100: true, 18 | }, 19 | include: ['src'], 20 | reporter: ['text', 'json', 'html'], 21 | }, 22 | }, 23 | }) 24 | --------------------------------------------------------------------------------