├── .gitignore ├── .husky ├── pre-commit └── prepare-commit-msg ├── tsup.config.ts ├── test ├── tsconfig.json ├── patterns │ ├── hex-color.test.ts │ ├── base64url.test.ts │ ├── hexadecimal.test.ts │ ├── rgb-color.test.ts │ ├── base64.test.ts │ ├── credit-card.test.ts │ ├── jwt.test.ts │ ├── hsl-color.test.ts │ ├── lat-long.test.ts │ ├── email-address.test.ts │ └── uuid.test.ts ├── util │ └── pipe.test.ts ├── test-utils │ └── test-pattern.ts ├── arbitrary.test.ts ├── regex.test.ts └── combinators.test.ts ├── jest.config.js ├── tsconfig.json ├── src ├── patterns │ ├── index.ts │ ├── jwt.ts │ ├── base64url.ts │ ├── hexadecimal.ts │ ├── hex-color.ts │ ├── base64.ts │ ├── uuid.ts │ ├── lat-long.ts │ ├── email-address.ts │ ├── rgb-color.ts │ ├── hsl-color.ts │ └── credit-card.ts ├── types.ts ├── index.ts ├── combinators.ts ├── arbitrary.ts ├── arbitrary-deferred.ts ├── regex.ts ├── character-classes.ts ├── util │ └── pipe.ts └── base.ts ├── .prettierrc ├── LICENSE ├── package.json ├── README.md └── .github └── workflows └── ci.yml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | *.log 5 | coverage 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | exec < /dev/tty && node_modules/.bin/cz --hook || true 5 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts', 'src/arbitrary.ts', 'src/arbitrary-deferred.ts'], 5 | format: ['cjs', 'esm', 'iife'], 6 | dts: true, 7 | }) 8 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/test", 5 | "rootDir": "..", 6 | "types": ["jest"] 7 | }, 8 | "include": ["../src/**/*.ts", "**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | collectCoverage: true, 6 | collectCoverageFrom: ['src/**/*.ts'], 7 | coverageDirectory: 'coverage', 8 | coverageReporters: ['lcov', 'text-summary'], 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "skipLibCheck": true, 9 | "noUnusedLocals": true, 10 | "noImplicitAny": true, 11 | "outDir": "dist" 12 | }, 13 | "include": ["src/**/*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /test/patterns/hex-color.test.ts: -------------------------------------------------------------------------------- 1 | import { hexColor } from '../../src/patterns/hex-color' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'hexColor', 6 | pattern: hexColor, 7 | validCases: ['#ff0000ff', '#ff0034', '#CCCCCC', '0f38', 'fff', '#f00'], 8 | invalidCases: ['#ff', 'fff0a', '#ff12FG', '#bbh'], 9 | }) 10 | -------------------------------------------------------------------------------- /src/patterns/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base64' 2 | export * from './base64url' 3 | export * from './credit-card' 4 | export * from './email-address' 5 | export * from './hex-color' 6 | export * from './hexadecimal' 7 | export * from './hsl-color' 8 | export * from './jwt' 9 | export * from './lat-long' 10 | export * from './rgb-color' 11 | export * from './uuid' 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "singleQuote": true, 4 | "useTabs": true, 5 | "trailingComma": "all", 6 | "printWidth": 80, 7 | "proseWrap": "always", 8 | "endOfLine": "lf", 9 | "semi": false, 10 | "importOrder": ["^[./]"], 11 | "importOrderSeparation": true, 12 | "importOrderSortSpecifiers": true, 13 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 14 | } 15 | -------------------------------------------------------------------------------- /src/patterns/jwt.ts: -------------------------------------------------------------------------------- 1 | import { char, maybe, sequence, subgroup } from '../base' 2 | import { Pattern } from '../types' 3 | import { base64Url } from './base64url' 4 | 5 | /** 6 | * Matches a [JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519). 7 | */ 8 | export const jwt: Pattern = sequence( 9 | subgroup(base64Url), 10 | char('.'), 11 | subgroup(base64Url), 12 | maybe(subgroup(sequence(char('.'), subgroup(base64Url)))), 13 | ) 14 | -------------------------------------------------------------------------------- /test/util/pipe.test.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from '../../src/util/pipe' 2 | 3 | describe('pipe', () => { 4 | it.each([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])( 5 | 'threads a value through a series of %d functions', 6 | (n) => { 7 | const a = 1 8 | const f = jest.fn((x: number) => x + 1) 9 | 10 | const result = pipe( 11 | a, 12 | // @ts-ignore 13 | ...[...Array(n)].map(() => f), 14 | ) 15 | 16 | expect(result).toBe(n + 1) 17 | }, 18 | ) 19 | }) 20 | -------------------------------------------------------------------------------- /src/patterns/base64url.ts: -------------------------------------------------------------------------------- 1 | import { and, anyNumber } from '../base' 2 | import { word } from '../character-classes' 3 | import { Pattern } from '../types' 4 | import { pipe } from '../util/pipe' 5 | 6 | /** 7 | * Matches any 8 | * [base64url](https://datatracker.ietf.org/doc/html/rfc4648#section-5) string. 9 | * 10 | * @since 1.0.0 11 | * @category Pattern 12 | */ 13 | export const base64Url: Pattern = pipe( 14 | word, 15 | and('-'), 16 | anyNumber({ greedy: true }), 17 | ) 18 | -------------------------------------------------------------------------------- /test/patterns/base64url.test.ts: -------------------------------------------------------------------------------- 1 | import { base64Url } from '../../src/patterns/base64url' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'base64url', 6 | pattern: base64Url, 7 | validCases: [ 8 | '', 9 | 'bGFkaWVzIGFuZCBnZW50bGVtZW4sIHdlIGFyZSBmbG9hdGluZyBpbiBzcGFjZQ', 10 | '1234', 11 | 'bXVtLW5ldmVyLXByb3Vk', 12 | 'PDw_Pz8-Pg', 13 | 'VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw', 14 | ], 15 | invalidCases: [ 16 | ' AA', 17 | '\tAA', 18 | '\rAA', 19 | '\nAA', 20 | 'This+isa/bad+base64Url==', 21 | '0K3RgtC+INC30LDQutC+0LTQuNGA0L7QstCw0L3QvdCw0Y8g0YHRgtGA0L7QutCw', 22 | ], 23 | }) 24 | -------------------------------------------------------------------------------- /src/patterns/hexadecimal.ts: -------------------------------------------------------------------------------- 1 | import { andThen, atLeastOne, exactString, maybe, or, subgroup } from '../base' 2 | import { xdigit } from '../character-classes' 3 | import { Pattern } from '../types' 4 | import { pipe } from '../util/pipe' 5 | 6 | /** 7 | * Matches a hexadecimal number, with or without a leading '0x' or '0h', in any 8 | * combination of upper or lower case. 9 | * 10 | * @since 1.0.0 11 | * @category Pattern 12 | */ 13 | export const hexadecimal: Pattern = pipe( 14 | exactString('0x'), 15 | or(exactString('0X')), 16 | or(exactString('0h')), 17 | or(exactString('0H')), 18 | subgroup, 19 | maybe, 20 | andThen(atLeastOne()(xdigit)), 21 | ) 22 | -------------------------------------------------------------------------------- /test/patterns/hexadecimal.test.ts: -------------------------------------------------------------------------------- 1 | import { hexadecimal } from '../../src/patterns/hexadecimal' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'hexadecimal', 6 | pattern: hexadecimal, 7 | validCases: [ 8 | 'deadBEEF', 9 | 'ff0044', 10 | '0xff0044', 11 | '0XfF0044', 12 | '0x0123456789abcDEF', 13 | '0X0123456789abcDEF', 14 | '0hfedCBA9876543210', 15 | '0HfedCBA9876543210', 16 | '0123456789abcDEF', 17 | ], 18 | invalidCases: [ 19 | 'abcdefg', 20 | '', 21 | '..', 22 | '0xa2h', 23 | '0xa20x', 24 | '0x0123456789abcDEFq', 25 | '0hfedCBA9876543210q', 26 | '01234q56789abcDEF', 27 | ], 28 | }) 29 | -------------------------------------------------------------------------------- /src/patterns/hex-color.ts: -------------------------------------------------------------------------------- 1 | import { andThen, between, char, exactly, maybe, or, subgroup } from '../base' 2 | import { hexDigit } from '../character-classes' 3 | import { Pattern } from '../types' 4 | import { pipe } from '../util/pipe' 5 | 6 | /** 7 | * Matches a hex color, with or without a leading '#'. Matches both short form 8 | * (3-digit) and long form (6-digit) hex colors, and can also match alpha values 9 | * (4- or 8-digit). 10 | */ 11 | export const hexColor: Pattern = pipe( 12 | maybe(char('#')), 13 | andThen( 14 | subgroup( 15 | pipe( 16 | between(3, 4)(hexDigit), 17 | or(exactly(6)(hexDigit)), 18 | or(exactly(8)(hexDigit)), 19 | ), 20 | ), 21 | ), 22 | ) 23 | -------------------------------------------------------------------------------- /test/patterns/rgb-color.test.ts: -------------------------------------------------------------------------------- 1 | import { rgbColor } from '../../src/patterns/rgb-color' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'rgbColor', 6 | pattern: rgbColor, 7 | validCases: [ 8 | 'rgb(0,0,0)', 9 | 'rgb(255,255,255)', 10 | 'rgba(0,0,0,0)', 11 | 'rgba(255,255,255,1)', 12 | 'rgba(255,255,255,.1)', 13 | 'rgba(255,255,255,0.1)', 14 | 'rgba(255,255,255,.12)', 15 | 'rgb(5%,5%,5%)', 16 | 'rgba(5%,5%,5%,.3)', 17 | ], 18 | 19 | invalidCases: [ 20 | 'rgb(0,0,0,)', 21 | 'rgb(0,0,)', 22 | 'rgb(0,0,256)', 23 | 'rgb()', 24 | 'rgba(0,0,0)', 25 | 'rgba(255,255,255,2)', 26 | 'rgba(255,255,256,0.1)', 27 | 'rgb(4,4,5%)', 28 | 'rgba(5%,5%,5%)', 29 | 'rgba(3,3,3%,.3)', 30 | 'rgb(101%,101%,101%)', 31 | 'rgba(3%,3%,101%,0.3)', 32 | ], 33 | }) 34 | -------------------------------------------------------------------------------- /test/test-utils/test-pattern.ts: -------------------------------------------------------------------------------- 1 | import fc from 'fast-check' 2 | 3 | import { type Pattern, regexFromPattern } from '../../src' 4 | import { arbitraryFromPattern } from '../../src/arbitrary' 5 | 6 | type TestPatternConfig = { 7 | name: string 8 | pattern: Pattern 9 | caseInsensitive?: boolean 10 | validCases: ReadonlyArray 11 | invalidCases: ReadonlyArray 12 | } 13 | 14 | export const testPattern = ({ 15 | name, 16 | pattern, 17 | caseInsensitive = false, 18 | validCases, 19 | invalidCases, 20 | }: TestPatternConfig) => { 21 | describe(name, () => { 22 | const regex = regexFromPattern(pattern, caseInsensitive) 23 | 24 | test.each(validCases)('valid: %s', (c) => { 25 | expect(regex.test(c)).toBe(true) 26 | }) 27 | 28 | test.each(invalidCases)('invalid: %s', (c) => { 29 | expect(regex.test(c)).toBe(false) 30 | }) 31 | 32 | test('arbitrary matches regex', () => { 33 | fc.assert( 34 | fc.property(arbitraryFromPattern(pattern), (s) => regex.test(s)), 35 | ) 36 | }) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/patterns/base64.ts: -------------------------------------------------------------------------------- 1 | import { 2 | and, 3 | andThen, 4 | anyNumber, 5 | char, 6 | characterClass, 7 | exactly, 8 | maybe, 9 | sequence, 10 | subgroup, 11 | } from '../base' 12 | import { alnum } from '../character-classes' 13 | import { oneOf } from '../combinators' 14 | import { Pattern } from '../types' 15 | import { pipe } from '../util/pipe' 16 | 17 | export const base64Character = pipe(alnum, and(characterClass(false, '+', '/'))) 18 | 19 | /** 20 | * Matches a base64 string, with or without trailing '=' characters. However, if 21 | * they are present, they must be correct (i.e. pad out the string so its length 22 | * is a multiple of 4) 23 | * 24 | * @since 1.0.0 25 | * @category Pattern 26 | */ 27 | export const base64: Pattern = pipe( 28 | base64Character, 29 | exactly(4), 30 | subgroup, 31 | anyNumber(), 32 | andThen( 33 | maybe( 34 | subgroup( 35 | oneOf( 36 | sequence(exactly(2)(base64Character), exactly(2)(char('='))), 37 | sequence(exactly(3)(base64Character), char('=')), 38 | ), 39 | ), 40 | ), 41 | ), 42 | ) 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jonathan Skeate 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/patterns/base64.test.ts: -------------------------------------------------------------------------------- 1 | import { base64 } from '../../src/patterns/base64' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'base64', 6 | pattern: base64, 7 | validCases: [ 8 | '', 9 | 'Zg==', 10 | 'Zm8=', 11 | 'Zm9v', 12 | 'Zm9vYg==', 13 | 'Zm9vYmE=', 14 | 'Zm9vYmFy', 15 | 'TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4=', 16 | 'Vml2YW11cyBmZXJtZW50dW0gc2VtcGVyIHBvcnRhLg==', 17 | 'U3VzcGVuZGlzc2UgbGVjdHVzIGxlbw==', 18 | 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMPNS1Ufof9EW/M98FNw' + 19 | 'UAKrwflsqVxaxQjBQnHQmiI7Vac40t8x7pIb8gLGV6wL7sBTJiPovJ0V7y7oc0Ye' + 20 | 'rhKh0Rm4skP2z/jHwwZICgGzBvA0rH8xlhUiTvcwDCJ0kc+fh35hNt8srZQM4619' + 21 | 'FTgB66Xmp4EtVyhpQV+t02g6NzK72oZI0vnAvqhpkxLeLiMCyrI416wHm5Tkukhx' + 22 | 'QmcL2a6hNOyu0ixX/x2kSFXApEnVrJ+/IxGyfyw8kf4N2IZpW5nEP847lpfj0SZZ' + 23 | 'Fwrd1mnfnDbYohX2zRptLy2ZUn06Qo9pkG5ntvFEPo9bfZeULtjYzIl6K8gJ2uGZ' + 24 | 'HQIDAQAB', 25 | ], 26 | invalidCases: [ 27 | '12345', 28 | 'Vml2YW11cyBmZXJtZtesting123', 29 | 'Zg=', 30 | 'Z===', 31 | 'Zm=8', 32 | '=m9vYg==', 33 | 'Zm9vYmFy====', 34 | ], 35 | }) 36 | -------------------------------------------------------------------------------- /test/patterns/credit-card.test.ts: -------------------------------------------------------------------------------- 1 | import { creditCard } from '../../src/patterns/credit-card' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'creditCard', 6 | pattern: creditCard, 7 | validCases: [ 8 | // NOTE: If a test case here is commented out, 9 | // it existed in the validators.js test cases but I cannot find any 10 | // corroboration that it should be considered valid. 11 | '375556917985515', 12 | '36050234196908', 13 | '4716461583322103', 14 | '4716221051885662', 15 | '4929722653797141', 16 | '5398228707871527', 17 | '6283875070985593', 18 | '6263892624162870', 19 | //'6234917882863855', 20 | //'6234698580215388', 21 | '6226050967750613', 22 | '6246281879460688', 23 | '2222155765072228', 24 | '2225855203075256', 25 | '2720428011723762', 26 | '2718760626256570', 27 | // '6765780016990268', 28 | // '4716989580001715211', 29 | '8171999927660000', 30 | // '8171999900000000021', 31 | ], 32 | invalidCases: [ 33 | 'foo', 34 | 'foo', 35 | // this is only invalid per checksum 36 | // '5398228707871528', 37 | // this is only invalid per checksum 38 | // '2718760626256571', 39 | '2721465526338453', 40 | '2220175103860763', 41 | '375556917985515999999993', 42 | '899999996234917882863855', 43 | 'prefix6234917882863855', 44 | '623491788middle2863855', 45 | '6234917882863855suffix', 46 | '4716989580001715213', 47 | ], 48 | }) 49 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @since 1.0.0 3 | * @category Model 4 | */ 5 | export type Pattern = Disjunction | TermSequence | Term 6 | 7 | /** 8 | * @since 1.0.0 9 | * @category Model 10 | */ 11 | export type Disjunction = { 12 | tag: 'disjunction' 13 | left: Pattern 14 | right: TermSequence | Term 15 | } 16 | 17 | /** 18 | * @since 1.0.0 19 | * @category Model 20 | */ 21 | export type TermSequence = { tag: 'termSequence'; terms: ReadonlyArray } 22 | 23 | /** 24 | * @since 1.0.0 25 | * @category Model 26 | */ 27 | export type Term = Atom | QuantifiedAtom 28 | 29 | /** 30 | * @since 1.0.0 31 | * @category Model 32 | */ 33 | export type QuantifiedAtom = { tag: 'quantifiedAtom'; atom: Atom } & ( 34 | | { kind: 'star'; greedy: boolean } 35 | | { kind: 'plus'; greedy: boolean } 36 | | { kind: 'question' } 37 | | { kind: 'exactly'; count: number } 38 | | { kind: 'minimum'; min: number } 39 | | { kind: 'between'; min: number; max: number } 40 | ) 41 | 42 | /** 43 | * @since 1.0.0 44 | * @category Model 45 | */ 46 | export type CharacterClass = { 47 | tag: 'atom' 48 | kind: 'characterClass' 49 | exclude: boolean 50 | ranges: ReadonlyArray<{ lower: number; upper: number }> 51 | } 52 | 53 | /** 54 | * @since 1.0.0 55 | * @category Model 56 | */ 57 | export type Atom = 58 | | CharacterClass 59 | | ({ tag: 'atom' } & ( 60 | | { kind: 'character'; char: string } 61 | | { kind: 'anything' } 62 | | { kind: 'subgroup'; subpattern: Pattern } 63 | )) 64 | 65 | export type Char = string 66 | -------------------------------------------------------------------------------- /test/patterns/jwt.test.ts: -------------------------------------------------------------------------------- 1 | import { jwt } from '../../src/patterns/jwt' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'jwt', 6 | pattern: jwt, 7 | validCases: [ 8 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQiOiJ5b3UiLCJyZWFsbHkiOiJqdXN0IiwiZGVjb2RlIjoidGhpcz8iLCJ3b3ciOjEzMzd9.wlKUhamhmhW60ZRztn7Fz2lhXN1YWRQ2O2VIGhibDtU', 9 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb3JlbSI6Imlwc3VtIn0.ymiJSsMJXR6tMSr8G9usjQ15_8hKPDv_CArLhxw28MI', 10 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkb2xvciI6InNpdCIsImFtZXQiOlsibG9yZW0iLCJpcHN1bSJdfQ.rRpe04zbWbbJjwM43VnHzAboDzszJtGrNsUxaqQ-GQ8', 11 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqb2huIjp7ImFnZSI6MjUsImhlaWdodCI6MTg1fSwiamFrZSI6eyJhZ2UiOjMwLCJoZWlnaHQiOjI3MH19.YRLPARDmhGMC3BBk_OhtwwK21PIkVCqQe8ncIRPKo-E', 12 | // No signature 13 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ', 14 | ], 15 | 16 | invalidCases: [ 17 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqb2huIjp7ImFnZSI6MjUsImhlaWdodCI6MTg1fSwiamFrZSI6eyJhZ2UiOjMwLCJoZWlnaHQiOjI3MH19.YRLPARDmhGMC3BBk_OhtwwK21PIkVCqQe8ncIRPKo-E.YRLPARDmhGMC3BBk_OhtwwK21PIkVCqQe8ncIRPKo-E', 18 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqb2huIjp7ImFnZSI6MjUsImhlaWdodCI6MTg1fSwiamFrZSI6eyJhZ2UiOjMwLCJoZWlnaHQiOjI3MH19.YRLPARDmhGMC3BBk_OhtwwK21PIkVCqQe8ncIRPKo-E.YRLPARDmhGMC3BBk_OhtwwK21PIkVCqQe8ncIRPKo-E.YRLPARDmhGMC3BBk_OhtwwK21PIkVCqQe8ncIRPKo-E', 19 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', 20 | '$Zs.ewu.su84', 21 | 'ks64$S/9.dy$§kz.3sd73b', 22 | ], 23 | }) 24 | -------------------------------------------------------------------------------- /test/patterns/hsl-color.test.ts: -------------------------------------------------------------------------------- 1 | import { hslColor } from '../../src/patterns/hsl-color' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'hslColor', 6 | pattern: hslColor, 7 | caseInsensitive: true, 8 | validCases: [ 9 | 'hsl(360,0000000000100%,000000100%)', 10 | 'hsl(000010, 00000000001%, 00000040%)', 11 | 'HSL(00000,0000000000100%,000000100%)', 12 | 'hsL(0, 0%, 0%)', 13 | 'hSl( 360 , 100% , 100% )', 14 | 'Hsl( 00150 , 000099% , 01% )', 15 | 'hsl(01080, 03%, 4%)', 16 | 'hsl(-540, 03%, 4%)', 17 | 'hsla(+540, 03%, 4%)', 18 | 'hsla(+540, 03%, 4%, 500)', 19 | 'hsl(+540deg, 03%, 4%, 500)', 20 | 'hsl(+540gRaD, 03%, 4%, 500)', 21 | 'hsl(+540.01e-98rad, 03%, 4%, 500)', 22 | 'hsl(-540.5turn, 03%, 4%, 500)', 23 | 'hsl(+540, 03%, 4%, 500e+80)', 24 | 'hsl(+540, 03%, 4%, 500e-01)', 25 | 'hsl(4.71239rad, 60%, 70%)', 26 | 'hsl(270deg, 60%, 70%)', 27 | 'hsl(200, +.1%, 62%, 1)', 28 | 'hsl(270 60% 70%)', 29 | 'hsl(200, +.1e-9%, 62e10%, 1)', 30 | 'hsl(.75turn, 60%, 70%)', 31 | 'hsl(200grad+.1%62%/1)', 32 | 'hsl(200grad +.1% 62% / 1)', 33 | 'hsl(270, 60%, 50%, .15)', 34 | 'hsl(270, 60%, 50%, 15%)', 35 | 'hsl(270 60% 50% / .15)', 36 | 'hsl(270 60% 50% / 15%)', 37 | ], 38 | invalidCases: [ 39 | 'hsl (360,0000000000100%,000000100%)', 40 | 'hsl(0260, 100 %, 100%)', 41 | 'hsl(0160, 100%, 100%, 100 %)', 42 | 'hsl(-0160, 100%, 100a)', 43 | 'hsl(-0160, 100%, 100)', 44 | 'hsl(-0160 100%, 100%, )', 45 | 'hsl(270 deg, 60%, 70%)', 46 | 'hsl( deg, 60%, 70%)', 47 | 'hsl(, 60%, 70%)', 48 | 'hsl(3000deg, 70%)', 49 | ], 50 | }) 51 | -------------------------------------------------------------------------------- /test/patterns/lat-long.test.ts: -------------------------------------------------------------------------------- 1 | import { latLong } from '../../src/patterns/lat-long' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'latLong', 6 | pattern: latLong, 7 | validCases: [ 8 | '(-17.738223, 85.605469)', 9 | '(-12.3456789, +12.3456789)', 10 | '(-60.978437, -0.175781)', 11 | '(77.719772, -37.529297)', 12 | '(7.264394, 165.058594)', 13 | '0.955766, -19.863281', 14 | '(31.269161,164.355469)', 15 | '+12.3456789, -12.3456789', 16 | '-15.379543, -137.285156', 17 | '(11.770570, -162.949219)', 18 | '-55.034319, 113.027344', 19 | '58.025555, 36.738281', 20 | '55.720923,-28.652344', 21 | '-90.00000,-180.00000', 22 | '(-71, -146)', 23 | '(-71.616864, -146.616864)', 24 | '-0.55, +0.22', 25 | '90, 180', 26 | '+90, -180', 27 | '-90,+180', 28 | '90,180', 29 | '0, 0', 30 | ], 31 | invalidCases: [ 32 | '(0, 0', 33 | '0, 0)', 34 | '(020.000000, 010.000000000)', 35 | '89.9999999989, 360.0000000', 36 | '90.1000000, 180.000000', 37 | '+90.000000, -180.00001', 38 | '090.0000, 0180.0000', 39 | '126, -158', 40 | '(-126.400010, -158.400010)', 41 | '-95, -96', 42 | '-95.738043, -96.738043', 43 | '137, -148', 44 | '(-137.5942, -148.5942)', 45 | '(-120, -203)', 46 | '(-119, -196)', 47 | '+119.821728, -196.821728', 48 | '(-110, -223)', 49 | '-110.369532, 223.369532', 50 | '(-120.969949, +203.969949)', 51 | '-116, -126', 52 | '-116.894222, -126.894222', 53 | '-112, -160', 54 | '-112.96381, -160.96381', 55 | '-90., -180.', 56 | '+90.1, -180.1', 57 | '(-17.738223, 85.605469', 58 | '0.955766, -19.863281)', 59 | '+,-', 60 | '(,)', 61 | ',', 62 | ' ', 63 | ], 64 | }) 65 | -------------------------------------------------------------------------------- /test/arbitrary.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from 'fast-check' 2 | 3 | import * as k from '../src' 4 | import { arbitraryFromPattern } from '../src/arbitrary' 5 | import { arbitraryFromPattern as arbitraryFromPatternDeferred } from '../src/arbitrary-deferred' 6 | import { pipe } from '../src/util/pipe' 7 | 8 | describe('arbitrary derivation', () => { 9 | const pattern: k.Pattern = pipe( 10 | k.exactString('foo'), 11 | k.between(5, 9), 12 | k.andThen(k.atLeastOne()(k.char('z'))), 13 | k.andThen(k.atLeastOne({ greedy: true })(k.char('y'))), 14 | k.subgroup, 15 | k.maybe, 16 | k.andThen( 17 | k.sequence( 18 | pipe(k.anything, k.anyNumber({ greedy: true })), 19 | pipe(k.anything, k.anyNumber({ greedy: false })), 20 | pipe(k.anything, k.anyNumber()), 21 | k.times(3)(k.non(k.lower)), 22 | ), 23 | ), 24 | k.andThen( 25 | k.characterClass( 26 | false, 27 | ['0', '4'], 28 | 'A', 29 | [35, 39], 30 | ['Q', 'T'], 31 | [31, 45], 32 | [94, 127], 33 | [255, 256], 34 | ), 35 | ), 36 | k.subgroup, 37 | k.or(k.atLeast(2)(k.exactString('bar'))), 38 | ) 39 | 40 | it('can create Arbitraries', () => { 41 | const arbitrary = arbitraryFromPattern(pattern) 42 | 43 | // woof, bad testing practices ahead, but I'm not sure of a better way to test Arbitraries 44 | const regex = k.regexFromPattern(pattern) 45 | 46 | fc.assert(fc.property(arbitrary, (s) => regex.test(s))) 47 | }) 48 | 49 | it('can create Arbitraries in a deferred call manner', () => { 50 | const arbitrary = arbitraryFromPatternDeferred(fc)(pattern) 51 | const regex = k.regexFromPattern(pattern) 52 | fc.assert(fc.property(arbitrary, (s) => regex.test(s))) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/patterns/uuid.ts: -------------------------------------------------------------------------------- 1 | import { char, characterClass, exactly, sequence } from '../base' 2 | import { hexDigit } from '../character-classes' 3 | 4 | const nHexDigits = (n: number) => exactly(n)(hexDigit) 5 | 6 | export const uuidV1 = sequence( 7 | nHexDigits(8), 8 | char('-'), 9 | nHexDigits(4), 10 | char('-'), 11 | char('1'), 12 | nHexDigits(3), 13 | char('-'), 14 | nHexDigits(4), 15 | char('-'), 16 | nHexDigits(12), 17 | ) 18 | 19 | export const uuidV2 = sequence( 20 | nHexDigits(8), 21 | char('-'), 22 | nHexDigits(4), 23 | char('-'), 24 | char('2'), 25 | nHexDigits(3), 26 | char('-'), 27 | nHexDigits(4), 28 | char('-'), 29 | nHexDigits(12), 30 | ) 31 | 32 | export const uuidV3 = sequence( 33 | nHexDigits(8), 34 | char('-'), 35 | nHexDigits(4), 36 | char('-'), 37 | char('3'), 38 | nHexDigits(3), 39 | char('-'), 40 | nHexDigits(4), 41 | char('-'), 42 | nHexDigits(12), 43 | ) 44 | 45 | export const uuidV4 = sequence( 46 | nHexDigits(8), 47 | char('-'), 48 | nHexDigits(4), 49 | char('-'), 50 | char('4'), 51 | nHexDigits(3), 52 | char('-'), 53 | characterClass(false, 'A', 'a', 'B', 'b', '8', '9'), 54 | nHexDigits(3), 55 | char('-'), 56 | nHexDigits(12), 57 | ) 58 | 59 | export const uuidV5 = sequence( 60 | nHexDigits(8), 61 | char('-'), 62 | nHexDigits(4), 63 | char('-'), 64 | char('5'), 65 | nHexDigits(3), 66 | char('-'), 67 | characterClass(false, 'A', 'a', 'B', 'b', '8', '9'), 68 | nHexDigits(3), 69 | char('-'), 70 | nHexDigits(12), 71 | ) 72 | 73 | export const anyUUID = sequence( 74 | nHexDigits(8), 75 | char('-'), 76 | nHexDigits(4), 77 | char('-'), 78 | nHexDigits(4), 79 | char('-'), 80 | nHexDigits(4), 81 | char('-'), 82 | nHexDigits(12), 83 | ) 84 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * `kuvio` defines an API for constructing `Pattern`s. These `Pattern`s can be 3 | * used to generate things like regular expressions or fast-check Arbitraries. 4 | * They can also be composed together, allowing for the construction of complex 5 | * patterns, reuse of patterns, and the ability to easily test patterns. 6 | * 7 | * @since 0.0.1 8 | * @example 9 | * import * as k from 'kuvio' 10 | * 11 | * const digit = k.characterClass(false, ['0', '9']) 12 | * 13 | * const areaCode = pipe( 14 | * k.pipe( 15 | * k.char('('), 16 | * k.andThen(k.times(3)(digit)), 17 | * k.andThen(k.char(')')), 18 | * k.andThen(k.maybe(k.char(' '))), 19 | * ), 20 | * k.or(k.times(3)(digit)), 21 | * k.subgroup, 22 | * ) 23 | * 24 | * const prefix = k.times(3)(digit) 25 | * 26 | * const lineNumber = k.times(4)(digit) 27 | * 28 | * export const usPhoneNumber = k.pipe( 29 | * areaCode, 30 | * k.andThen(pipe(k.char('-'), k.maybe)), 31 | * k.andThen(prefix), 32 | * k.andThen(k.char('-')), 33 | * k.andThen(lineNumber), 34 | * ) 35 | * 36 | * assert.equal(k.regexFromPattern(usPhoneNumber).test('(123) 456-7890'), true) 37 | * assert.equal(k.regexFromPattern(usPhoneNumber).test('(123)456-7890'), true) 38 | * assert.equal(k.regexFromPattern(usPhoneNumber).test('123-456-7890'), true) 39 | * assert.equal(k.regexFromPattern(usPhoneNumber).test('1234567890'), false) 40 | */ 41 | 42 | export * from './base' 43 | export * from './types' 44 | 45 | export * from './character-classes' 46 | export * from './combinators' 47 | 48 | export * from './regex' 49 | export * as patterns from './patterns' 50 | export * from './util/pipe' 51 | -------------------------------------------------------------------------------- /test/regex.test.ts: -------------------------------------------------------------------------------- 1 | import * as k from '../src' 2 | 3 | describe('kuvio', () => { 4 | const pattern: k.Pattern = k.pipe( 5 | k.exactString('foo'), 6 | k.between(5, 9), 7 | k.andThen(k.atLeastOne()(k.char('z'))), 8 | k.andThen(k.atLeastOne({ greedy: true })(k.char('y'))), 9 | k.subgroup, 10 | k.maybe, 11 | k.andThen( 12 | k.sequence( 13 | k.pipe(k.anything, k.anyNumber({ greedy: true })), 14 | k.pipe(k.anything, k.anyNumber({ greedy: false })), 15 | k.pipe(k.anything, k.anyNumber()), 16 | k.times(3)(k.non(k.lower)), 17 | ), 18 | ), 19 | k.andThen( 20 | k.characterClass( 21 | false, 22 | ['0', '4'], 23 | 'A', 24 | [35, 39], 25 | ['Q', 'T'], 26 | [31, 45], 27 | [94, 127], 28 | [255, 256], 29 | ), 30 | ), 31 | k.subgroup, 32 | k.or(k.atLeast(2)(k.exactString('bar'))), 33 | ) 34 | 35 | const testPattern = 36 | "(((foo){5,9}z+?y+)?.*.*?.*?[^a-z]{3}[0-4A#-'Q-T\\x1f-\\x2d\\x5e-\\x7f\\xff-\\u0100])|(bar){2,}" 37 | 38 | it('can create RegExps', () => { 39 | const actual = k.regexFromPattern(pattern) 40 | 41 | expect(actual.source).toEqual(`^(${testPattern})$`) 42 | expect(actual.flags).toEqual('') 43 | }) 44 | 45 | it('can create case-insensitive RegExps', () => { 46 | const actual = k.regexFromPattern(pattern, true) 47 | 48 | expect(actual.source).toEqual(`^(${testPattern})$`) 49 | expect(actual.flags).toEqual('i') 50 | }) 51 | 52 | it('can create global RegExps', () => { 53 | const actual = k.regexFromPattern(pattern, false, true) 54 | 55 | expect(actual.source).toEqual(testPattern) 56 | expect(actual.flags).toEqual('g') 57 | }) 58 | 59 | it('can create multiline RegExps', () => { 60 | const actual = k.regexFromPattern(pattern, false, false, true) 61 | 62 | expect(actual.source).toEqual(`^(${testPattern})$`) 63 | expect(actual.flags).toEqual('m') 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/patterns/lat-long.ts: -------------------------------------------------------------------------------- 1 | import { 2 | andThen, 3 | anyNumber, 4 | atLeastOne, 5 | char, 6 | characterClass, 7 | maybe, 8 | sequence, 9 | subgroup, 10 | } from '../base' 11 | import { digit, space } from '../character-classes' 12 | import { integerRange, oneOf } from '../combinators' 13 | import { pipe } from '../util/pipe' 14 | 15 | const latPattern = pipe( 16 | maybe(characterClass(false, '+', '-')), 17 | andThen( 18 | subgroup( 19 | oneOf( 20 | sequence( 21 | char('9'), 22 | char('0'), 23 | maybe( 24 | subgroup( 25 | pipe(char('.'), andThen(atLeastOne({ greedy: true })(char('0')))), 26 | ), 27 | ), 28 | ), 29 | pipe( 30 | integerRange(0, 89), 31 | subgroup, 32 | andThen( 33 | maybe( 34 | subgroup( 35 | pipe(char('.'), andThen(atLeastOne({ greedy: true })(digit))), 36 | ), 37 | ), 38 | ), 39 | ), 40 | ), 41 | ), 42 | ), 43 | ) 44 | 45 | const longPattern = pipe( 46 | maybe(characterClass(false, '+', '-')), 47 | andThen( 48 | subgroup( 49 | oneOf( 50 | sequence( 51 | char('1'), 52 | char('8'), 53 | char('0'), 54 | maybe( 55 | subgroup( 56 | pipe(char('.'), andThen(atLeastOne({ greedy: true })(char('0')))), 57 | ), 58 | ), 59 | ), 60 | pipe( 61 | integerRange(0, 179), 62 | subgroup, 63 | andThen( 64 | maybe( 65 | subgroup( 66 | pipe(char('.'), andThen(atLeastOne({ greedy: true })(digit))), 67 | ), 68 | ), 69 | ), 70 | ), 71 | ), 72 | ), 73 | ), 74 | ) 75 | 76 | /** 77 | * @since 1.0.0 78 | * @category Pattern 79 | */ 80 | export const latLong = oneOf( 81 | pipe( 82 | latPattern, 83 | andThen(char(',')), 84 | andThen(anyNumber({ greedy: true })(space)), 85 | andThen(longPattern), 86 | ), 87 | pipe( 88 | char('('), 89 | andThen(latPattern), 90 | andThen(char(',')), 91 | andThen(anyNumber({ greedy: true })(space)), 92 | andThen(longPattern), 93 | andThen(char(')')), 94 | ), 95 | ) 96 | -------------------------------------------------------------------------------- /test/patterns/email-address.test.ts: -------------------------------------------------------------------------------- 1 | import { emailAddress } from '../../src/patterns/email-address' 2 | import { testPattern } from '../test-utils/test-pattern' 3 | 4 | testPattern({ 5 | name: 'emailAddress', 6 | pattern: emailAddress, 7 | validCases: [ 8 | 'foo@bar.com', 9 | 'x@x.au', 10 | 'foo@bar.com.au', 11 | 'foo+bar@bar.com', 12 | 'test123+ext@gmail.com', 13 | 'some.name.midd.leNa.me.and.locality+extension@GoogleMail.com', 14 | '"foobar"@example.com', 15 | '" foo m端ller "@example.com', 16 | '"foo\\@bar"@example.com', 17 | `${'a'.repeat(64)}@${'a'.repeat(63)}.com`, 18 | `${'a'.repeat(64)}@${'a'.repeat(63)}.com`, 19 | `${'a'.repeat(31)}@gmail.com`, 20 | 'test@gmail.com', 21 | 'test.1@gmail.com', 22 | 'test@1337.com', 23 | ], 24 | invalidCases: [ 25 | 'invalidemail@', 26 | 'invalid.com', 27 | '@invalid.com', 28 | 'foo@bar.com.', 29 | 'somename@gmail.com', 30 | 'foo@bar.co.uk.', 31 | 'z@co.c', 32 | 'hans.m端ller@test.com', 33 | 'test|123@m端ller.com', 34 | 'gmailgmailgmailgmailgmail@gmail.com', 35 | // local part too long 36 | // `${'a'.repeat(65)}@${'a'.repeat(63)}.com`, 37 | // dns label too long 38 | `${'a'.repeat(64)}@${'a'.repeat(64)}.com`, 39 | // domain name too long 40 | // `${'a'.repeat(64)}@${'a'.repeat(63)}.${'a'.repeat(63)}.${'a'.repeat( 41 | // 63, 42 | // )}.${'a'.repeat(60)}.com`, 43 | 'test1@invalid.co m', 44 | 'test2@invalid.co m', 45 | 'test3@invalid.co m', 46 | 'test4@invalid.co m', 47 | 'test5@invalid.co m', 48 | 'test6@invalid.co m', 49 | 'test7@invalid.co m', 50 | 'test8@invalid.co m', 51 | 'test9@invalid.co m', 52 | 'test10@invalid.co m', 53 | 'test11@invalid.co m', 54 | 'test12@invalid.co m', 55 | 'test13@invalid.co m', 56 | 'multiple..dots@stillinvalid.com', 57 | 'test123+invalid! sub_address@gmail.com', 58 | 'gmail...ignores...dots...@gmail.com', 59 | 'ends.with.dot.@gmail.com', 60 | 'multiple..dots@gmail.com', 61 | 'wrong()[]",:;<>@@gmail.com', 62 | '"wrong()[]",:;<>@@gmail.com', 63 | 'username@domain.com�', 64 | 'username@domain.com©', 65 | ], 66 | }) 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kuvio", 3 | "version": "0.0.1", 4 | "description": "Create string patterns and derive things from them, such as regexes", 5 | "files": [ 6 | "dist" 7 | ], 8 | "sideEffects": false, 9 | "main": "./dist/index.js", 10 | "module": "./dist/index.mjs", 11 | "types": "./dist/index.d.ts", 12 | "exports": { 13 | ".": { 14 | "types": "./dist/index.d.ts", 15 | "node": "./dist/index.js", 16 | "require": "./dist/index.js", 17 | "import": "./dist/index.mjs", 18 | "default": "./dist/index.global.js" 19 | }, 20 | "./arbitrary": { 21 | "types": "./dist/arbitrary.d.ts", 22 | "node": "./dist/arbitrary.js", 23 | "require": "./dist/arbitrary.js", 24 | "import": "./dist/arbitrary.mjs", 25 | "default": "./dist/arbitrary.global.js" 26 | }, 27 | "./arbitrary-deferred": { 28 | "types": "./dist/arbitrary-deferred.d.ts", 29 | "node": "./dist/arbitrary-deferred.js", 30 | "require": "./dist/arbitrary-deferred.js", 31 | "import": "./dist/arbitrary-deferred.mjs", 32 | "default": "./dist/arbitrary-deferred.global.js" 33 | } 34 | }, 35 | "scripts": { 36 | "build": "rimraf dist && tsup", 37 | "test": "jest", 38 | "prepublishOnly": "pnpm run build", 39 | "prepare": "husky install" 40 | }, 41 | "keywords": [ 42 | "pattern", 43 | "regular expression", 44 | "regex", 45 | "string", 46 | "string pattern", 47 | "string patterns", 48 | "fast-check", 49 | "fastcheck" 50 | ], 51 | "author": "Jonathan Skeate", 52 | "license": "MIT", 53 | "devDependencies": { 54 | "@trivago/prettier-plugin-sort-imports": "^4.1.1", 55 | "@types/jest": "^29.5.1", 56 | "commitizen": "^4.3.0", 57 | "cz-conventional-changelog": "^3.3.0", 58 | "fast-check": "^3.8.1", 59 | "husky": "^8.0.0", 60 | "jest": "^29.5.0", 61 | "prettier": "2.8.8", 62 | "rimraf": "^5.0.0", 63 | "ts-jest": "^29.1.0", 64 | "tsup": "6.7.0", 65 | "typescript": "5.0.4" 66 | }, 67 | "peerDependencies": { 68 | "fast-check": "^3.0.0" 69 | }, 70 | "config": { 71 | "commitizen": { 72 | "path": "./node_modules/cz-conventional-changelog" 73 | } 74 | }, 75 | "packageManager": "pnpm@9.10.0+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c" 76 | } 77 | -------------------------------------------------------------------------------- /src/patterns/email-address.ts: -------------------------------------------------------------------------------- 1 | import { 2 | and, 3 | andThen, 4 | anyNumber, 5 | atLeast, 6 | atLeastOne, 7 | atMost, 8 | between, 9 | char, 10 | characterClass, 11 | or, 12 | sequence, 13 | subgroup, 14 | } from '../base' 15 | import { alnum, alpha, digit } from '../character-classes' 16 | import { Pattern } from '../types' 17 | import { pipe } from '../util/pipe' 18 | 19 | // (".+") 20 | const localPartQuoted = pipe( 21 | char('"'), 22 | andThen(atLeastOne({ greedy: true })(characterClass(true, '"', [0, 0x1f]))), 23 | andThen(char('"')), 24 | ) 25 | 26 | const localPartUnquotedAllowedCharacters = characterClass( 27 | false, 28 | ['A', 'Z'], 29 | ['a', 'z'], 30 | ['0', '9'], 31 | '!', 32 | '#', 33 | '$', 34 | '%', 35 | '&', 36 | "'", 37 | '*', 38 | '+', 39 | '-', 40 | '/', 41 | '=', 42 | '?', 43 | '^', 44 | '_', 45 | '`', 46 | '{', 47 | '|', 48 | '}', 49 | '~', 50 | ) 51 | 52 | // [^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)* 53 | const localPartUnquoted = pipe( 54 | atLeastOne({ greedy: true })(localPartUnquotedAllowedCharacters), 55 | andThen( 56 | pipe( 57 | char('.'), 58 | andThen(atLeastOne({ greedy: true })(localPartUnquotedAllowedCharacters)), 59 | subgroup, 60 | anyNumber({ greedy: true }), 61 | ), 62 | ), 63 | ) 64 | const localPart = pipe(localPartUnquoted, or(localPartQuoted), subgroup) 65 | 66 | const ipAddressByte = between(1, 3)(digit) 67 | 68 | // \[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}] 69 | const domainIpAddress = pipe( 70 | sequence( 71 | char('['), 72 | ipAddressByte, 73 | char('.'), 74 | ipAddressByte, 75 | char('.'), 76 | ipAddressByte, 77 | char('.'), 78 | ipAddressByte, 79 | char(']'), 80 | ), 81 | ) 82 | 83 | // ([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,} 84 | const domainName = pipe( 85 | alnum, 86 | and('-'), 87 | atMost(63), 88 | andThen(char('.')), 89 | subgroup, 90 | atLeastOne({ greedy: true }), 91 | andThen(atLeast(2)(alpha)), 92 | ) 93 | 94 | const domain = pipe(domainIpAddress, or(domainName), subgroup) 95 | 96 | /** 97 | * @since 1.1.0 98 | * @category Pattern 99 | */ 100 | export const emailAddress: Pattern = pipe( 101 | localPart, 102 | andThen(char('@')), 103 | andThen(domain), 104 | ) 105 | -------------------------------------------------------------------------------- /src/patterns/rgb-color.ts: -------------------------------------------------------------------------------- 1 | import { 2 | atLeastOne, 3 | char, 4 | exactString, 5 | maybe, 6 | sequence, 7 | subgroup, 8 | } from '../base' 9 | import { digit } from '../character-classes' 10 | import { integerRange, oneOf } from '../combinators' 11 | 12 | /** 13 | * Matches an RGB color in the format `rgb(0, 0, 0)`. 14 | */ 15 | export const rgbColorDecimal = sequence( 16 | exactString('rgb('), 17 | subgroup(integerRange(0, 255)), 18 | char(','), 19 | subgroup(integerRange(0, 255)), 20 | char(','), 21 | subgroup(integerRange(0, 255)), 22 | char(')'), 23 | ) 24 | 25 | /** 26 | * Matches an RGBA color in the format `rgba(0, 0, 0, 0)`. 27 | */ 28 | export const rgbColorWithAlphaDecimal = sequence( 29 | exactString('rgba('), 30 | subgroup(integerRange(0, 255)), 31 | char(','), 32 | subgroup(integerRange(0, 255)), 33 | char(','), 34 | subgroup(integerRange(0, 255)), 35 | char(','), 36 | subgroup( 37 | oneOf( 38 | char('0'), 39 | char('1'), 40 | exactString('1.0'), 41 | sequence( 42 | maybe(char('0')), 43 | char('.'), 44 | atLeastOne({ greedy: true })(digit), 45 | ), 46 | ), 47 | ), 48 | char(')'), 49 | ) 50 | 51 | /** 52 | * Matches an RGB color in the format `rgb(0%, 0%, 0%)`. 53 | */ 54 | export const rgbColorPercent = sequence( 55 | exactString('rgb('), 56 | subgroup(integerRange(0, 100)), 57 | exactString('%,'), 58 | subgroup(integerRange(0, 100)), 59 | exactString('%,'), 60 | subgroup(integerRange(0, 100)), 61 | exactString('%)'), 62 | ) 63 | 64 | /** 65 | * Matches an RGBA color in the format `rgba(0%, 0%, 0%, 0)`. 66 | */ 67 | export const rgbColorWithAlphaPercent = sequence( 68 | exactString('rgba('), 69 | subgroup(integerRange(0, 100)), 70 | exactString('%,'), 71 | subgroup(integerRange(0, 100)), 72 | exactString('%,'), 73 | subgroup(integerRange(0, 100)), 74 | exactString('%,'), 75 | subgroup( 76 | oneOf( 77 | char('0'), 78 | char('1'), 79 | exactString('1.0'), 80 | sequence( 81 | maybe(char('0')), 82 | char('.'), 83 | atLeastOne({ greedy: true })(digit), 84 | ), 85 | ), 86 | ), 87 | char(')'), 88 | ) 89 | 90 | /** 91 | * Matches an RGB color in any of the following formats: 92 | * - `rgb(0, 0, 0)` 93 | * - `rgba(0, 0, 0, 0)` 94 | * - `rgb(0%, 0%, 0%)` 95 | * - `rgba(0%, 0%, 0%, 0)` 96 | * 97 | * @since 1.1.0 98 | * @category Pattern 99 | */ 100 | export const rgbColor = oneOf( 101 | rgbColorDecimal, 102 | rgbColorWithAlphaDecimal, 103 | rgbColorPercent, 104 | rgbColorWithAlphaPercent, 105 | ) 106 | -------------------------------------------------------------------------------- /src/patterns/hsl-color.ts: -------------------------------------------------------------------------------- 1 | import { 2 | andThen, 3 | anyNumber, 4 | atLeastOne, 5 | char, 6 | exactString, 7 | maybe, 8 | sequence, 9 | subgroup, 10 | } from '../base' 11 | import { blank, digit } from '../character-classes' 12 | import { integerRange, oneOf } from '../combinators' 13 | import { pipe } from '../util/pipe' 14 | 15 | const anyDecimal = subgroup( 16 | sequence(char('.'), atLeastOne({ greedy: true })(digit)), 17 | ) 18 | 19 | const zeroDecimal = subgroup( 20 | sequence(char('.'), atLeastOne({ greedy: true })(char('0'))), 21 | ) 22 | 23 | const exponential = subgroup( 24 | sequence( 25 | char('e'), 26 | maybe(subgroup(oneOf(char('+'), char('-')))), 27 | atLeastOne({ greedy: true })(digit), 28 | ), 29 | ) 30 | 31 | const hue = subgroup( 32 | sequence( 33 | maybe(subgroup(oneOf(char('+'), char('-')))), 34 | subgroup( 35 | oneOf( 36 | pipe(atLeastOne({ greedy: true })(digit), andThen(maybe(anyDecimal))), 37 | anyDecimal, 38 | ), 39 | ), 40 | maybe(exponential), 41 | maybe( 42 | subgroup( 43 | oneOf( 44 | exactString('deg'), 45 | exactString('grad'), 46 | exactString('rad'), 47 | exactString('turn'), 48 | ), 49 | ), 50 | ), 51 | ), 52 | ) 53 | 54 | const percentage = subgroup( 55 | sequence( 56 | maybe(char('+')), 57 | anyNumber({ greedy: true })(char('0')), 58 | subgroup( 59 | oneOf( 60 | pipe(exactString('100'), andThen(maybe(zeroDecimal))), 61 | pipe(subgroup(integerRange(0, 99)), andThen(maybe(anyDecimal))), 62 | anyDecimal, 63 | ), 64 | ), 65 | maybe(exponential), 66 | char('%'), 67 | ), 68 | ) 69 | 70 | const alpha = subgroup( 71 | sequence( 72 | anyNumber({ greedy: true })(digit), 73 | subgroup(oneOf(digit, anyDecimal)), 74 | maybe(exponential), 75 | maybe(char('%')), 76 | ), 77 | ) 78 | 79 | const anySpace = anyNumber({ greedy: true })(blank) 80 | 81 | const commaDelimiter = subgroup(sequence(anySpace, char(','), anySpace)) 82 | 83 | const slashDelimiter = subgroup(sequence(anySpace, char('/'), anySpace)) 84 | 85 | /** 86 | * Matches an HSL color in the format `hsl(0, 0%, 0%)`. 87 | * 88 | * @since 1.0.0 89 | * @category Pattern 90 | */ 91 | export const hslColor = sequence( 92 | exactString('hsl'), 93 | maybe(char('a')), 94 | char('('), 95 | anySpace, 96 | hue, 97 | subgroup( 98 | oneOf( 99 | sequence( 100 | commaDelimiter, 101 | percentage, 102 | commaDelimiter, 103 | percentage, 104 | maybe(subgroup(sequence(commaDelimiter, alpha))), 105 | ), 106 | sequence( 107 | anySpace, 108 | percentage, 109 | anySpace, 110 | percentage, 111 | maybe(subgroup(sequence(slashDelimiter, alpha))), 112 | ), 113 | ), 114 | ), 115 | anySpace, 116 | char(')'), 117 | ) 118 | -------------------------------------------------------------------------------- /src/combinators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | andThen, 3 | char, 4 | characterClass, 5 | empty, 6 | or, 7 | sequence, 8 | subgroup, 9 | } from './base' 10 | import { digit } from './character-classes' 11 | import { Pattern, Term, TermSequence } from './types' 12 | import { pipe } from './util/pipe' 13 | 14 | /** 15 | * Form a disjunction of multiple terms or term sequences. 16 | * 17 | * @since 0.0.1 18 | */ 19 | export const oneOf: ( 20 | pattern: Pattern, 21 | ...terms: ReadonlyArray 22 | ) => Pattern = (pattern, ...patterns) => 23 | patterns.reduce((ored, next) => pipe(ored, or(next)), pattern) 24 | 25 | const integerRange_: ( 26 | min: string, 27 | max: string, 28 | omitInitialZeros?: boolean, 29 | ) => Pattern = (min, max, omitInitialZeros = false) => { 30 | const curMinDigit = Number(min[0] ?? '0') 31 | const restMin = min.slice(1) 32 | const curMaxDigit = Number(max[0] ?? '9') 33 | const restMax = max.slice(1) 34 | 35 | const res = 36 | restMin.length === 0 37 | ? curMinDigit === curMaxDigit 38 | ? char(min) 39 | : characterClass(false, [min, max]) 40 | : curMinDigit === curMaxDigit 41 | ? pipe( 42 | char(curMinDigit.toString(10)), 43 | andThen(subgroup(integerRange_(restMin, restMax))), 44 | ) 45 | : oneOf( 46 | curMinDigit === 0 && omitInitialZeros 47 | ? integerRange_(restMin, restMax.replace(/./g, '9'), true) 48 | : pipe( 49 | char(curMinDigit.toString(10)), 50 | andThen( 51 | subgroup(integerRange_(restMin, restMin.replace(/./g, '9'))), 52 | ), 53 | ), 54 | ...(curMaxDigit - curMinDigit > 1 55 | ? [ 56 | pipe( 57 | characterClass(false, [ 58 | (curMinDigit + 1).toString(10), 59 | (curMaxDigit - 1).toString(10), 60 | ]), 61 | andThen( 62 | sequence(empty, ...restMin.split('').map(() => digit)), 63 | ), 64 | ), 65 | ] 66 | : []), 67 | pipe( 68 | char(curMaxDigit.toString(10)), 69 | andThen( 70 | subgroup(integerRange_(restMin.replace(/./g, '0'), restMax)), 71 | ), 72 | ), 73 | ) 74 | 75 | return res 76 | } 77 | 78 | /** 79 | * Create a pattern that matches integers in a given range. Does not currently handle 80 | * negatives (it returns an empty pattern if either number is negative) 81 | * 82 | * @since 0.0.1 83 | */ 84 | export const integerRange: (min: number, max: number) => Pattern = ( 85 | min, 86 | max, 87 | ) => { 88 | if ( 89 | min > max || 90 | Number.isNaN(min) || 91 | Number.isNaN(max) || 92 | !Number.isInteger(min) || 93 | !Number.isInteger(max) || 94 | min < 0 || 95 | max < 0 96 | ) { 97 | return empty 98 | } 99 | 100 | const maxStr = max.toString(10) 101 | const minStr = min.toString(10).padStart(maxStr.length, '0') 102 | return integerRange_(minStr, maxStr, true) 103 | } 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kuvio 2 | 3 | ![build](https://img.shields.io/github/actions/workflow/status/skeate/kuvio/ci.yml) 4 | [![Codacy 5 | Badge](https://img.shields.io/codacy/grade/6c56da2df56d4dceb69fd38239640205)](https://app.codacy.com/gh/skeate/kuvio/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) 6 | [![codecov](https://img.shields.io/codecov/c/github/skeate/kuvio)](https://codecov.io/github/skeate/kuvio) 7 | [![dependencies](https://img.shields.io/librariesio/release/npm/kuvio)](https://libraries.io/npm/kuvio) 8 | [![size](https://img.shields.io/bundlephobia/minzip/kuvio)](https://bundlephobia.com/package/kuvio) 9 | ![downloads](https://img.shields.io/npm/dw/kuvio) 10 | [![version](https://img.shields.io/npm/v/kuvio)](https://www.npmjs.com/package/kuvio) 11 | 12 | `kuvio` is a tool to construct composable string patterns, from which you can 13 | derive things like regular expressions or [fast-check][] `Arbitrary`s. As of 14 | v1.4.0, it requires no dependencies. 15 | 16 | `kuvio` is specifically for string-like patterns. If you want to extend this 17 | concept to more complicated data types, check out [schemata-ts][]! `kuvio` was 18 | originally developed as part of `schemata-ts` but was extracted as it seems 19 | useful independently. 20 | 21 | ## Usage 22 | 23 | `kuvio` comes with several useful patterns built-in, but you can also create 24 | your own. 25 | 26 | ```typescript 27 | import * as k from 'kuvio'; 28 | 29 | // Use built-in patterns 30 | const creditCardRegex = k.regexFromPattern(k.patterns.creditCard); 31 | 32 | // Create your own patterns. 33 | const areaCode = k.exactly(3)(k.digit) 34 | const exchangeCode = k.exactly(3)(k.digit) 35 | const lineNumber = k.exactly(4)(k.digit) 36 | 37 | // Create pattern functions 38 | const parenthesize = (p: k.Pattern) => k.subgroup( 39 | k.sequence(k.char('('), p, k.char(')')) 40 | ) 41 | 42 | // Compose patterns to make more complex patterns 43 | const phoneNumberPattern = k.sequence( 44 | parenthesize(areaCode), 45 | k.char(' '), 46 | k.subgroup(exchangeCode), 47 | k.char('-'), 48 | k.subgroup(lineNumber), 49 | ) 50 | ``` 51 | 52 | See the [patterns](src/patterns) directory for the built-in patterns, which can also be useful examples for creating your own. 53 | 54 | #### Note regarding `fast-check` usage 55 | 56 | Arbitraries are intended primarily for use in test code; in order to 57 | help keep `fast-check` out of your production code, `kuvio` does not include 58 | `fast-check` in the main export. If you want to use the `Arbitrary` functions, 59 | you'll need to import them separately from `kuvio/arbitrary`. 60 | 61 | `kuvio` also exports a version of the `Arbitrary` functions that take the 62 | `fast-check` library as an argument, to prevent any possible accidental 63 | inclusion of `fast-check` in production code. This _shouldn't_ be an issue, but 64 | there are many bundlers and we cannot test them all. You can import these from 65 | `kuvio/arbitrary-deferred`. 66 | 67 | [fast-check]: https://github.com/dubzzz/fast-check 68 | [schemata-ts]: https://github.com/jacob-alford/schemata-ts 69 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | if: "!contains(github.event.head_commit.message, 'skip-ci')" 12 | 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest] 16 | node-version: [18.x, 19.x] 17 | 18 | runs-on: ${{ matrix.os }} 19 | 20 | # Steps represent a sequence of tasks that will be executed as part of the job 21 | steps: 22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 23 | - uses: actions/checkout@v3 24 | 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: Cache ~/.pnpm-store 30 | uses: actions/cache@v2 31 | env: 32 | cache-name: cache-pnpm-store 33 | with: 34 | path: ~/.pnpm-store 35 | key: 36 | ${{ runner.os }}-${{ matrix.node-version }}-test-${{ env.cache-name 37 | }}-${{ hashFiles('**/pnpm-lock.yaml') }} 38 | restore-keys: | 39 | ${{ runner.os }}-${{ matrix.node-version }}-test-${{ env.cache-name }}- 40 | ${{ runner.os }}-${{ matrix.node-version }}-test- 41 | ${{ runner.os }}- 42 | 43 | - name: Install pnpm 44 | run: npm i -g pnpm 45 | 46 | - name: Install deps 47 | run: pnpm i 48 | 49 | # Runs a set of commands using the runners shell 50 | - name: Build and Test 51 | run: pnpm test 52 | 53 | - name: Upload coverage reports to Codecov 54 | uses: codecov/codecov-action@v3 55 | 56 | release: 57 | runs-on: ubuntu-latest 58 | needs: ['test'] 59 | permissions: 60 | contents: write 61 | issues: write 62 | pull-requests: write 63 | id-token: write 64 | if: 65 | "!contains(github.event.head_commit.message, 'skip-release') && 66 | !contains(github.event.head_commit.message, 'skip-ci') && 67 | github.event_name != 'pull_request'" 68 | steps: 69 | - uses: actions/checkout@v3 70 | with: 71 | fetch-depth: 0 72 | - uses: actions/setup-node@v3 73 | with: 74 | node-version: 'lts/*' 75 | - name: Cache ~/.pnpm-store 76 | uses: actions/cache@v2 77 | env: 78 | cache-name: cache-pnpm-store 79 | with: 80 | path: ~/.pnpm-store 81 | key: 82 | ${{ runner.os }}-${{ matrix.node-version }}-release-${{ 83 | env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }} 84 | restore-keys: | 85 | ${{ runner.os }}-${{ matrix.node-version }}-release-${{ env.cache-name }}- 86 | ${{ runner.os }}-${{ matrix.node-version }}-release- 87 | ${{ runner.os }}- 88 | - name: Install pnpm 89 | run: npm i -g pnpm 90 | - name: Install dependencies 91 | run: pnpm i 92 | - name: 93 | Verify the integrity of provenance attestations and registry 94 | signatures for installed dependencies 95 | run: npm audit signatures 96 | - name: Release 97 | run: pnpm dlx semantic-release@20 --branches main 98 | env: 99 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 100 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 101 | -------------------------------------------------------------------------------- /test/patterns/uuid.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | anyUUID, 3 | uuidV1, 4 | uuidV2, 5 | uuidV3, 6 | uuidV4, 7 | uuidV5, 8 | } from '../../src/patterns/uuid' 9 | import { testPattern } from '../test-utils/test-pattern' 10 | 11 | const valid = { 12 | 1: ['E034B584-7D89-11E9-9669-1AECF481A97B'], 13 | 2: ['A987FBC9-4BED-2078-CF07-9141BA07C9F3'], 14 | 3: ['A987FBC9-4BED-3078-CF07-9141BA07C9F3'], 15 | 4: [ 16 | '713ae7e3-cb32-45f9-adcb-7c4fa86b90c1', 17 | '625e63f3-58f5-40b7-83a1-a72ad31acffb', 18 | '57b73598-8764-4ad0-a76a-679bb6640eb1', 19 | '9c858901-8a57-4791-81fe-4c455b099bc9', 20 | ], 21 | 5: [ 22 | '987FBC97-4BED-5078-AF07-9141BA07C9F3', 23 | '987FBC97-4BED-5078-BF07-9141BA07C9F3', 24 | '987FBC97-4BED-5078-8F07-9141BA07C9F3', 25 | '987FBC97-4BED-5078-9F07-9141BA07C9F3', 26 | ], 27 | any: [ 28 | 'A987FBC9-4BED-3078-CF07-9141BA07C9F3', 29 | 'A117FBC9-4BED-3078-CF07-9141BA07C9F3', 30 | 'A127FBC9-4BED-3078-CF07-9141BA07C9F3', 31 | ], 32 | } 33 | 34 | const invalid = { 35 | 1: [ 36 | 'xxxA987FBC9-4BED-3078-CF07-9141BA07C9F3', 37 | 'AAAAAAAA-1111-2222-AAAG', 38 | 'AAAAAAAA-1111-2222-AAAG-111111111111', 39 | 'A987FBC9-4BED-4078-8F07-9141BA07C9F3', 40 | 'A987FBC9-4BED-5078-AF07-9141BA07C9F3', 41 | ], 42 | 2: [ 43 | '', 44 | 'xxxA987FBC9-4BED-3078-CF07-9141BA07C9F3', 45 | '11111', 46 | 'AAAAAAAA-1111-1111-AAAG-111111111111', 47 | 'A987FBC9-4BED-4078-8F07-9141BA07C9F3', 48 | 'A987FBC9-4BED-5078-AF07-9141BA07C9F3', 49 | ], 50 | 3: [ 51 | '', 52 | 'xxxA987FBC9-4BED-3078-CF07-9141BA07C9F3', 53 | '934859', 54 | 'AAAAAAAA-1111-1111-AAAG-111111111111', 55 | 'A987FBC9-4BED-4078-8F07-9141BA07C9F3', 56 | 'A987FBC9-4BED-5078-AF07-9141BA07C9F3', 57 | ], 58 | 4: [ 59 | '', 60 | 'xxxA987FBC9-4BED-3078-CF07-9141BA07C9F3', 61 | '934859', 62 | 'AAAAAAAA-1111-1111-AAAG-111111111111', 63 | 'A987FBC9-4BED-5078-AF07-9141BA07C9F3', 64 | 'A987FBC9-4BED-3078-CF07-9141BA07C9F3', 65 | ], 66 | 5: [ 67 | '', 68 | 'xxxA987FBC9-4BED-3078-CF07-9141BA07C9F3', 69 | '934859', 70 | 'AAAAAAAA-1111-1111-AAAG-111111111111', 71 | '9c858901-8a57-4791-81fe-4c455b099bc9', 72 | 'A987FBC9-4BED-3078-CF07-9141BA07C9F3', 73 | ], 74 | any: [ 75 | '', 76 | 'xxxA987FBC9-4BED-3078-CF07-9141BA07C9F3', 77 | 'A987FBC9-4BED-3078-CF07-9141BA07C9F3xxx', 78 | 'A987FBC94BED3078CF079141BA07C9F3', 79 | '934859', 80 | '987FBC9-4BED-3078-CF07A-9141BA07C9F3', 81 | 'AAAAAAAA-1111-1111-AAAG-111111111111', 82 | ], 83 | } 84 | 85 | testPattern({ 86 | name: 'uuid v1', 87 | pattern: uuidV1, 88 | validCases: valid['1'], 89 | invalidCases: invalid['1'], 90 | }) 91 | testPattern({ 92 | name: 'uuid v2', 93 | pattern: uuidV2, 94 | validCases: valid['2'], 95 | invalidCases: invalid['2'], 96 | }) 97 | testPattern({ 98 | name: 'uuid v3', 99 | pattern: uuidV3, 100 | validCases: valid['3'], 101 | invalidCases: invalid['3'], 102 | }) 103 | testPattern({ 104 | name: 'uuid v4', 105 | pattern: uuidV4, 106 | validCases: valid['4'], 107 | invalidCases: invalid['4'], 108 | }) 109 | testPattern({ 110 | name: 'uuid v5', 111 | pattern: uuidV5, 112 | validCases: valid['5'], 113 | invalidCases: invalid['5'], 114 | }) 115 | testPattern({ 116 | name: 'any uuid', 117 | pattern: anyUUID, 118 | validCases: [ 119 | ...valid['1'], 120 | ...valid['2'], 121 | ...valid['3'], 122 | ...valid['4'], 123 | ...valid['5'], 124 | ...valid.any, 125 | ], 126 | invalidCases: invalid.any, 127 | }) 128 | -------------------------------------------------------------------------------- /src/arbitrary.ts: -------------------------------------------------------------------------------- 1 | import * as fc from 'fast-check' 2 | 3 | import { Atom, Pattern, QuantifiedAtom, Term } from './types' 4 | 5 | /** @internal */ 6 | export const arbitraryFromAtom: (atom: Atom) => fc.Arbitrary = ( 7 | atom, 8 | ) => { 9 | switch (atom.kind) { 10 | case 'anything': 11 | return fc.char() 12 | case 'character': 13 | return fc.constant(atom.char) 14 | case 'characterClass': 15 | return ( 16 | atom.exclude 17 | ? fc 18 | .integer({ min: 1, max: 0xffff }) 19 | .filter((i) => 20 | atom.ranges.every(({ lower, upper }) => i > upper || i < lower), 21 | ) 22 | : fc.oneof( 23 | ...atom.ranges.map(({ lower, upper }) => 24 | fc.integer({ min: lower, max: upper }), 25 | ), 26 | ) 27 | ).map((charCode) => String.fromCharCode(charCode)) 28 | case 'subgroup': 29 | return arbitraryFromPattern(atom.subpattern) 30 | } 31 | } 32 | 33 | /** @internal */ 34 | export const arbitraryFromQuantifiedAtom: ( 35 | quantifiedAtom: QuantifiedAtom, 36 | ) => fc.Arbitrary = (quantifiedAtom) => { 37 | switch (quantifiedAtom.kind) { 38 | case 'star': 39 | return fc 40 | .array(arbitraryFromAtom(quantifiedAtom.atom)) 41 | .map((strs) => strs.join('')) 42 | case 'plus': 43 | return fc 44 | .array(arbitraryFromAtom(quantifiedAtom.atom), { minLength: 1 }) 45 | .map((strs) => strs.join('')) 46 | case 'question': 47 | return fc 48 | .array(arbitraryFromAtom(quantifiedAtom.atom), { 49 | minLength: 0, 50 | maxLength: 1, 51 | }) 52 | .map((strs) => strs.join('')) 53 | case 'exactly': 54 | return fc 55 | .array(arbitraryFromAtom(quantifiedAtom.atom), { 56 | minLength: quantifiedAtom.count, 57 | maxLength: quantifiedAtom.count, 58 | }) 59 | .map((strs) => strs.join('')) 60 | case 'between': 61 | return fc 62 | .array(arbitraryFromAtom(quantifiedAtom.atom), { 63 | minLength: quantifiedAtom.min, 64 | maxLength: quantifiedAtom.max, 65 | }) 66 | .map((strs) => strs.join('')) 67 | case 'minimum': 68 | return fc 69 | .array(arbitraryFromAtom(quantifiedAtom.atom), { 70 | minLength: quantifiedAtom.min, 71 | }) 72 | .map((strs) => strs.join('')) 73 | } 74 | } 75 | 76 | const arbitraryFromTerm: (term: Term) => fc.Arbitrary = (term) => { 77 | switch (term.tag) { 78 | case 'atom': 79 | return arbitraryFromAtom(term) 80 | case 'quantifiedAtom': 81 | return arbitraryFromQuantifiedAtom(term) 82 | } 83 | } 84 | 85 | const chainConcatAll: ( 86 | fcs: ReadonlyArray>, 87 | ) => fc.Arbitrary = (fcs) => 88 | fcs.length === 0 89 | ? fc.constant('') 90 | : fcs[0].chain((headStr) => 91 | chainConcatAll(fcs.slice(1)).map((tailStr) => headStr + tailStr), 92 | ) 93 | 94 | /** 95 | * Construct a `fast-check` `Arbitrary` instance from a given `Pattern`. 96 | * 97 | * @since 1.0.0 98 | */ 99 | export const arbitraryFromPattern: ( 100 | pattern: Pattern, 101 | ) => fc.Arbitrary = (pattern) => { 102 | switch (pattern.tag) { 103 | case 'atom': 104 | return arbitraryFromAtom(pattern) 105 | case 'disjunction': 106 | return fc.oneof( 107 | arbitraryFromPattern(pattern.left), 108 | arbitraryFromPattern(pattern.right), 109 | ) 110 | case 'quantifiedAtom': 111 | return arbitraryFromQuantifiedAtom(pattern) 112 | case 'termSequence': 113 | return chainConcatAll(pattern.terms.map(arbitraryFromTerm)) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/combinators.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from 'fast-check' 2 | 3 | import * as k from '../src' 4 | 5 | describe('combinators', () => { 6 | describe('integerRange', () => { 7 | describe('invalid range', () => { 8 | const pattern = k.integerRange(Infinity, NaN) 9 | const actual = k.regexFromPattern(pattern) 10 | expect(actual.source).toEqual('^()$') 11 | }) 12 | describe('one digit ranges', () => { 13 | test('1-9', () => { 14 | const pattern = k.integerRange(1, 9) 15 | const actual = k.regexFromPattern(pattern) 16 | expect(actual.source).toEqual('^([1-9])$') 17 | }) 18 | 19 | test('5-7', () => { 20 | const pattern = k.integerRange(5, 7) 21 | const actual = k.regexFromPattern(pattern) 22 | expect(actual.source).toEqual('^([5-7])$') 23 | }) 24 | }) 25 | 26 | describe('two digit ranges', () => { 27 | test('10-99', () => { 28 | const pattern = k.integerRange(10, 99) 29 | const actual = k.regexFromPattern(pattern) 30 | expect(actual.source).toEqual('^(1\\d|[2-8]\\d|9\\d)$') 31 | }) 32 | 33 | test('41-74', () => { 34 | const pattern = k.integerRange(41, 74) 35 | const actual = k.regexFromPattern(pattern) 36 | for (let i = 10; i < 100; i++) { 37 | if (i >= 41 && i <= 74) { 38 | expect([i.toString(), actual.test(i.toString())]).toEqual([ 39 | i.toString(), 40 | true, 41 | ]) 42 | } else { 43 | expect([i.toString(), actual.test(i.toString())]).toEqual([ 44 | i.toString(), 45 | false, 46 | ]) 47 | } 48 | } 49 | }) 50 | }) 51 | 52 | describe('three digit ranges', () => { 53 | test('100-999', () => { 54 | const pattern = k.integerRange(100, 999) 55 | const actual = k.regexFromPattern(pattern) 56 | expect(actual.source).toEqual( 57 | '^(1(0\\d|[1-8]\\d|9\\d)|[2-8]\\d\\d|9(0\\d|[1-8]\\d|9\\d))$', 58 | ) 59 | }) 60 | 61 | test('421-734', () => { 62 | const pattern = k.integerRange(421, 734) 63 | const actual = k.regexFromPattern(pattern) 64 | 65 | for (let i = 100; i < 1000; i++) { 66 | if (i >= 421 && i <= 734) { 67 | expect([i.toString(), actual.test(i.toString())]).toEqual([ 68 | i.toString(), 69 | true, 70 | ]) 71 | } else { 72 | expect([i.toString(), actual.test(i.toString())]).toEqual([ 73 | i.toString(), 74 | false, 75 | ]) 76 | } 77 | } 78 | }) 79 | }) 80 | 81 | describe('mixed digit ranges', () => { 82 | test('5-226', () => { 83 | const pattern = k.integerRange(5, 226) 84 | const actual = k.regexFromPattern(pattern) 85 | 86 | for (let i = 1; i < 500; i++) { 87 | if (i >= 5 && i <= 226) { 88 | expect([i.toString(), actual.test(i.toString())]).toEqual([ 89 | i.toString(), 90 | true, 91 | ]) 92 | } else { 93 | expect([i.toString(), actual.test(i.toString())]).toEqual([ 94 | i.toString(), 95 | false, 96 | ]) 97 | } 98 | } 99 | }) 100 | 101 | test('arbitrary ranges', () => { 102 | fc.assert( 103 | fc.property( 104 | fc.tuple( 105 | fc.integer({ min: 0, max: 200 }), 106 | fc.integer({ min: 1, max: 799 }), 107 | fc.array(fc.integer({ min: 0, max: 1000 }), { 108 | minLength: 1, 109 | maxLength: 100, 110 | }), 111 | ), 112 | ([min, addition, checks]) => { 113 | const pattern = k.integerRange(min, min + addition) 114 | const actual = k.regexFromPattern(pattern) 115 | return checks.every((n) => 116 | n >= min && n <= min + addition 117 | ? actual.test(n.toString()) 118 | : !actual.test(n.toString()), 119 | ) 120 | }, 121 | ), 122 | ) 123 | }) 124 | }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /src/arbitrary-deferred.ts: -------------------------------------------------------------------------------- 1 | import type * as FastCheck from 'fast-check' 2 | 3 | import { Atom, Pattern, QuantifiedAtom, Term } from './types' 4 | import { pipe } from './util/pipe' 5 | 6 | /** @internal */ 7 | export const arbitraryFromAtom: ( 8 | fc: typeof FastCheck, 9 | ) => (atom: Atom) => FastCheck.Arbitrary = (fc) => (atom) => { 10 | switch (atom.kind) { 11 | case 'anything': 12 | return fc.char() 13 | case 'character': 14 | return fc.constant(atom.char) 15 | case 'characterClass': 16 | return ( 17 | atom.exclude 18 | ? fc 19 | .integer({ min: 1, max: 0xffff }) 20 | .filter((i) => 21 | atom.ranges.every(({ lower, upper }) => i > upper || i < lower), 22 | ) 23 | : fc.oneof( 24 | ...atom.ranges.map(({ lower, upper }) => 25 | fc.integer({ min: lower, max: upper }), 26 | ), 27 | ) 28 | ).map((charCode) => String.fromCharCode(charCode)) 29 | case 'subgroup': 30 | return arbitraryFromPattern(fc)(atom.subpattern) 31 | } 32 | } 33 | 34 | /** @internal */ 35 | export const arbitraryFromQuantifiedAtom: ( 36 | fc: typeof FastCheck, 37 | ) => (quantifiedAtom: QuantifiedAtom) => FastCheck.Arbitrary = 38 | (fc) => (quantifiedAtom) => { 39 | switch (quantifiedAtom.kind) { 40 | case 'star': 41 | return fc 42 | .array(arbitraryFromAtom(fc)(quantifiedAtom.atom)) 43 | .map((strs) => strs.join('')) 44 | case 'plus': 45 | return fc 46 | .array(arbitraryFromAtom(fc)(quantifiedAtom.atom), { minLength: 1 }) 47 | .map((strs) => strs.join('')) 48 | case 'question': 49 | return fc 50 | .array(arbitraryFromAtom(fc)(quantifiedAtom.atom), { 51 | minLength: 0, 52 | maxLength: 1, 53 | }) 54 | .map((strs) => strs.join('')) 55 | case 'exactly': 56 | return fc 57 | .array(arbitraryFromAtom(fc)(quantifiedAtom.atom), { 58 | minLength: quantifiedAtom.count, 59 | maxLength: quantifiedAtom.count, 60 | }) 61 | .map((strs) => strs.join('')) 62 | case 'between': 63 | return fc 64 | .array(arbitraryFromAtom(fc)(quantifiedAtom.atom), { 65 | minLength: quantifiedAtom.min, 66 | maxLength: quantifiedAtom.max, 67 | }) 68 | .map((strs) => strs.join('')) 69 | case 'minimum': 70 | return fc 71 | .array(arbitraryFromAtom(fc)(quantifiedAtom.atom), { 72 | minLength: quantifiedAtom.min, 73 | }) 74 | .map((strs) => strs.join('')) 75 | } 76 | } 77 | 78 | const arbitraryFromTerm: ( 79 | fc: typeof FastCheck, 80 | ) => (term: Term) => FastCheck.Arbitrary = (fc) => (term) => { 81 | switch (term.tag) { 82 | case 'atom': 83 | return arbitraryFromAtom(fc)(term) 84 | case 'quantifiedAtom': 85 | return arbitraryFromQuantifiedAtom(fc)(term) 86 | } 87 | } 88 | 89 | const chainConcatAll: ( 90 | fc: typeof FastCheck, 91 | ) => ( 92 | fcs: ReadonlyArray>, 93 | ) => FastCheck.Arbitrary = (fc) => (fcs) => 94 | fcs.length === 0 95 | ? fc.constant('') 96 | : fcs[0].chain((headStr) => 97 | chainConcatAll(fc)(fcs.slice(1)).map((tailStr) => headStr + tailStr), 98 | ) 99 | 100 | /** 101 | * Construct a `fast-check` `Arbitrary` instance from a given `Pattern`. 102 | * 103 | * @since 1.0.0 104 | */ 105 | export const arbitraryFromPattern: ( 106 | fc: typeof FastCheck, 107 | ) => (pattern: Pattern) => FastCheck.Arbitrary = (fc) => (pattern) => { 108 | switch (pattern.tag) { 109 | case 'atom': 110 | return arbitraryFromAtom(fc)(pattern) 111 | case 'disjunction': 112 | return fc.oneof( 113 | arbitraryFromPattern(fc)(pattern.left), 114 | arbitraryFromPattern(fc)(pattern.right), 115 | ) 116 | case 'quantifiedAtom': 117 | return arbitraryFromQuantifiedAtom(fc)(pattern) 118 | case 'termSequence': 119 | return pipe(pattern.terms.map(arbitraryFromTerm(fc)), chainConcatAll(fc)) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/regex.ts: -------------------------------------------------------------------------------- 1 | import { Atom, Pattern, QuantifiedAtom, Term } from './types' 2 | 3 | const repr = (n: number): string => 4 | // < 32 -> control characters 5 | // 45 -> '-'.. seems like `/[--z]/` for example actually works, but looks 6 | // weird. 7 | // 47 -> '/' doesn't need to be escaped in JS, but it's helpful for copying 8 | // into regex debuggers since it must be escaped in some languages 9 | // 92 -> '\' just to avoid any issues with escaping 10 | // 93 -> ']' which needs to be escaped 11 | // 94 -> '^' which might get parsed as class exclusion marker, so escape just in case 12 | // 127 -> del 13 | // >127 -> outside normal ascii range. escape 'em 14 | n < 32 || n === 45 || n === 47 || n === 92 || n === 93 || n === 94 || n >= 127 15 | ? n > 255 16 | ? `\\u${n.toString(16).padStart(4, '0')}` 17 | : `\\x${n.toString(16).padStart(2, '0')}` 18 | : String.fromCharCode(n) 19 | 20 | const charEscapes = new Map([ 21 | ['[', '\\['], 22 | [']', '\\]'], 23 | ['.', '\\.'], 24 | ['(', '\\('], 25 | [')', '\\)'], 26 | ['+', '\\+'], 27 | ]) 28 | 29 | const regexStringFromAtom: (atom: Atom) => string = (atom) => { 30 | switch (atom.kind) { 31 | case 'anything': 32 | return '.' 33 | case 'character': 34 | return charEscapes.get(atom.char) ?? atom.char 35 | case 'characterClass': { 36 | const { exclude, ranges } = atom 37 | return ranges.length === 1 && 38 | ranges[0].lower === 48 && 39 | ranges[0].upper === 57 40 | ? `\\d` 41 | : `[${exclude ? '^' : ''}${ranges 42 | .map(({ lower, upper }) => 43 | lower === upper ? repr(lower) : `${repr(lower)}-${repr(upper)}`, 44 | ) 45 | .join('')}]` 46 | } 47 | case 'subgroup': 48 | return `(${regexStringFromPattern(atom.subpattern)})` 49 | } 50 | } 51 | 52 | const regexStringFromQuantifiedAtom: ( 53 | quantifiedAtom: QuantifiedAtom, 54 | ) => string = (quantifiedAtom) => { 55 | switch (quantifiedAtom.kind) { 56 | case 'star': 57 | return `${regexStringFromAtom(quantifiedAtom.atom)}*${ 58 | quantifiedAtom.greedy ? '' : '?' 59 | }` 60 | case 'plus': 61 | return `${regexStringFromAtom(quantifiedAtom.atom)}+${ 62 | quantifiedAtom.greedy ? '' : '?' 63 | }` 64 | case 'question': 65 | return `${regexStringFromAtom(quantifiedAtom.atom)}?` 66 | case 'exactly': 67 | return `${regexStringFromAtom(quantifiedAtom.atom)}{${ 68 | quantifiedAtom.count 69 | }}` 70 | case 'between': 71 | return `${regexStringFromAtom(quantifiedAtom.atom)}{${ 72 | quantifiedAtom.min 73 | },${quantifiedAtom.max}}` 74 | case 'minimum': 75 | return `${regexStringFromAtom(quantifiedAtom.atom)}{${ 76 | quantifiedAtom.min 77 | },}` 78 | } 79 | } 80 | 81 | const regexStringFromTerm: (term: Term) => string = (term) => { 82 | switch (term.tag) { 83 | case 'atom': 84 | return regexStringFromAtom(term) 85 | case 'quantifiedAtom': 86 | return regexStringFromQuantifiedAtom(term) 87 | } 88 | } 89 | 90 | const regexStringFromPattern: (pattern: Pattern) => string = (pattern) => { 91 | switch (pattern.tag) { 92 | case 'atom': 93 | return regexStringFromAtom(pattern) 94 | case 'disjunction': 95 | return `${regexStringFromPattern(pattern.left)}|${regexStringFromPattern( 96 | pattern.right, 97 | )}` 98 | case 'quantifiedAtom': 99 | return regexStringFromQuantifiedAtom(pattern) 100 | case 'termSequence': 101 | return pattern.terms.map(regexStringFromTerm).join('') 102 | } 103 | } 104 | 105 | /** 106 | * Construct a regular expression (`RegExp`) from a given `Pattern`. 107 | * 108 | * @since 1.0.0 109 | */ 110 | export const regexFromPattern = ( 111 | pattern: Pattern, 112 | caseInsensitive = false, 113 | global = false, 114 | multiline = false, 115 | ): RegExp => 116 | new RegExp( 117 | `${global ? '' : '^('}${regexStringFromPattern(pattern)}${ 118 | global ? '' : ')$' 119 | }`, 120 | `${global ? 'g' : ''}${caseInsensitive ? 'i' : ''}${multiline ? 'm' : ''}`, 121 | ) 122 | -------------------------------------------------------------------------------- /src/character-classes.ts: -------------------------------------------------------------------------------- 1 | import { and, characterClass } from './base' 2 | import { CharacterClass } from './types' 3 | import { pipe } from './util/pipe' 4 | 5 | /** 6 | * Any upper case letter in ASCII. See [POSIX 7 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 8 | * 9 | * @since 0.0.1 10 | */ 11 | export const upper: CharacterClass = characterClass(false, ['A', 'Z']) 12 | 13 | /** 14 | * Any lower case letter in ASCII. See [POSIX 15 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 16 | * 17 | * @since 0.0.1 18 | */ 19 | export const lower: CharacterClass = characterClass(false, ['a', 'z']) 20 | 21 | /** 22 | * Any upper or lower case letter in ASCII. See [POSIX 23 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 24 | * 25 | * @since 0.0.1 26 | */ 27 | export const alpha: CharacterClass = pipe(upper, and(lower)) 28 | 29 | /** 30 | * Any digit character in ASCII. See [POSIX 31 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 32 | * 33 | * @since 0.0.1 34 | */ 35 | export const digit: CharacterClass = characterClass(false, ['0', '9']) 36 | 37 | /** 38 | * Any hexadecimal digit in ASCII. See [POSIX 39 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 40 | * 41 | * @since 0.0.1 42 | */ 43 | export const xdigit: CharacterClass = pipe(digit, and(['A', 'F'], ['a', 'f'])) 44 | 45 | /** 46 | * Alias of `xdigit` 47 | * 48 | * @since 0.0.1 49 | */ 50 | export const hexDigit: CharacterClass = xdigit 51 | 52 | /** 53 | * Any alphanumeric character in ASCII. See [POSIX 54 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 55 | * 56 | * @since 0.0.1 57 | */ 58 | export const alnum: CharacterClass = pipe(alpha, and(digit)) 59 | 60 | /** 61 | * Any alphanumeric character in ASCII, or an underscore ('_'). See [POSIX 62 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 63 | * 64 | * @since 0.0.1 65 | */ 66 | export const word: CharacterClass = pipe(alnum, and('_')) 67 | 68 | /** 69 | * Any punctuation character in ASCII. See [POSIX 70 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 71 | * 72 | * @since 0.0.1 73 | */ 74 | export const punct: CharacterClass = characterClass( 75 | false, 76 | ['!', '/'], 77 | [':', '@'], 78 | ['[', '_'], 79 | ['{', '~'], 80 | ) 81 | 82 | /** 83 | * Space or tab. See [POSIX 84 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 85 | * 86 | * @since 0.0.1 87 | */ 88 | export const blank: CharacterClass = characterClass(false, ' ', '\t') 89 | 90 | /** 91 | * Any whitespace character in ASCII. See [POSIX 92 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 93 | * 94 | * @since 0.0.1 95 | */ 96 | export const space: CharacterClass = pipe(blank, and('\n', '\r', '\f', '\v')) 97 | 98 | /** 99 | * Any character in ASCII which has a graphical representation (i.e. not control 100 | * characters or space). See [POSIX 101 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 102 | * 103 | * @since 0.0.1 104 | */ 105 | export const graph: CharacterClass = characterClass(false, [33, 127]) 106 | 107 | /** 108 | * Any non-control character in ASCII. See [POSIX 109 | * equivalent](https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions#Character_classes) 110 | * 111 | * @since 0.0.1 112 | */ 113 | export const print: CharacterClass = pipe(graph, and(' ')) 114 | -------------------------------------------------------------------------------- /src/util/pipe.ts: -------------------------------------------------------------------------------- 1 | export function pipe(a: A): A 2 | export function pipe(a: A, ab: (a: A) => B): B 3 | export function pipe(a: A, ab: (a: A) => B, bc: (b: B) => C): C 4 | export function pipe( 5 | a: A, 6 | ab: (a: A) => B, 7 | bc: (b: B) => C, 8 | cd: (c: C) => D, 9 | ): D 10 | export function pipe( 11 | a: A, 12 | ab: (a: A) => B, 13 | bc: (b: B) => C, 14 | cd: (c: C) => D, 15 | de: (d: D) => E, 16 | ): E 17 | export function pipe( 18 | a: A, 19 | ab: (a: A) => B, 20 | bc: (b: B) => C, 21 | cd: (c: C) => D, 22 | de: (d: D) => E, 23 | ef: (e: E) => F, 24 | ): F 25 | export function pipe( 26 | a: A, 27 | ab: (a: A) => B, 28 | bc: (b: B) => C, 29 | cd: (c: C) => D, 30 | de: (d: D) => E, 31 | ef: (e: E) => F, 32 | fg: (f: F) => G, 33 | ): G 34 | export function pipe( 35 | a: A, 36 | ab: (a: A) => B, 37 | bc: (b: B) => C, 38 | cd: (c: C) => D, 39 | de: (d: D) => E, 40 | ef: (e: E) => F, 41 | fg: (f: F) => G, 42 | gh: (g: G) => H, 43 | ): H 44 | export function pipe( 45 | a: A, 46 | ab: (a: A) => B, 47 | bc: (b: B) => C, 48 | cd: (c: C) => D, 49 | de: (d: D) => E, 50 | ef: (e: E) => F, 51 | fg: (f: F) => G, 52 | gh: (g: G) => H, 53 | hi: (h: H) => I, 54 | ): I 55 | export function pipe( 56 | a: A, 57 | ab: (a: A) => B, 58 | bc: (b: B) => C, 59 | cd: (c: C) => D, 60 | de: (d: D) => E, 61 | ef: (e: E) => F, 62 | fg: (f: F) => G, 63 | gh: (g: G) => H, 64 | hi: (h: H) => I, 65 | ij: (i: I) => J, 66 | ): J 67 | export function pipe( 68 | a: A, 69 | ab: (a: A) => B, 70 | bc: (b: B) => C, 71 | cd: (c: C) => D, 72 | de: (d: D) => E, 73 | ef: (e: E) => F, 74 | fg: (f: F) => G, 75 | gh: (g: G) => H, 76 | hi: (h: H) => I, 77 | ij: (i: I) => J, 78 | jk: (j: J) => K, 79 | ): K 80 | export function pipe( 81 | a: A, 82 | ab: (a: A) => B, 83 | bc: (b: B) => C, 84 | cd: (c: C) => D, 85 | de: (d: D) => E, 86 | ef: (e: E) => F, 87 | fg: (f: F) => G, 88 | gh: (g: G) => H, 89 | hi: (h: H) => I, 90 | ij: (i: I) => J, 91 | jk: (j: J) => K, 92 | kl: (k: K) => L, 93 | ): L 94 | export function pipe( 95 | a: A, 96 | ab: (a: A) => B, 97 | bc: (b: B) => C, 98 | cd: (c: C) => D, 99 | de: (d: D) => E, 100 | ef: (e: E) => F, 101 | fg: (f: F) => G, 102 | gh: (g: G) => H, 103 | hi: (h: H) => I, 104 | ij: (i: I) => J, 105 | jk: (j: J) => K, 106 | kl: (k: K) => L, 107 | lm: (l: L) => M, 108 | ): M 109 | export function pipe( 110 | a: A, 111 | ab: (a: A) => B, 112 | bc: (b: B) => C, 113 | cd: (c: C) => D, 114 | de: (d: D) => E, 115 | ef: (e: E) => F, 116 | fg: (f: F) => G, 117 | gh: (g: G) => H, 118 | hi: (h: H) => I, 119 | ij: (i: I) => J, 120 | jk: (j: J) => K, 121 | kl: (k: K) => L, 122 | lm: (l: L) => M, 123 | mn: (m: M) => N, 124 | ): N 125 | export function pipe( 126 | a: A, 127 | ab: (a: A) => B, 128 | bc: (b: B) => C, 129 | cd: (c: C) => D, 130 | de: (d: D) => E, 131 | ef: (e: E) => F, 132 | fg: (f: F) => G, 133 | gh: (g: G) => H, 134 | hi: (h: H) => I, 135 | ij: (i: I) => J, 136 | jk: (j: J) => K, 137 | kl: (k: K) => L, 138 | lm: (l: L) => M, 139 | mn: (m: M) => N, 140 | no: (n: N) => O, 141 | ): O 142 | 143 | export function pipe( 144 | a: A, 145 | ab: (a: A) => B, 146 | bc: (b: B) => C, 147 | cd: (c: C) => D, 148 | de: (d: D) => E, 149 | ef: (e: E) => F, 150 | fg: (f: F) => G, 151 | gh: (g: G) => H, 152 | hi: (h: H) => I, 153 | ij: (i: I) => J, 154 | jk: (j: J) => K, 155 | kl: (k: K) => L, 156 | lm: (l: L) => M, 157 | mn: (m: M) => N, 158 | no: (n: N) => O, 159 | op: (o: O) => P, 160 | ): P 161 | 162 | export function pipe( 163 | a: A, 164 | ab: (a: A) => B, 165 | bc: (b: B) => C, 166 | cd: (c: C) => D, 167 | de: (d: D) => E, 168 | ef: (e: E) => F, 169 | fg: (f: F) => G, 170 | gh: (g: G) => H, 171 | hi: (h: H) => I, 172 | ij: (i: I) => J, 173 | jk: (j: J) => K, 174 | kl: (k: K) => L, 175 | lm: (l: L) => M, 176 | mn: (m: M) => N, 177 | no: (n: N) => O, 178 | op: (o: O) => P, 179 | pq: (p: P) => Q, 180 | ): Q 181 | 182 | export function pipe( 183 | a: A, 184 | ab: (a: A) => B, 185 | bc: (b: B) => C, 186 | cd: (c: C) => D, 187 | de: (d: D) => E, 188 | ef: (e: E) => F, 189 | fg: (f: F) => G, 190 | gh: (g: G) => H, 191 | hi: (h: H) => I, 192 | ij: (i: I) => J, 193 | jk: (j: J) => K, 194 | kl: (k: K) => L, 195 | lm: (l: L) => M, 196 | mn: (m: M) => N, 197 | no: (n: N) => O, 198 | op: (o: O) => P, 199 | pq: (p: P) => Q, 200 | qr: (q: Q) => R, 201 | ): R 202 | 203 | export function pipe( 204 | a: A, 205 | ab: (a: A) => B, 206 | bc: (b: B) => C, 207 | cd: (c: C) => D, 208 | de: (d: D) => E, 209 | ef: (e: E) => F, 210 | fg: (f: F) => G, 211 | gh: (g: G) => H, 212 | hi: (h: H) => I, 213 | ij: (i: I) => J, 214 | jk: (j: J) => K, 215 | kl: (k: K) => L, 216 | lm: (l: L) => M, 217 | mn: (m: M) => N, 218 | no: (n: N) => O, 219 | op: (o: O) => P, 220 | pq: (p: P) => Q, 221 | qr: (q: Q) => R, 222 | rs: (r: R) => S, 223 | ): S 224 | 225 | export function pipe< 226 | A, 227 | B, 228 | C, 229 | D, 230 | E, 231 | F, 232 | G, 233 | H, 234 | I, 235 | J, 236 | K, 237 | L, 238 | M, 239 | N, 240 | O, 241 | P, 242 | Q, 243 | R, 244 | S, 245 | T, 246 | >( 247 | a: A, 248 | ab: (a: A) => B, 249 | bc: (b: B) => C, 250 | cd: (c: C) => D, 251 | de: (d: D) => E, 252 | ef: (e: E) => F, 253 | fg: (f: F) => G, 254 | gh: (g: G) => H, 255 | hi: (h: H) => I, 256 | ij: (i: I) => J, 257 | jk: (j: J) => K, 258 | kl: (k: K) => L, 259 | lm: (l: L) => M, 260 | mn: (m: M) => N, 261 | no: (n: N) => O, 262 | op: (o: O) => P, 263 | pq: (p: P) => Q, 264 | qr: (q: Q) => R, 265 | rs: (r: R) => S, 266 | st: (s: S) => T, 267 | ): T 268 | export function pipe(): unknown { 269 | let ret = arguments[0] 270 | for (let i = 1; i < arguments.length; i++) { 271 | ret = arguments[i](ret) 272 | } 273 | return ret 274 | } 275 | -------------------------------------------------------------------------------- /src/base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Atom, 3 | Char, 4 | CharacterClass, 5 | Disjunction, 6 | Pattern, 7 | QuantifiedAtom, 8 | Term, 9 | TermSequence, 10 | } from './types' 11 | 12 | /** 13 | * A pattern of a single, specific character 14 | * 15 | * @since 0.0.1 16 | */ 17 | export const char: (c: Char) => Atom = (c) => ({ 18 | tag: 'atom', 19 | kind: 'character', 20 | char: c, 21 | }) 22 | 23 | /** 24 | * A pattern of any single character 25 | * 26 | * @since 0.0.1 27 | */ 28 | export const anything: Atom = { tag: 'atom', kind: 'anything' } 29 | 30 | const convertRanges: ( 31 | ranges: ReadonlyArray< 32 | readonly [Char, Char] | Char | readonly [number, number] 33 | >, 34 | ) => CharacterClass['ranges'] = (ranges) => 35 | ranges.map((range) => { 36 | if (typeof range === 'string') { 37 | return { lower: range.charCodeAt(0), upper: range.charCodeAt(0) } as const 38 | } 39 | const [c1, c2] = range 40 | const lower = typeof c1 === 'string' ? c1.charCodeAt(0) : c1 41 | const upper = typeof c2 === 'string' ? c2.charCodeAt(0) : c2 42 | return { lower, upper } as const 43 | }) 44 | 45 | /** 46 | * A pattern of a single character that matches a list of characters or ranges. The ranges 47 | * can either be charcter to character (e.g. `['A', 'Z']`) or number to number (e.g. 48 | * `[0x3040, 0x309F]` which matches the Hiragana range in Unicode.) 49 | * 50 | * If the first argument (`exclude`) is true, then the pattern is a single character that 51 | * is _not_ in the given ranges. 52 | * 53 | * @since 0.0.1 54 | */ 55 | export const characterClass: ( 56 | exclude: boolean, 57 | ...ranges: ReadonlyArray< 58 | readonly [Char, Char] | Char | readonly [number, number] 59 | > 60 | ) => CharacterClass = (exclude, ...ranges) => ({ 61 | tag: 'atom', 62 | kind: 'characterClass', 63 | exclude, 64 | ranges: convertRanges(ranges), 65 | }) 66 | 67 | /** 68 | * Turn a `Pattern` into an `Atom`. In regular expression terms, this is wrapping the 69 | * pattern in parentheses. 70 | * 71 | * @since 0.0.1 72 | */ 73 | export const subgroup: (subpattern: Pattern) => Atom = (subpattern) => 74 | subpattern.tag === 'atom' 75 | ? subpattern 76 | : { 77 | tag: 'atom', 78 | kind: 'subgroup', 79 | subpattern, 80 | } 81 | 82 | /** 83 | * Repeat an `Atom` any number of times (including zero). 84 | * 85 | * @since 0.0.1 86 | */ 87 | export const anyNumber: (opts?: { 88 | greedy: boolean 89 | }) => (atom: Atom) => QuantifiedAtom = 90 | (opts = { greedy: false }) => 91 | (atom) => ({ 92 | tag: 'quantifiedAtom', 93 | atom, 94 | greedy: opts.greedy, 95 | kind: 'star', 96 | }) 97 | 98 | /** 99 | * Repeat an `Atom` any number of times, but at least once. 100 | * 101 | * @since 0.0.1 102 | */ 103 | export const atLeastOne: (opts?: { 104 | greedy: boolean 105 | }) => (atom: Atom) => QuantifiedAtom = 106 | (opts = { greedy: false }) => 107 | (atom) => ({ 108 | tag: 'quantifiedAtom', 109 | atom, 110 | greedy: opts.greedy, 111 | kind: 'plus', 112 | }) 113 | 114 | /** 115 | * Make an `Atom` optional -- it can occur in the pattern once or not at all. 116 | * 117 | * @since 0.0.1 118 | */ 119 | export const maybe: (atom: Atom) => QuantifiedAtom = (atom) => ({ 120 | tag: 'quantifiedAtom', 121 | atom, 122 | greedy: false, 123 | kind: 'question', 124 | }) 125 | 126 | /** 127 | * Repeat an `Atom` an exact number of times. (Aliased to `exactly` for better readability 128 | * in some situations) 129 | * 130 | * @since 0.0.1 131 | */ 132 | export const times: (count: number) => (atom: Atom) => QuantifiedAtom = 133 | (count) => (atom) => ({ 134 | tag: 'quantifiedAtom', 135 | atom, 136 | greedy: true, 137 | kind: 'exactly', 138 | count, 139 | }) 140 | 141 | /** 142 | * Alias of `times` 143 | * 144 | * @since 0.0.1 145 | */ 146 | export const exactly: (count: number) => (atom: Atom) => QuantifiedAtom = times 147 | 148 | /** 149 | * Repeat an `Atom` at least some number of times. For example, `atLeast(3)(char('a'))` 150 | * represents `aaa`, `aaaaaa`, and `aaaaaaaaaaaaaaaaaaaaaaaa` but not `aa` 151 | * 152 | * @since 0.0.1 153 | */ 154 | export const atLeast: (min: number) => (atom: Atom) => QuantifiedAtom = 155 | (min) => (atom) => ({ 156 | tag: 'quantifiedAtom', 157 | atom, 158 | kind: 'minimum', 159 | min, 160 | }) 161 | 162 | /** 163 | * Repeat an `Atom` some number of times in the given range, inclusive. 164 | * 165 | * @since 0.0.1 166 | */ 167 | export const between: ( 168 | min: number, 169 | max: number, 170 | ) => (atom: Atom) => QuantifiedAtom = (min, max) => (atom) => ({ 171 | tag: 'quantifiedAtom', 172 | atom, 173 | greedy: true, 174 | kind: 'between', 175 | min, 176 | max, 177 | }) 178 | 179 | /** 180 | * Repeat an `Atom` at most some number of times. For example, `atMost(3)(char('a'))` 181 | * represents ``, `a`, and `aaa` but not `aaaa` 182 | * 183 | * @since 1.1.0 184 | */ 185 | export const atMost: (min: number) => (atom: Atom) => QuantifiedAtom = 186 | (max) => (atom) => ({ 187 | tag: 'quantifiedAtom', 188 | atom, 189 | kind: 'between', 190 | min: 0, 191 | max, 192 | }) 193 | 194 | /** 195 | * Create a disjunction of two patterns. In regular expression terms, this corresponds to `|`. 196 | * 197 | * @since 0.0.1 198 | */ 199 | export const or: ( 200 | right: TermSequence | Atom | QuantifiedAtom, 201 | ) => (left: Pattern) => Disjunction = (right) => (left) => ({ 202 | tag: 'disjunction', 203 | left, 204 | right, 205 | }) 206 | 207 | const getTerms: (termOrSeq: Term | TermSequence) => TermSequence['terms'] = ( 208 | termOrSeq, 209 | ) => { 210 | switch (termOrSeq.tag) { 211 | case 'termSequence': 212 | return termOrSeq.terms 213 | case 'atom': 214 | return [termOrSeq] 215 | case 'quantifiedAtom': 216 | return [termOrSeq] 217 | } 218 | } 219 | 220 | /** 221 | * Append a term or term sequence onto another. 222 | * 223 | * @since 0.0.1 224 | */ 225 | export const andThen: ( 226 | term: Term | TermSequence, 227 | ) => (alt: TermSequence | Term) => TermSequence = (term) => (alt) => ({ 228 | tag: 'termSequence', 229 | terms: [...getTerms(alt), ...getTerms(term)], 230 | }) 231 | 232 | /** 233 | * Construct an `Atom` for a specific string. 234 | * 235 | * @since 0.0.1 236 | */ 237 | export const exactString: (s: string) => Atom = (s) => 238 | subgroup({ 239 | tag: 'termSequence', 240 | terms: s.split('').map(char), 241 | }) 242 | 243 | /** 244 | * Concatenate `Term`s 245 | * 246 | * @since 0.0.1 247 | */ 248 | export const sequence: ( 249 | term: Term, 250 | ...terms: ReadonlyArray 251 | ) => TermSequence = (term, ...terms) => ({ 252 | tag: 'termSequence', 253 | terms: [term, ...terms], 254 | }) 255 | 256 | /** 257 | * Modify a character class with more ranges, or combine two character classes together. 258 | * 259 | * @since 0.0.1 260 | */ 261 | export const and: { 262 | ( 263 | ...ranges: ReadonlyArray< 264 | readonly [Char, Char] | Char | readonly [number, number] 265 | > 266 | ): (cc: CharacterClass) => CharacterClass 267 | (ccb: CharacterClass): (cca: CharacterClass) => CharacterClass 268 | } = 269 | ( 270 | first, 271 | ...addl: ReadonlyArray< 272 | readonly [Char, Char] | Char | readonly [number, number] 273 | > 274 | ) => 275 | (cc) => ({ 276 | tag: 'atom', 277 | kind: 'characterClass', 278 | exclude: cc.exclude, 279 | ranges: cc.ranges.concat( 280 | typeof first === 'string' || first instanceof Array 281 | ? convertRanges([first, ...addl]) 282 | : first.ranges, 283 | ), 284 | }) 285 | 286 | /** 287 | * Invert a character class 288 | * 289 | * @since 0.0.1 290 | */ 291 | export const non: (cc: CharacterClass) => CharacterClass = (cc) => ({ 292 | ...cc, 293 | exclude: !cc.exclude, 294 | }) 295 | 296 | /** 297 | * An empty pattern. 298 | * 299 | * @since 0.0.1 300 | */ 301 | export const empty: Atom = { tag: 'atom', kind: 'character', char: '' } 302 | -------------------------------------------------------------------------------- /src/patterns/credit-card.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * NOTE: This pattern can check certain aspects of a credit card number, but not 3 | * all. Specifically, credit card numbers often use a [Luhn 4 | * checksum](https://en.wikipedia.org/wiki/Luhn_algorithm) to verify that the 5 | * number is valid. This pattern does not check that checksum, so it will accept 6 | * some invalid credit card numbers. 7 | * 8 | * If you want Luhn checksum validation, you can use the 9 | * [`schemata-ts`](https://github.com/jacob-alford/schemata-ts) library. 10 | */ 11 | import { 12 | andThen, 13 | between, 14 | char, 15 | characterClass, 16 | exactString, 17 | exactly, 18 | or, 19 | sequence, 20 | subgroup, 21 | } from '../base' 22 | import { digit } from '../character-classes' 23 | import { oneOf } from '../combinators' 24 | import { pipe } from '../util/pipe' 25 | 26 | // source: https://en.wikipedia.org/w/index.php?title=Payment_card_number&oldid=1110892430 27 | // afaict the 13-digit variant has not been a thing for years, but maybe there 28 | // are still some valid cards floating around? 29 | // /(^4(\d{12}|\d{15})$)/ 30 | const visa = pipe( 31 | char('4'), 32 | andThen(pipe(exactly(12)(digit), or(exactly(15)(digit)), subgroup)), 33 | ) 34 | 35 | // source: https://web.archive.org/web/20180514224309/https://www.mastercard.us/content/dam/mccom/global/documents/mastercard-rules.pdf 36 | // /(^(5[1-5]\d{4}|222[1-9]\d{2}|22[3-9]\d{3}|2[3-6]\d{4}|27[01]\d{3}|2720\d{2})\d{10}$)/ 37 | const mastercard = pipe( 38 | subgroup( 39 | pipe( 40 | sequence(char('5'), characterClass(false, ['1', '5']), exactly(4)(digit)), 41 | or( 42 | sequence( 43 | exactString('222'), 44 | characterClass(false, ['1', '9']), 45 | exactly(2)(digit), 46 | ), 47 | ), 48 | or( 49 | sequence( 50 | exactString('22'), 51 | characterClass(false, ['3', '9']), 52 | exactly(3)(digit), 53 | ), 54 | ), 55 | or( 56 | sequence( 57 | exactString('2'), 58 | characterClass(false, ['3', '6']), 59 | exactly(4)(digit), 60 | ), 61 | ), 62 | or( 63 | sequence( 64 | exactString('27'), 65 | characterClass(false, '0', '1'), 66 | exactly(3)(digit), 67 | ), 68 | ), 69 | or(sequence(exactString('2720'), exactly(2)(digit))), 70 | ), 71 | ), 72 | andThen(exactly(10)(digit)), 73 | ) 74 | 75 | // source: https://web.archive.org/web/20210504163517/https://www.americanexpress.com/content/dam/amex/hk/en/staticassets/merchant/pdf/support-and-services/useful-information-and-downloads/GuidetoCheckingCardFaces.pdf 76 | // /(^3[47]\d{13}$)/ 77 | const amex = sequence( 78 | char('3'), 79 | characterClass(false, '4', '7'), 80 | exactly(13)(digit), 81 | ) 82 | 83 | // US/Canada DCI cards will match as Mastercard (source: https://web.archive.org/web/20081204135437/http://www.mastercard.com/in/merchant/en/solutions_resources/dinersclub.html) 84 | // Others match regex below (source: https://web.archive.org/web/20170822221741/https://www.discovernetwork.com/downloads/IPP_VAR_Compliance.pdf) 85 | // /^(3(0([0-5]\d{5}|95\d{4})|[89]\d{6})\d{8,11}|36\d{6}\d{6,11})$/ 86 | const dinersClub = pipe( 87 | sequence( 88 | char('3'), 89 | subgroup( 90 | pipe( 91 | sequence( 92 | char('0'), 93 | subgroup( 94 | pipe( 95 | sequence(characterClass(false, ['0', '5']), exactly(5)(digit)), 96 | or(sequence(exactString('95'), exactly(4)(digit))), 97 | ), 98 | ), 99 | ), 100 | or(sequence(characterClass(false, '8', '9'), exactly(6)(digit))), 101 | ), 102 | ), 103 | between(8, 11)(digit), 104 | ), 105 | or(sequence(exactString('36'), exactly(6)(digit), between(6, 11)(digit))), 106 | subgroup, 107 | ) 108 | 109 | // source: https://web.archive.org/web/20170822221741/https://www.discovernetwork.com/downloads/IPP_VAR_Compliance.pdf 110 | // /(^(6011(0[5-9]\d{2}|[2-4]\d{3}|74\d{2}|7[7-9]\d{2}|8[6-9]\d{2}|9\d{3})|64[4-9]\d{5}|650[0-5]\d{4}|65060[1-9]\d{2}|65061[1-9]\d{2}|6506[2-9]\d{3}|650[7-9]\d{4}|65[1-9]\d{5})\d{8,11}$)/, 111 | const discover = pipe( 112 | oneOf( 113 | pipe( 114 | exactString('6011'), 115 | andThen( 116 | subgroup( 117 | oneOf( 118 | sequence( 119 | char('0'), 120 | characterClass(false, ['5', '9']), 121 | exactly(2)(digit), 122 | ), 123 | sequence(characterClass(false, ['2', '4']), exactly(3)(digit)), 124 | sequence(exactString('74'), exactly(2)(digit)), 125 | sequence( 126 | exactString('7'), 127 | characterClass(false, ['7', '9']), 128 | exactly(2)(digit), 129 | ), 130 | sequence( 131 | exactString('8'), 132 | characterClass(false, ['6', '9']), 133 | exactly(2)(digit), 134 | ), 135 | sequence(exactString('9'), exactly(3)(digit)), 136 | ), 137 | ), 138 | ), 139 | ), 140 | sequence( 141 | exactString('64'), 142 | characterClass(false, ['4', '9']), 143 | exactly(5)(digit), 144 | ), 145 | sequence( 146 | exactString('650'), 147 | characterClass(false, ['0', '5']), 148 | exactly(4)(digit), 149 | ), 150 | sequence( 151 | exactString('65060'), 152 | characterClass(false, ['1', '9']), 153 | exactly(2)(digit), 154 | ), 155 | sequence( 156 | exactString('65061'), 157 | characterClass(false, ['1', '9']), 158 | exactly(2)(digit), 159 | ), 160 | sequence( 161 | exactString('6506'), 162 | characterClass(false, ['2', '9']), 163 | exactly(3)(digit), 164 | ), 165 | sequence( 166 | exactString('650'), 167 | characterClass(false, ['7', '9']), 168 | exactly(4)(digit), 169 | ), 170 | sequence( 171 | exactString('65'), 172 | characterClass(false, ['1', '9']), 173 | exactly(5)(digit), 174 | ), 175 | ), 176 | subgroup, 177 | andThen(between(8, 11)(digit)), 178 | ) 179 | 180 | // /^(352[89]\d{4}|35[3-8]\d{5})\d{8,11}$/ 181 | const jcb = pipe( 182 | sequence( 183 | exactString('352'), 184 | characterClass(false, '8', '9'), 185 | exactly(4)(digit), 186 | ), 187 | or( 188 | sequence( 189 | exactString('35'), 190 | characterClass(false, ['3', '8']), 191 | exactly(5)(digit), 192 | ), 193 | ), 194 | subgroup, 195 | andThen(between(8, 11)(digit)), 196 | ) 197 | 198 | // Rupay 199 | // some are JCB co-branded so will match as JCB above 200 | // for the rest, best source I could find is just wikipedia: 201 | // https://en.wikipedia.org/w/index.php?title=Payment_card_number&oldid=1110892430 202 | // /^((60|65|81|82)\d{14}|508\d{14})$/ 203 | const rupay = subgroup( 204 | oneOf( 205 | sequence( 206 | subgroup( 207 | oneOf( 208 | exactString('60'), 209 | exactString('65'), 210 | exactString('81'), 211 | exactString('82'), 212 | ), 213 | ), 214 | exactly(14)(digit), 215 | ), 216 | sequence(exactString('508'), exactly(14)(digit)), 217 | ), 218 | ) 219 | 220 | // /^62(2(12[6-9]\d{2}|1[3-9]\d{3}|[2-8]\d|9[01]\d{3}|92[0-5]\d{2})|[4-6]\d{5}|8[2-8]\d{4})\d{8,11}$/ 221 | const unionPay = sequence( 222 | exactString('62'), 223 | subgroup( 224 | oneOf( 225 | sequence( 226 | char('2'), 227 | subgroup( 228 | oneOf( 229 | sequence( 230 | exactString('12'), 231 | characterClass(false, ['6', '9']), 232 | exactly(2)(digit), 233 | ), 234 | sequence( 235 | char('1'), 236 | characterClass(false, ['3', '9']), 237 | exactly(3)(digit), 238 | ), 239 | sequence(characterClass(false, ['2', '8']), digit), 240 | sequence( 241 | exactString('9'), 242 | characterClass(false, '0', '1'), 243 | exactly(3)(digit), 244 | ), 245 | sequence( 246 | exactString('92'), 247 | characterClass(false, ['0', '5']), 248 | exactly(2)(digit), 249 | ), 250 | ), 251 | ), 252 | ), 253 | sequence(characterClass(false, ['4', '6']), exactly(5)(digit)), 254 | sequence( 255 | exactString('8'), 256 | characterClass(false, ['2', '8']), 257 | exactly(4)(digit), 258 | ), 259 | ), 260 | ), 261 | between(8, 11)(digit), 262 | ) 263 | 264 | /** 265 | * @since 1.1.0 266 | * @category Pattern 267 | */ 268 | export const creditCard = oneOf( 269 | visa, 270 | mastercard, 271 | amex, 272 | dinersClub, 273 | discover, 274 | jcb, 275 | rupay, 276 | unionPay, 277 | ) 278 | --------------------------------------------------------------------------------