├── .gitignore ├── ARCHITECTURE.md ├── LICENSE ├── README.md ├── bench ├── README.md ├── basic.ts ├── select.ts └── util.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── codegen.ts ├── hir.ts ├── index.ts ├── pattycake.ts └── plugin.ts ├── test └── plugin.test.ts ├── tsconfig.json └── tsup.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | The compiler has three primary steps: 4 | 5 | 1. Determine is `match` is imported from `ts-pattern` and the name the user assigns to it 6 | 2. HIR step: Visit each CallExpression, and try to convert it to a high-level intermediate representation (HIR) 7 | 3. Codegen step: Codegen the HIR back into JS/TS 8 | 9 | ## HIR step 10 | 11 | We create a high-level intermediate representation of the source's AST. The HIR is friendlier to work with when executing the codegen step, because it clearly encodes information important for codegen like pattern match branches, etc. 12 | 13 | Here is a rough example of what it looks like: 14 | 15 | ```typescript 16 | export type PatternMatch = { 17 | // the expression being matched uppon 18 | expr: Expr; 19 | branches: Array; 20 | otherwise: Expr | undefined; 21 | exhaustive: boolean; 22 | }; 23 | 24 | export type Branch = { 25 | patterns: Array; 26 | guard: FnExpr | undefined; 27 | then: b.Expression; 28 | }; 29 | ``` 30 | 31 | This is far more amenable to work with than Babel's normal JS/TS AST, which would represent a ts-pattern match as an extremely unwieldly nested tree of CallExpressions and MemberExpressions (see [here](https://astexplorer.net/#/gist/9822811045bb4224053b95da23fbd0fd/7b8f40e7269cc22a8f470691303ee8b3c7cb8de9) for an example). 32 | 33 | ## Codegen 34 | 35 | The codegen step is responsible for turning the Hir back into Babel's AST. A simplified overview is that its really just a for-loop over the branches of the Hir. It turns each branch into an if statement. It will also generate code for the `otherwise` case if provided, and also a runtime exhaustiveness check if necessary. 36 | 37 | Normally, it wraps the entire chain of if statements, otherwise case, and exhaustiveness check in an immediately-invoked function expression. But this has a performance cost, so when possible, it will try to avoid this and instead generate a block. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Zack Radisic, Aiden Bai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > ⚠️ Note: this is highly experimental software. Here be dragons 🐉 2 | 3 | # 🎂 pattycake 4 | 5 | **Zero-runtime pattern matching. (~10-12x faster 🔥)** 6 | 7 | Pattycake is an optimizing compiler for [ts-pattern](https://github.com/gvergnaud/ts-pattern) that lets you have your cake (expressive pattern matching), and eat it too (zero runtime overhead). 8 | 9 | ## Install 10 | 11 | ```bash 12 | npm install pattycake 13 | ``` 14 | 15 |
16 | Next.js 17 | 18 | ```js 19 | // next.config.js 20 | const pattycake = require('pattycake'); 21 | 22 | module.exports = pattycake.next({ 23 | // your next.js config 24 | }); 25 | ``` 26 | 27 |
28 | 29 |
30 | Vite 31 | 32 | ```js 33 | // vite.config.js 34 | import { defineConfig } from 'vite'; 35 | import pattycake from 'pattycake'; 36 | 37 | export default defineConfig({ 38 | plugins: [pattycake.vite()], 39 | }); 40 | ``` 41 | 42 |
43 | 44 |
45 | Create React App 46 | 47 | ```js 48 | const pattycake = require('pattycake'); 49 | 50 | module.exports = { 51 | webpack: { 52 | plugins: { add: [pattycake.webpack()] }, 53 | }, 54 | }; 55 | ``` 56 | 57 |
58 | 59 |
60 | Webpack 61 | 62 | ```js 63 | const pattycake = require('pattycake'); 64 | 65 | module.exports = { 66 | plugins: [pattycake.webpack()], 67 | }; 68 | ``` 69 | 70 |
71 | 72 | ## About 73 | 74 | `ts-pattern` is a great library that brings the ergonomics of pattern matching from languages like Rust and OCaml to Typescript, but at the cost of being orders of magnitude slower. 75 | 76 | `pattycake` compiles ts-pattern's `match()` expressions into an optimized chain of if statements to completely eliminate that cost. In our initial benchmarks, it outperforms `ts-pattern` by usually ~10-12x. 77 | 78 | In essence, `pattycake` converts a `ts-pattern` `match()` expression like this: 79 | 80 | ```typescript 81 | let html = match(result) 82 | .with( 83 | { type: 'error', error: { foo: [1, 2] }, nice: '' }, 84 | () => '

Oups! An error occured

', 85 | ) 86 | .with({ type: 'ok', data: { type: 'text' } }, function (data) { 87 | return '

420

'; 88 | }) 89 | .with( 90 | { type: 'ok', data: { type: 'img', src: 'hi' } }, 91 | (src) => ``, 92 | ) 93 | .otherwise(() => 'idk bro'); 94 | ``` 95 | 96 | Into this: 97 | 98 | ```typescript 99 | let html; 100 | out: { 101 | if ( 102 | result.type === 'error' && 103 | Array.isArray(result.error.foo) && 104 | result.error.foo.length >= 2 && 105 | result.error.foo[0] === 1 && 106 | result.error.foo[1] === 2 107 | ) { 108 | html = '

Oups! An error occured

'; 109 | break out; 110 | } 111 | if (result.type === 'ok' && result.data.type === 'text') { 112 | let data = result; 113 | html = '

420

'; 114 | break out; 115 | } 116 | if ( 117 | result.type === 'ok' && 118 | result.data.type === 'img' && 119 | result.data.src === 'hi' 120 | ) { 121 | let src = result; 122 | html = ``; 123 | break out; 124 | } 125 | html = 'idk bro'; 126 | break out; 127 | } 128 | ``` 129 | 130 | ## Feature parity with ts-pattern 131 | 132 | - [x] [Literal patterns](https://github.com/gvergnaud/ts-pattern#literals) 133 | - [x] string 134 | - [x] number 135 | - [x] booleans 136 | - [x] bigint 137 | - [x] undefined 138 | - [x] null 139 | - [x] NaN 140 | - [x] [Object patterns](https://github.com/gvergnaud/ts-pattern#objects) 141 | - [x] [Array/tuples patterns](https://github.com/gvergnaud/ts-pattern#tuples-arrays) 142 | - [ ] `.when()` 143 | - [ ] [Wildcards](https://github.com/gvergnaud/ts-pattern#wildcards) patterns 144 | - [x] `P._` 145 | - [x] `P.string` 146 | - [x] `P.number` 147 | - [ ] Special matcher functions 148 | - [ ] `P.not` 149 | - [ ] `P.when` 150 | - [x] `P.select` 151 | - [ ] `P.array` 152 | - [ ] `P.map` 153 | - [ ] `P.set` 154 | 155 | ## Notes 156 | 157 | ### Fallback / compatibility with `ts-pattern` 158 | 159 | If `pattycake` is unable to optimize a `match()` expression, it will fallback to using `ts-pattern`. This is enabled right now because we don't support the full feature set of ts-pattern. 160 | 161 | ### Inlining handlers 162 | 163 | One performance problem of `ts-pattern`'s are handler functions: 164 | 165 | ```typescript 166 | match(foo) 167 | .with({ foo: 'bar' }, () => /* this is a handler function */) 168 | .with({ foo: 'baz' }, () => /* another one */) 169 | ``` 170 | 171 | Function calls usually have an overhead, and a lot of the time these handlers are small little functions (e.g. `(result) => result + 1`) which can be much faster if just directly inlined in the code. 172 | 173 | Additionally, a `match()` with many branches means creating a lot of function objects in the runtime. 174 | 175 | The JIT-compiler and optimizer in JS engines can do inlining of functions, but in general with JIT you need to run your code several times or it to determine what to optimize. 176 | 177 | So when possible, `pattycake` will try to inline function expression (anonymous functions / arrow functions) handlers directly into the code if it is small. 178 | 179 | ### IIFEs 180 | 181 | When possible, `pattycake` will try to generate a block of code (like in the example above). But there are times where this is not possible without breaking the semantics of source code. 182 | 183 | ## Roadmap 184 | 185 | Right now, the goal is to support the full feature set of ts-pattern, or at least a sufficient amount. After, the ideal is 186 | that we compile pattern matching expressions into code that would be faster than what you would write by hand. 187 | -------------------------------------------------------------------------------- /bench/README.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | 3 | The compiled code is generated and copy pasted into the source, no build system for benchmarks yet: `¯\_(ツ)\_/¯` 4 | 5 | To run the benchmarks use Bun: 6 | 7 | ```bash 8 | bun run ./basic.ts 9 | bun run ./select.ts 10 | ``` 11 | -------------------------------------------------------------------------------- /bench/basic.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { match, P } from 'ts-pattern'; 3 | import type { Result } from './util'; 4 | import { benchmark, generateRandomData, printBenchmarkStats } from './util'; 5 | 6 | function tspattern(result: Result) { 7 | const html = match(result) 8 | .with( 9 | { type: 'error', error: { foo: [1, 2] }, nice: '' }, 10 | () => '

