From 5ee19f848f225c71265200bbcb0e10b19a3eb762 Mon Sep 17 00:00:00 2001 From: Adrien Gautier Date: Wed, 17 Apr 2024 19:42:40 +0000 Subject: [PATCH] feat: templates! --- README.md | 194 ++++++++++--- package.json | 13 +- prettier.config.js | 16 +- .../__snapshots__/template.test.ts.snap | 3 + {tests => src/__tests__}/index.test-types.ts | 21 +- {tests => src/__tests__}/index.test.ts | 17 +- src/__tests__/template.test.ts | 104 +++++++ src/constants.ts | 1 + src/core.ts | 35 +++ src/index.ts | 129 +++------ src/template.ts | 55 ++++ src/types/__tests__/core.test-types.ts | 36 +++ src/types/__tests__/index.test-types.ts | 27 ++ src/types/__tests__/template.test-types.ts | 263 ++++++++++++++++++ src/types/core.types.ts | 60 ++++ src/types/index.types.ts | 9 + src/types/template.types.ts | 127 +++++++++ src/utils/__tests__/escapeRegExp.test.ts | 24 ++ .../generateValuesFromTemplate.test.ts | 28 ++ .../__tests__/getRegExpFromValues.test.ts | 30 ++ src/utils/__tests__/isSoit.test.ts | 17 ++ src/utils/__tests__/toCaptureGroup.test.ts | 9 + src/utils/escapeRegExp.ts | 5 + src/utils/generateValuesFromTemplate.ts | 40 +++ src/utils/getRegExpFromValues.ts | 17 ++ src/utils/isSoit.ts | 6 + src/utils/toCaptureGroup.ts | 6 + tsconfig.json | 2 +- 28 files changed, 1134 insertions(+), 160 deletions(-) create mode 100644 src/__tests__/__snapshots__/template.test.ts.snap rename {tests => src/__tests__}/index.test-types.ts (94%) rename {tests => src/__tests__}/index.test.ts (84%) create mode 100644 src/__tests__/template.test.ts create mode 100644 src/constants.ts create mode 100644 src/core.ts create mode 100644 src/template.ts create mode 100644 src/types/__tests__/core.test-types.ts create mode 100644 src/types/__tests__/index.test-types.ts create mode 100644 src/types/__tests__/template.test-types.ts create mode 100644 src/types/core.types.ts create mode 100644 src/types/index.types.ts create mode 100644 src/types/template.types.ts create mode 100644 src/utils/__tests__/escapeRegExp.test.ts create mode 100644 src/utils/__tests__/generateValuesFromTemplate.test.ts create mode 100644 src/utils/__tests__/getRegExpFromValues.test.ts create mode 100644 src/utils/__tests__/isSoit.test.ts create mode 100644 src/utils/__tests__/toCaptureGroup.test.ts create mode 100644 src/utils/escapeRegExp.ts create mode 100644 src/utils/generateValuesFromTemplate.ts create mode 100644 src/utils/getRegExpFromValues.ts create mode 100644 src/utils/isSoit.ts create mode 100644 src/utils/toCaptureGroup.ts diff --git a/README.md b/README.md index f05cccb..538ed49 100644 --- a/README.md +++ b/README.md @@ -2,98 +2,211 @@ ![typescript](https://img.shields.io/badge/written%20for-typescript-3178c6?style=flat-square) [![codecov](https://img.shields.io/codecov/c/github/adrgautier/soit?style=flat-square&token=IPTGBDRRJE)](https://codecov.io/gh/adrgautier/soit) ![prettier](https://img.shields.io/badge/code%20style-prettier-ff69b4?style=flat-square) [![npm](https://img.shields.io/npm/v/soit?style=flat-square)](https://www.npmjs.com/package/soit) -**Soit** (French for: either) is like an enhanced [Set()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) function which simplifies **type narrowing** and aims to replace TypeScript enums. +**Soit** (French for: either) is like an enhanced [Set()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) function which provides **type narrowing** and **template** `beta` utils. ## Motivation -The `enum` feature of TypeScript is not ideal. It does not provide type guards and is not iterable. +One of the main goal of TypeScript is to deal with **uncertainty**, to ensure that all possibilities have been taken into account during compilation. Sometimes the type itself can be uncertain (e.g. is it a `string` or a `number`?), but it is also common to know all possible values before runtime. -I wanted a simple lib which provides a way to [narrow type](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) to a given set of values and can be iterated. +The simplest way to declare all possible values is to write a union: +```ts +type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE"; +``` -> Inspired from the [enum feature of zod](https://github.com/colinhacks/zod/tree/v1#zod-enums). +Another approach is to use the enum feature of TypeScript: +```ts +enum HTTPMethod { + GET = "GET", + POST = "POST", + PUT = "PUT", + DELETE = "DELETE" +} +``` + +Whatever approach you use, you won't be able (easily) to [narrow a type down](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) to this set of values or to [get an array from](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from) this set of values. + +I created *Soit* to be a simple lib that provide the features aforementioned. ## Declaration -A *Soit* instance can be created by passing literals (string, number or boolean) in an array to the `Soit` function. +A *Soit* instance can be created by passing an array of *literals* to the `Soit()` function. ```ts -const isWarmColor = Soit(["red", "orange"]); -``` +import Soit from "soit"; -You can infer the corresponding union using the `Infer` "helper" provided by the lib. -```ts -type WarmColor = Infer; // infers "red" | "orange" +const isHTTPMethod = Soit(["GET", "POST", "PUT", "DELETE"]); ``` -You can pass any string, number or boolean you want. +A [literal](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types) is a specific string, number or boolean. ```ts -const isColdColor = Soit(["one", 1, true]); +const isOne = Soit(["1", "one", 1, true]); ``` -*Soit* instances are **iterable** and can be used to create new definitions. +You can infer the corresponding type using the `Infer` generic provided by the lib. ```ts -const isColor = Soit([...isWarmColor, "green", "blue"]); +import { Infer } from "soit"; -type Color = Infer; // infers "red" | "orange" | "green" | "blue" +type HTTPMethod = Infer; // infers "GET" | "POST" | "PUT" | "DELETE" ``` -## Guard +## Guard behavior A *Soit* instance is intended to be used as a type guard: ```ts -function handleColor(color: Color) { - if(isWarmColor(color)) { - // color can be "red" | "orange" +function handleHTTPMethod(method: string) { + if(isHTTPMethod(method)) { + // method's value is "GET", "POST", "PUT" or "DELETE" } - // color can be "blue" | "green" + throw new Error("Unknown HTTP method."); } ``` -## Array utils +## Iterable behavior Because the *Soit* instance is **iterable**, you can access the corresponding array: ```ts -const colors = Array.from(isColor); +const HTTPMethodArray = Array.from(isHTTPMethod); ``` You may prefer this syntax: ```ts -const colors = [...isColor]; +const HTTPMethodArray = [...isHTTPMethod]; ``` -`map` and `forEach` can be used without `Array.from()`. +## Array methods `deprecated` + +A *Soit* instance gives access to two Array methods : `map` and `forEach` + ```ts -isColor.forEach((color) => console.log(color)); +isHTTPMethod.forEach(method => console.log(method)); -const uppercaseColors = isColor.map(color => color.toUpperCase()); +const lowerCaseHTTPMethodArray = isHTTPMethod.map(method => method.toLowerCase()); ``` -## Set utils +> `map` and `forEach` are simple shortcuts, e.g. : +> ```ts +> [...isHTTPMethod].forEach(method => console.log(method)); +> ``` +> +> The map method for instance can be confusing because it does not return a new *Soit* instance. +> For this reason, both methods will be removed with the next major release. + +## Set methods + +Set methods aim to create new *Soit* instances by adding or subtracting values from an existing instance. + +> I created these methods before the new [composition methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set#set_composition) where added to the **Set** object. +> This new API will certainly influence the naming of *Soit* methods in the next major release. ### `.subset([])` You can create subsets using the `subset` method. ```ts -const isWarmColor = isColor.subset(["red", "orange"]); +const isHTTPMutationMethod = isHTTPMethod.subset(["POST", "PUT", "DELETE"]); ``` -> This checks on build time that `"red"` and `"orange"` do exist in the `isColor` instance. +> This checks on build time that `"POST"`, `"PUT"` and `"DELETE"` do exist in the `isHTTPMethod` instance. ### `.extend([])` You can extend an existing *Soit* instance using the `extend` method. ```ts -const isColor = isWarmColor.extend(["blue", "green"]); +const isHTTPMethod = isHTTPMutationMethod.extend(["GET"]); ``` ### `.difference([])` + You can create a new instance without the specified values using the `difference` method. ```ts -const isColdColor = isColor.difference(["red", "orange", "yellow"]); +const isHTTPQueryMethod = isHTTPMethod.difference(["POST", "PUT", "DELETE"]); ``` > The given array don't need to be a subset and can contain values that don't exist in the initial instance. +## Template `beta` + +The template feature allows mimicking the [template literal type](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#handbook-content) mechanism, but with runtime utils. +Let's take the following template literal type: + +```ts +type TimeGetter = `get${"Seconds" | "Minutes" | "Hours"}`; +``` + +The `TimeGetter` type will only accept the following values: `"getSeconds"`, `"getMinutes"` and `"getHours"`. + +Here is how you would use the template feature from *Soit*: + +```ts +const isUnit = Soit(["Seconds", "Minutes", "Hours"]); +const isTimeGetter = Soit.Template("get", isUnit); +``` +The `Template()` function is able to construct the corresponding template using the strings as the "static" parts and the *Soit* instances as the "dynamic" parts. + +You can get the corresponding type with the usual `Infer` generic. + +```ts +type TimeGetter = Infer; +``` + +### Guard behavior + +Like a *Soit* instance, a *SoitTemplate* is intended to be used as a type guard: +```ts +if(isTimeGetter(method)) { ... } +``` +The `isTimeGetter` guard will only accept the following values: `"getSeconds"`, `"getMinutes"` and `"getHours"`. + +### Capture method 🪄 + +A *SoitTemplate* instance offers the `capture` method to retrieve the "dynamic" parts of the template from a string. + +```ts +const [unit] = isTimeGetter.capture("getSeconds"); // unit === "Seconds" +``` + +### Iterable behavior and Array method + +A *SoitTemplate* instance is iterable. + +```ts +const timeGetterMethods = [...isTimeGetter]; // ["getSeconds", "getMinutes", "getHours"] +``` + +As with a regular *Soit* instance, you get the `map` and `forEach` shortcuts. + +## Using *Soit* with other tools + +### [TS-Pattern](https://github.com/gvergnaud/ts-pattern) + +You can easily integrate *Soit* instances to your patterns using the [P.when](https://github.com/gvergnaud/ts-pattern?tab=readme-ov-file#pwhen-patterns) util : + +```ts +import { P } from "ts-pattern"; + +const pattern = P.when(isHTTPMethod); +``` + +The inference will work as expected with TS-Pattern logic : + +```ts +type Pattern = P.infer; // infers "GET" | "POST" | "PUT" | "DELETE" +``` + +### [Zod](https://github.com/colinhacks/zod) + +You can integrate *Soit* instances to your Zod schemas using the [custom](https://zod.dev/?id=custom-schemas) util: + +```ts +import * as z from "zod"; +import { Infer } from "soit"; + +type HTTPMethod = Infer; + +const HTTPMethodSchema = z.custom(isHTTPMethod); +``` + +Zod is not able to infer the type on its own, therefore you need to pass the corresponding type (inferred beforehand) in the generic. + ## Troubleshoot ### `Type 'string' is not assignable to type 'never'.` ts(2345) @@ -101,13 +214,24 @@ const isColdColor = isColor.difference(["red", "orange", "yellow"]); You are maybe trying to create a new *Soit* instance using a named array. ```ts -const warmColors = ["red", "orange"]; -const isWarmColor = Soit(warmColors); // error ts(2345) +const HTTPMutationMethods = ["POST", "PUT", "DELETE"]; +const isHTTPMutationMethods = Soit(HTTPMutationMethods); // error ts(2345) ``` *Soit* throw this error to prevent passing an unknown set of value (i.e. `string[]`). The solution here is to use the `as const` declaration in order to freeze the values and allow a proper type inference. ```ts -const warmColors = ["red", "orange"] as const; -const isWarmColor = Soit(warmColors); +const HTTPMutationMethods = ["POST", "PUT", "DELETE"] as const; +const isHTTPMutationMethods = Soit(HTTPMutationMethods); +``` + +The `Template()` function also requires freezing the values to allow a proper type inference : +```ts +const template = ['get', Soit(['Seconds', 'Minutes', 'Hours'])] as const; +const isTimeGetter = Soit.Template(...template); ``` + +This error can also occur if you pass a *Soit* instance directly to a template. You can use `as const` as follows: +```ts +const isTimeGetter = Soit.Template('get', Soit(['Seconds', 'Minutes', 'Hours'] as const)); +``` \ No newline at end of file diff --git a/package.json b/package.json index faeb76d..53fc77b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "soit", - "version": "2.0.0", + "version": "2.1.0", "description": "Create a type guard from a list of literals.", "main": "dist/index.js", "sideEffects": false, @@ -19,7 +19,7 @@ "build": "npm run clean && tsc", "test": "jest --coverage && npm run compile", "compile": "tsc -p ./tsconfig.test.json --noEmit", - "prettier": "prettier '(src|tests)/**/*.ts' --write" + "prettier": "prettier 'src/**/*.ts' --write" }, "repository": { "type": "git", @@ -36,21 +36,20 @@ "license": "MIT", "jest": { "transform": { - "^.+\\.tsx?$": "ts-jest" + "^.+\\.ts$": "ts-jest" }, - "testRegex": "/tests/.*.test.ts$", + "testRegex": "/src/.*.test.ts$", "testPathIgnorePatterns": [ "/node_modules/", "/dist/" ], "collectCoverageFrom": [ - "src/**/*.ts" + "src/**/*.ts", + "!src/**/*types.ts" ], "moduleFileExtensions": [ "ts", - "tsx", "js", - "jsx", "json", "node" ] diff --git a/prettier.config.js b/prettier.config.js index 09a6af1..38a9e85 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -1,24 +1,10 @@ module.exports = { - // write: true, semi: true, useTabs: false, // Indent lines with tabs instead of spaces. printWidth: 80, // Specify the length of line that the printer will wrap on. tabWidth: 2, // Specify the number of spaces per indentation-level. singleQuote: true, // Use single quotes instead of double quotes. - /** - * Print trailing commas wherever possible. - * Valid options: - * - "none" - no trailing commas - * - "es5" - trailing commas where valid in ES5 (objects, arrays, etc) - * - "all" - trailing commas wherever possible (function arguments) - */ - trailingComma: 'es5', - /** - * Specify which parse to use. - * Valid options: - * - "flow" - * - "babylon" - */ + trailingComma: 'es5', // Print trailing commas wherever possible. parser: 'typescript', arrowParens: 'avoid', }; diff --git a/src/__tests__/__snapshots__/template.test.ts.snap b/src/__tests__/__snapshots__/template.test.ts.snap new file mode 100644 index 0000000..fbd8ef5 --- /dev/null +++ b/src/__tests__/__snapshots__/template.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SoitTemplate should test a complex template 1`] = `"0-0-0|0-0-1|0-0-2|0-0-3|0-0-4|0-0-5|0-0-6|0-0-7|0-0-8|0-0-9|0-1-0|0-1-1|0-1-2|0-1-3|0-1-4|0-1-5|0-1-6|0-1-7|0-1-8|0-1-9|0-2-0|0-2-1|0-2-2|0-2-3|0-2-4|0-2-5|0-2-6|0-2-7|0-2-8|0-2-9|0-3-0|0-3-1|0-3-2|0-3-3|0-3-4|0-3-5|0-3-6|0-3-7|0-3-8|0-3-9|0-4-0|0-4-1|0-4-2|0-4-3|0-4-4|0-4-5|0-4-6|0-4-7|0-4-8|0-4-9|0-5-0|0-5-1|0-5-2|0-5-3|0-5-4|0-5-5|0-5-6|0-5-7|0-5-8|0-5-9|0-6-0|0-6-1|0-6-2|0-6-3|0-6-4|0-6-5|0-6-6|0-6-7|0-6-8|0-6-9|0-7-0|0-7-1|0-7-2|0-7-3|0-7-4|0-7-5|0-7-6|0-7-7|0-7-8|0-7-9|0-8-0|0-8-1|0-8-2|0-8-3|0-8-4|0-8-5|0-8-6|0-8-7|0-8-8|0-8-9|0-9-0|0-9-1|0-9-2|0-9-3|0-9-4|0-9-5|0-9-6|0-9-7|0-9-8|0-9-9|1-0-0|1-0-1|1-0-2|1-0-3|1-0-4|1-0-5|1-0-6|1-0-7|1-0-8|1-0-9|1-1-0|1-1-1|1-1-2|1-1-3|1-1-4|1-1-5|1-1-6|1-1-7|1-1-8|1-1-9|1-2-0|1-2-1|1-2-2|1-2-3|1-2-4|1-2-5|1-2-6|1-2-7|1-2-8|1-2-9|1-3-0|1-3-1|1-3-2|1-3-3|1-3-4|1-3-5|1-3-6|1-3-7|1-3-8|1-3-9|1-4-0|1-4-1|1-4-2|1-4-3|1-4-4|1-4-5|1-4-6|1-4-7|1-4-8|1-4-9|1-5-0|1-5-1|1-5-2|1-5-3|1-5-4|1-5-5|1-5-6|1-5-7|1-5-8|1-5-9|1-6-0|1-6-1|1-6-2|1-6-3|1-6-4|1-6-5|1-6-6|1-6-7|1-6-8|1-6-9|1-7-0|1-7-1|1-7-2|1-7-3|1-7-4|1-7-5|1-7-6|1-7-7|1-7-8|1-7-9|1-8-0|1-8-1|1-8-2|1-8-3|1-8-4|1-8-5|1-8-6|1-8-7|1-8-8|1-8-9|1-9-0|1-9-1|1-9-2|1-9-3|1-9-4|1-9-5|1-9-6|1-9-7|1-9-8|1-9-9|2-0-0|2-0-1|2-0-2|2-0-3|2-0-4|2-0-5|2-0-6|2-0-7|2-0-8|2-0-9|2-1-0|2-1-1|2-1-2|2-1-3|2-1-4|2-1-5|2-1-6|2-1-7|2-1-8|2-1-9|2-2-0|2-2-1|2-2-2|2-2-3|2-2-4|2-2-5|2-2-6|2-2-7|2-2-8|2-2-9|2-3-0|2-3-1|2-3-2|2-3-3|2-3-4|2-3-5|2-3-6|2-3-7|2-3-8|2-3-9|2-4-0|2-4-1|2-4-2|2-4-3|2-4-4|2-4-5|2-4-6|2-4-7|2-4-8|2-4-9|2-5-0|2-5-1|2-5-2|2-5-3|2-5-4|2-5-5|2-5-6|2-5-7|2-5-8|2-5-9|2-6-0|2-6-1|2-6-2|2-6-3|2-6-4|2-6-5|2-6-6|2-6-7|2-6-8|2-6-9|2-7-0|2-7-1|2-7-2|2-7-3|2-7-4|2-7-5|2-7-6|2-7-7|2-7-8|2-7-9|2-8-0|2-8-1|2-8-2|2-8-3|2-8-4|2-8-5|2-8-6|2-8-7|2-8-8|2-8-9|2-9-0|2-9-1|2-9-2|2-9-3|2-9-4|2-9-5|2-9-6|2-9-7|2-9-8|2-9-9|3-0-0|3-0-1|3-0-2|3-0-3|3-0-4|3-0-5|3-0-6|3-0-7|3-0-8|3-0-9|3-1-0|3-1-1|3-1-2|3-1-3|3-1-4|3-1-5|3-1-6|3-1-7|3-1-8|3-1-9|3-2-0|3-2-1|3-2-2|3-2-3|3-2-4|3-2-5|3-2-6|3-2-7|3-2-8|3-2-9|3-3-0|3-3-1|3-3-2|3-3-3|3-3-4|3-3-5|3-3-6|3-3-7|3-3-8|3-3-9|3-4-0|3-4-1|3-4-2|3-4-3|3-4-4|3-4-5|3-4-6|3-4-7|3-4-8|3-4-9|3-5-0|3-5-1|3-5-2|3-5-3|3-5-4|3-5-5|3-5-6|3-5-7|3-5-8|3-5-9|3-6-0|3-6-1|3-6-2|3-6-3|3-6-4|3-6-5|3-6-6|3-6-7|3-6-8|3-6-9|3-7-0|3-7-1|3-7-2|3-7-3|3-7-4|3-7-5|3-7-6|3-7-7|3-7-8|3-7-9|3-8-0|3-8-1|3-8-2|3-8-3|3-8-4|3-8-5|3-8-6|3-8-7|3-8-8|3-8-9|3-9-0|3-9-1|3-9-2|3-9-3|3-9-4|3-9-5|3-9-6|3-9-7|3-9-8|3-9-9|4-0-0|4-0-1|4-0-2|4-0-3|4-0-4|4-0-5|4-0-6|4-0-7|4-0-8|4-0-9|4-1-0|4-1-1|4-1-2|4-1-3|4-1-4|4-1-5|4-1-6|4-1-7|4-1-8|4-1-9|4-2-0|4-2-1|4-2-2|4-2-3|4-2-4|4-2-5|4-2-6|4-2-7|4-2-8|4-2-9|4-3-0|4-3-1|4-3-2|4-3-3|4-3-4|4-3-5|4-3-6|4-3-7|4-3-8|4-3-9|4-4-0|4-4-1|4-4-2|4-4-3|4-4-4|4-4-5|4-4-6|4-4-7|4-4-8|4-4-9|4-5-0|4-5-1|4-5-2|4-5-3|4-5-4|4-5-5|4-5-6|4-5-7|4-5-8|4-5-9|4-6-0|4-6-1|4-6-2|4-6-3|4-6-4|4-6-5|4-6-6|4-6-7|4-6-8|4-6-9|4-7-0|4-7-1|4-7-2|4-7-3|4-7-4|4-7-5|4-7-6|4-7-7|4-7-8|4-7-9|4-8-0|4-8-1|4-8-2|4-8-3|4-8-4|4-8-5|4-8-6|4-8-7|4-8-8|4-8-9|4-9-0|4-9-1|4-9-2|4-9-3|4-9-4|4-9-5|4-9-6|4-9-7|4-9-8|4-9-9|5-0-0|5-0-1|5-0-2|5-0-3|5-0-4|5-0-5|5-0-6|5-0-7|5-0-8|5-0-9|5-1-0|5-1-1|5-1-2|5-1-3|5-1-4|5-1-5|5-1-6|5-1-7|5-1-8|5-1-9|5-2-0|5-2-1|5-2-2|5-2-3|5-2-4|5-2-5|5-2-6|5-2-7|5-2-8|5-2-9|5-3-0|5-3-1|5-3-2|5-3-3|5-3-4|5-3-5|5-3-6|5-3-7|5-3-8|5-3-9|5-4-0|5-4-1|5-4-2|5-4-3|5-4-4|5-4-5|5-4-6|5-4-7|5-4-8|5-4-9|5-5-0|5-5-1|5-5-2|5-5-3|5-5-4|5-5-5|5-5-6|5-5-7|5-5-8|5-5-9|5-6-0|5-6-1|5-6-2|5-6-3|5-6-4|5-6-5|5-6-6|5-6-7|5-6-8|5-6-9|5-7-0|5-7-1|5-7-2|5-7-3|5-7-4|5-7-5|5-7-6|5-7-7|5-7-8|5-7-9|5-8-0|5-8-1|5-8-2|5-8-3|5-8-4|5-8-5|5-8-6|5-8-7|5-8-8|5-8-9|5-9-0|5-9-1|5-9-2|5-9-3|5-9-4|5-9-5|5-9-6|5-9-7|5-9-8|5-9-9|6-0-0|6-0-1|6-0-2|6-0-3|6-0-4|6-0-5|6-0-6|6-0-7|6-0-8|6-0-9|6-1-0|6-1-1|6-1-2|6-1-3|6-1-4|6-1-5|6-1-6|6-1-7|6-1-8|6-1-9|6-2-0|6-2-1|6-2-2|6-2-3|6-2-4|6-2-5|6-2-6|6-2-7|6-2-8|6-2-9|6-3-0|6-3-1|6-3-2|6-3-3|6-3-4|6-3-5|6-3-6|6-3-7|6-3-8|6-3-9|6-4-0|6-4-1|6-4-2|6-4-3|6-4-4|6-4-5|6-4-6|6-4-7|6-4-8|6-4-9|6-5-0|6-5-1|6-5-2|6-5-3|6-5-4|6-5-5|6-5-6|6-5-7|6-5-8|6-5-9|6-6-0|6-6-1|6-6-2|6-6-3|6-6-4|6-6-5|6-6-6|6-6-7|6-6-8|6-6-9|6-7-0|6-7-1|6-7-2|6-7-3|6-7-4|6-7-5|6-7-6|6-7-7|6-7-8|6-7-9|6-8-0|6-8-1|6-8-2|6-8-3|6-8-4|6-8-5|6-8-6|6-8-7|6-8-8|6-8-9|6-9-0|6-9-1|6-9-2|6-9-3|6-9-4|6-9-5|6-9-6|6-9-7|6-9-8|6-9-9|7-0-0|7-0-1|7-0-2|7-0-3|7-0-4|7-0-5|7-0-6|7-0-7|7-0-8|7-0-9|7-1-0|7-1-1|7-1-2|7-1-3|7-1-4|7-1-5|7-1-6|7-1-7|7-1-8|7-1-9|7-2-0|7-2-1|7-2-2|7-2-3|7-2-4|7-2-5|7-2-6|7-2-7|7-2-8|7-2-9|7-3-0|7-3-1|7-3-2|7-3-3|7-3-4|7-3-5|7-3-6|7-3-7|7-3-8|7-3-9|7-4-0|7-4-1|7-4-2|7-4-3|7-4-4|7-4-5|7-4-6|7-4-7|7-4-8|7-4-9|7-5-0|7-5-1|7-5-2|7-5-3|7-5-4|7-5-5|7-5-6|7-5-7|7-5-8|7-5-9|7-6-0|7-6-1|7-6-2|7-6-3|7-6-4|7-6-5|7-6-6|7-6-7|7-6-8|7-6-9|7-7-0|7-7-1|7-7-2|7-7-3|7-7-4|7-7-5|7-7-6|7-7-7|7-7-8|7-7-9|7-8-0|7-8-1|7-8-2|7-8-3|7-8-4|7-8-5|7-8-6|7-8-7|7-8-8|7-8-9|7-9-0|7-9-1|7-9-2|7-9-3|7-9-4|7-9-5|7-9-6|7-9-7|7-9-8|7-9-9|8-0-0|8-0-1|8-0-2|8-0-3|8-0-4|8-0-5|8-0-6|8-0-7|8-0-8|8-0-9|8-1-0|8-1-1|8-1-2|8-1-3|8-1-4|8-1-5|8-1-6|8-1-7|8-1-8|8-1-9|8-2-0|8-2-1|8-2-2|8-2-3|8-2-4|8-2-5|8-2-6|8-2-7|8-2-8|8-2-9|8-3-0|8-3-1|8-3-2|8-3-3|8-3-4|8-3-5|8-3-6|8-3-7|8-3-8|8-3-9|8-4-0|8-4-1|8-4-2|8-4-3|8-4-4|8-4-5|8-4-6|8-4-7|8-4-8|8-4-9|8-5-0|8-5-1|8-5-2|8-5-3|8-5-4|8-5-5|8-5-6|8-5-7|8-5-8|8-5-9|8-6-0|8-6-1|8-6-2|8-6-3|8-6-4|8-6-5|8-6-6|8-6-7|8-6-8|8-6-9|8-7-0|8-7-1|8-7-2|8-7-3|8-7-4|8-7-5|8-7-6|8-7-7|8-7-8|8-7-9|8-8-0|8-8-1|8-8-2|8-8-3|8-8-4|8-8-5|8-8-6|8-8-7|8-8-8|8-8-9|8-9-0|8-9-1|8-9-2|8-9-3|8-9-4|8-9-5|8-9-6|8-9-7|8-9-8|8-9-9|9-0-0|9-0-1|9-0-2|9-0-3|9-0-4|9-0-5|9-0-6|9-0-7|9-0-8|9-0-9|9-1-0|9-1-1|9-1-2|9-1-3|9-1-4|9-1-5|9-1-6|9-1-7|9-1-8|9-1-9|9-2-0|9-2-1|9-2-2|9-2-3|9-2-4|9-2-5|9-2-6|9-2-7|9-2-8|9-2-9|9-3-0|9-3-1|9-3-2|9-3-3|9-3-4|9-3-5|9-3-6|9-3-7|9-3-8|9-3-9|9-4-0|9-4-1|9-4-2|9-4-3|9-4-4|9-4-5|9-4-6|9-4-7|9-4-8|9-4-9|9-5-0|9-5-1|9-5-2|9-5-3|9-5-4|9-5-5|9-5-6|9-5-7|9-5-8|9-5-9|9-6-0|9-6-1|9-6-2|9-6-3|9-6-4|9-6-5|9-6-6|9-6-7|9-6-8|9-6-9|9-7-0|9-7-1|9-7-2|9-7-3|9-7-4|9-7-5|9-7-6|9-7-7|9-7-8|9-7-9|9-8-0|9-8-1|9-8-2|9-8-3|9-8-4|9-8-5|9-8-6|9-8-7|9-8-8|9-8-9|9-9-0|9-9-1|9-9-2|9-9-3|9-9-4|9-9-5|9-9-6|9-9-7|9-9-8|9-9-9"`; diff --git a/tests/index.test-types.ts b/src/__tests__/index.test-types.ts similarity index 94% rename from tests/index.test-types.ts rename to src/__tests__/index.test-types.ts index f23c57c..2290b48 100644 --- a/tests/index.test-types.ts +++ b/src/__tests__/index.test-types.ts @@ -4,7 +4,8 @@ * the file needs to compile without error. */ import { expectType, expectNever, TypeEqual } from 'ts-expect'; -import Soit, { Infer } from '../src/index'; +import Soit, { Infer } from '../index'; +import { _soitTemplate } from '../template'; /** * Should prevent unknown literals array @@ -26,7 +27,7 @@ import Soit, { Infer } from '../src/index'; /** * Should prevent unknown object inference */ -{ +/*{ const randomFunction = () => {}; const randomArray = ['']; const randomObject = {}; @@ -43,7 +44,7 @@ import Soit, { Infer } from '../src/index'; // @ts-expect-error type Fail4 = Infer; -} +}*/ /** * Should guard and infer any given string literal @@ -231,7 +232,7 @@ import Soit, { Infer } from '../src/index'; } /** - * Sould be iterable + * Should be iterable */ { const isSet1 = Soit(['one', 'two', 'three']); @@ -241,6 +242,18 @@ import Soit, { Infer } from '../src/index'; expectType>(true); } +/** + * Template should be iterable + */ +{ + const isSet1 = Soit(['one', 'two', 'three']); + const isTemplate = Soit.Template(isSet1, '-', isSet1); + type Template = Infer; + + const templatePossibleValues = Array.from(isTemplate); + expectType>(true); +} + /** * Should expose map and forEach */ diff --git a/tests/index.test.ts b/src/__tests__/index.test.ts similarity index 84% rename from tests/index.test.ts rename to src/__tests__/index.test.ts index a984d5e..b8b94ef 100644 --- a/tests/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import Soit from '../src/index'; +import Soit from '../index'; describe('Soit', () => { it('should guard for any given string literal', () => { @@ -93,4 +93,19 @@ describe('Soit', () => { expect(isDifferenceSet('four')).toBe(false); expect(Array.from(isDifferenceSet)).toEqual(['one', 'two']); }); + it('should be able to create a template', () => { + const isSet = Soit(['one', 'two', 'three']); + const isTemplate = Soit.Template('$', isSet); + expect(Array.from(isTemplate)).toEqual(['$one', '$two', '$three']); + expect(isTemplate('$one')).toBe(true); + expect(isTemplate('$two')).toBe(true); + expect(isTemplate('$three')).toBe(true); + expect(isTemplate('$four')).toBe(false); + expect(isTemplate('one')).toBe(false); + expect(isTemplate.capture('$one')).toEqual(['one']); + expect(isTemplate.capture('$two')).toEqual(['two']); + expect(isTemplate.capture('$three')).toEqual(['three']); + expect(isTemplate.capture('$four')).toBe(null); + expect(isTemplate.capture('one')).toBe(null); + }); }); diff --git a/src/__tests__/template.test.ts b/src/__tests__/template.test.ts new file mode 100644 index 0000000..030c16a --- /dev/null +++ b/src/__tests__/template.test.ts @@ -0,0 +1,104 @@ +import { _soitCore } from '../core'; +import { _soitTemplate } from '../template'; +import Soit from '../index'; + +describe('SoitTemplate', () => { + it('should test a simple template', () => { + const mapMock = jest.fn(); + const forEachMock = jest.fn(); + const template = _soitTemplate( + _soitCore(['a', 'b']), + '-', + _soitCore(['c', 'd']) + ); + expect(template('a-c')).toBe(true); + expect(template('a-d')).toBe(true); + expect(template('b-c')).toBe(true); + expect(template('b-d')).toBe(true); + expect(template('a')).toBe(false); + expect(template('c')).toBe(false); + expect(template('a-d-e')).toBe(false); + expect(Array.from(template)).toEqual(['a-c', 'a-d', 'b-c', 'b-d']); + expect(template.capture('a-d')).toEqual(['a', 'd']); + expect(template.capture('ad')).toEqual(null); + expect(template.capture('1-2')).toEqual(null); + + template.map(mapMock); + expect(mapMock).toHaveBeenCalledTimes(4); + expect(mapMock).toHaveBeenNthCalledWith(1, 'a-c', 0, [ + 'a-c', + 'a-d', + 'b-c', + 'b-d', + ]); + expect(mapMock).toHaveBeenNthCalledWith(2, 'a-d', 1, [ + 'a-c', + 'a-d', + 'b-c', + 'b-d', + ]); + expect(mapMock).toHaveBeenNthCalledWith(3, 'b-c', 2, [ + 'a-c', + 'a-d', + 'b-c', + 'b-d', + ]); + expect(mapMock).toHaveBeenNthCalledWith(4, 'b-d', 3, [ + 'a-c', + 'a-d', + 'b-c', + 'b-d', + ]); + + template.forEach(forEachMock); + expect(forEachMock).toHaveBeenCalledTimes(4); + expect(forEachMock).toHaveBeenNthCalledWith(1, 'a-c', 0, [ + 'a-c', + 'a-d', + 'b-c', + 'b-d', + ]); + expect(forEachMock).toHaveBeenNthCalledWith(2, 'a-d', 1, [ + 'a-c', + 'a-d', + 'b-c', + 'b-d', + ]); + expect(forEachMock).toHaveBeenNthCalledWith(3, 'b-c', 2, [ + 'a-c', + 'a-d', + 'b-c', + 'b-d', + ]); + expect(forEachMock).toHaveBeenNthCalledWith(4, 'b-d', 3, [ + 'a-c', + 'a-d', + 'b-c', + 'b-d', + ]); + }); + it('should test a complex template', () => { + const mapMock = jest.fn(); + const forEachMock = jest.fn(); + const digit = Soit([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + const template = Soit.Template(digit, '-', digit, '-', digit); + expect(template('1-2-3')).toBe(true); + const result = template.capture('1-2-3'); + if (result) { + const [a, b, c] = result; + expect(a).toBe('1'); + expect(b).toBe('2'); + expect(c).toBe('3'); + } + expect(template.capture('1-2-3')).toEqual(['1', '2', '3']); + expect(Array.from(template).join('|')).toMatchSnapshot(); + expect(template.capture('a-b-c')).toBe(null); + expect(template.capture('123')).toBe(null); + + template.map(mapMock); + expect(mapMock).toHaveBeenCalledTimes(1000); + + template.forEach(forEachMock); + expect(forEachMock).toHaveBeenCalledTimes(1000); + }); +}); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..bf6131f --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const SOIT_SYMBOL = Symbol(); diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..a35def6 --- /dev/null +++ b/src/core.ts @@ -0,0 +1,35 @@ +import { ArrayUtils, Literal, SetUtils, Soit } from './types/core.types'; +import { SOIT_SYMBOL } from './constants'; + +export function _soitCore(values: readonly V[]): Soit { + const _set = new Set(values); + + const _values = Array.from(_set); + + function _guard(testedValue: Literal): testedValue is V { + return values.some(o => o === testedValue); + } + + const _arrayUtils: ArrayUtils = { + forEach: (...args) => _values.forEach(...args), + map: (...args) => _values.map(...args), + }; + + const _setUtils: SetUtils = { + subset: subsetValues => _soitCore(subsetValues), + extend: additionalValues => _soitCore([..._values, ...additionalValues]), + // we cannot rely on filter and includes typings + difference: differenceValues => + _soitCore( + _values.filter(value => !differenceValues.includes(value as any)) as any + ), + }; + + const _iterable: Iterable = { + [Symbol.iterator]: () => _set[Symbol.iterator](), + }; + + return Object.assign(_guard, _iterable, _setUtils, _arrayUtils, { + [SOIT_SYMBOL]: true, + }); +} diff --git a/src/index.ts b/src/index.ts index 5da9e5f..df16803 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,110 +1,45 @@ -type Literal = string | number | boolean; - -/* This type ensures that the input type is not a set of unknown values. */ -type SubLiteral = string extends T - ? never - : number extends T - ? never - : boolean extends T - ? never - : Literal; - -type Guard = { - /** - * Uses the `Soit` instance as a type guard: - * ```ts - * const is123 = Soit([1, 2, 3]); - * if(is123(value)) { ... } - * ``` - * - * @param {Literal} value - * @returns {boolean} true or false - */ - (testedValue: Literal): testedValue is V; -}; - -type ArrayUtils = Pick, 'forEach' | 'map'>; - -type SetUtils = { - /** - * Creates a subset of the current set. - * - * @param values (array of Literals) - * @returns `Soit` instance - */ - subset: (subsetValues: readonly S[]) => Soit; - /** - * Creates a extended set from the current set. - * - * @param values (array of Literals) - * @returns `Soit` instance - */ - extend: >( - additionalValues: readonly A[] - ) => Soit; - /** - * Creates a new set from the current set by excluding values. - * - * @param values (array of Literals) - * @returns `Soit` instance - */ - difference: >( - differenceValues: readonly D[] - ) => Soit>; -}; - -/* This type aims to display an alias when manipulating a Soit instance. */ -type Soit = Guard & - Iterable & - ArrayUtils & - SetUtils; - -function _soit(values: readonly V[]) { - const _set = new Set(values); - - const _values = Array.from(_set); - - function _guard(testedValue: Literal): testedValue is V { - return values.some(o => o === testedValue); - } - - const _arrayUtils: ArrayUtils = { - forEach: (...args) => _values.forEach(...args), - map: (...args) => _values.map(...args), - }; - - const _setUtils: SetUtils = { - subset: subsetValues => _soit(subsetValues), - extend: additionalValues => _soit([..._values, ...additionalValues]), - // we cannot rely on filter and includes typings - difference: differenceValues => - _soit( - _values.filter(value => !differenceValues.includes(value as any)) as any - ), - }; - - const _iterable: Iterable = { - [Symbol.iterator]: () => _set[Symbol.iterator](), - }; - - return Object.assign(_guard, _iterable, _setUtils, _arrayUtils); -} +import { Literal, Primitive, Soit } from './types/core.types'; +import { + PrimitiveTemplate, + SoitTemplate, + TemplateValues, +} from './types/template.types'; +import { _soitCore } from './core'; +import { _soitTemplate } from './template'; /** - * Creates a new `Soit` instance with the given set of values. + * Creates a `Soit` instance with the given set of values. * * ```ts * const is123 = Soit([1, 2, 3]); - * if(is123(value)) { ... } * ``` * * @param values array of Literals * @returns `Soit` instance */ -function Soit>(values: readonly V[]): Soit { - return _soit(values); +function Soit>(values: readonly V[]): Soit { + return _soitCore(values); } -export type Infer = S extends Soit ? V : never; +/** + * Creates a `SoitTemplate` instance with the given template. + * + * ```ts + * const isBorderWidth = Soit.Template(is123, 'px'); + * ``` + * + * The `isBorderWidth` instance will only accept the following values: `1px`, `2px`, `3px`. + * + * @returns `SoitTemplate` instance + */ +function Template>( + ...templateValues: T +): SoitTemplate { + return _soitTemplate(...templateValues); +} + +Object.assign(Soit, { Template }); + +export default Soit as typeof Soit & { Template: typeof Template }; -export default Soit; +export type { Infer } from './types/index.types'; diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..8f063fc --- /dev/null +++ b/src/template.ts @@ -0,0 +1,55 @@ +import { ArrayUtils } from './types/core.types'; +import { + SoitTemplate, + TemplateUtils, + TemplateValues, + ValuesFromTemplate, +} from './types/template.types'; +import { generateValuesFromTemplate } from './utils/generateValuesFromTemplate'; +import { getRegExpFromValues } from './utils/getRegExpFromValues'; +import { isSoit } from './utils/isSoit'; + +export function _soitTemplate( + ...template: T +): SoitTemplate { + const _set = new Set>( + generateValuesFromTemplate(template) + ); + + const _values = Array.from(_set); + + function _guard(testedValue: string): testedValue is ValuesFromTemplate { + return getRegExpFromValues(template).test(testedValue); + } + + const _arrayUtils: ArrayUtils> = { + forEach: (...args) => _values.forEach(...args), + map: (...args) => _values.map(...args), + }; + + const _templateUtils: TemplateUtils = { + capture: (testedValue: string) => { + const result = getRegExpFromValues(template).exec(testedValue); + + if (result === null) { + return null; + } + + const captureGroupCount = template.filter(isSoit).length; + + let captures: any = []; + + for (let i = 1; i <= captureGroupCount; i++) { + captures.push(result[i]); + } + + return captures; + }, + }; + + const _iterable: Iterable> = { + [Symbol.iterator]: () => _set[Symbol.iterator](), + }; + + return Object.assign(_guard, _iterable, _arrayUtils, _templateUtils); +} diff --git a/src/types/__tests__/core.test-types.ts b/src/types/__tests__/core.test-types.ts new file mode 100644 index 0000000..619eb66 --- /dev/null +++ b/src/types/__tests__/core.test-types.ts @@ -0,0 +1,36 @@ +import { TypeEqual, expectType } from 'ts-expect'; +import { Primitive } from '../core.types'; + +/** + * Primitive + */ +/* should return never if unknown string */ +{ + type Result = Primitive; + expectType>(true); +} +/* should return never if unknown number */ +{ + type Result = Primitive; + expectType>(true); +} +/* should return never if unknown boolean */ +{ + type Result = Primitive; + expectType>(true); +} +/* should return unknown if defined string */ +{ + type Result = Primitive<'abc'>; + expectType>(true); +} +/* should return unknown if defined number */ +{ + type Result = Primitive<123>; + expectType>(true); +} +/* should return unknown if defined boolean */ +{ + type Result = Primitive; + expectType>(true); +} diff --git a/src/types/__tests__/index.test-types.ts b/src/types/__tests__/index.test-types.ts new file mode 100644 index 0000000..352f360 --- /dev/null +++ b/src/types/__tests__/index.test-types.ts @@ -0,0 +1,27 @@ +import { TypeEqual, expectType } from 'ts-expect'; +import Soit, { Infer } from '../..'; + +/* Infer generic should work for both Soit and SoitTemplate */ +{ + // GIVEN + const isDigit = Soit([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + const template = Soit.Template(isDigit, '-', isDigit, '-', isDigit); + + // WHEN + type Digit = Infer; + type Template = Infer; + + //THEN + expectType>(true); +} +/* Infer generic shoud return never if not Soit or SoitTemplate */ +{ + // GIVEN + const object = {}; + + // WHEN + type Object = Infer; + + // THEN + expectType>(true); +} diff --git a/src/types/__tests__/template.test-types.ts b/src/types/__tests__/template.test-types.ts new file mode 100644 index 0000000..2b6b8d9 --- /dev/null +++ b/src/types/__tests__/template.test-types.ts @@ -0,0 +1,263 @@ +import { TypeEqual, TypeOf, expectType } from 'ts-expect'; +import { Soit } from '../core.types'; +import { + ValuesFromTemplate, + CapturedGroupsFromTemplate, + PrimitiveTemplate, + CaptureGroupsFromTemplate, + NextGroups, +} from '../template.types'; +import { _soitTemplate } from '../../template'; + +/** + * PrimitiveTemplate + */ +/* should return never if contains unknown string */ +{ + expectType, never>>(true); + expectType, never>>(true); + expectType, never>>(true); + expectType, never>>(true); + expectType< + TypeEqual< + PrimitiveTemplate<[Soit<'a' | 'b' | 'c'>, string, Soit<1 | 2 | 3>]>, + never + > + >(true); +} +/* should return never if contains unknown number */ +{ + expectType, never>>(true); + expectType, never>>(true); + expectType, never>>(true); + expectType, never>>(true); + expectType< + TypeEqual< + PrimitiveTemplate<[Soit<'a' | 'b' | 'c'>, number, Soit<1 | 2 | 3>]>, + never + > + >(true); +} +/* should return never if contains unknown boolean */ +{ + expectType, never>>(true); + expectType, never>>(true); + expectType, never>>(true); + expectType, never>>(true); + expectType< + TypeEqual< + PrimitiveTemplate<[Soit<'a' | 'b' | 'c'>, boolean, Soit<1 | 2 | 3>]>, + never + > + >(true); +} +/* should return unknown if primitive */ +{ + expectType, unknown>>(true); + expectType, unknown>>(true); + expectType, unknown>>(true); + expectType, unknown>>(true); +} + +/** + * ValuesFromTemplate + */ +/* should return a union with all possible values from template (no Soit) */ +{ + type Result = ValuesFromTemplate<['simple', 'string', 'template']>; + expectType>(true); +} +/* should return a union with all possible values from template (one Soit) */ +{ + type Result = ValuesFromTemplate< + [Soit<'uncertain' | 'variable'>, 'string', 'template'] + >; + expectType< + TypeEqual + >(true); +} +/* should return a union with all possible values from template (many Soit) */ +{ + type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + type Result = ValuesFromTemplate< + [Soit, '-', Soit, '-', Soit] + >; + expectType>(true); +} + +/** + * NextGroups + */ +/* should capture the first value */ +{ + type Result = NextGroups< + Soit<'uncertain' | 'variable'>, + 'uncertainstringtemplate', + '', + [], + true + >; + expectType>(true); +} +/* should capture the first value (number) */ +{ + type Result = NextGroups, '100px', '', [], true>; + expectType>(true); +} +/* should capture the first value (boolean) */ +{ + type Result = NextGroups, 'true!', '', [], true>; + expectType>(true); +} +/* should not capture partial value if no rest expected */ +{ + type Result = NextGroups< + Soit<'uncertain' | 'variable'>, + 'uncertainstringtemplate', + '', + [] + >; + expectType>(true); +} +/* should capture whole value if no rest expected */ +{ + type Result = NextGroups, 'uncertain', '', []>; + expectType>(true); +} +/* should capture last part if no rest expected */ +{ + type Result = NextGroups, '100em', '100', []>; + expectType>(true); +} +/* should capture return null if capture group dont match */ +{ + type Result = NextGroups< + Soit<'uncertain' | 'variable'>, + 'unknownstringtemplate', + '', + [], + true + >; + expectType>(true); +} +/* should accumulate captured values */ +{ + type Result = NextGroups, '100px', '100', ['100'], true>; + expectType>(true); +} +/* should keep previous captured values */ +{ + type Result = NextGroups< + 'string', + 'uncertainstringtemplate', + 'uncertain', + ['uncertain'], + true + >; + expectType>(true); +} +/* should keep previous captured values (with no rest) */ +{ + type Result = NextGroups< + 'template', + 'uncertainstringtemplate', + 'uncertainstring', + ['uncertain'] + >; + expectType>(true); +} +/* should loose previous captured values if no match */ +{ + type Result = NextGroups< + 'string', + 'uncertainstringtemplate', + 'uncertain', + ['uncertain'] + >; + expectType>(true); +} + +/** + * CaptureGroupsFromTemplate + */ +/* should return empty capture tuple if no Soit in template */ +{ + // GIVEN + type TemplateValues = ['simple', 'string', 'template']; + + // WHEN + type Result = CaptureGroupsFromTemplate; + + // THEN + expectType>(true); +} +/* should return capture tuple with one value if one Soit in template */ +{ + // GIVEN + type TemplateValues = [Soit<'uncertain' | 'variable'>, 'string', 'template']; + + // WHEN + type Result = CaptureGroupsFromTemplate; + + // THEN + expectType>(true); +} +/* should return capture tuple with two values if two Soit in template */ +{ + // GIVEN + type TemplateValues = [Soit, '-', Soit<1 | 2 | 3>]; + + // WHEN + type Result = CaptureGroupsFromTemplate; + + // THEN + expectType>(true); + expectType>(true); +} + +/** + * CapturedGroupsFromTemplate + */ +/* should return empty capture tuple if no Soit in template */ +{ + // GIVEN + type TemplateValues = ['simple', 'string', 'template']; + + // WHEN + type Result = CapturedGroupsFromTemplate< + TemplateValues, + ValuesFromTemplate + >; + + // THEN + expectType>(true); +} +/* should return capture tuple with one value if one Soit in template */ +{ + // GIVEN + type TemplateValues = [Soit<'uncertain' | 'variable'>, 'string', 'template']; + + // WHEN + type Result = CapturedGroupsFromTemplate< + TemplateValues, + ValuesFromTemplate + >; + + // THEN + expectType>(true); +} +/* should return capture tuple with two values if two Soit in template */ +{ + // GIVEN + type TemplateValues = [Soit, '-', Soit<1 | 2 | 3>]; + + // WHEN + type Result = CapturedGroupsFromTemplate< + TemplateValues, + ValuesFromTemplate + >; + + // THEN + expectType>(true); + expectType>(true); +} diff --git a/src/types/core.types.ts b/src/types/core.types.ts new file mode 100644 index 0000000..db3caf9 --- /dev/null +++ b/src/types/core.types.ts @@ -0,0 +1,60 @@ +export type Literal = string | number | boolean; + +/* Ensures that the input type is a finite primitive. */ +export type Primitive = string extends T + ? never + : number extends T + ? never + : boolean extends T + ? never + : unknown; + +export type Guard = { + /** + * Uses the `Soit` instance as a type guard: + * ```ts + * const is123 = Soit([1, 2, 3]); + * if(is123(value)) { ... } + * ``` + * + * @param {Literal} value + * @returns {boolean} true or false + */ + (testedValue: Literal): testedValue is V; +}; + +export type ArrayUtils = Pick, 'forEach' | 'map'>; + +export type SetUtils = { + /** + * Creates a subset of the current set. + * + * @param values (array of Literals) + * @returns `Soit` instance + */ + subset: >(subsetValues: readonly S[]) => Soit; + /** + * Creates a extended set from the current set. + * + * @param values (array of Literals) + * @returns `Soit` instance + */ + extend: >( + additionalValues: readonly A[] + ) => Soit; + /** + * Creates a new set from the current set by excluding values. + * + * @param values (array of Literals) + * @returns `Soit` instance + */ + difference: >( + differenceValues: readonly D[] + ) => Soit>; +}; + +/* This type aims to display an alias when manipulating a Soit instance. */ +export type Soit = Guard & + Iterable & + ArrayUtils & + SetUtils; diff --git a/src/types/index.types.ts b/src/types/index.types.ts new file mode 100644 index 0000000..7795814 --- /dev/null +++ b/src/types/index.types.ts @@ -0,0 +1,9 @@ +import { Soit } from './core.types'; +import { SoitTemplate, ValuesFromTemplate } from './template.types'; + +export type Infer = // TODO: constraint S to Soit or SoitTemplate + S extends Soit + ? V + : S extends SoitTemplate + ? ValuesFromTemplate + : never; diff --git a/src/types/template.types.ts b/src/types/template.types.ts new file mode 100644 index 0000000..065fdbb --- /dev/null +++ b/src/types/template.types.ts @@ -0,0 +1,127 @@ +import { ArrayUtils, Literal, Primitive, Soit } from './core.types'; + +/* Represents a part/chunk of a template. */ +export type TemplateValue = Literal | Soit; + +/* Represents the template. */ +export type TemplateValues = readonly [TemplateValue, ...TemplateValue[]]; + +/* Ensures that all parts/chunks of a template is a finite primitive. */ +export type PrimitiveTemplate = + T[number] extends Soit | Primitive ? unknown : never; + +/* Converts a template value to be used in a template literal. */ +type TemplateValueToString = V extends Soit + ? LiteralToString + : LiteralToString; + +/* Converts a literal into a string. */ +type LiteralToString = L extends Literal + ? `${L}` + : never; + +/* Get the union of all possible values from a template. */ +export type ValuesFromTemplate< + T extends TemplateValues, + Acc extends string = '', +> = T extends [ + infer T0 extends Literal | Soit, + ...infer TRest extends TemplateValues, +] + ? ValuesFromTemplate}`> + : `${Acc}${TemplateValueToString}`; + +/* Get all the possible capture groups from a template. */ +export type CaptureGroupsFromTemplate< + T extends TemplateValues, + Acc extends string[] = [], +> = T extends [ + infer T0 extends Literal | Soit, + ...infer TRest extends TemplateValues, +] + ? CaptureGroupsFromTemplate< + TRest, + T0 extends Soit ? [...Acc, LiteralToString] : Acc + > + : T[0] extends Soit + ? [...Acc, LiteralToString] | null + : Acc | null; + +type RestToken = WithRest extends true ? string : ''; + +/* Get the accumulated capture groups for the current recursive loop. */ +export type NextGroups< + TChunk extends TemplateValue, + TestedValues extends string, + AccValues extends string, + AccGroups extends string[], + WithRest extends boolean = false, +> = TChunk extends Soit + ? TestedValues extends `${AccValues}${TargetValues}${infer Rest extends + RestToken}` + ? TestedValues extends `${AccValues}${infer Group}${Rest}` + ? [...AccGroups, Group] + : null + : null + : TestedValues extends `${AccValues}${LiteralToString}${RestToken}` + ? AccGroups + : null; + +/* Get the expected capture groups for the tested value. */ +export type CapturedGroupsFromTemplate< + T extends TemplateValues, + TestedValues extends string, + AccValues extends string = '', + AccGroups extends string[] | null = [], +> = string extends TestedValues + ? CaptureGroupsFromTemplate // if the tested value is a string, return all possible capture groups + : AccGroups extends unknown[] + ? T extends [ + infer T0 extends Literal | Soit, + ...infer TRest extends TemplateValues, + ] + ? CapturedGroupsFromTemplate< + TRest, + TestedValues, + `${AccValues}${TemplateValueToString}`, + NextGroups + > + : NextGroups + : null; + +export type TemplateUtils = { + /** + * Uses the `SoitTemplate` instance to capture the values from a string: + * ```ts + * const isGetter = Soit.Template('get', Soit(['Seconds', 'Minutes', 'Hours'])); + * const [unit] = isGetter.capture('getSeconds'); + * ``` + * + * @param {string} value + * @returns {array | null} array of captured values or null + */ + capture: ( + testedValue: V + ) => CapturedGroupsFromTemplate; +}; + +export type TemplateGuard = { + /** + * Uses the `SoitTemplate` instance as a type guard: + * ```ts + * const isGetter = Soit.Template('get', Soit(['Seconds', 'Minutes', 'Hours'])); + * if(isGetter(method)) { ... } + * ``` + * + * @param {string} value + * @returns {boolean} true or false + */ + (testedValue: string): testedValue is V; +}; + +export type SoitTemplate = TemplateGuard< + ValuesFromTemplate +> & + Iterable> & + ArrayUtils> & + TemplateUtils; diff --git a/src/utils/__tests__/escapeRegExp.test.ts b/src/utils/__tests__/escapeRegExp.test.ts new file mode 100644 index 0000000..d0309c0 --- /dev/null +++ b/src/utils/__tests__/escapeRegExp.test.ts @@ -0,0 +1,24 @@ +import { escapeRegExp } from '../escapeRegExp'; + +describe('escapeRegExp', () => { + it('should escape special characters to be used in a regular expression', () => { + expect(escapeRegExp('a')).toBe('a'); + expect(escapeRegExp('a.b')).toBe('a\\.b'); + expect(escapeRegExp('a-b')).toBe('a\\-b'); + expect(escapeRegExp('a^b')).toBe('a\\^b'); + expect(escapeRegExp('a$b')).toBe('a\\$b'); + expect(escapeRegExp('a*b')).toBe('a\\*b'); + expect(escapeRegExp('a+b')).toBe('a\\+b'); + expect(escapeRegExp('a?b')).toBe('a\\?b'); + expect(escapeRegExp('a.b')).toBe('a\\.b'); + expect(escapeRegExp('a(b')).toBe('a\\(b'); + expect(escapeRegExp('a)b')).toBe('a\\)b'); + expect(escapeRegExp('a|b')).toBe('a\\|b'); + expect(escapeRegExp('a[b')).toBe('a\\[b'); + expect(escapeRegExp('a]b')).toBe('a\\]b'); + expect(escapeRegExp('a{b')).toBe('a\\{b'); + expect(escapeRegExp('a}b')).toBe('a\\}b'); + expect(escapeRegExp('a/b')).toBe('a\\/b'); + expect(escapeRegExp('a\\b')).toBe('a\\\\b'); + }); +}); diff --git a/src/utils/__tests__/generateValuesFromTemplate.test.ts b/src/utils/__tests__/generateValuesFromTemplate.test.ts new file mode 100644 index 0000000..ba87263 --- /dev/null +++ b/src/utils/__tests__/generateValuesFromTemplate.test.ts @@ -0,0 +1,28 @@ +import { _soitCore } from '../../core'; +import { generateValuesFromTemplate } from '../generateValuesFromTemplate'; + +describe('generateValuesFromTemplate', () => { + it('should generate values from a simple template', () => { + const template = ['a', 'b', 'c'] as any; + const values = generateValuesFromTemplate(template); + expect(values).toEqual(['abc']); + }); + it('should generate values from a dynamic template', () => { + const template = [ + _soitCore([1, 2, 3]), + _soitCore(['a', 'b', 'c']), + ] as const; + const values = generateValuesFromTemplate(template); + expect(values).toEqual([ + '1a', + '1b', + '1c', + '2a', + '2b', + '2c', + '3a', + '3b', + '3c', + ]); + }); +}); diff --git a/src/utils/__tests__/getRegExpFromValues.test.ts b/src/utils/__tests__/getRegExpFromValues.test.ts new file mode 100644 index 0000000..2ab1111 --- /dev/null +++ b/src/utils/__tests__/getRegExpFromValues.test.ts @@ -0,0 +1,30 @@ +import { _soitCore } from '../../core'; +import { getRegExpFromValues } from '../getRegExpFromValues'; + +describe('getRegExpFromValues', () => { + it('should generate a regex from a simple template', () => { + const template = ['a', 'b', 'c'] as const; + const regExp = getRegExpFromValues(template); + expect(regExp.test('abc')).toBe(true); + expect(regExp.test('abcd')).toBe(false); + }); + it('should generate a regex from a dynamic template', () => { + const template = [ + _soitCore(['1', '2', '3']), + _soitCore(['a', 'b', 'c']), + ] as const; + const regExp = getRegExpFromValues(template); + expect(regExp.test('1a')).toBe(true); + expect(regExp.test('1b')).toBe(true); + expect(regExp.test('1c')).toBe(true); + expect(regExp.test('2a')).toBe(true); + expect(regExp.test('2b')).toBe(true); + expect(regExp.test('2c')).toBe(true); + expect(regExp.test('3a')).toBe(true); + expect(regExp.test('3b')).toBe(true); + expect(regExp.test('3c')).toBe(true); + expect(regExp.test('4a')).toBe(false); + expect(regExp.test('4b')).toBe(false); + expect(regExp.test('4c')).toBe(false); + }); +}); diff --git a/src/utils/__tests__/isSoit.test.ts b/src/utils/__tests__/isSoit.test.ts new file mode 100644 index 0000000..a9174d3 --- /dev/null +++ b/src/utils/__tests__/isSoit.test.ts @@ -0,0 +1,17 @@ +import { SOIT_SYMBOL } from '../../constants'; +import { isSoit } from '../isSoit'; + +describe('isSoit', () => { + it('should return true if the value is a Soit instance', () => { + const soit = () => {}; + soit[SOIT_SYMBOL] = true; + expect(isSoit(soit as any)).toBe(true); + }); + + it('should return false if the value is not a Soit instance', () => { + expect(isSoit('a')).toBe(false); + expect(isSoit(0)).toBe(false); + expect(isSoit(true)).toBe(false); + expect(isSoit(false)).toBe(false); + }); +}); diff --git a/src/utils/__tests__/toCaptureGroup.test.ts b/src/utils/__tests__/toCaptureGroup.test.ts new file mode 100644 index 0000000..125bcd3 --- /dev/null +++ b/src/utils/__tests__/toCaptureGroup.test.ts @@ -0,0 +1,9 @@ +import { _soitCore } from '../../core'; +import { toCaptureGroup } from '../toCaptureGroup'; + +describe('toCaptureGroup', () => { + it('should return a string with a capture group', () => { + const value = _soitCore(['a', 'b', 'c']); + expect(toCaptureGroup(value)).toBe('(a|b|c)'); + }); +}); diff --git a/src/utils/escapeRegExp.ts b/src/utils/escapeRegExp.ts new file mode 100644 index 0000000..725ad3a --- /dev/null +++ b/src/utils/escapeRegExp.ts @@ -0,0 +1,5 @@ +import { Literal } from '../types/core.types'; + +export function escapeRegExp(literal: Literal) { + return String(literal).replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); +} diff --git a/src/utils/generateValuesFromTemplate.ts b/src/utils/generateValuesFromTemplate.ts new file mode 100644 index 0000000..01652bd --- /dev/null +++ b/src/utils/generateValuesFromTemplate.ts @@ -0,0 +1,40 @@ +import { isSoit } from './isSoit'; +import { + TemplateValue, + TemplateValues, + ValuesFromTemplate, +} from '../types/template.types'; + +function hasRestTemplate( + template: readonly TemplateValue[] +): template is TemplateValues { + return template.length > 0; +} + +export function generateValuesFromTemplate( + template: T, + acc: string +): string[]; + +export function generateValuesFromTemplate( + template: T +): ValuesFromTemplate[]; + +export function generateValuesFromTemplate< + T extends TemplateValues, + A extends string, +>(template: T, acc: string = ''): any[] { + const [t0, ...restTemplate] = template; + if (!hasRestTemplate(restTemplate)) { + if (isSoit(t0)) { + return [...t0].map(value => `${acc}${value}`); + } + return [`${acc}${t0}`]; + } + if (isSoit(t0)) { + return [...t0].flatMap(value => + generateValuesFromTemplate(restTemplate, `${acc}${value}`) + ); + } + return generateValuesFromTemplate(restTemplate, `${acc}${t0}`); +} diff --git a/src/utils/getRegExpFromValues.ts b/src/utils/getRegExpFromValues.ts new file mode 100644 index 0000000..61aed00 --- /dev/null +++ b/src/utils/getRegExpFromValues.ts @@ -0,0 +1,17 @@ +import { isSoit } from './isSoit'; +import { escapeRegExp } from './escapeRegExp'; +import { toCaptureGroup } from './toCaptureGroup'; +import { TemplateValues } from '../types/template.types'; + +export function getRegExpFromValues(templateValues: TemplateValues) { + return new RegExp( + `^${templateValues + .map(value => { + if (isSoit(value)) { + return toCaptureGroup(value); + } + return escapeRegExp(value); + }) + .join('')}$` + ); +} diff --git a/src/utils/isSoit.ts b/src/utils/isSoit.ts new file mode 100644 index 0000000..3152328 --- /dev/null +++ b/src/utils/isSoit.ts @@ -0,0 +1,6 @@ +import { SOIT_SYMBOL } from '../constants'; +import { Literal, Soit } from '../types/core.types'; + +export function isSoit(value: Literal | Soit): value is Soit { + return typeof value === 'function' && value.hasOwnProperty(SOIT_SYMBOL); +} diff --git a/src/utils/toCaptureGroup.ts b/src/utils/toCaptureGroup.ts new file mode 100644 index 0000000..f914141 --- /dev/null +++ b/src/utils/toCaptureGroup.ts @@ -0,0 +1,6 @@ +import { Soit } from '../types/core.types'; +import { escapeRegExp } from './escapeRegExp'; + +export function toCaptureGroup(value: Soit): string { + return `(${value.map(escapeRegExp).join('|')})`; +} diff --git a/tsconfig.json b/tsconfig.json index 76745ff..aa1eecc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -64,5 +64,5 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "**/*.test.ts"] + "exclude": ["node_modules", "**/*.test.ts", "**/*.test-types.ts"] }