├── .all-contributorsrc ├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── bug_types.yml │ └── feature_request.yml ├── pull_request_template.md └── workflows │ └── workflow.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── ava.config.js ├── eslint.config.js ├── gulpfile.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── compare.js ├── compare.test.js ├── main.d.ts ├── main.js ├── main.test-d.ts ├── normalize.js ├── normalize.test.js ├── parse │ ├── escape.js │ ├── main.js │ ├── main.test.js │ ├── query.js │ └── query.test.js ├── serialize.js ├── serialize.test.js ├── tokens │ ├── any.js │ ├── common.js │ ├── escape.js │ ├── indices.js │ ├── main.js │ ├── main.test.js │ ├── other.js │ ├── prop.js │ ├── regexp.js │ └── slice.js └── validate │ ├── arrays.js │ ├── path.js │ ├── string.js │ ├── throw.js │ └── token.js └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "wild-wild-parser", 3 | "projectOwner": "ehmicky", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "linkToUsage": false, 12 | "contributors": [ 13 | { 14 | "login": "ehmicky", 15 | "name": "ehmicky", 16 | "avatar_url": "https://avatars2.githubusercontent.com/u/8136211?v=4", 17 | "profile": "https://fosstodon.org/@ehmicky", 18 | "contributions": [ 19 | "code", 20 | "design", 21 | "ideas", 22 | "doc" 23 | ] 24 | } 25 | ], 26 | "contributorsPerLine": 7, 27 | "skipCi": true, 28 | "commitConvention": "none" 29 | } 30 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | max_line_length = 80 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug 3 | title: Please replace with a clear and descriptive title 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for reporting this bug! 9 | - type: checkboxes 10 | attributes: 11 | label: Guidelines 12 | options: 13 | - label: 14 | Please search other issues to make sure this bug has not already 15 | been reported. 16 | required: true 17 | - label: 18 | If this is related to a typo or the documentation being unclear, 19 | please click on the relevant page's `Edit` button (pencil icon) and 20 | suggest a correction instead. 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Describe the bug 25 | placeholder: A clear and concise description of what the bug is. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Steps to reproduce 31 | placeholder: | 32 | Step-by-step instructions on how to reproduce the behavior. 33 | Example: 34 | 1. Type the following command: [...] 35 | 2. etc. 36 | validations: 37 | required: true 38 | - type: textarea 39 | attributes: 40 | label: Configuration 41 | placeholder: Command line options and/or configuration file, if any. 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: Environment 47 | description: | 48 | Enter the following command in a terminal and copy/paste its output: 49 | ```bash 50 | npx envinfo --system --binaries --browsers --npmPackages wild-wild-parser 51 | ``` 52 | validations: 53 | required: true 54 | - type: checkboxes 55 | attributes: 56 | label: Pull request (optional) 57 | description: 58 | Pull requests are welcome! If you would like to help us fix this bug, 59 | please check our [contributions 60 | guidelines](../blob/main/CONTRIBUTING.md). 61 | options: 62 | - label: I can submit a pull request. 63 | required: false 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_types.yml: -------------------------------------------------------------------------------- 1 | name: Bug report (TypeScript types) 2 | description: Report a bug about TypeScript types 3 | title: Please replace with a clear and descriptive title 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for reporting this bug! 9 | - type: checkboxes 10 | attributes: 11 | label: Guidelines 12 | options: 13 | - label: 14 | Please search other issues to make sure this bug has not already 15 | been reported. 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Describe the bug 20 | placeholder: A clear and concise description of what the bug is. 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Steps to reproduce 26 | description: | 27 | Please reproduce the bug using the [TypeScript playground](https://www.typescriptlang.org/play) or [Bug workbench](https://www.typescriptlang.org/dev/bug-workbench), then paste the URL here. 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Environment 33 | description: | 34 | Enter the following command in a terminal and copy/paste its output: 35 | ```bash 36 | npx envinfo --system --binaries --browsers --npmPackages wild-wild-parser,typescript --npmGlobalPackages typescript 37 | ``` 38 | validations: 39 | required: true 40 | - type: checkboxes 41 | attributes: 42 | label: Pull request (optional) 43 | description: 44 | Pull requests are welcome! If you would like to help us fix this bug, 45 | please check our [contributions 46 | guidelines](../blob/main/CONTRIBUTING.md). 47 | options: 48 | - label: I can submit a pull request. 49 | required: false 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: Please replace with a clear and descriptive title 4 | labels: [enhancement] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for suggesting a new feature! 9 | - type: checkboxes 10 | attributes: 11 | label: Guidelines 12 | options: 13 | - label: 14 | Please search other issues to make sure this feature has not already 15 | been requested. 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Which problem is this feature request solving? 20 | placeholder: I'm always frustrated when [...] 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Describe the solution you'd like 26 | placeholder: This could be fixed by [...] 27 | validations: 28 | required: true 29 | - type: checkboxes 30 | attributes: 31 | label: Pull request (optional) 32 | description: 33 | Pull requests are welcome! If you would like to help us fix this bug, 34 | please check our [contributions 35 | guidelines](../blob/main/CONTRIBUTING.md). 36 | options: 37 | - label: I can submit a pull request. 38 | required: false 39 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 🎉 Thanks for sending this pull request! 🎉 2 | 3 | Please make sure the title is clear and descriptive. 4 | 5 | If you are fixing a typo or documentation, please skip these instructions. 6 | 7 | Otherwise please fill in the sections below. 8 | 9 | **Which problem is this pull request solving?** 10 | 11 | Example: I'm always frustrated when [...] 12 | 13 | **List other issues or pull requests related to this problem** 14 | 15 | Example: This fixes #5012 16 | 17 | **Describe the solution you've chosen** 18 | 19 | Example: I've fixed this by [...] 20 | 21 | **Describe alternatives you've considered** 22 | 23 | Example: Another solution would be [...] 24 | 25 | **Checklist** 26 | 27 | Please add a `x` inside each checkbox: 28 | 29 | - [ ] I have read the [contribution guidelines](../blob/main/CONTRIBUTING.md). 30 | - [ ] I have added tests (we are enforcing 100% test coverage). 31 | - [ ] I have added documentation in the `README.md`, the `docs` directory (if 32 | any) 33 | - [ ] The status checks are successful (continuous integration). Those can be 34 | seen below. 35 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | jobs: 4 | combinations: 5 | uses: ehmicky/dev-tasks/.github/workflows/build.yml@main 6 | secrets: inherit 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | npm-debug.log 4 | node_modules 5 | /core 6 | .eslintcache 7 | .lycheecache 8 | .npmrc 9 | .yarn-error.log 10 | !.github/ 11 | /coverage 12 | /build 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 5.0.1 2 | 3 | ## Documentation 4 | 5 | - Improve documentation in `README.md` 6 | 7 | # 5.0.0 8 | 9 | ## Breaking changes 10 | 11 | - Minimal supported Node.js version is now `18.18.0` 12 | 13 | # 4.0.0 14 | 15 | ## Breaking changes 16 | 17 | - Minimal supported Node.js version is now `16.17.0` 18 | 19 | # 3.4.0 20 | 21 | ## Features 22 | 23 | - Improve tree-shaking support 24 | 25 | # 3.3.0 26 | 27 | ## Features 28 | 29 | - Add browser support 30 | 31 | # 3.2.0 32 | 33 | ## Features 34 | 35 | - Reduce npm package size by 96%, from ~1110KB to ~45KB 36 | 37 | # 3.1.0 38 | 39 | ## Features 40 | 41 | - Reduce npm package size 42 | 43 | # 3.0.1 44 | 45 | ## Bug fixes 46 | 47 | - Improve small bug with TypeScript types on `SliceToken` 48 | 49 | # 3.0.0 50 | 51 | ## Breaking changes 52 | 53 | - Minimal supported Node.js version is now `14.18.0` 54 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | This text is available in 4 | [many other languages](https://www.contributor-covenant.org/translations). 5 | 6 | ## Our Pledge 7 | 8 | We as members, contributors, and leaders pledge to make participation in our 9 | community a harassment-free experience for everyone, regardless of age, body 10 | size, visible or invisible disability, ethnicity, sex characteristics, gender 11 | identity and expression, level of experience, education, socio-economic status, 12 | nationality, personal appearance, race, religion, or sexual identity and 13 | orientation. 14 | 15 | We pledge to act and interact in ways that contribute to an open, welcoming, 16 | diverse, inclusive, and healthy community. 17 | 18 | ## Our Standards 19 | 20 | Examples of behavior that contributes to a positive environment for our 21 | community include: 22 | 23 | - Demonstrating empathy and kindness toward other people 24 | - Being respectful of differing opinions, viewpoints, and experiences 25 | - Giving and gracefully accepting constructive feedback 26 | - Accepting responsibility and apologizing to those affected by our mistakes, 27 | and learning from the experience 28 | - Focusing on what is best not just for us as individuals, but for the overall 29 | community 30 | 31 | Examples of unacceptable behavior include: 32 | 33 | - The use of sexualized language or imagery, and sexual attention or advances of 34 | any kind 35 | - Trolling, insulting or derogatory comments, and personal or political attacks 36 | - Public or private harassment 37 | - Publishing others' private information, such as a physical or email address, 38 | without their explicit permission 39 | - Other conduct which could reasonably be considered inappropriate in a 40 | professional setting 41 | 42 | ## Enforcement Responsibilities 43 | 44 | Community leaders are responsible for clarifying and enforcing our standards of 45 | acceptable behavior and will take appropriate and fair corrective action in 46 | response to any behavior that they deem inappropriate, threatening, offensive, 47 | or harmful. 48 | 49 | Community leaders have the right and responsibility to remove, edit, or reject 50 | comments, commits, code, wiki edits, issues, and other contributions that are 51 | not aligned to this Code of Conduct, and will communicate reasons for moderation 52 | decisions when appropriate. 53 | 54 | ## Scope 55 | 56 | This Code of Conduct applies within all community spaces, and also applies when 57 | an individual is officially representing the community in public spaces. 58 | Examples of representing our community include using an official e-mail address, 59 | posting via an official social media account, or acting as an appointed 60 | representative at an online or offline event. 61 | 62 | ## Enforcement 63 | 64 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 65 | reported to the community leaders responsible for enforcement at 66 | ehmicky+report@gmail.com All complaints will be reviewed and investigated 67 | promptly and fairly. 68 | 69 | All community leaders are obligated to respect the privacy and security of the 70 | reporter of any incident. 71 | 72 | ## Enforcement Guidelines 73 | 74 | Community leaders will follow these Community Impact Guidelines in determining 75 | the consequences for any action they deem in violation of this Code of Conduct: 76 | 77 | ### 1. Correction 78 | 79 | **Community Impact**: Use of inappropriate language or other behavior deemed 80 | unprofessional or unwelcome in the community. 81 | 82 | **Consequence**: A private, written warning from community leaders, providing 83 | clarity around the nature of the violation and an explanation of why the 84 | behavior was inappropriate. A public apology may be requested. 85 | 86 | ### 2. Warning 87 | 88 | **Community Impact**: A violation through a single incident or series of 89 | actions. 90 | 91 | **Consequence**: A warning with consequences for continued behavior. No 92 | interaction with the people involved, including unsolicited interaction with 93 | those enforcing the Code of Conduct, for a specified period of time. This 94 | includes avoiding interactions in community spaces as well as external channels 95 | like social media. Violating these terms may lead to a temporary or permanent 96 | ban. 97 | 98 | ### 3. Temporary Ban 99 | 100 | **Community Impact**: A serious violation of community standards, including 101 | sustained inappropriate behavior. 102 | 103 | **Consequence**: A temporary ban from any sort of interaction or public 104 | communication with the community for a specified period of time. No public or 105 | private interaction with the people involved, including unsolicited interaction 106 | with those enforcing the Code of Conduct, is allowed during this period. 107 | Violating these terms may lead to a permanent ban. 108 | 109 | ### 4. Permanent Ban 110 | 111 | **Community Impact**: Demonstrating a pattern of violation of community 112 | standards, including sustained inappropriate behavior, harassment of an 113 | individual, or aggression toward or disparagement of classes of individuals. 114 | 115 | **Consequence**: A permanent ban from any sort of public interaction within the 116 | community. 117 | 118 | ## Attribution 119 | 120 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 121 | version 2.0, available at 122 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 123 | 124 | Community Impact Guidelines were inspired by 125 | [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | 129 | For answers to common questions about this code of conduct, see the FAQ at 130 | https://www.contributor-covenant.org/faq. Translations are available at 131 | https://www.contributor-covenant.org/translations. 132 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | 3 | 🎉 Thanks for considering contributing to this project! 🎉 4 | 5 | These guidelines will help you send a pull request. 6 | 7 | If you're submitting an issue instead, please skip this document. 8 | 9 | If your pull request is related to a typo or the documentation being unclear, 10 | please click on the relevant page's `Edit` button (pencil icon) and directly 11 | suggest a correction instead. 12 | 13 | This project was made with ❤️. The simplest way to give back is by starring and 14 | sharing it online. 15 | 16 | Everyone is welcome regardless of personal background. We enforce a 17 | [Code of conduct](CODE_OF_CONDUCT.md) in order to promote a positive and 18 | inclusive environment. 19 | 20 | # Development process 21 | 22 | First fork and clone the repository. If you're not sure how to do this, please 23 | watch 24 | [these videos](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 25 | 26 | Run: 27 | 28 | ```bash 29 | npm install 30 | ``` 31 | 32 | Make sure everything is correctly setup with: 33 | 34 | ```bash 35 | npm test 36 | ``` 37 | 38 | We use Gulp tasks to lint, test and build this project. Please check 39 | [dev-tasks](https://github.com/ehmicky/dev-tasks/blob/main/README.md) to learn 40 | how to use them. You don't need to know Gulp to use these tasks. 41 | 42 | # Requirements 43 | 44 | Our coding style is documented 45 | [here](https://github.com/ehmicky/eslint-config#coding-style). Linting and 46 | formatting should automatically handle it though. 47 | 48 | After submitting the pull request, please make sure the Continuous Integration 49 | checks are passing. 50 | 51 | We enforce 100% test coverage: each line of code must be tested. 52 | 53 | New options, methods, properties, configuration and behavior must be documented 54 | in all of these: 55 | 56 | - the `README.md` 57 | - the `docs` directory (if any) 58 | 59 | Please use the same style as the rest of the documentation and examples. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 ehmicky 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | wild-wild-parser logo 2 | 3 | [![Node](https://img.shields.io/badge/-Node.js-808080?logo=node.js&colorA=404040&logoColor=66cc33)](https://www.npmjs.com/package/wild-wild-parser) 4 | [![Browsers](https://img.shields.io/badge/-Browsers-808080?logo=firefox&colorA=404040)](https://unpkg.com/wild-wild-parser?module) 5 | [![TypeScript](https://img.shields.io/badge/-Typed-808080?logo=typescript&colorA=404040&logoColor=0096ff)](/src/main.d.ts) 6 | [![Codecov](https://img.shields.io/badge/-Tested%20100%25-808080?logo=codecov&colorA=404040)](https://codecov.io/gh/ehmicky/wild-wild-parser) 7 | [![Minified size](https://img.shields.io/bundlephobia/minzip/wild-wild-parser?label&colorA=404040&colorB=808080&logo=webpack)](https://bundlephobia.com/package/wild-wild-parser) 8 | [![Mastodon](https://img.shields.io/badge/-Mastodon-808080.svg?logo=mastodon&colorA=404040&logoColor=9590F9)](https://fosstodon.org/@ehmicky) 9 | [![Medium](https://img.shields.io/badge/-Medium-808080.svg?logo=medium&colorA=404040)](https://medium.com/@ehmicky) 10 | 11 | 🤠 Parser for object property paths with wildcards and regexps. 🌵 12 | 13 | [`wild-wild-path`](https://github.com/ehmicky/wild-wild-path) is a library which 14 | gets/sets object properties using 15 | [dot-delimited paths](https://github.com/ehmicky/wild-wild-path#%EF%B8%8F-deep-properties), 16 | [wildcards](https://github.com/ehmicky/wild-wild-path#-wildcards), 17 | [regexps](https://github.com/ehmicky/wild-wild-path#%EF%B8%8F-regexps), 18 | [slices](https://github.com/ehmicky/wild-wild-path#%EF%B8%8F-array-slices) and 19 | [unions](https://github.com/ehmicky/wild-wild-path#-unions). `wild-wild-parser` 20 | allows manipulating 21 | [its query format](https://github.com/ehmicky/wild-wild-path#queries): 22 | 23 | - 🚂 [Parse](#parsequeryquerystring)/[serialize](#serializequeryqueryarray), 24 | i.e. convert between 25 | [query strings](https://github.com/ehmicky/wild-wild-path#query-strings) and 26 | [query arrays](https://github.com/ehmicky/wild-wild-path#query-arrays) 27 | - ⭐ [Normalize](#normalizequeryquery) queries 28 | - 🗺️ [Compare](#issamequeryfirstquery-secondquery) queries 29 | 30 | # Install 31 | 32 | ```bash 33 | npm install wild-wild-parser 34 | ``` 35 | 36 | This package works in both Node.js >=18.18.0 and 37 | [browsers](https://raw.githubusercontent.com/ehmicky/dev-tasks/main/src/browserslist). 38 | 39 | This is an ES module. It must be loaded using 40 | [an `import` or `import()` statement](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c), 41 | not `require()`. If TypeScript is used, it must be configured to 42 | [output ES modules](https://www.typescriptlang.org/docs/handbook/esm-node.html), 43 | not CommonJS. 44 | 45 | # API 46 | 47 | ## parseQuery(queryString) 48 | 49 | `queryString` 50 | [`QueryString`](https://github.com/ehmicky/wild-wild-path#query-strings)\ 51 | _Return value_: 52 | [`QueryArray`](https://github.com/ehmicky/wild-wild-path#query-arrays) 53 | 54 | Convert a 55 | [query string](https://github.com/ehmicky/wild-wild-path#query-strings) into a 56 | [query array](https://github.com/ehmicky/wild-wild-path#query-arrays). 57 | 58 | ```js 59 | parseQuery('users.0.*') // [['users', 0, { type: 'any' }]] 60 | parseQuery('users admins') // [['users'], ['admins']] 61 | parseQuery('users./[/') // Throws: invalid RegExp 62 | ``` 63 | 64 | ## serializeQuery(queryArray) 65 | 66 | `queryArray` 67 | [`QueryArray`](https://github.com/ehmicky/wild-wild-path#query-arrays)\ 68 | _Return value_: 69 | [`QueryString`](https://github.com/ehmicky/wild-wild-path#query-strings) 70 | 71 | Convert a [query array](https://github.com/ehmicky/wild-wild-path#query-arrays) 72 | into a [query string](https://github.com/ehmicky/wild-wild-path#query-strings). 73 | 74 | ```js 75 | serializeQuery(['users', 0, { type: 'any' }]) // 'users.0.*' 76 | serializeQuery([['users'], ['admins']]) // 'users admins' 77 | serializeQuery([true]) // Throws: `true` is not a valid query 78 | ``` 79 | 80 | ## normalizeQuery(query) 81 | 82 | `query` [`Query`](https://github.com/ehmicky/wild-wild-path#queries)\ 83 | _Return value_: 84 | [`QueryArray`](https://github.com/ehmicky/wild-wild-path#query-arrays) 85 | 86 | If the query is a 87 | [query string](https://github.com/ehmicky/wild-wild-path#query-strings), convert 88 | it into a [query array](https://github.com/ehmicky/wild-wild-path#query-arrays). 89 | If it is already a query array, normalize it to a canonical form. 90 | 91 | ```js 92 | normalizeQuery('users.0.*') // [['users', 0, { type: 'any' }]] 93 | normalizeQuery(['users']) // [['users']] 94 | normalizeQuery([['users'], ['admins']]) // [['users'], ['admins']] 95 | normalizeQuery([{ type: 'slice' }]) // [[{ type: 'slice', from: 0 }]] 96 | normalizeQuery('users./[/') // Throws: invalid RegExp 97 | normalizeQuery([true]) // Throws: `true` is not a valid query 98 | ``` 99 | 100 | ## parsePath(pathString) 101 | 102 | `pathString` [`PathString`](https://github.com/ehmicky/wild-wild-path#paths)\ 103 | _Return value_: [`PathArray`](https://github.com/ehmicky/wild-wild-path#paths) 104 | 105 | Same as [`parseQuery()`](#parsequeryquerystring) but only for a 106 | [path query](https://github.com/ehmicky/wild-wild-path#paths). 107 | 108 | ```js 109 | parsePath('users.0') // ['users', 0] 110 | parsePath('*') // Throws: this is a valid query but not a path 111 | parsePath('users./[/') // Throws: invalid RegExp 112 | ``` 113 | 114 | ## serializePath(pathArray) 115 | 116 | `pathArray` [`PathArray`](https://github.com/ehmicky/wild-wild-path#paths)\ 117 | _Return value_: [`PathString`](https://github.com/ehmicky/wild-wild-path#paths) 118 | 119 | Same as [`serializeQuery()`](#serializequeryqueryarray) but only for a 120 | [path query](https://github.com/ehmicky/wild-wild-path#paths). 121 | 122 | ```js 123 | serializePath(['users', 0]) // 'users.0' 124 | serializePath([{ type: 'any' }]) // Throws: this is a valid query but not a path 125 | serializePath([true]) // Throws: `true` is not a valid query 126 | ``` 127 | 128 | ## normalizePath(path) 129 | 130 | `path` [`Path`](https://github.com/ehmicky/wild-wild-path#paths)\ 131 | _Return value_: [`PathArray`](https://github.com/ehmicky/wild-wild-path#paths) 132 | 133 | Same as [`normalizeQuery()`](#normalizequeryquery) but only for a 134 | [path query](https://github.com/ehmicky/wild-wild-path#paths). 135 | 136 | ```js 137 | normalizePath('users.0') // ['users', 0] 138 | normalizePath(['users', 0]) // ['users', 0] 139 | normalizePath('*') // Throws: `*` is a valid query but not a path 140 | normalizePath([true]) // Throws: `true` is not a valid query 141 | ``` 142 | 143 | ## isSameQuery(firstQuery, secondQuery) 144 | 145 | `firstQuery` [`Query`](https://github.com/ehmicky/wild-wild-path#queries)\ 146 | `secondQuery` [`Query`](https://github.com/ehmicky/wild-wild-path#queries)\ 147 | _Return value_: `boolean` 148 | 149 | Return `true` if both queries are the same, even if they use different formats 150 | ([string](https://github.com/ehmicky/wild-wild-path#query-strings) or 151 | [array](https://github.com/ehmicky/wild-wild-path#query-arrays)) or if they are 152 | syntactically different but semantically identical. 153 | 154 | ```js 155 | isSameQuery('users.0.*', 'users.0.*') // true 156 | isSameQuery('users.0.*', ['users', 0, { type: 'any' }]) // true 157 | isSameQuery(['users', 0, { type: 'any' }], ['users', 0, { type: 'any' }]) // true 158 | isSameQuery('users.0.*', 'users.1.*') // false 159 | isSameQuery('0:2', ':2') // true 160 | isSameQuery([['user']], ['user']) // true 161 | isSameQuery([true], 'user') // Throws: `true` is not a valid query 162 | ``` 163 | 164 | ## isSamePath(firstPath, secondPath) 165 | 166 | `firstPath` [`Path`](https://github.com/ehmicky/wild-wild-path#paths)\ 167 | `secondPath` [`Path`](https://github.com/ehmicky/wild-wild-path#paths)\ 168 | _Return value_: `boolean` 169 | 170 | Same as [`isSameQuery()`](#issamepathfirstpath-secondpath) but only for a 171 | [path query](https://github.com/ehmicky/wild-wild-path#paths). 172 | 173 | ```js 174 | isSamePath('user.name', 'user.name') // true 175 | isSamePath('user.name', ['user', 'name']) // true 176 | isSamePath(['user', 'name'], ['user', 'name']) // true 177 | isSamePath('user.name', 'user.lastName') // false 178 | isSamePath('*', 'user.name') // Throws: `*` is a valid query but not a path 179 | isSamePath([true], 'user.name') // Throws: `true` is not a valid query 180 | ``` 181 | 182 | ## isParentPath(parentPath, childPath) 183 | 184 | `parentPath` [`Path`](https://github.com/ehmicky/wild-wild-path#paths)\ 185 | `childPath` [`Path`](https://github.com/ehmicky/wild-wild-path#paths)\ 186 | _Return value_: `boolean` 187 | 188 | Return `true` if the first argument is a parent path to the second. Queries that 189 | are not [paths](https://github.com/ehmicky/wild-wild-path#paths) cannot be used. 190 | 191 | ```js 192 | isParentPath('user', 'user.name') // true 193 | isParentPath('user', 'user.settings.name') // true 194 | isParentPath('user', ['user', 'settings', 'name']) // true 195 | isParentPath(['user'], ['user', 'settings', 'name']) // true 196 | isParentPath('user', 'user') // false 197 | isParentPath('user.name', 'user') // false 198 | isParentPath('user.name', 'user.settings') // false 199 | isParentPath('*', 'user.name') // Throws: `*` is valid query but not a path 200 | isParentPath([true], 'user.name') // Throws: `true` is not a valid query 201 | ``` 202 | 203 | ## isSameToken(firstToken, secondToken) 204 | 205 | `firstToken` [`Token`](https://github.com/ehmicky/wild-wild-path#query-arrays)\ 206 | `secondToken` [`Token`](https://github.com/ehmicky/wild-wild-path#query-arrays)\ 207 | _Return value_: `boolean` 208 | 209 | Same as [`isSameQuery()`](#issamepathfirstpath-secondpath) but only for 210 | [query array](https://github.com/ehmicky/wild-wild-path#query-arrays) individual 211 | tokens. 212 | 213 | 214 | 215 | ```js 216 | isSameToken('user', 'user') // true 217 | isSameToken('user', 'users') // false 218 | isSameToken(2, 2) // true 219 | isSameToken(0, -0) // false 220 | isSameToken(/Name/, /Name/) // true 221 | isSameToken(/Name/, /name/i) // false 222 | isSameToken({ type: 'slice' }, { type: 'slice', from: 0 }) // true 223 | isSameToken('user', true) // Throws: invalid token `true` 224 | ``` 225 | 226 | ## getTokenType(token) 227 | 228 | `token` [`Token`](https://github.com/ehmicky/wild-wild-path#query-arrays)\ 229 | _Return value_: `string` 230 | 231 | Retrieve the type of a 232 | [query array](https://github.com/ehmicky/wild-wild-path#query-arrays) individual 233 | token among: `"prop"`, `"index"`, `"slice"`, `"regExp"`, `"any"` or `"anyDeep"`. 234 | `"unknown"` is returned if the token is invalid. 235 | 236 | 237 | 238 | ```js 239 | getTokenType('user') // "prop" 240 | getTokenType(0) // "index" 241 | getTokenType(/Name/) // "regExp" 242 | getTokenType({ type: 'slice', from: 0, to: 2 }) // "slice" 243 | getTokenType({ type: 'any' }) // "any" 244 | getTokenType({ type: 'anyDeep' }) // "anyDeep" 245 | getTokenType(true) // "unknown" 246 | ``` 247 | 248 | # Related projects 249 | 250 | - [`wild-wild-path`](https://github.com/ehmicky/wild-wild-path): get/set object 251 | properties using `wild-wild-parser`'s paths 252 | - [`wild-wild-utils`](https://github.com/ehmicky/wild-wild-utils): functional 253 | utilities using `wild-wild-parser`'s paths 254 | 255 | # Support 256 | 257 | For any question, _don't hesitate_ to [submit an issue on GitHub](../../issues). 258 | 259 | Everyone is welcome regardless of personal background. We enforce a 260 | [Code of conduct](CODE_OF_CONDUCT.md) in order to promote a positive and 261 | inclusive environment. 262 | 263 | # Contributing 264 | 265 | This project was made with ❤️. The simplest way to give back is by starring and 266 | sharing it online. 267 | 268 | If the documentation is unclear or has a typo, please click on the page's `Edit` 269 | button (pencil icon) and suggest a correction. 270 | 271 | If you would like to help us fix a bug or add a new feature, please check our 272 | [guidelines](CONTRIBUTING.md). Pull requests are welcome! 273 | 274 | 275 | 276 | 277 | 278 | 281 | 282 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | export { default } from '@ehmicky/dev-tasks/ava.config.js' 2 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | export { default } from '@ehmicky/eslint-config' 2 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | export * from '@ehmicky/dev-tasks' 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wild-wild-parser", 3 | "version": "5.0.1", 4 | "type": "module", 5 | "exports": { 6 | "types": "./build/src/main.d.ts", 7 | "default": "./build/src/main.js" 8 | }, 9 | "main": "./build/src/main.js", 10 | "types": "./build/src/main.d.ts", 11 | "files": [ 12 | "build/src/**/*.{js,json,d.ts}", 13 | "!build/src/**/*.test.js", 14 | "!build/src/{helpers,fixtures}" 15 | ], 16 | "sideEffects": false, 17 | "scripts": { 18 | "test": "gulp test" 19 | }, 20 | "description": "🤠 Parser for object property paths with wildcards and regexps 🌵", 21 | "keywords": [ 22 | "wildcard", 23 | "glob", 24 | "globbing", 25 | "globstar", 26 | "regex", 27 | "regexp", 28 | "regular-expression", 29 | "path", 30 | "recursion", 31 | "functional-programming", 32 | "map", 33 | "filter", 34 | "algorithm", 35 | "data-structures", 36 | "javascript", 37 | "json", 38 | "library", 39 | "nodejs", 40 | "parsing", 41 | "typescript" 42 | ], 43 | "license": "Apache-2.0", 44 | "homepage": "https://www.github.com/ehmicky/wild-wild-parser", 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/ehmicky/wild-wild-parser.git" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/ehmicky/wild-wild-parser/issues" 51 | }, 52 | "author": "ehmicky (https://github.com/ehmicky)", 53 | "directories": { 54 | "lib": "src" 55 | }, 56 | "devDependencies": { 57 | "@ehmicky/dev-tasks": "^3.0.34", 58 | "@ehmicky/eslint-config": "^20.0.32", 59 | "@ehmicky/prettier-config": "^1.0.6", 60 | "test-each": "^7.0.1" 61 | }, 62 | "engines": { 63 | "node": ">=18.18.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | export { default } from '@ehmicky/prettier-config' 2 | -------------------------------------------------------------------------------- /src/compare.js: -------------------------------------------------------------------------------- 1 | import { normalizePath, normalizeQuery } from './normalize.js' 2 | import { getValidTokenType } from './validate/token.js' 3 | 4 | // Check if two queries are equal. 5 | // Works with: 6 | // - Normalization, e.g. `:` === `0:` 7 | // - Unions, e.g. `a b` === `b a` 8 | // - Duplicates, e.g. `a a` === `a` 9 | export const isSameQuery = (queryA, queryB) => { 10 | const queryArraysA = normalizeQuery(queryA) 11 | const queryArraysB = normalizeQuery(queryB) 12 | return ( 13 | queryArraysA.every((queryArrayA) => 14 | hasSameQueryArray(queryArraysB, queryArrayA), 15 | ) && 16 | queryArraysB.every((queryArrayB) => 17 | hasSameQueryArray(queryArraysA, queryArrayB), 18 | ) 19 | ) 20 | } 21 | 22 | const hasSameQueryArray = (queryArrays, queryArrayA) => 23 | queryArrays.some((queryArrayB) => isSameQueryArray(queryArrayA, queryArrayB)) 24 | 25 | // Check if two paths are equal 26 | export const isSamePath = (pathA, pathB) => { 27 | const pathC = normalizePath(pathA) 28 | const pathD = normalizePath(pathB) 29 | return isSameQueryArray(pathC, pathD) 30 | } 31 | 32 | const isSameQueryArray = (queryArrayA, queryArrayB) => 33 | queryArrayA.length === queryArrayB.length && 34 | queryArrayA.every((tokenA, index) => isSameToken(tokenA, queryArrayB[index])) 35 | 36 | // Check if a path is a parent to another 37 | export const isParentPath = (parentPath, childPath) => { 38 | const parentPathA = normalizePath(parentPath) 39 | const childPathA = normalizePath(childPath) 40 | return ( 41 | childPathA.length > parentPathA.length && 42 | childPathA.every( 43 | (childToken, index) => 44 | index >= parentPathA.length || 45 | isSameToken(childToken, parentPathA[index]), 46 | ) 47 | ) 48 | } 49 | 50 | // Check if two tokens are equal 51 | export const isSameToken = (tokenA, tokenB) => { 52 | if (Object.is(tokenA, tokenB)) { 53 | return true 54 | } 55 | 56 | const tokenTypeA = getValidTokenType(tokenA) 57 | const tokenTypeB = getValidTokenType(tokenB) 58 | return tokenTypeA === tokenTypeB && tokenTypeA.equals(tokenA, tokenB) 59 | } 60 | -------------------------------------------------------------------------------- /src/compare.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { 5 | isParentPath, 6 | isSamePath, 7 | isSameQuery, 8 | isSameToken, 9 | } from 'wild-wild-parser' 10 | 11 | each( 12 | [ 13 | { queryA: [], queryB: ['a'], output: false }, 14 | { queryA: [], queryB: [], output: true }, 15 | { queryA: ['a'], queryB: [['a']], output: true }, 16 | { queryA: [['a'], ['b']], queryB: 'a b', output: true }, 17 | { queryA: ':', queryB: '0:', output: true }, 18 | { queryA: 'a', queryB: 'a.b', output: false }, 19 | { queryA: 'a b', queryB: 'b a', output: true }, 20 | { queryA: 'a a', queryB: 'a', output: true }, 21 | { queryA: 'a a b', queryB: 'b a', output: true }, 22 | ], 23 | ({ title }, { queryA, queryB, output }) => { 24 | test(`isSameQuery() output | ${title}`, (t) => { 25 | t.is(isSameQuery(queryA, queryB), output) 26 | }) 27 | }, 28 | ) 29 | 30 | each([{ queryA: true, queryB: [] }], ({ title }, { queryA, queryB }) => { 31 | test(`isSameQuery() validates input | ${title}`, (t) => { 32 | t.throws(isSameQuery.bind(undefined, queryA, queryB)) 33 | }) 34 | }) 35 | 36 | each( 37 | [ 38 | { queryA: [], queryB: [], output: true }, 39 | { queryA: [], queryB: ['a'], output: false }, 40 | { queryA: ['a'], queryB: 'a', output: true }, 41 | { queryA: 'a', queryB: 'a.b', output: false }, 42 | ], 43 | ({ title }, { queryA, queryB, output }) => { 44 | test(`isSamePath() output | ${title}`, (t) => { 45 | t.is(isSamePath(queryA, queryB), output) 46 | }) 47 | }, 48 | ) 49 | 50 | each( 51 | [ 52 | { queryA: true, queryB: [] }, 53 | { queryA: 'a b', queryB: 'a' }, 54 | { queryA: '0:', queryB: 'a' }, 55 | { queryA: -0, queryB: 0 }, 56 | ], 57 | ({ title }, { queryA, queryB }) => { 58 | test(`isSamePath() validates input | ${title}`, (t) => { 59 | t.throws(isSamePath.bind(undefined, queryA, queryB)) 60 | }) 61 | }, 62 | ) 63 | 64 | const setLastIndex = (regExp, string) => { 65 | regExp.test(string) 66 | return regExp 67 | } 68 | 69 | each( 70 | [ 71 | // Prop tokens 72 | { tokenA: 'a', tokenB: 'a', output: true }, 73 | { tokenA: 'a ', tokenB: 'a', output: false }, 74 | 75 | // Index tokens 76 | { tokenA: '1', tokenB: 1, output: false }, 77 | { tokenA: 1, tokenB: 1, output: true }, 78 | { tokenA: 0, tokenB: -0, output: false }, 79 | { tokenA: -0, tokenB: -0, output: true }, 80 | 81 | // Slice tokens 82 | { tokenA: { type: 'slice' }, tokenB: { type: 'slice' }, output: true }, 83 | { 84 | tokenA: { type: 'slice' }, 85 | tokenB: { type: 'slice', other: true }, 86 | output: true, 87 | }, 88 | { 89 | tokenA: { type: 'slice', from: 1, to: 1 }, 90 | tokenB: { type: 'slice', from: 1, to: 1 }, 91 | output: true, 92 | }, 93 | { 94 | tokenA: { type: 'slice', from: 1 }, 95 | tokenB: { type: 'slice', from: 1, to: undefined }, 96 | output: true, 97 | }, 98 | { 99 | tokenA: { type: 'slice', from: 1 }, 100 | tokenB: { type: 'slice', from: 1, to: -0 }, 101 | output: true, 102 | }, 103 | { 104 | tokenA: { type: 'slice' }, 105 | tokenB: { type: 'slice', from: 0 }, 106 | output: true, 107 | }, 108 | 109 | // RegExp tokens 110 | { tokenA: /a/u, tokenB: /a/u, output: true }, 111 | { tokenA: /a/u, tokenB: /a/gu, output: false }, 112 | { tokenA: /a/u, tokenB: /ab/u, output: false }, 113 | { tokenA: /./gu, tokenB: setLastIndex(/./gu, 'aa'), output: false }, 114 | 115 | // any tokens 116 | { tokenA: { type: 'any' }, tokenB: { type: 'any' }, output: true }, 117 | { 118 | tokenA: { type: 'any' }, 119 | tokenB: { type: 'any', other: true }, 120 | output: true, 121 | }, 122 | 123 | // anyDeep tokens 124 | { tokenA: { type: 'anyDeep' }, tokenB: { type: 'anyDeep' }, output: true }, 125 | { 126 | tokenA: { type: 'anyDeep' }, 127 | tokenB: { type: 'anyDeep', other: true }, 128 | output: true, 129 | }, 130 | ], 131 | ({ title }, { tokenA, tokenB, output }) => { 132 | test(`isSameToken() output | ${title}`, (t) => { 133 | t.is(isSameToken(tokenA, tokenB), output) 134 | }) 135 | }, 136 | ) 137 | 138 | each( 139 | [ 140 | { tokenA: true, tokenB: 'a' }, 141 | { tokenA: 'a', tokenB: true }, 142 | ], 143 | ({ title }, { tokenA, tokenB }) => { 144 | test(`isSameToken() validates input | ${title}`, (t) => { 145 | t.throws(isSameToken.bind(undefined, tokenA, tokenB)) 146 | }) 147 | }, 148 | ) 149 | 150 | each( 151 | [ 152 | { parentPath: ['a'], childPath: 'a.b', output: true }, 153 | { parentPath: 'a', childPath: 'a.b', output: true }, 154 | { parentPath: 'a', childPath: 'a', output: false }, 155 | { parentPath: '.', childPath: '.', output: false }, 156 | { parentPath: 'a.b', childPath: 'a', output: false }, 157 | { parentPath: 'a', childPath: 'a.b.c', output: true }, 158 | { parentPath: 'a.b', childPath: 'a.b.c', output: true }, 159 | { parentPath: 'c', childPath: 'a.b', output: false }, 160 | { parentPath: '.', childPath: 'a', output: true }, 161 | ], 162 | ({ title }, { parentPath, childPath, output }) => { 163 | test(`isParentPath() output | ${title}`, (t) => { 164 | t.is(isParentPath(parentPath, childPath), output) 165 | }) 166 | }, 167 | ) 168 | 169 | each( 170 | [ 171 | { parentPath: true, childPath: [] }, 172 | { parentPath: 'a b', childPath: 'a b.c' }, 173 | { parentPath: '*', childPath: '*.a' }, 174 | ], 175 | ({ title }, { parentPath, childPath }) => { 176 | test(`isParentPath() validates input | ${title}`, (t) => { 177 | t.throws(isParentPath.bind(undefined, parentPath, childPath)) 178 | }) 179 | }, 180 | ) 181 | -------------------------------------------------------------------------------- /src/main.d.ts: -------------------------------------------------------------------------------- 1 | type RegExpToken = RegExp 2 | 3 | type IndexToken = number 4 | 5 | type PropToken = string 6 | 7 | interface AnyDeepToken { 8 | type: 'anyDeep' 9 | } 10 | 11 | interface AnyToken { 12 | type: 'any' 13 | } 14 | 15 | interface SliceToken { 16 | type: 'slice' 17 | from?: IndexToken | undefined 18 | to?: IndexToken | undefined 19 | } 20 | 21 | export type QueryToken = Readonly< 22 | AnyDeepToken | AnyToken | RegExpToken | SliceToken | IndexToken | PropToken 23 | > 24 | export type PathToken = Readonly 25 | 26 | export type TokenType = 27 | | 'unknown' 28 | | 'anyDeep' 29 | | 'any' 30 | | 'regExp' 31 | | 'slice' 32 | | 'index' 33 | | 'prop' 34 | 35 | export type PathString = string 36 | export type QueryString = string 37 | 38 | export type PathArray = PathToken[] 39 | export type QueryArray = QueryToken[] 40 | 41 | export type Path = PathString | PathArray 42 | export type Query = QueryString | QueryArray 43 | 44 | /** 45 | * Retrieve the type of a query array individual token among: `"prop"`, 46 | * `"index"`, `"slice"`, `"regExp"`, `"any"` or `"anyDeep"`. `"unknown"` is 47 | * returned if the token is invalid. 48 | * 49 | * @example 50 | * ```js 51 | * getTokenType('user') // "prop" 52 | * getTokenType(0) // "index" 53 | * getTokenType(/Name/) // "regExp" 54 | * getTokenType({ type: 'slice', from: 0, to: 2 }) // "slice" 55 | * getTokenType({ type: 'any' }) // "any" 56 | * getTokenType({ type: 'anyDeep' }) // "anyDeep" 57 | * getTokenType(true) // "unknown" 58 | * ``` 59 | */ 60 | export function getTokenType(token: QueryToken): TokenType 61 | 62 | /** 63 | * Same as `isSameQuery()` but only for query array individual tokens. 64 | * 65 | * @example 66 | * ```js 67 | * isSameToken('user', 'user') // true 68 | * isSameToken('user', 'users') // false 69 | * isSameToken(2, 2) // true 70 | * isSameToken(0, -0) // false 71 | * isSameToken(/Name/, /Name/) // true 72 | * isSameToken(/Name/, /name/i) // false 73 | * isSameToken({ type: 'slice' }, { type: 'slice', from: 0 }) // true 74 | * isSameToken('user', true) // Throws: invalid token `true` 75 | * ``` 76 | */ 77 | export function isSameToken( 78 | firstToken: QueryToken, 79 | secondToken: QueryToken, 80 | ): boolean 81 | 82 | /** 83 | * Same as `isSameQuery()` but only for a path query. 84 | * 85 | * @example 86 | * ```js 87 | * isSamePath('user.name', 'user.name') // true 88 | * isSamePath('user.name', ['user', 'name']) // true 89 | * isSamePath(['user', 'name'], ['user', 'name']) // true 90 | * isSamePath('user.name', 'user.lastName') // false 91 | * isSamePath('*', 'user.name') // Throws: `*` is a valid query but not a path 92 | * isSamePath([true], 'user.name') // Throws: `true` is not a valid query 93 | * ``` 94 | */ 95 | export function isSamePath(firstPath: Path, secondPath: Path): boolean 96 | 97 | /** 98 | * Return `true` if the first argument is a parent path to the second. 99 | * Queries that are not paths cannot be used. 100 | * 101 | * @example 102 | * ```js 103 | * isParentPath('user', 'user.name') // true 104 | * isParentPath('user', 'user.settings.name') // true 105 | * isParentPath('user', ['user', 'settings', 'name']) // true 106 | * isParentPath(['user'], ['user', 'settings', 'name']) // true 107 | * isParentPath('user', 'user') // false 108 | * isParentPath('user.name', 'user') // false 109 | * isParentPath('user.name', 'user.settings') // false 110 | * isParentPath('*', 'user.name') // Throws: `*` is valid query but not a path 111 | * isParentPath([true], 'user.name') // Throws: `true` is not a valid query 112 | * ``` 113 | */ 114 | export function isParentPath(parentPath: Path, childPath: Path): boolean 115 | 116 | /** 117 | * Return `true` if both queries are the same, even if they use different 118 | * formats (string or array) or if they are syntactically different but 119 | * semantically identical. 120 | * 121 | * @example 122 | * ```js 123 | * isSameQuery('users.0.*', 'users.0.*') // true 124 | * isSameQuery('users.0.*', ['users', 0, { type: 'any' }]) // true 125 | * isSameQuery(['users', 0, { type: 'any' }], ['users', 0, { type: 'any' }]) // true 126 | * isSameQuery('users.0.*', 'users.1.*') // false 127 | * isSameQuery('0:2', ':2') // true 128 | * isSameQuery([['user']], ['user']) // true 129 | * isSameQuery([true], 'user') // Throws: `true` is not a valid query 130 | * ``` 131 | */ 132 | export function isSameQuery(firstQuery: Query, secondQuery: Query): boolean 133 | 134 | /** 135 | * Same as `parseQuery()` but only for a path query. 136 | * 137 | * @example 138 | * ```js 139 | * parsePath('users.0') // ['users', 0] 140 | * parsePath('*') // Throws: this is a valid query but not a path 141 | * parsePath('users./[/') // Throws: invalid RegExp 142 | * ``` 143 | */ 144 | export function parsePath(pathString: PathString): PathArray 145 | 146 | /** 147 | * Convert a query string into a query array. 148 | * 149 | * @example 150 | * ```js 151 | * parseQuery('users.0.*') // [['users', 0, { type: 'any' }]] 152 | * parseQuery('users admins') // [['users'], ['admins']] 153 | * parseQuery('users./[/') // Throws: invalid RegExp 154 | * ``` 155 | */ 156 | export function parseQuery(queryString: QueryString): QueryArray 157 | 158 | /** 159 | * Same as `serializeQuery()` but only for a path query. 160 | * 161 | * @example 162 | * ```js 163 | * serializePath(['users', 0]) // 'users.0' 164 | * serializePath([{ type: 'any' }]) // Throws: this is a valid query but not a path 165 | * serializePath([true]) // Throws: `true` is not a valid query 166 | * ``` 167 | */ 168 | export function serializePath(pathArray: PathArray): PathString 169 | 170 | /** 171 | * Convert a query array into a query string. 172 | * 173 | * @example 174 | * ```js 175 | * serializeQuery(['users', 0, { type: 'any' }]) // 'users.0.*' 176 | * serializeQuery([['users'], ['admins']]) // 'users admins' 177 | * serializeQuery([true]) // Throws: `true` is not a valid query 178 | * ``` 179 | */ 180 | export function serializeQuery(queryArray: QueryArray): QueryString 181 | 182 | /** 183 | * Same as `normalizeQuery()` but only for a path query. 184 | * 185 | * @example 186 | * ```js 187 | * normalizePath('users.0') // ['users', 0] 188 | * normalizePath(['users', 0]) // ['users', 0] 189 | * normalizePath('*') // Throws: `*` is a valid query but not a path 190 | * normalizePath([true]) // Throws: `true` is not a valid query 191 | * ``` 192 | */ 193 | export function normalizePath(path: Path): PathArray 194 | 195 | /** 196 | * If the query is a query string, convert it into a query array. 197 | * If it is already a query array, normalize it to a canonical form. 198 | * 199 | * @example 200 | * ```js 201 | * normalizeQuery('users.0.*') // [['users', 0, { type: 'any' }]] 202 | * normalizeQuery(['users']) // [['users']] 203 | * normalizeQuery([['users'], ['admins']]) // [['users'], ['admins']] 204 | * normalizeQuery([{ type: 'slice' }]) // [[{ type: 'slice', from: 0 }]] 205 | * normalizeQuery('users./[/') // Throws: invalid RegExp 206 | * normalizeQuery([true]) // Throws: `true` is not a valid query 207 | * ``` 208 | */ 209 | export function normalizeQuery(query: Query): QueryArray 210 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | export { 2 | isParentPath, 3 | isSamePath, 4 | isSameQuery, 5 | isSameToken, 6 | } from './compare.js' 7 | export { normalizePath, normalizeQuery } from './normalize.js' 8 | export { parsePath, parseQuery } from './parse/main.js' 9 | export { serializePath, serializeQuery } from './serialize.js' 10 | export { getTokenType } from './tokens/main.js' 11 | -------------------------------------------------------------------------------- /src/main.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectAssignable, expectType } from 'tsd' 2 | 3 | import { 4 | getTokenType, 5 | isParentPath, 6 | isSamePath, 7 | isSameQuery, 8 | isSameToken, 9 | normalizePath, 10 | normalizeQuery, 11 | parsePath, 12 | parseQuery, 13 | serializePath, 14 | serializeQuery, 15 | type Path, 16 | type PathArray, 17 | type PathString, 18 | type PathToken, 19 | type Query, 20 | type QueryArray, 21 | type QueryString, 22 | type QueryToken, 23 | type TokenType, 24 | } from 'wild-wild-parser' 25 | 26 | expectAssignable('prop') 27 | expectAssignable({ type: 'any' }) 28 | expectAssignable(['prop']) 29 | expectAssignable([{ type: 'any' }]) 30 | expectAssignable('prop') 31 | expectAssignable('*') 32 | expectAssignable('index') 33 | expectAssignable(['prop']) 34 | expectAssignable('prop') 35 | expectAssignable([{ type: 'any' }]) 36 | expectAssignable('*') 37 | 38 | expectType(getTokenType('prop')) 39 | // @ts-expect-error 40 | getTokenType({ type: 'other' }) 41 | // @ts-expect-error 42 | getTokenType({ type: 'anyDeep', other: true }) 43 | getTokenType({ type: 'anyDeep' }) 44 | // @ts-expect-error 45 | getTokenType({ type: 'any', other: true }) 46 | getTokenType({ type: 'any' }) 47 | getTokenType(/regexp/u) 48 | getTokenType('prop') 49 | getTokenType(0) 50 | getTokenType(-0) 51 | getTokenType({ type: 'slice' }) 52 | getTokenType({ type: 'slice', from: undefined, to: undefined }) 53 | getTokenType({ type: 'slice', from: 0, to: 1 }) 54 | // @ts-expect-error 55 | getTokenType({ type: 'slice', other: true }) 56 | 57 | expectType(isSameToken(0, '0')) 58 | // @ts-expect-error 59 | isSameToken(0, true) 60 | expectType(isSamePath([0], '0')) 61 | // @ts-expect-error 62 | isSamePath([0], true) 63 | expectType(isParentPath([0], '0')) 64 | // @ts-expect-error 65 | isParentPath([0], true) 66 | expectType(isSameQuery([{ type: 'any' }], '*')) 67 | // @ts-expect-error 68 | isSameQuery([{ type: 'any' }], true) 69 | 70 | expectType(parsePath('prop')) 71 | // @ts-expect-error 72 | parsePath(true) 73 | expectType(parseQuery('*')) 74 | // @ts-expect-error 75 | parseQuery(true) 76 | expectType(serializePath(['prop'])) 77 | // @ts-expect-error 78 | serializePath(true) 79 | expectType(serializeQuery([{ type: 'any' }])) 80 | // @ts-expect-error 81 | serializeQuery(true) 82 | expectType(normalizePath('prop')) 83 | normalizePath(['prop']) 84 | // @ts-expect-error 85 | normalizePath(true) 86 | expectType(normalizeQuery('*')) 87 | normalizeQuery([{ type: 'any' }]) 88 | // @ts-expect-error 89 | normalizeQuery(true) 90 | -------------------------------------------------------------------------------- /src/normalize.js: -------------------------------------------------------------------------------- 1 | import { parsePath, parseQuery } from './parse/main.js' 2 | import { normalizeQueryArrays } from './validate/arrays.js' 3 | import { validatePath } from './validate/path.js' 4 | import { isQueryString } from './validate/string.js' 5 | 6 | // There are two formats: 7 | // - Query string 8 | // - Tokens are dot-separated 9 | // - Unions are space-separated 10 | // - This is more convenient wherever a string is better, including in CLI 11 | // flags, in URLs, in files, etc. 12 | // - \ must escape the following characters: . \ space 13 | // - If a token is meant as a property name but could be interpreted as a 14 | // different type, it must be start with \ 15 | // - A leading dot can be optionally used, e.g. `.one`. It is ignored. 16 | // - A lone dot targets the root. 17 | // - Property names that are empty strings can be specified, e.g. `..a..b.` 18 | // parses as `["", "a", "", "b", ""]` 19 | // - Array[s] of tokens 20 | // - Tokens are elements of the inner arrays 21 | // - Unions use optional outer arrays 22 | // - An empty inner array targets the root. 23 | // - This does not need any escaping, making it better with dynamic input 24 | // - This is faster as it does not perform any parsing 25 | // Unions must not have 0 elements: 26 | // - Empty arrays are interpreted as a single array of tokens targetting the 27 | // root 28 | // - Empty query strings throw an error 29 | // - This is because: 30 | // - Empty unions semantics might be confusing 31 | // - Empty arrays are ambiguous with root queries 32 | // - Which are a much more common use case 33 | // - Also, this allows paths to be a strict subset of query arrays 34 | // - Otherwise, root queries would need to be wrapped in an outer 35 | // array 36 | // - Downside: if a union of query arrays is computed dynamically by the 37 | // consumer logic, it might need to test whether the array is empty 38 | // Each object property is matched by a token among the following types: 39 | // - Property name 40 | // - String format: "propName" 41 | // - Array format: "propName" 42 | // - Empty keys are supported with empty strings 43 | // - Array index 44 | // - String format: "1" 45 | // - Array format: 1 46 | // - We distinguish between property names and array indices that are 47 | // integers 48 | // - Negatives indices can be used to get elements at the end, e.g. -2 49 | // - Including -0 which can be used to append elements 50 | // - Array slices 51 | // - String format: "0:2" 52 | // - Array format: { type: "slice", from: 0, end: 2 } 53 | // - Matches multiple indices of an array 54 | // - Negatives indices like the array indices format 55 | // - `from` is included, `to` is excluded (like `Array.slice()`) 56 | // - `from` defaults to 0 and `to` to -0 57 | // - Wildcard 58 | // - String format: "*" 59 | // - Array format: { type: "any" } 60 | // - We use objects instead of strings or symbols as both are valid as 61 | // object properties which creates a risk for injections 62 | // - Matches any object property or array item 63 | // - Regular expression 64 | // - String format: "/regexp/" or "/regexp/flags" 65 | // - Array format: RegExp instance 66 | // - Matches any object property with a matching name 67 | // - ^ and $ must be used if the RegExp needs to match from the beginning 68 | // or until the end 69 | // Symbols are always ignored: 70 | // - Both in the query string|array and in the target value 71 | // - This is because symbols cannot be serialized in a query string 72 | // - This would remove the guarantee that both string|array syntaxes are 73 | // equivalent and interchangeable 74 | // - We do not use `symbol.description` as this should not be used for 75 | // identity purpose 76 | // Exceptions are thrown on syntax errors: 77 | // - I.e. query or path syntax errors, or wrong arguments 78 | // - But queries matching nothing do not throw: instead they return nothing 79 | 80 | // Parse a path string into an array of tokens. 81 | // If the query is already an array of tokens, only validate and normalize it. 82 | export const normalizePath = (path) => { 83 | if (isQueryString(path)) { 84 | return parsePath(path) 85 | } 86 | 87 | validatePath(path, path) 88 | return path 89 | } 90 | 91 | // Same as `normalizePath()` but for any query 92 | export const normalizeQuery = (query) => 93 | isQueryString(query) ? parseQuery(query) : normalizeQueryArrays(query, query) 94 | -------------------------------------------------------------------------------- /src/normalize.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { normalizePath, normalizeQuery } from 'wild-wild-parser' 5 | 6 | each( 7 | [ 8 | { query: 'a.b', output: [['a', 'b']] }, 9 | { query: '.', output: [[]] }, 10 | { query: ['a'], output: [['a']] }, 11 | { query: [], output: [[]] }, 12 | { query: [[-0]], output: [[-0]] }, 13 | { 14 | query: [[{ type: 'slice', to: -0 }]], 15 | output: [[{ type: 'slice', from: 0 }]], 16 | }, 17 | { query: [[{ type: 'any', other: true }]], output: [[{ type: 'any' }]] }, 18 | { 19 | query: [[{ type: 'anyDeep', other: true }]], 20 | output: [[{ type: 'anyDeep' }]], 21 | }, 22 | ], 23 | ({ title }, { query, output }) => { 24 | test(`normalizeQuery() output | ${title}`, (t) => { 25 | t.deepEqual(normalizeQuery(query), output) 26 | }) 27 | }, 28 | ) 29 | 30 | each(['', [true]], ({ title }, arg) => { 31 | test(`normalizeQuery() validates input | ${title}`, (t) => { 32 | t.throws(normalizeQuery.bind(undefined, arg)) 33 | }) 34 | }) 35 | 36 | each( 37 | [ 38 | { query: ['a', 'b'], output: ['a', 'b'] }, 39 | { query: 'a.b', output: ['a', 'b'] }, 40 | ], 41 | ({ title }, { query, output }) => { 42 | test(`normalizePath() output | ${title}`, (t) => { 43 | t.deepEqual(normalizePath(query), output) 44 | }) 45 | }, 46 | ) 47 | 48 | each([[-1], '-1'], ({ title }, arg) => { 49 | test(`normalizePath() validates input | ${title}`, (t) => { 50 | t.throws(normalizePath.bind(undefined, arg)) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /src/parse/escape.js: -------------------------------------------------------------------------------- 1 | import { 2 | ARRAY_SEPARATOR_NAME, 3 | ESCAPE, 4 | SPECIAL_CHARS, 5 | TOKEN_SEPARATOR, 6 | } from '../tokens/escape.js' 7 | import { throwQueryError } from '../validate/throw.js' 8 | 9 | // Parse escape character in a query string 10 | export const parseEscape = (state, queryString) => { 11 | const nextChar = queryString[state.index + 1] 12 | 13 | if (SPECIAL_CHARS.has(nextChar)) { 14 | state.index += 1 15 | state.chars += nextChar 16 | return 17 | } 18 | 19 | if (state.chars.length !== 0) { 20 | throwQueryError( 21 | queryString, 22 | `Character "${ESCAPE}" must either be at the start of a token, or be followed by ${ARRAY_SEPARATOR_NAME} or ${TOKEN_SEPARATOR} or ${ESCAPE}`, 23 | ) 24 | } 25 | 26 | state.isProp = true 27 | } 28 | -------------------------------------------------------------------------------- /src/parse/main.js: -------------------------------------------------------------------------------- 1 | import { normalizeArraysPath } from '../validate/path.js' 2 | import { validateEmptyQuery, validateQueryString } from '../validate/string.js' 3 | 4 | import { parseQueryString } from './query.js' 5 | 6 | // Parse a query string into an array of tokens. 7 | // Also validate and normalize it. 8 | export const parsePath = (pathString) => { 9 | const queryArrays = parseQuery(pathString) 10 | return normalizeArraysPath(queryArrays, pathString) 11 | } 12 | 13 | // Same as `parsePath()` but for any query 14 | export const parseQuery = (queryString) => { 15 | validateQueryString(queryString) 16 | const queryArrays = parseQueryString(queryString) 17 | validateEmptyQuery(queryArrays, queryString) 18 | return queryArrays 19 | } 20 | -------------------------------------------------------------------------------- /src/parse/main.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { parsePath } from 'wild-wild-parser' 5 | 6 | each( 7 | [ 8 | { queryString: 'a.b', output: ['a', 'b'] }, 9 | { queryString: '.', output: [] }, 10 | { queryString: '..', output: [''] }, 11 | { queryString: '0', output: [0] }, 12 | ], 13 | ({ title }, { queryString, output }) => { 14 | test(`parsePath() output | ${title}`, (t) => { 15 | t.deepEqual(parsePath(queryString), output) 16 | }) 17 | }, 18 | ) 19 | 20 | each(['a b', '-1', '-0', ':', '/a/', '*', '**'], ({ title }, arg) => { 21 | test(`parsePath() validates input | ${title}`, (t) => { 22 | t.throws(parsePath.bind(undefined, arg)) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/parse/query.js: -------------------------------------------------------------------------------- 1 | import { ARRAY_SEPARATOR, ESCAPE, TOKEN_SEPARATOR } from '../tokens/escape.js' 2 | import { getStringTokenType } from '../tokens/main.js' 3 | 4 | import { parseEscape } from './escape.js' 5 | 6 | // Parse a query string. 7 | // Use imperative logic for performance 8 | export const parseQueryString = (queryString) => { 9 | const state = getInitialState() 10 | 11 | // eslint-disable-next-line fp/no-loops 12 | for (; state.index <= queryString.length; state.index += 1) { 13 | const char = queryString[state.index] 14 | 15 | // eslint-disable-next-line max-depth 16 | if (char === ESCAPE) { 17 | parseEscape(state, queryString) 18 | } else if (char === ARRAY_SEPARATOR || state.index === queryString.length) { 19 | addQueryArray(state) 20 | } else if (char === TOKEN_SEPARATOR) { 21 | addToken(state) 22 | } else { 23 | state.chars += char 24 | } 25 | } 26 | 27 | return state.arrays 28 | } 29 | 30 | const getInitialState = () => { 31 | const state = { arrays: [], index: 0 } 32 | resetQueryArrayState(state) 33 | resetTokenState(state) 34 | return state 35 | } 36 | 37 | const addQueryArray = (state) => { 38 | if (hasNoQueryArray(state)) { 39 | return 40 | } 41 | 42 | if (!hasOnlyDots(state)) { 43 | addToken(state) 44 | } 45 | 46 | // eslint-disable-next-line fp/no-mutating-methods 47 | state.arrays.push(state.array) 48 | resetQueryArrayState(state) 49 | } 50 | 51 | // When the query is an empty string or when two spaces are consecutive 52 | const hasNoQueryArray = (state) => 53 | state.firstToken && state.chars.length === 0 && state.array.length === 0 54 | 55 | const resetQueryArrayState = (state) => { 56 | state.array = [] 57 | state.firstToken = true 58 | state.onlyDots = true 59 | } 60 | 61 | const addToken = (state) => { 62 | if (handleLeadingDot(state)) { 63 | return 64 | } 65 | 66 | state.onlyDots = hasOnlyDots(state) 67 | const tokenType = getStringTokenType(state.chars, state.isProp) 68 | const token = tokenType.normalize(tokenType.parse(state.chars)) 69 | // eslint-disable-next-line fp/no-mutating-methods 70 | state.array.push(token) 71 | resetTokenState(state) 72 | } 73 | 74 | // In principle, the root query should be an empty string. 75 | // But we use a lone dot instead because: 76 | // - It distinguishes it from an absence of query 77 | // - It allows parsing it in the middle of a space-separated list (as opposed 78 | // to an empty string) 79 | // However, we create ambiguities for queries with only dots (including a 80 | // lone dot), where the last dot should not create an additional token. 81 | const hasOnlyDots = (state) => state.onlyDots && state.chars.length === 0 82 | 83 | // We ignore leading dots, because they are used to represent the root. 84 | // We do not require them for simplicity. 85 | const handleLeadingDot = (state) => { 86 | if (!state.firstToken) { 87 | return false 88 | } 89 | 90 | state.firstToken = false 91 | return state.chars.length === 0 92 | } 93 | 94 | const resetTokenState = (state) => { 95 | state.isProp = false 96 | state.chars = '' 97 | } 98 | -------------------------------------------------------------------------------- /src/parse/query.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { parseQuery } from 'wild-wild-parser' 5 | 6 | each( 7 | [ 8 | // Dots delimiters 9 | { queryString: 'a.b', output: [['a', 'b']] }, 10 | 11 | // Space delimiters 12 | { queryString: 'a b', output: [['a'], ['b']] }, 13 | { queryString: 'a c', output: [['a'], ['c']] }, 14 | { queryString: ' a', output: [['a']] }, 15 | { queryString: 'a ', output: [['a']] }, 16 | 17 | // Dots non-delimiters 18 | { queryString: '.', output: [[]] }, 19 | { queryString: '..', output: [['']] }, 20 | { queryString: '...', output: [['', '']] }, 21 | { queryString: '.a', output: [['a']] }, 22 | { queryString: '.a.', output: [['a', '']] }, 23 | { queryString: '..a', output: [['', 'a']] }, 24 | 25 | // Escape characters 26 | { queryString: '\\a', output: [['a']] }, 27 | { queryString: '\\0', output: [['0']] }, 28 | { queryString: '\\*', output: [['*']] }, 29 | { queryString: '\\**', output: [['**']] }, 30 | { queryString: '\\:', output: [[':']] }, 31 | { queryString: '\\/a/', output: [['/a/']] }, 32 | { queryString: '\\.', output: [['.']] }, 33 | { queryString: '\\ ', output: [[' ']] }, 34 | { queryString: '\\\\', output: [['\\']] }, 35 | 36 | // Prop tokens 37 | { queryString: 'a', output: [['a']] }, 38 | 39 | // Index tokens 40 | { queryString: '1', output: [[1]] }, 41 | { queryString: '0', output: [[0]] }, 42 | { queryString: '-1', output: [[-1]] }, 43 | { queryString: '-0', output: [[-0]] }, 44 | { queryString: '0.1', output: [[0, 1]] }, 45 | { queryString: '010', output: [[10]] }, 46 | { queryString: '1a', output: [['1a']] }, 47 | { queryString: 'a1', output: [['a1']] }, 48 | { queryString: '1\\ ', output: [['1 ']] }, 49 | { queryString: '\\ 1', output: [[' 1']] }, 50 | { queryString: '1n', output: [['1n']] }, 51 | { queryString: '1e3', output: [['1e3']] }, 52 | { queryString: 'Infinity', output: [['Infinity']] }, 53 | { queryString: 'NaN', output: [['NaN']] }, 54 | 55 | // Slice tokens 56 | { queryString: ':', output: [[{ type: 'slice', from: 0 }]] }, 57 | { queryString: ':-0', output: [[{ type: 'slice', from: 0 }]] }, 58 | { queryString: '0:', output: [[{ type: 'slice', from: 0 }]] }, 59 | { queryString: '1:1', output: [[{ type: 'slice', from: 1, to: 1 }]] }, 60 | { queryString: '-1:-1', output: [[{ type: 'slice', from: -1, to: -1 }]] }, 61 | { queryString: 'a:b', output: [['a:b']] }, 62 | { queryString: '1:1a', output: [['1:1a']] }, 63 | 64 | // RegExp tokens 65 | // eslint-disable-next-line require-unicode-regexp 66 | { queryString: '/a/', output: [[/a/]] }, 67 | { queryString: '/a/u', output: [[/a/u]] }, 68 | { queryString: '/a/b/u', output: [[/a\/b/u]] }, 69 | { queryString: '/a\\.b/u', output: [[/a.b/u]] }, 70 | { queryString: '/a\\ b/u', output: [[/a b/u]] }, 71 | { queryString: '/a\\\\b/u', output: [[/a\b/u]] }, 72 | { queryString: '/a/b/u', output: [[/a\/b/u]] }, 73 | { queryString: '/a\\\\/b/u', output: [[/a\/b/u]] }, 74 | { queryString: '/\\.*/u', output: [[/.*/u]] }, 75 | { queryString: '/.*/u', output: [['/', '*/u']] }, 76 | { queryString: '//', output: [['//']] }, 77 | { queryString: '/', output: [['/']] }, 78 | { queryString: 'b/a/', output: [['b/a/']] }, 79 | 80 | // any tokens 81 | { queryString: '*', output: [[{ type: 'any' }]] }, 82 | { queryString: '*a', output: [['*a']] }, 83 | { queryString: 'a*', output: [['a*']] }, 84 | 85 | // anyDeep tokens 86 | { queryString: '**', output: [[{ type: 'anyDeep' }]] }, 87 | { queryString: '**a', output: [['**a']] }, 88 | { queryString: 'a**', output: [['a**']] }, 89 | { queryString: '***', output: [['***']] }, 90 | ], 91 | ({ title }, { queryString, output }) => { 92 | test(`parseQuery() output | ${title}`, (t) => { 93 | t.deepEqual(parseQuery(queryString), output) 94 | }) 95 | }, 96 | ) 97 | 98 | each( 99 | [[], [[]], ['a'], [['a']], '', ' ', '\\', 'a\\a', '/a/k', '/[/'], 100 | ({ title }, arg) => { 101 | test(`parseQuery() validates input | ${title}`, (t) => { 102 | t.throws(parseQuery.bind(undefined, arg)) 103 | }) 104 | }, 105 | ) 106 | -------------------------------------------------------------------------------- /src/serialize.js: -------------------------------------------------------------------------------- 1 | import { ARRAY_SEPARATOR, TOKEN_SEPARATOR } from './tokens/escape.js' 2 | import { getObjectTokenType } from './tokens/main.js' 3 | import { normalizeQueryArrays } from './validate/arrays.js' 4 | import { validatePath } from './validate/path.js' 5 | 6 | // Inverse of `parseQuery()` 7 | export const serializeQuery = (queryArrays) => { 8 | const queryArraysA = normalizeQueryArrays(queryArrays, queryArrays) 9 | return queryArraysA.map(serializeQueryArray).join(ARRAY_SEPARATOR) 10 | } 11 | 12 | // Inverse of `parsePath()` 13 | export const serializePath = (path) => { 14 | validatePath(path, path) 15 | return serializeQueryArray(path) 16 | } 17 | 18 | const serializeQueryArray = (queryArray) => 19 | queryArray.every(isEmptyToken) 20 | ? TOKEN_SEPARATOR.repeat(queryArray.length + 1) 21 | : queryArray.map(serializeToken).join(TOKEN_SEPARATOR) 22 | 23 | const isEmptyToken = (token) => token === EMPTY_TOKEN 24 | 25 | const EMPTY_TOKEN = '' 26 | 27 | export const serializeToken = (token, index) => { 28 | const tokenType = getObjectTokenType(token) 29 | return tokenType.serialize(token, index) 30 | } 31 | -------------------------------------------------------------------------------- /src/serialize.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { serializePath, serializeQuery } from 'wild-wild-parser' 5 | 6 | each( 7 | [ 8 | // Dots delimiters 9 | { queryArrays: ['a', 'b'], output: 'a.b' }, 10 | 11 | // Space delimiters 12 | { queryArrays: [['a'], ['b']], output: 'a b' }, 13 | 14 | // Dots non-delimiters 15 | { queryArrays: [], output: '.' }, 16 | { queryArrays: [[]], output: '.' }, 17 | { queryArrays: [''], output: '..' }, 18 | { queryArrays: ['', ''], output: '...' }, 19 | { queryArrays: ['a', ''], output: 'a.' }, 20 | { queryArrays: ['', 'a'], output: '..a' }, 21 | 22 | // Escape characters 23 | { queryArrays: ['0'], output: '\\0' }, 24 | { queryArrays: ['*'], output: '\\*' }, 25 | { queryArrays: ['**'], output: '\\**' }, 26 | { queryArrays: [':'], output: '\\:' }, 27 | { queryArrays: ['/a/'], output: '\\/a/' }, 28 | { queryArrays: ['.'], output: '\\.' }, 29 | { queryArrays: [' '], output: '\\ ' }, 30 | { queryArrays: ['\\'], output: '\\\\' }, 31 | 32 | // Prop tokens 33 | { queryArrays: ['a'], output: 'a' }, 34 | 35 | // Index tokens 36 | { queryArrays: [1], output: '1' }, 37 | { queryArrays: [0], output: '0' }, 38 | { queryArrays: [-1], output: '-1' }, 39 | { queryArrays: [-0], output: '-0' }, 40 | { queryArrays: [0, 1], output: '0.1' }, 41 | { queryArrays: ['1 '], output: '1\\ ' }, 42 | { queryArrays: [' 1'], output: '\\ 1' }, 43 | { queryArrays: ['1n'], output: '1n' }, 44 | { queryArrays: ['1e3'], output: '1e3' }, 45 | { queryArrays: ['Infinity'], output: 'Infinity' }, 46 | { queryArrays: ['NaN'], output: 'NaN' }, 47 | 48 | // Slice tokens 49 | { queryArrays: [{ type: 'slice', from: 0 }], output: '0:' }, 50 | { queryArrays: [{ type: 'slice', from: 0, to: -0 }], output: '0:' }, 51 | { queryArrays: [{ type: 'slice', from: 0, to: undefined }], output: '0:' }, 52 | { queryArrays: [{ type: 'slice' }], output: '0:' }, 53 | { queryArrays: [{ type: 'slice', from: 1, to: 1 }], output: '1:1' }, 54 | { queryArrays: [{ type: 'slice', from: -1, to: -1 }], output: '-1:-1' }, 55 | { 56 | queryArrays: [Object.create({}, { type: { value: 'slice' } })], 57 | output: '0:', 58 | }, 59 | { queryArrays: [{ type: 'slice', other: true }], output: '0:' }, 60 | 61 | // RegExp tokens 62 | // eslint-disable-next-line require-unicode-regexp 63 | { queryArrays: [/a/], output: '/a/' }, 64 | { queryArrays: [/a/u], output: '/a/u' }, 65 | { queryArrays: [/a.b/u], output: '/a\\.b/u' }, 66 | { queryArrays: [/a b/u], output: '/a\\ b/u' }, 67 | { queryArrays: [/a\b/u], output: '/a\\\\b/u' }, 68 | { queryArrays: [/a\/b/u], output: '/a\\\\/b/u' }, 69 | 70 | // any tokens 71 | { queryArrays: [{ type: 'any' }], output: '*' }, 72 | { 73 | queryArrays: [Object.create({}, { type: { value: 'any' } })], 74 | output: '*', 75 | }, 76 | { queryArrays: [{ type: 'any', other: true }], output: '*' }, 77 | 78 | // anyDeep tokens 79 | { queryArrays: [{ type: 'anyDeep' }], output: '**' }, 80 | { 81 | queryArrays: [Object.create({}, { type: { value: 'anyDeep' } })], 82 | output: '**', 83 | }, 84 | { queryArrays: [{ type: 'anyDeep', other: true }], output: '**' }, 85 | ], 86 | ({ title }, { queryArrays, output }) => { 87 | test(`serializeQuery() output | ${title}`, (t) => { 88 | t.deepEqual(serializeQuery(queryArrays), output) 89 | }) 90 | }, 91 | ) 92 | 93 | each( 94 | [ 95 | '', 96 | 'a', 97 | [true], 98 | [[true]], 99 | ['a', ['b']], 100 | [{}], 101 | // eslint-disable-next-line no-magic-numbers 102 | [0.1], 103 | [1n], 104 | [Number.POSITIVE_INFINITY], 105 | [Number.NaN], 106 | [{ type: 'slice', from: 'from' }], 107 | [{ type: 'slice', to: 'to' }], 108 | ], 109 | ({ title }, arg) => { 110 | test(`serializeQuery() validates input | ${title}`, (t) => { 111 | t.throws(serializeQuery.bind(undefined, arg)) 112 | }) 113 | }, 114 | ) 115 | 116 | each( 117 | [ 118 | { queryString: ['a', 'b'], output: 'a.b' }, 119 | { queryString: [], output: '.' }, 120 | { queryString: [''], output: '..' }, 121 | { queryString: [0], output: '0' }, 122 | ], 123 | ({ title }, { queryString, output }) => { 124 | test(`serializePath() output | ${title}`, (t) => { 125 | t.deepEqual(serializePath(queryString), output) 126 | }) 127 | }, 128 | ) 129 | 130 | each( 131 | [ 132 | 'a', 133 | [['a'], ['b']], 134 | [-1], 135 | [-0], 136 | [{ type: 'slice' }], 137 | [/a/u], 138 | [{ type: 'any' }], 139 | [{ type: 'anyDeep' }], 140 | ], 141 | ({ title }, arg) => { 142 | test(`serializePath() validates input | ${title}`, (t) => { 143 | t.throws(serializePath.bind(undefined, arg)) 144 | }) 145 | }, 146 | ) 147 | -------------------------------------------------------------------------------- /src/tokens/any.js: -------------------------------------------------------------------------------- 1 | import { isTokenObject } from './common.js' 2 | 3 | // Create a token type with: 4 | // - The string format being a specific string 5 | // - The array format being a plain object with a single `type` property 6 | const createSimpleTokenType = (name, tokenString) => ({ 7 | name, 8 | testObject: testObject.bind(undefined, name), 9 | serialize: serialize.bind(undefined, tokenString), 10 | testString: testString.bind(undefined, tokenString), 11 | parse: parse.bind(undefined, name), 12 | normalize, 13 | equals, 14 | }) 15 | 16 | // Check the type of a parsed token 17 | const testObject = (type, token) => isTokenObject(token, type) 18 | 19 | // Serialize a token to a string 20 | const serialize = (tokenString) => tokenString 21 | 22 | // Check the type of a serialized token 23 | const testString = (tokenString, chars) => chars === tokenString 24 | 25 | // Parse a string into a token 26 | const parse = (type) => ({ type }) 27 | 28 | // Normalize value after parsing or serializing 29 | const normalize = ({ type }) => ({ type }) 30 | 31 | // Check if two tokens are the same 32 | const equals = () => true 33 | 34 | export const ANY_TOKEN = createSimpleTokenType('any', '*') 35 | export const ANY_DEEP_TOKEN = createSimpleTokenType('anyDeep', '**') 36 | -------------------------------------------------------------------------------- /src/tokens/common.js: -------------------------------------------------------------------------------- 1 | // Check whether a token is an object of a given `type` 2 | export const isTokenObject = (token, type) => 3 | typeof token === 'object' && token !== null && token.type === type 4 | -------------------------------------------------------------------------------- /src/tokens/escape.js: -------------------------------------------------------------------------------- 1 | // Escaping character 2 | export const ESCAPE = '\\' 3 | 4 | // Query arrays separator. 5 | // We squash multiple ones in a row. 6 | // But we do not trim spaces at the start or end to allow root paths. 7 | export const ARRAY_SEPARATOR = ' ' 8 | export const ARRAY_SEPARATOR_NAME = 'a space' 9 | 10 | // Tokens separator 11 | export const TOKEN_SEPARATOR = '.' 12 | 13 | // Special characters to escape 14 | export const SPECIAL_CHARS = new Set([ESCAPE, TOKEN_SEPARATOR, ARRAY_SEPARATOR]) 15 | 16 | // Escape special characters 17 | export const escapeSpecialChars = (string) => 18 | string.replaceAll(SPECIAL_CHARS_REGEXP, `${ESCAPE}$&`) 19 | 20 | const SPECIAL_CHARS_REGEXP = /[\\. ]/gu 21 | -------------------------------------------------------------------------------- /src/tokens/indices.js: -------------------------------------------------------------------------------- 1 | // Check the type of a parsed token. 2 | // Integers specified as string tokens are assumed to be property names, not 3 | // array indices. 4 | export const testObject = (token) => Number.isInteger(token) 5 | 6 | // Serialize a token to a string 7 | const serialize = (token) => (Object.is(token, -0) ? '-0' : String(token)) 8 | 9 | // Check the type of a serialized token 10 | const testString = (chars) => INTEGER_REGEXP.test(chars) 11 | 12 | const INTEGER_REGEXP = /^-?\d+$/u 13 | 14 | // Parse a string into a token 15 | const parse = Number 16 | 17 | // Normalize value after parsing or serializing 18 | const normalize = (token) => token 19 | 20 | // Check if two tokens are the same 21 | const equals = (tokenA, tokenB) => Object.is(tokenA, tokenB) 22 | 23 | export const INDEX_TOKEN = { 24 | name: 'index', 25 | testObject, 26 | serialize, 27 | testString, 28 | parse, 29 | normalize, 30 | equals, 31 | } 32 | -------------------------------------------------------------------------------- /src/tokens/main.js: -------------------------------------------------------------------------------- 1 | import { INDEX_TOKEN } from './indices.js' 2 | import { OTHER_OBJECT_TOKEN_TYPES, OTHER_STRING_TOKEN_TYPES } from './other.js' 3 | import { PROP_TOKEN } from './prop.js' 4 | 5 | // Retrieve the type name of a given token parsed object 6 | export const getTokenType = (token) => { 7 | const tokenType = getObjectTokenType(token) 8 | return tokenType === undefined ? UNKNOWN_TYPE : tokenType.name 9 | } 10 | 11 | const UNKNOWN_TYPE = 'unknown' 12 | 13 | // Retrieve the type of a given token parsed object 14 | export const getObjectTokenType = (token) => 15 | OBJECT_TOKEN_TYPES.find((tokenType) => tokenType.testObject(token)) 16 | 17 | // Retrieve the type of a given token serialized string 18 | export const getStringTokenType = (chars, isProp) => 19 | isProp 20 | ? PROP_TOKEN 21 | : STRING_TOKEN_TYPES.find((tokenType) => tokenType.testString(chars)) 22 | 23 | // Order is significant as they are tested serially. 24 | // It is optimized for common use cases and performance. 25 | const STRING_TOKEN_TYPES = [...OTHER_STRING_TOKEN_TYPES, PROP_TOKEN] 26 | const OBJECT_TOKEN_TYPES = [PROP_TOKEN, ...OTHER_OBJECT_TOKEN_TYPES] 27 | 28 | // Check if a token is part of a path 29 | export const isPathToken = (token) => 30 | PROP_TOKEN.testObject(token) || 31 | (INDEX_TOKEN.testObject(token) && token >= 0 && !Object.is(token, -0)) 32 | -------------------------------------------------------------------------------- /src/tokens/main.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { getTokenType } from 'wild-wild-parser' 5 | 6 | each( 7 | [ 8 | { token: 'a', output: 'prop' }, 9 | { token: 0, output: 'index' }, 10 | { token: { type: 'slice' }, output: 'slice' }, 11 | { token: /a/u, output: 'regExp' }, 12 | { token: { type: 'any' }, output: 'any' }, 13 | { token: { type: 'anyDeep' }, output: 'anyDeep' }, 14 | { token: { type: 'anyDeep', other: true }, output: 'anyDeep' }, 15 | { token: true, output: 'unknown' }, 16 | ], 17 | ({ title }, { token, output }) => { 18 | test(`getTokenType() output | ${title}`, (t) => { 19 | t.deepEqual(getTokenType(token), output) 20 | }) 21 | }, 22 | ) 23 | -------------------------------------------------------------------------------- /src/tokens/other.js: -------------------------------------------------------------------------------- 1 | import { ANY_DEEP_TOKEN, ANY_TOKEN } from './any.js' 2 | import { INDEX_TOKEN } from './indices.js' 3 | import { REGEXP_TOKEN } from './regexp.js' 4 | import { SLICE_TOKEN } from './slice.js' 5 | 6 | // Retrieve the type of a given token serialized string, except the default 7 | // one (property name string) 8 | export const getOtherStringTokenType = (chars) => 9 | OTHER_STRING_TOKEN_TYPES.find((tokenType) => tokenType.testString(chars)) 10 | 11 | // Order is significant as they are tested serially. 12 | // It is optimized for common use cases and performance. 13 | export const OTHER_STRING_TOKEN_TYPES = [ 14 | ANY_TOKEN, 15 | ANY_DEEP_TOKEN, 16 | REGEXP_TOKEN, 17 | SLICE_TOKEN, 18 | INDEX_TOKEN, 19 | ] 20 | export const OTHER_OBJECT_TOKEN_TYPES = [ 21 | INDEX_TOKEN, 22 | ANY_TOKEN, 23 | ANY_DEEP_TOKEN, 24 | REGEXP_TOKEN, 25 | SLICE_TOKEN, 26 | ] 27 | -------------------------------------------------------------------------------- /src/tokens/prop.js: -------------------------------------------------------------------------------- 1 | import { ESCAPE, escapeSpecialChars, TOKEN_SEPARATOR } from './escape.js' 2 | import { getOtherStringTokenType } from './other.js' 3 | 4 | // Check the type of a parsed token 5 | const testObject = (token) => typeof token === 'string' 6 | 7 | // Serialize a token to a string 8 | const serialize = (token, index) => { 9 | if (token === '' && index === 0) { 10 | return TOKEN_SEPARATOR 11 | } 12 | 13 | const chars = escapeSpecialChars(token) 14 | return getOtherStringTokenType(chars) === undefined 15 | ? chars 16 | : `${ESCAPE}${chars}` 17 | } 18 | 19 | // Check the type of a serialized token 20 | const testString = () => true 21 | 22 | // Parse a string into a token 23 | const parse = (chars) => chars 24 | 25 | // Normalize value after parsing or serializing 26 | const normalize = (token) => token 27 | 28 | // Check if two tokens are the same 29 | const equals = (tokenA, tokenB) => tokenA === tokenB 30 | 31 | export const PROP_TOKEN = { 32 | name: 'prop', 33 | testObject, 34 | serialize, 35 | testString, 36 | parse, 37 | normalize, 38 | equals, 39 | } 40 | -------------------------------------------------------------------------------- /src/tokens/regexp.js: -------------------------------------------------------------------------------- 1 | import { escapeSpecialChars } from './escape.js' 2 | 3 | // Check the type of a parsed token 4 | const testObject = (token) => token instanceof RegExp 5 | 6 | // Serialize a token to a string. 7 | // We need to escape characters with special meaning in parsing. 8 | // Forward slashes are backslash escaped natively by `RegExp.source`. 9 | // - `new RegExp()` reverts this, but also allows them not be backslash escaped 10 | // - This means '/a/b/' and '/a\\/b/' queries are equivalent, but normalized 11 | // to the latter when parsing or serializing. 12 | const serialize = (token) => { 13 | const source = escapeSpecialChars(token.source) 14 | return `${REGEXP_DELIM}${source}${REGEXP_DELIM}${token.flags}` 15 | } 16 | 17 | // Check the type of a serialized token 18 | const testString = (chars) => 19 | chars[0] === REGEXP_DELIM && chars.lastIndexOf(REGEXP_DELIM) > 1 20 | 21 | // Parse a string into a token 22 | // This might throw if the RegExp is invalid. 23 | const parse = (chars) => { 24 | const endIndex = chars.lastIndexOf(REGEXP_DELIM) 25 | const regExpString = chars.slice(1, endIndex) 26 | const regExpFlags = chars.slice(endIndex + 1) 27 | return new RegExp(regExpString, regExpFlags) 28 | } 29 | 30 | const REGEXP_DELIM = '/' 31 | 32 | // Normalize value after parsing or serializing 33 | const normalize = (token) => token 34 | 35 | // Check if two tokens are the same 36 | const equals = (tokenA, tokenB) => 37 | tokenA.source === tokenB.source && 38 | tokenA.flags === tokenB.flags && 39 | tokenA.lastIndex === tokenB.lastIndex 40 | 41 | export const REGEXP_TOKEN = { 42 | name: 'regExp', 43 | testObject, 44 | serialize, 45 | testString, 46 | parse, 47 | normalize, 48 | equals, 49 | } 50 | -------------------------------------------------------------------------------- /src/tokens/slice.js: -------------------------------------------------------------------------------- 1 | import { isTokenObject } from './common.js' 2 | import { INDEX_TOKEN } from './indices.js' 3 | 4 | // Check the type of a parsed token. 5 | const testObject = (token) => 6 | isTokenObject(token, SLICE_TYPE) && isEdge(token.from) && isEdge(token.to) 7 | 8 | const isEdge = (edge) => edge === undefined || INDEX_TOKEN.testObject(edge) 9 | 10 | // Serialize a token to a string 11 | const serialize = ({ from, to }) => 12 | `${serializeEdge(from)}${SLICE_DELIM}${serializeEdge(to)}` 13 | 14 | const serializeEdge = (edge) => 15 | edge === undefined ? DEFAULT_EDGE_STRING : INDEX_TOKEN.serialize(edge) 16 | 17 | // Check the type of a serialized token 18 | const testString = (chars) => SLICE_REGEXP.test(chars) 19 | 20 | const SLICE_REGEXP = /^(-?\d+)?:(-?\d+)?$/u 21 | 22 | // Parse a string into a token 23 | const parse = (chars) => { 24 | const [from, to] = chars.split(SLICE_DELIM).map(parseEdge) 25 | return { type: SLICE_TYPE, from, to } 26 | } 27 | 28 | const parseEdge = (chars) => 29 | chars === DEFAULT_EDGE_STRING ? undefined : INDEX_TOKEN.parse(chars) 30 | 31 | const DEFAULT_EDGE_STRING = '' 32 | const SLICE_DELIM = ':' 33 | const SLICE_TYPE = 'slice' 34 | 35 | // Normalize value after parsing or serializing 36 | const normalize = ({ type, from = 0, to }) => 37 | Object.is(to, -0) || to === undefined ? { type, from } : { type, from, to } 38 | 39 | // Check if two tokens are the same 40 | const equals = (tokenA, tokenB) => { 41 | const { from: fromA, to: toA } = normalize(tokenA) 42 | const { from: fromB, to: toB } = normalize(tokenB) 43 | return Object.is(fromA, fromB) && Object.is(toA, toB) 44 | } 45 | 46 | export const SLICE_TOKEN = { 47 | name: 'slice', 48 | testObject, 49 | serialize, 50 | testString, 51 | parse, 52 | normalize, 53 | equals, 54 | } 55 | -------------------------------------------------------------------------------- /src/validate/arrays.js: -------------------------------------------------------------------------------- 1 | import { throwQueryError } from './throw.js' 2 | import { normalizeToken } from './token.js' 3 | 4 | // Normalize query arrays 5 | export const normalizeQueryArrays = (queryArrays, query) => { 6 | validateQueryArrays(queryArrays, query) 7 | const queryArraysA = 8 | queryArrays.every(Array.isArray) && queryArrays.length !== 0 9 | ? queryArrays 10 | : [queryArrays] 11 | return queryArraysA.map((queryArray) => 12 | normalizeQueryArray(queryArray, query), 13 | ) 14 | } 15 | 16 | const validateQueryArrays = (queryArrays, query) => { 17 | if (!Array.isArray(queryArrays)) { 18 | throwQueryError(query, 'It must be an array.') 19 | } 20 | } 21 | 22 | const normalizeQueryArray = (queryArray, query) => 23 | queryArray.map((token) => normalizeToken(token, query)) 24 | -------------------------------------------------------------------------------- /src/validate/path.js: -------------------------------------------------------------------------------- 1 | import { isPathToken } from '../tokens/main.js' 2 | 3 | import { throwQueryError, throwTokenError } from './throw.js' 4 | 5 | // Transform a queryArrays into a path, if possible 6 | // Paths are a subset of query strings|arrays which use: 7 | // - No unions 8 | // - Only prop and index tokens (positive only) 9 | // Those are the ones exposed in output, as opposed to query arrays which are 10 | // exposed in input. 11 | export const normalizeArraysPath = (queryArrays, query) => { 12 | if (queryArrays.length !== 1) { 13 | throwQueryError(query, 'It must not be a union.') 14 | } 15 | 16 | const [path] = queryArrays 17 | validatePathTokens(path, query) 18 | return path 19 | } 20 | 21 | // Ensure a queryArray is a path 22 | export const validatePath = (path, query) => { 23 | if (!Array.isArray(path)) { 24 | throwQueryError(query, 'It must be an array.') 25 | } 26 | 27 | if (path.some(Array.isArray)) { 28 | throwQueryError(query, 'It must not be a union.') 29 | } 30 | 31 | validatePathTokens(path, query) 32 | } 33 | 34 | const validatePathTokens = (path, query) => { 35 | path.forEach((prop) => { 36 | validatePathToken(prop, query) 37 | }) 38 | } 39 | 40 | const validatePathToken = (prop, query) => { 41 | if (!isPathToken(prop)) { 42 | throwTokenError( 43 | query, 44 | prop, 45 | 'It must be a property name or a positive array index.', 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/validate/string.js: -------------------------------------------------------------------------------- 1 | import { throwQueryError } from './throw.js' 2 | 3 | // Validate query string is a string 4 | export const validateQueryString = (queryString) => { 5 | if (!isQueryString(queryString)) { 6 | throwQueryError(queryString, 'It must be a string.') 7 | } 8 | } 9 | 10 | // Most methods accept both query and array syntaxes. 11 | // This checks which one is used. 12 | export const isQueryString = (query) => typeof query === 'string' 13 | 14 | // Empty query strings are ambiguous and not allowed 15 | export const validateEmptyQuery = (queryArrays, queryString) => { 16 | if (queryArrays.length === 0) { 17 | throwQueryError(queryString, 'It must not be an empty string.') 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/validate/throw.js: -------------------------------------------------------------------------------- 1 | // Throw an error when a token is invalid 2 | export const throwTokenError = (queryArray, token, message) => { 3 | throwQueryError(queryArray, `Invalid token: ${token}\n${message}`) 4 | } 5 | 6 | // Throw an error when a query is invalid 7 | export const throwQueryError = (query, message) => { 8 | throw new Error(`Invalid query: ${query}\n${message}`) 9 | } 10 | -------------------------------------------------------------------------------- /src/validate/token.js: -------------------------------------------------------------------------------- 1 | import { getObjectTokenType } from '../tokens/main.js' 2 | 3 | import { throwTokenError } from './throw.js' 4 | 5 | // Normalize a token 6 | export const normalizeToken = (token, query) => { 7 | const tokenType = getValidTokenType(token, query) 8 | return tokenType.normalize(token) 9 | } 10 | 11 | // Also validate that a token has an existing type 12 | export const getValidTokenType = (token, query) => { 13 | const tokenType = getObjectTokenType(token) 14 | validateToken(tokenType, token, query) 15 | return tokenType 16 | } 17 | 18 | const validateToken = (tokenType, token, query) => { 19 | if (tokenType === undefined) { 20 | throwTokenError( 21 | query, 22 | token, 23 | `It must be one of the following: 24 | - a property name string 25 | - an array index integer, positive or negative 26 | - a property name regular expression 27 | - { type: "any" } 28 | - { type: "slice", from: integer, to: integer }`, 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@ehmicky/dev-tasks/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------