Oups! An error occured

', 11 | ) 12 | .with({ type: 'ok', data: { type: 'text' } }, function (_) { 13 | return '

420

'; 14 | }) 15 | .with( 16 | { type: 'ok', data: { type: 'img', src: P.select() } }, 17 | (src) => ``, 18 | ) 19 | .otherwise(() => 'idk bro'); 20 | return html; 21 | } 22 | 23 | function pattycake(result: Result) { 24 | let html; 25 | __patsy_temp_0: { 26 | if ( 27 | result?.type === 'error' && 28 | Array.isArray(result?.error?.foo) && 29 | result?.error?.foo?.length >= 2 && 30 | result?.error?.foo[0] === 1 && 31 | result?.error?.foo[1] === 2 32 | ) { 33 | html = '

Oups! An error occured

'; 34 | break __patsy_temp_0; 35 | } 36 | if (result?.type === 'ok' && result?.data?.type === 'text') { 37 | let _ = result; 38 | html = '

420

'; 39 | break __patsy_temp_0; 40 | } 41 | if ( 42 | result?.type === 'ok' && 43 | result?.data?.type === 'img' && 44 | result?.data?.src === 'hi' 45 | ) { 46 | let src = result; 47 | html = ``; 48 | break __patsy_temp_0; 49 | } 50 | html = 'idk bro'; 51 | break __patsy_temp_0; 52 | } 53 | return html; 54 | } 55 | 56 | const iterCount = 1000; 57 | 58 | console.log( 59 | chalk.bold('Running benchmark'), 60 | `(${chalk.italic(iterCount)} iterations)\n`, 61 | ); 62 | 63 | const [pattycakeIters, pattycakeTotal] = benchmark( 64 | (results) => results.map(pattycake), 65 | () => generateRandomData(100000), 66 | iterCount, 67 | ); 68 | const [tspatIters, tspatTotal] = benchmark( 69 | (results) => results.map(tspattern), 70 | () => generateRandomData(100000), 71 | iterCount, 72 | ); 73 | 74 | printBenchmarkStats('pattycake', pattycakeIters, pattycakeTotal); 75 | printBenchmarkStats('ts-pattern', tspatIters, tspatTotal); 76 | 77 | console.log(chalk.bold('Summary')); 78 | console.log( 79 | ` '${chalk.blue('pattycake')}' ran ${chalk.bold.green( 80 | (tspatTotal / pattycakeTotal).toFixed(4), 81 | )} times faster than '${chalk.red('ts-pattern')}'`, 82 | ); 83 | 84 | export {}; 85 | -------------------------------------------------------------------------------- /bench/select.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { generateHeapSnapshot } from 'bun'; 3 | import { match, P } from 'ts-pattern'; 4 | import type { Result } from './util'; 5 | import { heapStats } from 'bun:jsc'; 6 | import { benchmark, generateRandomData, printBenchmarkStats } from './util'; 7 | 8 | function tspattern(result: Result) { 9 | const html = match(result) 10 | .with( 11 | { type: 'error', error: { foo: P.select('foo') }, nice: P.select('hi') }, 12 | ({ foo, hi }) => `

${foo} ${hi}

`, 13 | ) 14 | .with( 15 | { type: 'ok', data: { type: 'text', content: P.select() } }, 16 | function (content) { 17 | return `

${content}

`; 18 | }, 19 | ) 20 | .with( 21 | { type: 'ok', data: { type: 'img', src: P.select() } }, 22 | (src) => ``, 23 | ) 24 | .otherwise(() => 'idk bro'); 25 | return html; 26 | } 27 | 28 | function pattycake(result: Result) { 29 | let html; 30 | __patsy_temp_0: { 31 | if (result?.type === 'error') { 32 | let sel = { 33 | ['foo']: result?.error?.foo, 34 | ['hi']: result?.nice, 35 | }; 36 | html = `

$sel.foo} ${sel.hi}

`; 37 | break __patsy_temp_0; 38 | } 39 | if (result?.type === 'ok' && result?.data?.type === 'text') { 40 | let content = result?.data?.content; 41 | html = `

${content}

`; 42 | break __patsy_temp_0; 43 | } 44 | if (result?.type === 'ok' && result?.data?.type === 'img') { 45 | let src = result?.data?.src; 46 | html = ``; 47 | break __patsy_temp_0; 48 | } 49 | html = (() => 'idk bro')(result); 50 | break __patsy_temp_0; 51 | } 52 | } 53 | 54 | const iterCount = 1000; 55 | 56 | console.log( 57 | chalk.bold('Running benchmark'), 58 | `(${chalk.italic(iterCount)} iterations)\n`, 59 | ); 60 | 61 | const [pattycakeIters, pattycakeTotal] = benchmark( 62 | (results) => results.map(pattycake), 63 | () => generateRandomData(10000), 64 | iterCount, 65 | ); 66 | const [tspatIters, tspatTotal] = benchmark( 67 | (results) => results.map(tspattern), 68 | () => generateRandomData(10000), 69 | iterCount, 70 | ); 71 | 72 | printBenchmarkStats('pattycake', pattycakeIters, pattycakeTotal); 73 | printBenchmarkStats('ts-pattern', tspatIters, tspatTotal); 74 | 75 | console.log(chalk.bold('Summary')); 76 | console.log( 77 | ` '${chalk.blue('pattycake')}' ran ${chalk.bold.green( 78 | (tspatTotal / pattycakeTotal).toFixed(4), 79 | )} times faster than '${chalk.red('ts-pattern')}'`, 80 | ); 81 | 82 | export {}; 83 | -------------------------------------------------------------------------------- /bench/util.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export type Data = 4 | | { type: 'text'; content: string } 5 | | { type: 'img'; src: string }; 6 | 7 | export type Error = { 8 | foo: Array; 9 | }; 10 | 11 | export type Result = 12 | | { type: 'ok'; data: Data } 13 | | { type: 'error'; error: Error }; 14 | 15 | export function generateRandomData(amount: number): Array { 16 | const results: Array = []; 17 | 18 | for (let i = 0; i < amount; i++) { 19 | // Randomly choose between 'ok' and 'error' 20 | const isOk = Math.random() > 0.5; 21 | 22 | if (isOk) { 23 | // Further choose between 'text' and 'img' 24 | const isText = Math.random() > 0.5; 25 | const data: Data = isText 26 | ? { type: 'text', content: `Random Text ${i}` } 27 | : { type: 'img', src: `http://example.com/image${i}.jpg` }; 28 | 29 | results.push({ type: 'ok', data }); 30 | } else { 31 | // Generate a random array of numbers for the 'foo' field in Error 32 | // with a random length between 1 and 10 33 | const randomLength = Math.floor(Math.random() * 10) + 1; 34 | const randomArray = Array.from({ length: randomLength }, () => 35 | Math.floor(Math.random() * 100), 36 | ); 37 | 38 | results.push({ type: 'error', error: { foo: randomArray } }); 39 | } 40 | } 41 | 42 | return results; 43 | } 44 | 45 | // Benchmark function 46 | export function benchmark( 47 | fn: (input: Result[]) => void, 48 | data: () => Array, 49 | iterAmount: number, 50 | ): [iterations: number[], elapsed: number] { 51 | const iterations: number[] = []; 52 | const totalStart = performance.now(); 53 | 54 | for (let i = 0; i < iterAmount; i++) { 55 | const d = data(); 56 | const start = performance.now(); 57 | fn(d); 58 | const end = performance.now(); 59 | iterations.push(end - start); 60 | } 61 | 62 | return [iterations, performance.now() - totalStart]; 63 | } 64 | 65 | export function printBenchmarkStats( 66 | label: string, 67 | iterations: number[], 68 | elapsed: number, 69 | ) { 70 | // Calculate mean 71 | const mean = 72 | iterations.reduce((sum, val) => sum + val, 0) / iterations.length; 73 | 74 | // Calculate standard deviation 75 | const variance = 76 | iterations.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / 77 | iterations.length; 78 | const stdDev = Math.sqrt(variance); 79 | 80 | // Calculate min and max 81 | const min = Math.min(...iterations); 82 | const max = Math.max(...iterations); 83 | 84 | const fixAmount = 5; 85 | 86 | // Print stats 87 | console.log(chalk.bold('Benchmark:'), `${chalk.italic(label)}`); 88 | console.log( 89 | ` Time (${chalk.bold.green('mean')} ± ${chalk.bold.green( 90 | 'σ', 91 | )}): ${chalk.bold.green( 92 | mean.toFixed(fixAmount), 93 | )} ms ± ${chalk.bold.green( 94 | stdDev.toFixed(fixAmount), 95 | )} ms (per iteration)`, 96 | ); 97 | console.log( 98 | ` Range (${chalk.cyan('min')} … ${chalk.magenta('max')}): ${chalk.cyan( 99 | min.toFixed(fixAmount), 100 | )} ms … ${chalk.magenta(max.toFixed(fixAmount))} ms ${ 101 | iterations.length 102 | } runs`, 103 | ); 104 | console.log( 105 | ` Total: ${chalk.bold.green( 106 | elapsed.toFixed(fixAmount), 107 | )} ms`, 108 | ); 109 | console.log(); 110 | } 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pattycake", 3 | "version": "0.0.2", 4 | "description": "Zero-runtime pattern matching", 5 | "keywords": [ 6 | "pattern", 7 | "matching", 8 | "pattern-matching", 9 | "typescript", 10 | "match-with", 11 | "match", 12 | "switch", 13 | "adt" 14 | ], 15 | "homepage": "https://github.com/aidenybai/pattycake#readme", 16 | "bugs": { 17 | "url": "https://github.com/aidenybai/pattycake/issues" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+ssh://git@github.com/aidenybai/pattycake.git" 22 | }, 23 | "funding": "https://github.com/sponsors/aidenybai", 24 | "license": "MIT", 25 | "author": "Zack Radisic, Aiden Bai", 26 | "exports": { 27 | ".": { 28 | "import": "./dist/index.js", 29 | "require": "./dist/index.js", 30 | "types": "./dist/index.d.ts" 31 | }, 32 | "./package.json": "./package.json" 33 | }, 34 | "main": "dist/index.js", 35 | "module": "dist/index.js", 36 | "types": "dist/index.d.ts", 37 | "files": [ 38 | "dist", 39 | "README.md", 40 | "LICENSE" 41 | ], 42 | "scripts": { 43 | "build": "tsup", 44 | "bump": "pnpm run build && pnpx bumpp && npm publish", 45 | "test": "vitest --globals" 46 | }, 47 | "prettier": "@vercel/style-guide/prettier", 48 | "dependencies": { 49 | "@babel/core": "^7.22.20", 50 | "@babel/plugin-syntax-jsx": "^7.22.5", 51 | "@babel/plugin-syntax-typescript": "^7.22.5", 52 | "@babel/types": "^7.22.19", 53 | "unplugin": "^1.4.0" 54 | }, 55 | "devDependencies": { 56 | "@babel/helper-plugin-utils": "^7.22.5", 57 | "@babel/preset-typescript": "^7.22.15", 58 | "@babel/traverse": "^7.22.20", 59 | "@types/babel-plugin-syntax-jsx": "^6.18.0", 60 | "@types/babel-types": "^7.0.12", 61 | "@types/babel__core": "^7.20.2", 62 | "@types/babel__helper-plugin-utils": "^7.10.1", 63 | "@types/babel__traverse": "^7.20.2", 64 | "@vercel/style-guide": "^5.0.1", 65 | "babel-plugin-tester": "^11.0.4", 66 | "bumpp": "^9.2.0", 67 | "chalk": "^5.3.0", 68 | "prettier": "^3.0.3", 69 | "ts-pattern": "^5.0.5", 70 | "tsup": "^7.2.0", 71 | "typescript": "link:@vercel/style-guide/typescript", 72 | "vite": "^4.4.9", 73 | "vitest": "^0.34.4" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/codegen.ts: -------------------------------------------------------------------------------- 1 | import { NodePath } from '@babel/core'; 2 | import * as b from '@babel/types'; 3 | import { 4 | Expr, 5 | Hir, 6 | Pattern, 7 | PatternArray, 8 | PatternLiteral, 9 | PatternMatchBranch, 10 | PatternObject, 11 | PatternSelect, 12 | PatternSelectNamed, 13 | } from './hir'; 14 | import traverse from '@babel/traverse'; 15 | 16 | export type HirCodegenOpts = { 17 | /** 18 | * For some reason ts-pattern allows you to pattern match on arbitrary types that are unrelated to the expression being matched upon. As a result, optional chaining `foo?.bar?.baz` is necessary to avoid `property of undefined` errors. This incurs an additional runtime overhead, but you can disable it here. 19 | * */ 20 | disableOptionalChaining: boolean; 21 | mute?: boolean 22 | }; 23 | export type HirCodegen = ( 24 | | { 25 | kind: 'iife'; 26 | } 27 | | { 28 | kind: 'block'; 29 | outVar: b.LVal; 30 | outLabel: b.Identifier; 31 | patternOriginalOutVar: b.LVal | undefined; 32 | type: 'var-decl' | 'pattern-var-decl' | 'assignment'; 33 | } 34 | ) & { 35 | // monotonically increasing id used for generating unique identifiers 36 | counter: number; 37 | branchCtx: BranchCtx; 38 | } & HirCodegenOpts; 39 | 40 | type BranchCtx = { 41 | selections?: BranchCtxSelections; 42 | }; 43 | 44 | type BranchCtxSelections = 45 | | { type: 'anonymous'; expr: b.Expression } 46 | | { 47 | type: 'named'; 48 | selections: Array<[pattern: PatternSelectNamed, expr: b.Expression]>; 49 | }; 50 | 51 | function uniqueIdent(n: number): b.Identifier { 52 | return b.identifier(`__patsy_temp_${n}`); 53 | } 54 | function hirCodegenUniqueIdent(hc: HirCodegen): b.Identifier { 55 | return uniqueIdent(hc.counter++); 56 | } 57 | 58 | export function hirCodegenInit( 59 | path: NodePath, 60 | opts: HirCodegenOpts, 61 | ): HirCodegen { 62 | if (b.isVariableDeclarator(path.parent)) { 63 | const outVar = path.parent.id; 64 | if (!b.isLVal(outVar)) throw new Error('unimplemented'); 65 | 66 | if (b.isArrayPattern(path.parent.id)) { 67 | return { 68 | ...opts, 69 | kind: 'block', 70 | outVar: uniqueIdent(0), 71 | outLabel: uniqueIdent(1), 72 | counter: 2, 73 | patternOriginalOutVar: outVar, 74 | type: 'pattern-var-decl', 75 | branchCtx: {}, 76 | }; 77 | } 78 | 79 | return { 80 | ...opts, 81 | kind: 'block', 82 | outVar, 83 | outLabel: uniqueIdent(0), 84 | counter: 1, 85 | patternOriginalOutVar: undefined, 86 | type: 'var-decl', 87 | branchCtx: {}, 88 | }; 89 | } 90 | 91 | if (b.isAssignmentExpression(path.parent)) { 92 | return { 93 | ...opts, 94 | kind: 'block', 95 | outVar: path.parent.left, 96 | outLabel: uniqueIdent(0), 97 | counter: 1, 98 | patternOriginalOutVar: undefined, 99 | type: 'assignment', 100 | branchCtx: {}, 101 | }; 102 | } 103 | 104 | return { ...opts, kind: 'iife', counter: 0, branchCtx: {} }; 105 | } 106 | 107 | /** 108 | * Generates an immediately-invoked function expression for the pattern matching. 109 | **/ 110 | export function hirCodegen( 111 | hc: HirCodegen, 112 | hir: Hir, 113 | ): b.LabeledStatement | b.Expression { 114 | /** 115 | * This is the expression passed into ts-pattern's `match(x)` function. Bad name. Sorry. 116 | * */ 117 | let expr: b.Expression = hir.expr; 118 | const body: b.Statement[] = []; 119 | 120 | // If the expr to match is not an identifier, check if its expensive to compute. 121 | // If it is expensive, assign it and cache it to a variable. 122 | // If inexpensive (foo[0], foo.bar, etc.), just use that expression 123 | if (!b.isIdentifier(hir.expr) && !isInexpensiveExpr(hir.expr)) { 124 | expr = hirCodegenUniqueIdent(hc); 125 | body.push( 126 | b.variableDeclaration('const', [b.variableDeclarator(expr, hir.expr)]), 127 | ); 128 | } 129 | 130 | // Generate if statements for each branch 131 | for (const branch of hir.branches) { 132 | body.push(hirCodegenBranch(hc, expr, branch)); 133 | } 134 | 135 | // Call otherwise if set: 136 | if (hir.otherwise !== undefined) { 137 | // TODO: inline function expressions if trivial? 138 | body.push( 139 | ...hirCodegenOutput( 140 | hc, 141 | b.callExpression(b.parenthesizedExpression(hir.otherwise), [expr]), 142 | ), 143 | ); 144 | } else if (!hir.exhaustive) { 145 | // If no otherwise and not exhaustive generate error if no match. 146 | // 147 | // Basically want to create this: 148 | // ```typescript 149 | // let displayedValue; 150 | // try { 151 | // displayedValue = JSON.stringify(<< input expr >>); 152 | // } catch (e) { 153 | // displayedValue = << input expr >>; 154 | // } 155 | 156 | // throw new Error( 157 | // `Pattern matching error: no pattern matches value ${displayedValue}` 158 | // ); 159 | // ``` 160 | // 161 | // Create Identifiers 162 | const displayedValue = b.identifier('__patsy__displayedValue'); 163 | const e = b.identifier('e'); 164 | 165 | // Create JSON.stringify(this.input) 166 | const jsonStrExpr = b.callExpression( 167 | b.memberExpression(b.identifier('JSON'), b.identifier('stringify')), 168 | [expr], 169 | ); 170 | 171 | // Create try-catch block 172 | const tryCatch = b.tryStatement( 173 | b.blockStatement([ 174 | b.expressionStatement( 175 | b.assignmentExpression('=', displayedValue, jsonStrExpr), 176 | ), 177 | ]), 178 | b.catchClause( 179 | e, 180 | b.blockStatement([ 181 | b.expressionStatement( 182 | b.assignmentExpression('=', displayedValue, expr), 183 | ), 184 | ]), 185 | ), 186 | ); 187 | 188 | // Create `throw new Error(...)` statement 189 | const throwError = b.throwStatement( 190 | b.newExpression(b.identifier('Error'), [ 191 | b.templateLiteral( 192 | [ 193 | b.templateElement({ 194 | raw: 'Pattern matching error: no pattern matches value ', 195 | cooked: 'Pattern matching error: no pattern matches value ', 196 | }), 197 | b.templateElement({ raw: '', cooked: '' }), 198 | ], 199 | [displayedValue], 200 | ), 201 | ]), 202 | ); 203 | 204 | // Create `let displayedValue;` statement 205 | const letDisplayedValue = b.variableDeclaration('let', [ 206 | b.variableDeclarator(displayedValue, null), 207 | ]); 208 | 209 | body.push(...[letDisplayedValue, tryCatch, throwError]); 210 | } 211 | 212 | if (hc.kind === 'iife') { 213 | return b.callExpression( 214 | b.arrowFunctionExpression([], b.blockStatement(body)), 215 | [], 216 | ); 217 | } 218 | 219 | return b.labeledStatement(hc.outLabel, b.blockStatement(body)); 220 | } 221 | 222 | function hirCodegenBranch( 223 | hc: HirCodegen, 224 | expr: Expr, 225 | branch: PatternMatchBranch, 226 | ): b.Statement { 227 | hc.branchCtx = {}; 228 | const patternChecks = branch.patterns.map((pat) => 229 | hirCodegenPattern(hc, expr, pat), 230 | ); 231 | const then = hirCodegenPatternThen(hc, expr, branch.then); 232 | return b.ifStatement(concatConditionals(patternChecks), then); 233 | } 234 | 235 | // TODO: here is where we would also do the captures from P.select() 236 | function hirCodegenPatternThen( 237 | hc: HirCodegen, 238 | expr: Expr, 239 | then: Expr, 240 | ): b.BlockStatement { 241 | // Try to inline function expressions 242 | if (then.type === 'ArrowFunctionExpression') { 243 | if (then.params.some((p) => p.type !== 'Identifier')) { 244 | throw new Error(`only identifier param types supported now`); 245 | } 246 | return hirCodegenPatternThenFunction( 247 | hc, 248 | expr, 249 | then.params as unknown as b.Identifier[], 250 | then.body, 251 | ); 252 | } else if (then.type === 'FunctionExpression') { 253 | if (then.params.some((p) => p.type !== 'Identifier')) { 254 | throw new Error(`only identifier param types supported now`); 255 | } 256 | return hirCodegenPatternThenFunction( 257 | hc, 258 | expr, 259 | then.params as unknown as b.Identifier[], 260 | then.body, 261 | ); 262 | } 263 | 264 | // Otherwise its a function referenced by an identifier or some other 265 | // expression that resolves to a function, so call it with the args: 266 | // - if no branch selections => just the match expr 267 | // - if selectoins => the selection / selection object, then the match expr 268 | return b.blockStatement([ 269 | ...hirCodegenOutput( 270 | hc, 271 | b.callExpression( 272 | then, 273 | hc.branchCtx.selections !== undefined 274 | ? [hirCodegenConstructSelectionExpr(hc.branchCtx.selections), expr] 275 | : [expr], 276 | ), 277 | ), 278 | // b.returnStatement(b.callExpression(then, [expr])) 279 | ]); 280 | } 281 | 282 | /** 283 | * When `hc.kind` is "iife", it will emit a return statement 284 | * When `hc.kind` is "block", it will emit an assignment statement and a break statement 285 | * */ 286 | function hirCodegenOutput(hc: HirCodegen, value: Expr): b.Statement[] { 287 | switch (hc.kind) { 288 | case 'iife': { 289 | return [b.returnStatement(value)]; 290 | } 291 | case 'block': { 292 | return [ 293 | b.expressionStatement(b.assignmentExpression('=', hc.outVar, value)), 294 | b.breakStatement(hc.outLabel), 295 | ]; 296 | } 297 | } 298 | } 299 | 300 | /** 301 | * Returns an expression that represents the selection of the branch: 302 | * - anonymous selection => the expression referencing the match expr 303 | * - named selection => an object literal with keys being the names, and values being referencing the match expr 304 | * 305 | * Anonymous selection: 306 | * ```typescript 307 | * match(foo).with({ type: 'bar', name: P.select() }, (val) => console.log(val)) 308 | * ``` 309 | * The expression should be `foo.name` 310 | * 311 | * 312 | * Named selection: 313 | * ```typescript 314 | * match(foo).with({ type: 'bar', name: P.select('name'), age: P.select('age') }, (val) => console.log(val)) 315 | * ``` 316 | * The expression should be `{ name: foo.name, age: foo.age }` 317 | **/ 318 | function hirCodegenConstructSelectionExpr( 319 | selections: BranchCtxSelections, 320 | ): b.Expression { 321 | if (selections.type === 'anonymous') return selections.expr; 322 | 323 | const properties = selections.selections.map(([pattern, expr]) => 324 | b.objectProperty(pattern.name, expr, true), 325 | ); 326 | 327 | return b.objectExpression(properties); 328 | } 329 | 330 | function hirCodegenPatternThenFunction( 331 | hc: HirCodegen, 332 | expr: Expr, 333 | args: b.Identifier[], 334 | body: b.BlockStatement | b.Expression, 335 | ): b.BlockStatement { 336 | const block: b.Statement[] = []; 337 | // Bind the args to the handler 338 | if (args.length > 1 && hc.branchCtx.selections === undefined) { 339 | throw new Error('unimplemented more than one arg on result function'); 340 | } else if (args.length === 1) { 341 | block.push( 342 | b.variableDeclaration('let', [ 343 | b.variableDeclarator( 344 | args[0]!, 345 | hc.branchCtx.selections === undefined 346 | ? expr 347 | : hirCodegenConstructSelectionExpr(hc.branchCtx.selections), 348 | ), 349 | ]), 350 | ); 351 | } else if (args.length === 2 && hc.branchCtx.selections !== undefined) { 352 | block.push( 353 | // The first arg should be bound to the selection 354 | b.variableDeclaration('let', [ 355 | b.variableDeclarator( 356 | args[0]!, 357 | hirCodegenConstructSelectionExpr(hc.branchCtx.selections), 358 | ), 359 | ]), 360 | // the second arg is the matched expression 361 | b.variableDeclaration('let', [b.variableDeclarator(args[1]!, expr)]), 362 | ); 363 | } 364 | 365 | // For arrow expression we can just return the expr 366 | if (body.type !== 'BlockStatement') { 367 | block.push(...hirCodegenOutput(hc, body)); 368 | // blocks.push(b.returnStatement(body)); 369 | } else { 370 | hirCodegenRewriteReturns(hc, body); 371 | block.push(...body.body); 372 | } 373 | 374 | return b.blockStatement(block); 375 | } 376 | 377 | function hirCodegenRewriteReturns(hc: HirCodegen, body: b.BlockStatement) { 378 | // iife allow returns 379 | if (hc.kind === 'iife') return; 380 | 381 | traverse(body, { 382 | noScope: true, 383 | ReturnStatement(path) { 384 | const output = hirCodegenOutput( 385 | hc, 386 | path.node.argument || b.identifier('undefined'), 387 | ); 388 | path.replaceWithMultiple(output); 389 | }, 390 | }); 391 | } 392 | 393 | function hirCodegenPattern( 394 | hc: HirCodegen, 395 | expr: Expr, 396 | pattern: Pattern, 397 | ): b.Expression { 398 | switch (pattern.type) { 399 | case 'literal': { 400 | return hirCodegenPatternLiteral(expr, pattern.value); 401 | } 402 | case 'object': { 403 | return hirCodegenPatternObject(hc, expr, pattern.value); 404 | } 405 | case 'array': { 406 | return hirCodegenPatternArray(hc, expr, pattern.value); 407 | } 408 | case 'string': 409 | case 'number': 410 | case 'bigint': 411 | case 'boolean': { 412 | return hirCodegenPatternSimpleTypeof(hc, expr, pattern.type); 413 | } 414 | case 'wildcard': { 415 | return b.booleanLiteral(true); 416 | } 417 | case 'nullish': 418 | case 'symbol': 419 | case '_array': 420 | case 'set': 421 | case 'map': 422 | case 'when': 423 | case 'not': { 424 | throw new Error(`unimplemented pattern: ${pattern.type}`); 425 | } 426 | case 'select': { 427 | return hirCodegenPatternSelect(hc, expr, pattern.value); 428 | } 429 | } 430 | } 431 | 432 | function hirCodegenPatternSimpleTypeof( 433 | hc: HirCodegen, 434 | expr: b.Expression, 435 | type: 'string' | 'number' | 'bigint' | 'boolean', 436 | ): b.Expression { 437 | return b.binaryExpression( 438 | '===', 439 | b.unaryExpression('typeof', expr, true), 440 | b.stringLiteral(type), 441 | ); 442 | } 443 | 444 | function hirCodegenMemberExpr( 445 | hc: HirCodegen, 446 | object: Parameters[0], 447 | property: Parameters[1], 448 | ) { 449 | if (!hc.disableOptionalChaining) 450 | return b.optionalMemberExpression( 451 | object, 452 | property as b.Expression, 453 | false, 454 | true, 455 | ); 456 | return b.memberExpression(object, property); 457 | } 458 | 459 | function hirCodegenPatternArray( 460 | hc: HirCodegen, 461 | expr: Expr, 462 | arr: PatternArray, 463 | ): b.Expression { 464 | // Generate `Array.isArray(input)` 465 | const isArrayCall = b.callExpression( 466 | b.memberExpression(b.identifier('Array'), b.identifier('isArray')), 467 | [expr], 468 | ); 469 | 470 | // `input.length` 471 | const inputLength = hirCodegenMemberExpr(hc, expr, b.identifier('length')); 472 | 473 | // Generate `input.length >=` 474 | const boundsCheck = b.binaryExpression( 475 | '>=', 476 | inputLength, 477 | b.numericLiteral(arr.length), 478 | ); 479 | 480 | // Generate `Array.isArray(input) && input.length >= pattern.length` 481 | const finalExpression = b.logicalExpression('&&', isArrayCall, boundsCheck); 482 | 483 | const conditionals: Array = [finalExpression]; 484 | for (let i = 0; i < arr.length; i++) { 485 | // input[i] 486 | const arrayAccess = b.memberExpression(expr, b.numericLiteral(i), true); 487 | // Push input[i] === << codegen'd pattern >> 488 | conditionals.push(hirCodegenPattern(hc, arrayAccess, arr[i]!)); 489 | } 490 | return concatConditionals(conditionals); 491 | } 492 | 493 | function hirCodegenPatternSelect( 494 | hc: HirCodegen, 495 | expr: Expr, 496 | select: PatternSelect, 497 | ): b.Expression { 498 | if (hc.branchCtx.selections !== undefined) { 499 | if (hc.branchCtx.selections.type === 'anonymous') { 500 | throw new Error( 501 | 'Cannot have more than one anonymous `P.select()` in a single pattern match branch', 502 | ); 503 | } 504 | if (select.type !== 'named') 505 | throw new Error( 506 | 'Cannot mix anonymous and named `P.select()` in a single pattern match branch', 507 | ); 508 | 509 | hc.branchCtx.selections.selections.push([select, expr]); 510 | } else { 511 | if (select.type === 'anonymous') { 512 | hc.branchCtx.selections = { type: 'anonymous', expr }; 513 | } else { 514 | hc.branchCtx.selections = { type: 'named', selections: [[select, expr]] }; 515 | } 516 | } 517 | 518 | if (select.subpattern !== undefined) 519 | return hirCodegenPattern(hc, expr, select.subpattern); 520 | 521 | return b.booleanLiteral(true); 522 | } 523 | 524 | function hirCodegenPatternObject( 525 | hc: HirCodegen, 526 | expr: Expr, 527 | obj: PatternObject, 528 | ): b.Expression { 529 | const conditionals: Array = []; 530 | for (const [key, pat] of Object.entries(obj)) { 531 | if (pat.type === 'object') { 532 | conditionals.push( 533 | hirCodegenPattern( 534 | hc, 535 | hirCodegenMemberExpr(hc, expr, b.identifier(key)), 536 | pat, 537 | ), 538 | ); 539 | continue; 540 | } 541 | 542 | conditionals.push( 543 | // b.binaryExpression("===", b.memberExpression(expr, b.identifier(key)), hirCodegenPattern(expr, pat)) 544 | hirCodegenPattern( 545 | hc, 546 | hirCodegenMemberExpr(hc, expr, b.identifier(key)), 547 | pat, 548 | ), 549 | ); 550 | } 551 | return concatConditionals(conditionals); 552 | } 553 | 554 | function hirCodegenPatternLiteral( 555 | expr: Expr, 556 | lit: PatternLiteral, 557 | ): b.Expression { 558 | return b.binaryExpression('===', expr, patternLiteralToExpr(lit)); 559 | } 560 | 561 | function patternLiteralToExpr(lit: PatternLiteral): b.Expression { 562 | switch (lit.type) { 563 | case 'string': { 564 | return b.stringLiteral(lit.value); 565 | } 566 | case 'number': { 567 | return b.numericLiteral(lit.value); 568 | } 569 | case 'boolean': { 570 | return b.identifier('undefined'); 571 | } 572 | case 'bigint': { 573 | return b.bigIntLiteral(lit.value); 574 | } 575 | case 'undefined': { 576 | return b.identifier('undefined'); 577 | } 578 | case 'null': { 579 | return b.nullLiteral(); 580 | } 581 | case 'nan': { 582 | return b.identifier('NaN'); 583 | } 584 | } 585 | } 586 | 587 | /** 588 | * Turn an array of conditionals (expressions that return a boolean) into a single expression chained by multiple '&&' 589 | **/ 590 | function concatConditionals(conds_: Array): b.Expression { 591 | // `true` is redundant so we can get rid of it 592 | const conds = conds_.filter( 593 | (cond) => !(b.isBooleanLiteral(cond) && cond.value === true), 594 | ); 595 | if (conds.length === 0) return b.booleanLiteral(true); 596 | if (conds.length === 1) return conds[0]!; 597 | 598 | let i = conds.length - 1; 599 | let out: b.Expression = conds[i]!; 600 | i--; 601 | 602 | for (i; i >= 0; i--) { 603 | const cond = conds[i]!; 604 | // out = b.logicalExpression("&&", b.parenthesizedExpression(cond), b.parenthesizedExpression(out)) 605 | out = b.logicalExpression('&&', cond, out); 606 | } 607 | 608 | return out; 609 | } 610 | 611 | // TODO: expressions like arr[0], 'literal', 123, foo.bar 612 | function isInexpensiveExpr(expr: b.Expression): boolean { 613 | if (b.isIdentifier(expr)) return true; 614 | return false; 615 | } 616 | -------------------------------------------------------------------------------- /src/hir.ts: -------------------------------------------------------------------------------- 1 | import * as b from '@babel/types'; 2 | 3 | export type Hir = PatternMatch; 4 | 5 | export type PatternMatch = { 6 | expr: Expr; 7 | branches: Array; 8 | otherwise: Expr | undefined; 9 | exhaustive: boolean; 10 | }; 11 | 12 | export type Expr = b.Expression; 13 | export type FnExpr = b.ArrowFunctionExpression | b.FunctionExpression; 14 | 15 | export type PatternMatchBranch = { 16 | patterns: Array; 17 | guard: FnExpr | undefined; 18 | then: b.Expression; 19 | }; 20 | 21 | export type PatternMatchBranchSelections = 22 | | { 23 | type: 'anonymous'; 24 | } 25 | | { 26 | type: 'named'; 27 | captures: Array; 28 | }; 29 | 30 | export type Pattern = 31 | // Literals 32 | | { 33 | type: 'literal'; 34 | value: PatternLiteral; 35 | } 36 | | { type: 'object'; value: PatternObject } 37 | | { type: 'array'; value: PatternArray } 38 | // Simple patterns: P.string, P.number, etc. 39 | | { type: 'string' } 40 | | { type: 'number' } 41 | | { type: 'boolean' } 42 | | { type: 'nullish' } 43 | | { type: 'bigint' } 44 | | { type: 'symbol' } 45 | | { type: 'wildcard' } // P._ 46 | // Custom patterns: P.when 47 | | { type: '_array'; value: unknown } 48 | | { type: 'set'; subpattern: Pattern } 49 | | { type: 'map'; key: Pattern; value: Pattern } 50 | // https://github.com/gvergnaud/ts-pattern/tree/main#pwhen-patterns 51 | | { type: 'when'; value: unknown } 52 | | { type: 'not'; subpattern: Pattern } 53 | | { type: 'select'; value: PatternSelect }; 54 | 55 | /** 56 | * https://github.com/gvergnaud/ts-pattern#literals 57 | * */ 58 | export type PatternLiteral = 59 | | { type: 'string'; value: string } 60 | | { type: 'number'; value: number } 61 | | { type: 'boolean'; value: boolean } 62 | | { type: 'bigint'; value: string } 63 | | { type: 'nan' } 64 | | { type: 'null' } 65 | | { type: 'undefined' }; 66 | export type PatternObject = Record; 67 | export type PatternArray = Array; 68 | 69 | export type PatternSelect = PatternSelectAnonymous | PatternSelectNamed; 70 | export type PatternSelectAnonymous = { 71 | type: 'anonymous'; 72 | subpattern: Pattern | undefined; 73 | }; 74 | export type PatternSelectNamed = { 75 | type: 'named'; 76 | name: b.Expression; 77 | subpattern: Pattern | undefined; 78 | }; 79 | 80 | /** 81 | * Extra state to be stored when transforming to Hir 82 | * */ 83 | export type HirTransform = { 84 | matchIdentifier: string; 85 | patternIdentifier: string | undefined; 86 | }; 87 | 88 | export function callExpressionsFlat( 89 | ht: HirTransform, 90 | callExpr: b.CallExpression, 91 | ): b.CallExpression[] | undefined { 92 | const depth = hirHasPatternMatchRoot(ht, callExpr); 93 | if (depth === 0) return undefined; 94 | 95 | const buf = Array(depth).fill( 96 | undefined as unknown as b.CallExpression, 97 | ); 98 | hirPatternMatchTopDownCallExprs( 99 | ht, 100 | callExpr, 101 | buf as unknown as Array, 102 | depth - 1, 103 | ); 104 | 105 | return buf; 106 | } 107 | 108 | export function hirFromCallExpr( 109 | ht: HirTransform, 110 | callExpr: b.CallExpression, 111 | ): PatternMatch | undefined { 112 | const buf = callExpressionsFlat(ht, callExpr); 113 | if (buf === undefined) return undefined; 114 | 115 | return hirFromCallExprImpl(ht, buf); 116 | } 117 | 118 | function hirFromCallExprImpl( 119 | ht: HirTransform, 120 | callExprs: Array, 121 | ): PatternMatch | undefined { 122 | const expr = callExprs[0]!.arguments[0]; 123 | if (!b.isExpression(expr)) return undefined; 124 | 125 | let exhaustive: boolean = false; 126 | let otherwise: b.Expression | undefined = undefined; 127 | const branches: Array = []; 128 | 129 | for (let i = 1; i < callExprs.length; i++) { 130 | const callExpr = callExprs[i]!; 131 | const callee = callExpr!.callee; 132 | if (!b.isMemberExpression(callee)) { 133 | throw new Error('unreachable'); 134 | } 135 | const property = callee.property; 136 | if (!b.isIdentifier(property)) { 137 | throw new Error('unreachable'); 138 | } 139 | 140 | switch (property.name) { 141 | case 'with': { 142 | const branch = transformToPatternMatchBranch(ht, callExpr.arguments); 143 | branches.push(branch); 144 | break; 145 | } 146 | case 'otherwise': { 147 | const arg = callExpr.arguments[0]!; 148 | if (b.isExpression(arg)) { 149 | otherwise = arg; 150 | } else if (b.isSpreadElement(arg)) { 151 | throw new Error('spread elements not handled yet'); 152 | } else { 153 | throw new Error(`unhandled ${arg.type}`); 154 | } 155 | break; 156 | } 157 | case 'exhaustive': { 158 | exhaustive = true; 159 | break; 160 | } 161 | default: { 162 | throw new Error(`Unhandled ts-pattern API function: ${property.name}`); 163 | } 164 | } 165 | } 166 | 167 | return { 168 | expr, 169 | branches, 170 | exhaustive, 171 | otherwise, 172 | }; 173 | } 174 | 175 | function isFunction(expr: b.Node): expr is FnExpr { 176 | return b.isArrowFunctionExpression(expr) || b.isFunctionExpression(expr); 177 | } 178 | 179 | /** 180 | * The AST of a ts-pattern match expression is a nested 181 | * tree of CallExpressions, which is cumbersome and also upside-down for our purposes. This 182 | * functions converts this upside down tree into a flat array in the correct 183 | * order (the first CallExpression represents the initial `match()` call, the 184 | * next represents the first `.with()` call, etc.) 185 | **/ 186 | function hirPatternMatchTopDownCallExprs( 187 | ht: HirTransform, 188 | expr: b.CallExpression, 189 | buf: Array, 190 | i: number, 191 | ) { 192 | if (!b.isExpression(expr.callee)) return; 193 | if (b.isIdentifier(expr.callee) && expr.callee.name == ht.matchIdentifier) { 194 | buf[0] = expr; 195 | return; 196 | } 197 | 198 | if (b.isMemberExpression(expr.callee)) { 199 | if (b.isCallExpression(expr.callee.object)) { 200 | buf[i] = expr; 201 | return hirPatternMatchTopDownCallExprs( 202 | ht, 203 | expr.callee.object, 204 | buf, 205 | i - 1, 206 | ); 207 | } 208 | } 209 | 210 | return; 211 | } 212 | 213 | function hirHasPatternMatchRoot( 214 | ht: HirTransform, 215 | expr: b.CallExpression, 216 | ): number { 217 | return hirHasPatternMatchRootImpl(ht, expr, 1); 218 | } 219 | 220 | function hirHasPatternMatchRootImpl( 221 | ht: HirTransform, 222 | expr: b.CallExpression, 223 | depth: number, 224 | ): number { 225 | if (!b.isExpression(expr.callee)) return 0; 226 | if (b.isIdentifier(expr.callee) && expr.callee.name == ht.matchIdentifier) 227 | return depth; 228 | 229 | if (b.isMemberExpression(expr.callee)) { 230 | if (b.isCallExpression(expr.callee.object)) 231 | return hirHasPatternMatchRootImpl(ht, expr.callee.object, depth + 1); 232 | } 233 | 234 | return 0; 235 | } 236 | 237 | // function isPatternMatchExpr(val: b.Node | null | undefined): val is Expr { 238 | // return b.isExpression(val) || b.isSpreadElement(val); 239 | // } 240 | 241 | /** 242 | * See list of patterns here: https://github.com/gvergnaud/ts-pattern/tree/main#patterns 243 | * */ 244 | function transformToPatternMatchBranch( 245 | ht: HirTransform, 246 | args: b.CallExpression['arguments'], 247 | ): PatternMatchBranch { 248 | if (args.length < 2) { 249 | throw new Error(`Invalid amount of args: ${args.length}`); 250 | } 251 | // 2nd arg can possibly a guard function, see: https://github.com/gvergnaud/ts-pattern/tree/main#pwhen-and-guard-functions 252 | // But _only_ the 2nd arg, so won't work with multiple patterns. 253 | // 254 | // Unfortunately, there's no completely robust way to know at compile time 255 | // without type information if the 2nd arg is a function. We use a simple 256 | // heuristic by just checking the AST node type. For example: 257 | // 258 | // ```typescript 259 | // .with( 260 | // [{ status: 'loading' }, { type: 'cancel' }], 261 | // ([state, event]) => state.startTime + 2000 < Date.now(), // <-- guard is here 262 | // () => ({ status: 'idle' }) 263 | // ) 264 | // ``` 265 | // 266 | // ```typescript 267 | // match(name) 268 | // .with ('text', 'span', 'p', () => 'text') 269 | // .with('btn', 'button', () => 'button') 270 | // .otherwise(() => name); 271 | // ``` 272 | if (args.length === 3) { 273 | if (isFunction(args[1]!)) { 274 | const then = args[2]!; 275 | if (!b.isExpression(then)) throw new Error(`unsupported: ${then.type}}`); 276 | if (!b.isExpression(args[0])) 277 | throw new Error(`unsupported: ${args[0]!.type}`); 278 | return { 279 | patterns: [transformExprToPattern(ht, args[0])], 280 | guard: args[1], 281 | then, 282 | }; 283 | } 284 | } 285 | 286 | // Everything else is patterns 287 | const then = args[args.length - 1]!; 288 | if (!b.isExpression(then)) throw new Error(`unsupported: ${then.type}}`); 289 | return { 290 | patterns: args.slice(0, args.length - 1).map((arg) => { 291 | if (!b.isExpression(arg)) throw new Error('unimplemented'); 292 | return transformExprToPattern(ht, arg); 293 | }), 294 | guard: undefined, 295 | then, 296 | }; 297 | } 298 | 299 | function transformExprToPattern(ht: HirTransform, expr: b.Expression): Pattern { 300 | if (b.isObjectExpression(expr)) return transformToPatternObjExpr(ht, expr); 301 | 302 | if (b.isStringLiteral(expr)) 303 | return { type: 'literal', value: { type: 'string', value: expr.value } }; 304 | 305 | if (b.isNumericLiteral(expr)) 306 | return { type: 'literal', value: { type: 'number', value: expr.value } }; 307 | 308 | if (b.isBooleanLiteral(expr)) 309 | return { type: 'literal', value: { type: 'boolean', value: expr.value } }; 310 | 311 | if (b.isBigIntLiteral(expr)) 312 | return { type: 'literal', value: { type: 'bigint', value: expr.value } }; 313 | 314 | if (b.isArrayExpression(expr)) { 315 | return { 316 | type: 'array', 317 | value: expr.elements.map((el) => { 318 | if (!b.isExpression(el)) 319 | throw new Error(`unimplemented type: ${el?.type || 'null'}`); 320 | return transformExprToPattern(ht, el); 321 | }), 322 | }; 323 | } 324 | 325 | if ( 326 | b.isMemberExpression(expr) && 327 | b.isIdentifier(expr.object) && 328 | expr.object.name === ht.patternIdentifier && 329 | b.isIdentifier(expr.property) 330 | ) { 331 | return transformToSimpleTsPattern(ht, expr.property); 332 | } 333 | 334 | if ( 335 | b.isCallExpression(expr) && 336 | b.isMemberExpression(expr.callee) && 337 | b.isIdentifier(expr.callee.object) && 338 | expr.callee.object.name == ht.patternIdentifier && 339 | b.isIdentifier(expr.callee.property) 340 | ) { 341 | return transformToComplexTsPattern( 342 | ht, 343 | expr.callee.property, 344 | expr.arguments, 345 | ); 346 | } 347 | 348 | // TODO: fallback to runtime check 349 | throw new Error(`unimplemented ${expr.type}`); 350 | } 351 | 352 | function transformToComplexTsPattern( 353 | ht: HirTransform, 354 | functionName: b.Identifier, 355 | args: b.CallExpression['arguments'], 356 | ): Pattern { 357 | switch (functionName.name) { 358 | case 'select': { 359 | const selection = transformToSelectPattern(ht, args); 360 | return { 361 | type: 'select', 362 | value: selection, 363 | }; 364 | } 365 | case '_array': 366 | case 'set': 367 | case 'map': 368 | case 'when': 369 | case 'not': 370 | default: { 371 | throw new Error( 372 | `unimplemented pattern function: '${ht.patternIdentifier}.${functionName.name}'`, 373 | ); 374 | } 375 | } 376 | } 377 | 378 | function transformToSelectPattern( 379 | ht: HirTransform, 380 | args: b.CallExpression['arguments'], 381 | ): PatternSelect { 382 | if (args.length === 0) 383 | return { 384 | type: 'anonymous', 385 | subpattern: undefined, 386 | }; 387 | 388 | if (!b.isExpression(args[0]!)) 389 | throw new Error('Only expressions are supported for `P.select()`'); 390 | 391 | if (args.length === 1) { 392 | if (b.isStringLiteral(args[0]!)) 393 | return { 394 | type: 'named', 395 | name: args[0], 396 | subpattern: undefined, 397 | }; 398 | 399 | return { 400 | type: 'anonymous', 401 | subpattern: transformExprToPattern(ht, args[0]!), 402 | }; 403 | } 404 | 405 | if (!b.isExpression(args[1]!)) 406 | throw new Error('Only expressions are supported for `P.select()`'); 407 | 408 | return { 409 | type: 'named', 410 | name: args[0]!, 411 | subpattern: transformExprToPattern(ht, args[1]!), 412 | }; 413 | } 414 | 415 | /** 416 | * These are simple patterns from ts-pattern: 417 | * P.number, P.string, etc. 418 | **/ 419 | function transformToSimpleTsPattern( 420 | ht: HirTransform, 421 | expr: b.Identifier, 422 | ): Pattern { 423 | switch (expr.name) { 424 | case 'string': 425 | return { type: 'string' }; 426 | case 'number': 427 | return { type: 'number' }; 428 | case 'boolean': 429 | return { type: 'boolean' }; 430 | case 'nullish': 431 | return { type: 'nullish' }; 432 | case 'bigint': 433 | return { type: 'bigint' }; 434 | case 'symbol': 435 | return { type: 'symbol' }; 436 | case '_': 437 | return { type: 'wildcard' }; 438 | default: { 439 | throw new Error( 440 | `unrecognized pattern: '${ht.patternIdentifier}.${expr.name}'`, 441 | ); 442 | } 443 | } 444 | } 445 | 446 | function transformToPatternObjExpr( 447 | ht: HirTransform, 448 | objectExpr: b.ObjectExpression, 449 | ): Pattern { 450 | const value: PatternObject = {}; 451 | for (const prop of objectExpr.properties) { 452 | if (!b.isObjectProperty(prop)) { 453 | throw new Error(`invalid pattern property type: ${prop.type}`); 454 | } 455 | if (!b.isIdentifier(prop.key)) { 456 | throw new Error(`invalid pattern property key type: ${prop.key.type}`); 457 | } 458 | if (!b.isExpression(prop.value)) { 459 | throw new Error( 460 | `invalid pattern property value type: ${prop.value.type}`, 461 | ); 462 | } 463 | value[prop.key.name] = transformExprToPattern(ht, prop.value); 464 | } 465 | 466 | return { 467 | type: 'object', 468 | value, 469 | }; 470 | } 471 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { unplugin, babelPlugin, type Options } from './plugin'; 2 | 3 | export const babel = babelPlugin; 4 | export const vite = unplugin.vite; 5 | export const webpack = unplugin.webpack; 6 | export const rollup = unplugin.rollup; 7 | export const rspack = unplugin.rspack; 8 | export const esbuild = unplugin.esbuild; 9 | export const next = ( 10 | nextConfig: Record = {}, 11 | options: Options, 12 | ) => { 13 | return { 14 | ...nextConfig, 15 | webpack(config: Record, webpackOptions: Record) { 16 | config.plugins.unshift(webpack(options)); 17 | 18 | if (typeof nextConfig.webpack === 'function') { 19 | return nextConfig.webpack(config, webpackOptions); 20 | } 21 | return config; 22 | }, 23 | }; 24 | }; 25 | 26 | export default { 27 | vite, 28 | webpack, 29 | rollup, 30 | rspack, 31 | esbuild, 32 | next, 33 | unplugin, 34 | babel: babelPlugin, 35 | }; 36 | -------------------------------------------------------------------------------- /src/pattycake.ts: -------------------------------------------------------------------------------- 1 | import { NodePath, PluginObj } from '@babel/core'; 2 | import * as b from '@babel/types'; 3 | import { HirTransform, callExpressionsFlat, hirFromCallExpr } from './hir'; 4 | import { 5 | HirCodegen, 6 | HirCodegenOpts, 7 | hirCodegen, 8 | hirCodegenInit, 9 | } from './codegen'; 10 | 11 | export type Opts = HirCodegenOpts; 12 | 13 | type State = { 14 | matchIdentifier: string | undefined; 15 | patternIdentifier: string | undefined; 16 | }; 17 | const pattycakePlugin = (opts: Opts): PluginObj => { 18 | let state: State = { 19 | matchIdentifier: undefined, 20 | patternIdentifier: undefined, 21 | }; 22 | let hirTransform: HirTransform | undefined = undefined; 23 | return { 24 | name: 'pattycake', 25 | visitor: { 26 | Program(path) { 27 | path.traverse( 28 | { 29 | ImportDeclaration(path, state) { 30 | if (path.node.source.value != 'ts-pattern') return; 31 | 32 | for (const specifier of path.node.specifiers) { 33 | if (!b.isImportSpecifier(specifier)) continue; 34 | if ( 35 | b.isIdentifier(specifier.imported) && 36 | specifier.imported.name === 'match' 37 | ) { 38 | state.matchIdentifier = specifier.local.name; 39 | continue; 40 | } 41 | if ( 42 | b.isIdentifier(specifier.imported) && 43 | (specifier.imported.name === 'Pattern' || 44 | specifier.imported.name === 'P') 45 | ) { 46 | state.patternIdentifier = specifier.local.name; 47 | continue; 48 | } 49 | } 50 | }, 51 | }, 52 | state, 53 | ); 54 | if (state.matchIdentifier !== undefined) { 55 | hirTransform = { 56 | matchIdentifier: state.matchIdentifier, 57 | patternIdentifier: state.patternIdentifier, 58 | }; 59 | } 60 | }, 61 | CallExpression(path) { 62 | if (hirTransform === undefined) return; 63 | 64 | if (!terminatesMatchExpression(hirTransform, path)) return; 65 | 66 | try { 67 | const pat = hirFromCallExpr(hirTransform, path.node); 68 | if (pat === undefined) return; 69 | 70 | let hc: HirCodegen = hirCodegenInit(path, opts); 71 | const exprOrLabelStmt = hirCodegen(hc, pat); 72 | 73 | if (hc.kind === 'iife') { 74 | path.replaceWith(exprOrLabelStmt); 75 | return; 76 | } 77 | 78 | switch (hc.type) { 79 | case 'var-decl': { 80 | const letDecl = b.variableDeclaration('let', [ 81 | b.variableDeclarator(hc.outVar), 82 | ]); 83 | // parent should be VariableDeclarator 84 | // parent parent should be VariableDeclaration 85 | path.parentPath.parentPath!.replaceWithMultiple([ 86 | letDecl, 87 | exprOrLabelStmt, 88 | ]); 89 | break; 90 | } 91 | case 'pattern-var-decl': { 92 | const letDecl = b.variableDeclaration('let', [ 93 | b.variableDeclarator(hc.outVar), 94 | ]); 95 | if (!b.isIdentifier(hc.outVar)) throw new Error('unreachable'); 96 | const assignBack = b.variableDeclaration('let', [ 97 | b.variableDeclarator(hc.patternOriginalOutVar!, hc.outVar), 98 | ]); 99 | // parent should be VariableDeclarator 100 | // parent parent should be VariableDeclaration 101 | path.parentPath.parentPath!.replaceWithMultiple([ 102 | letDecl, 103 | exprOrLabelStmt, 104 | assignBack, 105 | ]); 106 | break; 107 | } 108 | case 'assignment': { 109 | // parent should be AssignmentExpression 110 | // parent parent should be ExpressionStatement 111 | path.parentPath.parentPath!.replaceWith(exprOrLabelStmt); 112 | break; 113 | } 114 | } 115 | } catch (err) { 116 | if (!opts.mute) { 117 | console.error(err); 118 | } 119 | } 120 | }, 121 | }, 122 | }; 123 | }; 124 | 125 | /** 126 | * Determines if this nested tree of call expressions is a complete ts-pattern match expression 127 | * 128 | * This is done simply by looking at the last call expression, and checking if the callee is a function that 129 | * terminates the match expression (.otherwise(), .run(), .exhaustive()) 130 | * 131 | * Without this, the compiler will attempt to build the HIR and codegen it for each chained function on the match expression 132 | **/ 133 | function terminatesMatchExpression( 134 | ht: HirTransform, 135 | callExpr: NodePath, 136 | ): boolean { 137 | const callExprs = callExpressionsFlat(ht, callExpr.node); 138 | if (callExprs === undefined) return false; 139 | 140 | const last = callExprs[callExprs.length - 1]!; 141 | if (!b.isMemberExpression(last.callee)) return false; 142 | if (!b.isIdentifier(last.callee.property)) return false; 143 | switch (last.callee.property.name) { 144 | case 'otherwise': 145 | case 'run': 146 | case 'exhaustive': 147 | return true; 148 | } 149 | return false; 150 | } 151 | 152 | export default pattycakePlugin; 153 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { PluginItem, PluginObj } from '@babel/core'; 2 | import { transformAsync } from '@babel/core'; 3 | import pluginSyntaxJsx from '@babel/plugin-syntax-jsx'; 4 | import pluginSyntaxTypescript from '@babel/plugin-syntax-typescript'; 5 | import { declare } from '@babel/helper-plugin-utils'; 6 | import { createUnplugin } from 'unplugin'; 7 | import pattycakePlugin, { Opts } from './pattycake'; 8 | 9 | export type Options = Opts; 10 | 11 | export const unplugin = createUnplugin((options: Opts) => { 12 | return { 13 | enforce: 'pre', 14 | name: 'pattycake', 15 | transformInclude(id: string) { 16 | return /\.[jt]s[x]?$/.test(id); 17 | }, 18 | async transform(code: string, id: string) { 19 | const plugins: PluginItem[] = [[pluginSyntaxJsx]]; 20 | 21 | const isTypescript = /\.ts[x]?$/.test(id); 22 | if (isTypescript) { 23 | plugins.push([ 24 | pluginSyntaxTypescript, 25 | { allExtensions: true, isTSX: id.endsWith('.tsx') }, 26 | ]); 27 | } 28 | 29 | plugins.push([babelPlugin, options]); 30 | 31 | const result = await transformAsync(code, { plugins, filename: id }); 32 | 33 | return result?.code || null; 34 | }, 35 | }; 36 | }); 37 | 38 | export const babelPlugin = declare((api, options: Opts) => { 39 | api.assertVersion(7); 40 | 41 | return pattycakePlugin(options); 42 | }); 43 | -------------------------------------------------------------------------------- /test/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { pluginTester } from 'babel-plugin-tester'; 2 | import { babelPlugin } from '../src/plugin'; 3 | 4 | pluginTester({ 5 | plugin: babelPlugin, 6 | filepath: 'test.ts', 7 | babelOptions: { 8 | presets: ['@babel/preset-typescript'], 9 | }, 10 | tests: [ 11 | { 12 | title: 'Basic', 13 | code: ` 14 | import { match } from 'ts-pattern'; 15 | 16 | type Data = 17 | | { type: 'text'; content: string } 18 | | { type: 'img'; src: string }; 19 | 20 | type Error = { 21 | foo: Array 22 | } 23 | 24 | type Result = 25 | | { type: 'ok'; data: Data } 26 | | { type: 'error'; error: Error }; 27 | 28 | const result: Result = undefined; 29 | 30 | const html = match(result) 31 | .with({ type: 'error', error: { foo: [1, 2]} }, () => "

Oups! An error occured

") 32 | .with({ type: 'ok', data: { type: 'text' } }, function (_) { 33 | return "

420

" 34 | }) 35 | .with({ type: 'ok', data: { type: 'img', src: 'hi' } }, (src) => \`\`) 36 | .otherwise(() => 'idk bro'); 37 | `, 38 | output: `import { match } from 'ts-pattern'; 39 | const result = undefined; 40 | let html; 41 | __patsy_temp_0: { 42 | if ( 43 | result?.type === 'error' && 44 | Array.isArray(result?.error?.foo) && 45 | result?.error?.foo?.length >= 2 && 46 | (result?.error?.foo)[0] === 1 && 47 | (result?.error?.foo)[1] === 2 48 | ) { 49 | html = '

Oups! An error occured

'; 50 | break __patsy_temp_0; 51 | } 52 | if (result?.type === 'ok' && result?.data?.type === 'text') { 53 | let _ = result; 54 | html = '

420

'; 55 | break __patsy_temp_0; 56 | } 57 | if ( 58 | result?.type === 'ok' && 59 | result?.data?.type === 'img' && 60 | result?.data?.src === 'hi' 61 | ) { 62 | let src = result; 63 | html = \`\`; 64 | break __patsy_temp_0; 65 | } 66 | html = (() => 'idk bro')(result); 67 | break __patsy_temp_0; 68 | }`, 69 | }, 70 | { 71 | title: 'Select', 72 | code: ` 73 | import { match, P } from 'ts-pattern'; 74 | 75 | type Data = 76 | | { type: 'text'; content: string } 77 | | { type: 'img'; src: string }; 78 | 79 | type Error = { 80 | foo: Array 81 | } 82 | 83 | type Result = 84 | | { type: 'ok'; data: Data } 85 | | { type: 'error'; error: Error }; 86 | 87 | const result: Result = undefined; 88 | 89 | const foo = match(result) 90 | // anonymous 91 | .with({ type: 'bar', name: P.select() }, (sel, matchExpr) => console.log(sel, matchExpr)) 92 | // anonymous with subpattern 93 | .with({ type: 'bar2', name: P.select(P.string) }, (sel, matchExpr) => console.log(sel, matchExpr)) 94 | // named 95 | .with({ type: 'baz', name: P.select('hey') }, (sel, matchExpr) => console.log(sel, matchExpr)) 96 | // named with sub pattern 97 | .with({ type: 'blah', name: P.select('hey', P.number) }, (sel, matchExpr) => console.log(sel, matchExpr)) 98 | `, 99 | output: `import { match, P } from 'ts-pattern'; 100 | const result = undefined; 101 | let foo; 102 | __patsy_temp_0: { 103 | if (result?.type === 'bar') { 104 | let sel = result?.name; 105 | let matchExpr = result; 106 | foo = console.log(sel, matchExpr); 107 | break __patsy_temp_0; 108 | } 109 | if (result?.type === 'bar2' && typeof result?.name === 'string') { 110 | let sel = result?.name; 111 | let matchExpr = result; 112 | foo = console.log(sel, matchExpr); 113 | break __patsy_temp_0; 114 | } 115 | if (result?.type === 'baz') { 116 | let sel = { 117 | ['hey']: result?.name, 118 | }; 119 | let matchExpr = result; 120 | foo = console.log(sel, matchExpr); 121 | break __patsy_temp_0; 122 | } 123 | if (result?.type === 'blah' && typeof result?.name === 'number') { 124 | let sel = { 125 | ['hey']: result?.name, 126 | }; 127 | let matchExpr = result; 128 | foo = console.log(sel, matchExpr); 129 | break __patsy_temp_0; 130 | } 131 | let __patsy__displayedValue; 132 | try { 133 | __patsy__displayedValue = JSON.stringify(result); 134 | } catch (e) { 135 | __patsy__displayedValue = result; 136 | } 137 | throw new Error( 138 | \`Pattern matching error: no pattern matches value \${__patsy__displayedValue}\` 139 | ); 140 | } 141 | `, 142 | }, 143 | ], 144 | }); 145 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vercel/style-guide/typescript", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "jsx": "react-jsx", 6 | "lib": ["dom", "esnext"], 7 | "outDir": "dist", 8 | "moduleResolution": "node", 9 | "module": "esnext", 10 | "target": "esnext", 11 | "noImplicitAny": false 12 | }, 13 | "include": ["./**/*.ts", "./**/*.tsx"], 14 | "exclude": ["node_modules", "dist", "*.d.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | splitting: false, 6 | sourcemap: true, 7 | clean: true, 8 | dts: true, 9 | }); 10 | --------------------------------------------------------------------------------