From c83b5dab2d8d85473755d252159b743cd2b8c74a Mon Sep 17 00:00:00 2001 From: Steeve Pastorelli Date: Fri, 22 Aug 2025 14:18:48 +0200 Subject: [PATCH 1/5] Add js-expr package to GBO repo --- bun.lock | 37 +- packages/js-expr/.gitignore | 1 + packages/js-expr/README.md | 3 + packages/js-expr/package.json | 34 + .../src/__tests__/autocomplete.test.ts | 885 ++++++++++++++++++ .../src/__tests__/input-values.test.ts | 48 + .../js-expr/src/__tests__/runtime.test.ts | 160 ++++ .../js-expr/src/__tests__/template.test.ts | 35 + packages/js-expr/src/autocomplete.ts | 568 +++++++++++ packages/js-expr/src/errors.ts | 24 + packages/js-expr/src/index.ts | 8 + packages/js-expr/src/input-values.ts | 77 ++ packages/js-expr/src/runtime.ts | 289 ++++++ .../src/symbols/__tests__/symbols.test.ts | 495 ++++++++++ packages/js-expr/src/symbols/index.ts | 3 + packages/js-expr/src/symbols/symbols-table.ts | 350 +++++++ packages/js-expr/src/symbols/symbols.ts | 268 ++++++ packages/js-expr/src/symbols/types.ts | 193 ++++ packages/js-expr/src/template.ts | 57 ++ packages/js-expr/src/types.ts | 176 ++++ packages/js-expr/src/utils.ts | 40 + packages/js-expr/tsconfig.build.json | 10 + packages/js-expr/tsconfig.json | 18 + .../js-expr/types/eval-estree-expression.d.ts | 63 ++ 24 files changed, 3839 insertions(+), 3 deletions(-) create mode 100644 packages/js-expr/.gitignore create mode 100644 packages/js-expr/README.md create mode 100644 packages/js-expr/package.json create mode 100644 packages/js-expr/src/__tests__/autocomplete.test.ts create mode 100644 packages/js-expr/src/__tests__/input-values.test.ts create mode 100644 packages/js-expr/src/__tests__/runtime.test.ts create mode 100644 packages/js-expr/src/__tests__/template.test.ts create mode 100644 packages/js-expr/src/autocomplete.ts create mode 100644 packages/js-expr/src/errors.ts create mode 100644 packages/js-expr/src/index.ts create mode 100644 packages/js-expr/src/input-values.ts create mode 100644 packages/js-expr/src/runtime.ts create mode 100644 packages/js-expr/src/symbols/__tests__/symbols.test.ts create mode 100644 packages/js-expr/src/symbols/index.ts create mode 100644 packages/js-expr/src/symbols/symbols-table.ts create mode 100644 packages/js-expr/src/symbols/symbols.ts create mode 100644 packages/js-expr/src/symbols/types.ts create mode 100644 packages/js-expr/src/template.ts create mode 100644 packages/js-expr/src/types.ts create mode 100644 packages/js-expr/src/utils.ts create mode 100644 packages/js-expr/tsconfig.build.json create mode 100644 packages/js-expr/tsconfig.json create mode 100644 packages/js-expr/types/eval-estree-expression.d.ts diff --git a/bun.lock b/bun.lock index e09851ea69..9d4888c02f 100644 --- a/bun.lock +++ b/bun.lock @@ -194,6 +194,23 @@ "react": "*", }, }, + "packages/js-expr": { + "name": "@gitbook/js-expr", + "version": "1.0.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-loose": "8.4.0", + "acorn-walk": "^8.3.4", + "assert-never": "^1.2.1", + "eval-estree-expression": "^2.0.3", + }, + "devDependencies": { + "@babel/types": "^7.26.0", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "bun-types": "^1.1.20", + }, + }, "packages/openapi-parser": { "name": "@gitbook/openapi-parser", "version": "3.0.0", @@ -424,9 +441,9 @@ "@babel/code-frame": ["@babel/code-frame@7.25.7", "", { "dependencies": { "@babel/highlight": "^7.25.7", "picocolors": "^1.0.0" } }, "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.7", "", {}, "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.7", "", {}, "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], "@babel/highlight": ["@babel/highlight@7.25.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.7", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw=="], @@ -434,7 +451,7 @@ "@babel/runtime": ["@babel/runtime@7.25.7", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w=="], - "@babel/types": ["@babel/types@7.25.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.7", "@babel/helper-validator-identifier": "^7.25.7", "to-fast-properties": "^2.0.0" } }, "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg=="], + "@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], @@ -660,6 +677,8 @@ "@gitbook/icons": ["@gitbook/icons@workspace:packages/icons"], + "@gitbook/js-expr": ["@gitbook/js-expr@workspace:packages/js-expr"], + "@gitbook/openapi-parser": ["@gitbook/openapi-parser@workspace:packages/openapi-parser"], "@gitbook/react-contentkit": ["@gitbook/react-contentkit@workspace:packages/react-contentkit"], @@ -1476,6 +1495,8 @@ "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + "acorn-loose": ["acorn-loose@8.4.0", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-M0EUka6rb+QC4l9Z3T0nJEzNOO7JcoJlYMrBlyBCiFSXRyxjLKayd4TbQs2FDRWQU1h9FR7QVNHt+PEaoNL5rQ=="], + "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], "agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], @@ -1826,6 +1847,8 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eval-estree-expression": ["eval-estree-expression@2.1.1", "", {}, "sha512-9kNUU4c+kUs5rKR7V5n81Ebp6fId1v01XSHshPuDIQ8N2VKAAzSzN3o/hfzERdNU6ZGh97LYFT7wWrL0cqhV3A=="], + "event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], "event-iterator": ["event-iterator@2.0.0", "", {}, "sha512-KGft0ldl31BZVV//jj+IAIGCxkvvUkkON+ScH6zfoX+l+omX6001ggyRSpI0Io2Hlro0ThXotswCtfzS8UkIiQ=="], @@ -3654,8 +3677,12 @@ "@aws-sdk/xml-builder/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@babel/highlight/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.7", "", {}, "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg=="], + "@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + "@babel/parser/@babel/types": ["@babel/types@7.25.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.7", "@babel/helper-validator-identifier": "^7.25.7", "to-fast-properties": "^2.0.0" } }, "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg=="], + "@changesets/parse/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], "@codemirror/lang-html/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="], @@ -4922,6 +4949,10 @@ "@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.7", "", {}, "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g=="], + + "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.7", "", {}, "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg=="], + "@changesets/parse/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "@codemirror/lang-json/@codemirror/language/@codemirror/view": ["@codemirror/view@6.34.1", "", { "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ=="], diff --git a/packages/js-expr/.gitignore b/packages/js-expr/.gitignore new file mode 100644 index 0000000000..53c37a1660 --- /dev/null +++ b/packages/js-expr/.gitignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/packages/js-expr/README.md b/packages/js-expr/README.md new file mode 100644 index 0000000000..057c23eef8 --- /dev/null +++ b/packages/js-expr/README.md @@ -0,0 +1,3 @@ +# `@gitbook/js-expr` + +Safely evaluate & parse user-defined JS expression. diff --git a/packages/js-expr/package.json b/packages/js-expr/package.json new file mode 100644 index 0000000000..78a6a60653 --- /dev/null +++ b/packages/js-expr/package.json @@ -0,0 +1,34 @@ +{ + "name": "@gitbook/js-expr", + "description": "Safely evaluate & parse user-defined JS expression.", + "version": "0.0.1", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "sideEffects": false, + "dependencies": { + "eval-estree-expression": "^2.0.3", + "acorn": "^8.14.0", + "acorn-loose": "8.4.0", + "acorn-walk": "^8.3.4", + "assert-never": "^1.2.1" + }, + "devDependencies": { + "bun-types": "^1.1.20", + "@types/estree": "^1.0.6", + "@babel/types": "^7.26.0", + "@types/json-schema": "^7.0.15" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "typecheck": "tsc --noEmit", + "unit": "bun test", + "clean": "rm -rf ./dist" + }, + "files": ["dist", "src", "README.md", "CHANGELOG.md"] +} diff --git a/packages/js-expr/src/__tests__/autocomplete.test.ts b/packages/js-expr/src/__tests__/autocomplete.test.ts new file mode 100644 index 0000000000..05d8a53a44 --- /dev/null +++ b/packages/js-expr/src/__tests__/autocomplete.test.ts @@ -0,0 +1,885 @@ +import { describe, expect, it } from 'bun:test'; + +import { ExpressionRuntime } from '../runtime'; +import { + SymbolArray, + SymbolBoolean, + SymbolNumber, + SymbolObject, + SymbolString, + SymbolType, + SymbolsTable, +} from '../symbols'; +import { + type AutocompleteSuggestions, + type AutocompleteSymbolSuggestion, + SUPPORTED_BINARY_OPERATORS, + SUPPORTED_CONDITIONAL_OPERATORS, + SUPPORTED_LOGICAL_OPERATORS, +} from '../types'; + +describe('autocomplete', () => { + const runtime = new ExpressionRuntime(); + const visitorClaimsHelloArraySymbol = SymbolArray({ + name: 'hello', + description: 'An array of string', + items: SymbolString(), + }); + const symbols = { + visitor: SymbolObject({ + name: 'visitor', + properties: { + claims: SymbolObject({ + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: SymbolString({ name: 'key' }), + flags: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + hello: visitorClaimsHelloArraySymbol, + role: SymbolString({ + name: 'role', + enum: ['admin', 'editor', 'reader'], + }), + }, + methods: [], + }), + }, + methods: [], + }), + }; + const context = new SymbolsTable(symbols); + const SCENARIOS: Array<{ + expressionWithCursor: string; + expectedSuggestions: AutocompleteSuggestions; + }> = [ + { + expressionWithCursor: 'visit', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolObject({ + name: 'visitor', + properties: { + claims: SymbolObject({ + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: SymbolString({ name: 'key' }), + flags: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + hello: SymbolArray({ + name: 'hello', + description: 'An array of string', + items: SymbolString(), + }), + role: SymbolString({ + name: 'role', + enum: ['admin', 'editor', 'reader'], + }), + }, + methods: [], + }), + }, + methods: [], + }), + ref: 'visitor', + parentRef: undefined, + childrenRefs: ['visitor.claims'], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor', + expectedSuggestions: [], + }, + { + expressionWithCursor: 'visitor.', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolObject({ + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: SymbolString({ name: 'key' }), + flags: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + hello: SymbolArray({ + name: 'hello', + description: 'An array of string', + items: SymbolString(), + }), + role: SymbolString({ + name: 'role', + enum: ['admin', 'editor', 'reader'], + }), + }, + methods: [], + }), + ref: 'visitor.claims', + parentRef: 'visitor', + childrenRefs: [ + 'visitor.claims.key', + 'visitor.claims.flags', + 'visitor.claims.hello', + 'visitor.claims.role', + ], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolString({ name: 'key' }), + ref: 'visitor.claims.key', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.key.length', + 'visitor.claims.key.at', + 'visitor.claims.key.endsWith', + 'visitor.claims.key.includes', + ], + }, + }, + { + type: 'symbol', + symbol: { + definition: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + ref: 'visitor.claims.flags', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.flags.FLAG1', + 'visitor.claims.flags.FLAG2', + 'visitor.claims.flags.FLAG3', + 'visitor.claims.flags.FLAG4', + ], + }, + }, + { + type: 'symbol', + symbol: { + definition: SymbolArray({ + name: 'hello', + description: 'An array of string', + items: SymbolString(), + }), + ref: 'visitor.claims.hello', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.hello.length', + 'visitor.claims.hello.at', + 'visitor.claims.hello.includes', + ], + }, + }, + { + type: 'symbol', + symbol: { + definition: SymbolString({ + name: 'role', + enum: ['admin', 'editor', 'reader'], + }), + ref: 'visitor.claims.role', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.role.length', + 'visitor.claims.role.at', + 'visitor.claims.role.endsWith', + 'visitor.claims.role.includes', + ], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.ke', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolString({ name: 'key' }), + ref: 'visitor.claims.key', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.key.length', + 'visitor.claims.key.at', + 'visitor.claims.key.endsWith', + 'visitor.claims.key.includes', + ], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.h', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolArray({ + name: 'hello', + description: 'An array of string', + items: SymbolString(), + }), + ref: 'visitor.claims.hello', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.hello.length', + 'visitor.claims.hello.at', + 'visitor.claims.hello.includes', + ], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.hello.', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolNumber({ + name: 'length', + description: `The length data property of an Array instance represents the number of elements in that array. + The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`, + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length', + }), + ref: 'visitor.claims.hello.length', + parentRef: 'visitor.claims.hello', + childrenRefs: [], + }, + }, + ...visitorClaimsHelloArraySymbol.methods.map( + (method) => ({ + type: 'symbol', + symbol: { + definition: method, + ref: `visitor.claims.hello.${method.name}`, + parentRef: 'visitor.claims.hello', + childrenRefs: [], + }, + }) + ), + ], + }, + { + expressionWithCursor: 'visitor.claims.f', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + ref: 'visitor.claims.flags', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.flags.FLAG1', + 'visitor.claims.flags.FLAG2', + 'visitor.claims.flags.FLAG3', + 'visitor.claims.flags.FLAG4', + ], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.fl', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + ref: 'visitor.claims.flags', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.flags.FLAG1', + 'visitor.claims.flags.FLAG2', + 'visitor.claims.flags.FLAG3', + 'visitor.claims.flags.FLAG4', + ], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolBoolean({ name: 'FLAG1' }), + ref: 'visitor.claims.flags.FLAG1', + parentRef: 'visitor.claims.flags', + childrenRefs: [], + }, + }, + { + type: 'symbol', + symbol: { + definition: SymbolBoolean({ name: 'FLAG2' }), + ref: 'visitor.claims.flags.FLAG2', + parentRef: 'visitor.claims.flags', + childrenRefs: [], + }, + }, + { + type: 'symbol', + symbol: { + definition: SymbolBoolean({ name: 'FLAG3' }), + ref: 'visitor.claims.flags.FLAG3', + parentRef: 'visitor.claims.flags', + childrenRefs: [], + }, + }, + { + type: 'symbol', + symbol: { + definition: SymbolBoolean({ name: 'FLAG4' }), + ref: 'visitor.claims.flags.FLAG4', + parentRef: 'visitor.claims.flags', + childrenRefs: [], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FL', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolBoolean({ name: 'FLAG1' }), + ref: 'visitor.claims.flags.FLAG1', + parentRef: 'visitor.claims.flags', + childrenRefs: [], + }, + }, + { + type: 'symbol', + symbol: { + definition: SymbolBoolean({ name: 'FLAG2' }), + ref: 'visitor.claims.flags.FLAG2', + parentRef: 'visitor.claims.flags', + childrenRefs: [], + }, + }, + { + type: 'symbol', + symbol: { + definition: SymbolBoolean({ name: 'FLAG3' }), + ref: 'visitor.claims.flags.FLAG3', + parentRef: 'visitor.claims.flags', + childrenRefs: [], + }, + }, + { + type: 'symbol', + symbol: { + definition: SymbolBoolean({ name: 'FLAG4' }), + ref: 'visitor.claims.flags.FLAG4', + parentRef: 'visitor.claims.flags', + childrenRefs: [], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1', + expectedSuggestions: [], + }, + { + expressionWithCursor: 'visitor.claims.key ', + expectedSuggestions: [...SUPPORTED_BINARY_OPERATORS].map((op) => ({ + type: 'operator', + ...op, + })), + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 ', + expectedSuggestions: [ + ...[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS].filter((op) => + ['==', '!=', '===', '!==', '&&', '||'].includes(op.operator) + ), + ...SUPPORTED_CONDITIONAL_OPERATORS, + ].map((op) => ({ + type: 'operator', + ...op, + })), + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 =', + expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) => + ['==', '==='].includes(op.operator) + ).map((op) => ({ + type: 'operator', + ...op, + })), + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 ==', + expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter( + (op) => op.operator === '===' + ).map((op) => ({ + type: 'operator', + ...op, + })), + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 !', + expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) => + ['!=', '!=='].includes(op.operator) + ).map((op) => ({ + type: 'operator', + ...op, + })), + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 !=', + expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter( + (op) => op.operator === '!==' + ).map((op) => ({ + type: 'operator', + ...op, + })), + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 == ', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.Boolean, data: true }, + }, + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.Boolean, data: false }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 == t', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.Boolean, data: true }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 == tr', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.Boolean, data: true }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 == true', + expectedSuggestions: [], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 == f', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.Boolean, data: false }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 == fa', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.Boolean, data: false }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 == non', + expectedSuggestions: [], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 == false', + expectedSuggestions: [], + }, + { + expressionWithCursor: 'visitor.claims.role == ', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'admin' }, + }, + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'editor' }, + }, + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'reader' }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.role == ad', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'admin' }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.role == admin', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'admin' }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.role == "ad', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'admin' }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.role == "admin', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'admin' }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.role == "admin"', + expectedSuggestions: [], + }, + { + expressionWithCursor: 'visitor.claims.role == edit', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'editor' }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.role == "edit', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'editor' }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.role == editor', + expectedSuggestions: [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.String, data: 'editor' }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.role == "editor"', + expectedSuggestions: [], + }, + { + expressionWithCursor: 'visitor.claims.hello == ', + expectedSuggestions: [], + }, + { + expressionWithCursor: 'visitor.claims.hello[1] == ', + expectedSuggestions: [ + { + type: 'literal-value', + value: { + kind: 'in-array', + srcSymbol: visitorClaimsHelloArraySymbol, + matchedLiteralString: '', + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.hello[1] == "test', + expectedSuggestions: [ + { + type: 'literal-value', + value: { + kind: 'in-array', + srcSymbol: visitorClaimsHelloArraySymbol, + matchedLiteralString: '"test', + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 == true ', + expectedSuggestions: [ + ...SUPPORTED_LOGICAL_OPERATORS.filter((op) => ['&&', '||'].includes(op.operator)), + ...SUPPORTED_CONDITIONAL_OPERATORS, + ].map((op) => ({ + type: 'operator', + ...op, + })), + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 !== true && v', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolObject({ + name: 'visitor', + properties: { + claims: SymbolObject({ + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: SymbolString({ name: 'key' }), + flags: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + hello: SymbolArray({ + name: 'hello', + description: 'An array of string', + items: SymbolString(), + }), + role: SymbolString({ + name: 'role', + enum: ['admin', 'editor', 'reader'], + }), + }, + methods: [], + }), + }, + methods: [], + }), + ref: 'visitor', + parentRef: undefined, + childrenRefs: ['visitor.claims'], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 ? ', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolObject({ + name: 'visitor', + properties: { + claims: SymbolObject({ + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: SymbolString({ name: 'key' }), + flags: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + hello: SymbolArray({ + name: 'hello', + description: 'An array of string', + items: SymbolString(), + }), + role: SymbolString({ + name: 'role', + enum: ['admin', 'editor', 'reader'], + }), + }, + methods: [], + }), + }, + methods: [], + }), + ref: 'visitor', + parentRef: undefined, + childrenRefs: ['visitor.claims'], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visit', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolObject({ + name: 'visitor', + properties: { + claims: SymbolObject({ + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: SymbolString({ name: 'key' }), + flags: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + hello: SymbolArray({ + name: 'hello', + description: 'An array of string', + items: SymbolString(), + }), + role: SymbolString({ + name: 'role', + enum: ['admin', 'editor', 'reader'], + }), + }, + methods: [], + }), + }, + methods: [], + }), + ref: 'visitor', + parentRef: undefined, + childrenRefs: ['visitor.claims'], + }, + }, + ], + }, + { + expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visitor.claims.fl', + expectedSuggestions: [ + { + type: 'symbol', + symbol: { + definition: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolBoolean({ name: 'FLAG1' }), + FLAG2: SymbolBoolean({ name: 'FLAG2' }), + FLAG3: SymbolBoolean({ name: 'FLAG3' }), + FLAG4: SymbolBoolean({ name: 'FLAG4' }), + }, + methods: [], + }), + ref: 'visitor.claims.flags', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.flags.FLAG1', + 'visitor.claims.flags.FLAG2', + 'visitor.claims.flags.FLAG3', + 'visitor.claims.flags.FLAG4', + ], + }, + }, + ], + }, + ]; + it.each(SCENARIOS)( + 'should provide matching suggestion for expression with cursor: $expressionWithCursor', + ({ expressionWithCursor, expectedSuggestions }) => { + const { expression, cursorOffset } = extractCursorPosition( + expressionWithCursor, + '' + ); + const { suggestions } = runtime.autocomplete(expression, cursorOffset, context); + expect(suggestions).toStrictEqual(expectedSuggestions); + } + ); +}); + +function extractCursorPosition( + expressionWithCursor: string, + cursorPlaceholder: string +): { expression: string; cursorOffset: number } { + const cursorOffset = expressionWithCursor.indexOf(cursorPlaceholder); + if (cursorOffset === -1) { + throw new Error( + `Cursor position (${cursorPlaceholder}) not found in the expression string.` + ); + } + + const expression = expressionWithCursor.replace(cursorPlaceholder, ''); + return { expression, cursorOffset }; +} diff --git a/packages/js-expr/src/__tests__/input-values.test.ts b/packages/js-expr/src/__tests__/input-values.test.ts new file mode 100644 index 0000000000..f36af0fadb --- /dev/null +++ b/packages/js-expr/src/__tests__/input-values.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'bun:test'; + +import { inferDefaultInputValuesFromObjectJSONSchema } from '../input-values'; + +describe('inferDefaultInputValuesFromObjectJSONSchema', () => { + it('should infer properly the default input value based on the JSON schema of an object', () => { + const defaultInputValues = inferDefaultInputValuesFromObjectJSONSchema({ + type: 'object', + properties: { + claims: { + type: 'object', + properties: { + key: { + type: 'string', + }, + flags: { + type: 'object', + properties: { + FLAG1: { type: 'string' }, + FLAG2: { type: 'string' }, + FLAG3: { type: 'string' }, + }, + }, + isAlphaUser: { + type: 'boolean', + }, + hello: { + type: 'string', + enum: ['enumValue1', 'enumValue2', 'enumValue3'], + }, + }, + }, + }, + }); + expect(defaultInputValues).toMatchObject({ + claims: { + key: 'default', + flags: { + FLAG1: 'default', + FLAG2: 'default', + FLAG3: 'default', + }, + isAlphaUser: true, + hello: 'enumValue1', + }, + }); + }); +}); diff --git a/packages/js-expr/src/__tests__/runtime.test.ts b/packages/js-expr/src/__tests__/runtime.test.ts new file mode 100644 index 0000000000..c14e675413 --- /dev/null +++ b/packages/js-expr/src/__tests__/runtime.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'bun:test'; + +import { ExpressionError } from '../errors'; +import { ExpressionRuntime } from '../runtime'; +import type { Logger } from '../types'; + +const SILENT_LOGGER: Logger = { + debug: () => {}, + info: () => {}, + error: () => {}, +}; + +describe('ExpressionRuntime', () => { + const runtime = new ExpressionRuntime(SILENT_LOGGER); + + describe('evaluate', () => { + it.each([ + { + scenario: 'simple condition', + condition: 'isBetaUser === true', + inputs: { isBetaUser: false }, + expectedResult: false, + }, + { + scenario: 'simple condition with multiple inputs variables', + condition: 'useProductA && !isBetaUser', + inputs: { + useProductA: true, + isBetaUser: false, + }, + expectedResult: true, + }, + { + scenario: 'condition with objects in inputs variables', + condition: 'products.includes("productA") && userSegments.alpha', + inputs: { + products: ['productA', 'productB'], + userSegments: { + alpha: true, + beta: false, + }, + }, + expectedResult: true, + }, + ])( + 'should properly evaluate/safeEvaluate a valid conditional expression: $scenario', + ({ condition, inputs, expectedResult }) => { + expect(runtime.evaluate(condition, inputs)).toBe(expectedResult); + expect(runtime.safeEvaluate(condition, inputs).value).toBe(expectedResult); + } + ); + + const INVALID_EXPRESSSIONS = [ + { + scenario: 'invalid syntax', + condition: 't}=d', + inputs: {}, + }, + { + scenario: 'non conditional expression', + condition: 'const a = 1;', + inputs: {}, + }, + { + scenario: 'unsafe expression', + condition: 'while (1) {}', + inputs: {}, + }, + { + scenario: 'unsafe expression', + condition: '[1, 2, 3].map(() => { while (1) {}})', + inputs: {}, + }, + ]; + + it.each(INVALID_EXPRESSSIONS)( + 'should return an object with the error for non conditional expression or syntax errors when using safeEvaluate is on (default): $scenario', + ({ condition, inputs }) => { + const result = runtime.safeEvaluate(condition, inputs); + expect(result.value).toBeUndefined(); + expect(result.error instanceof ExpressionError).toBe(true); + } + ); + + it.each(INVALID_EXPRESSSIONS)( + 'should throw an error when using evaluate with invalid expressions', + ({ condition, inputs }) => { + expect(() => runtime.evaluate(condition, inputs)).toThrowError(ExpressionError); + } + ); + }); + + describe('parse', () => { + it('should produce a valid ESTree compatible AST node for conditional expressions', () => { + const ast = runtime.parse('isBetaUser === true'); + + expect(ast.result).toEqual({ + type: 'BinaryExpression', + start: 0, + end: 19, + loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 19 } }, + left: { + type: 'Identifier', + start: 0, + end: 10, + loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } }, + name: 'isBetaUser', + }, + operator: '===', + right: { + type: 'Literal', + start: 15, + end: 19, + loc: { start: { line: 1, column: 15 }, end: { line: 1, column: 19 } }, + value: true, + raw: 'true', + }, + }); + }); + + it.each([ + { + scenario: 'invalid syntax', + condition: 't}=d', + }, + { + scenario: 'non conditional expression', + condition: 'const a = 1;', + }, + ])( + 'should throw an error for non conditional expressions or syntax errors: $scenario', + ({ condition }) => { + expect(() => runtime.parse(condition)).toThrowError(ExpressionError); + } + ); + }); + + describe.skip('generate', () => { + it.each([ + { + scenario: 'simple condition', + condition: 'isBetaUser === true', + }, + { + scenario: 'simple condition with multiple inputs variables', + condition: 'useProductA && !isBetaUser', + }, + { + scenario: 'condition with objects in inputs variables', + condition: 'products.includes("productA") && userSegments.alpha', + }, + ])( + 'should produce the original expression using an AST node produced by parse: $scenario', + ({ condition }) => { + const { result } = runtime.parse(condition); + expect(runtime.generate(result)).toStrictEqual(condition); + } + ); + }); +}); diff --git a/packages/js-expr/src/__tests__/template.test.ts b/packages/js-expr/src/__tests__/template.test.ts new file mode 100644 index 0000000000..56a3e70bc4 --- /dev/null +++ b/packages/js-expr/src/__tests__/template.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test'; + +import { ExpressionRuntime, parseTemplate } from '../'; + +describe('template expressions', () => { + it('should parse template into parts', () => { + const parts = parseTemplate('Hello {{ user.name }}!'); + expect(parts).toEqual([ + { type: 'text', value: 'Hello ', start: 0, end: 6 }, + { type: 'expression', value: 'user.name', start: 8, end: 19 }, + { type: 'text', value: '!', start: 21, end: 22 }, + ]); + }); + + it('should parse template starting with an expression', () => { + const parts = parseTemplate('{{ user.name }} is cool'); + expect(parts).toEqual([ + { type: 'expression', value: 'user.name', start: 2, end: 13 }, + { type: 'text', value: ' is cool', start: 15, end: 23 }, + ]); + }); + + it('should parse template without expressions', () => { + const parts = parseTemplate('Hello world'); + expect(parts).toEqual([{ type: 'text', value: 'Hello world', start: 0, end: 11 }]); + }); + + it('should evaluate template', () => { + const runtime = new ExpressionRuntime(); + const result = runtime.evaluateTemplate('Hello {{ user.name }}!', { + user: { name: 'John' }, + }); + expect(result).toBe('Hello John!'); + }); +}); diff --git a/packages/js-expr/src/autocomplete.ts b/packages/js-expr/src/autocomplete.ts new file mode 100644 index 0000000000..e87791f1b1 --- /dev/null +++ b/packages/js-expr/src/autocomplete.ts @@ -0,0 +1,568 @@ +import type { + Node as AcornNode, + AnyNode, + BinaryExpression, + Expression, + Identifier, + Literal, + MemberExpression, + PrivateIdentifier, + Super, +} from 'acorn'; +import { isDummy } from 'acorn-loose'; +import * as walk from 'acorn-walk'; + +import assertNever from 'assert-never'; + +import { type ExtractSymbolDef, SymbolType, SymbolsTable } from './symbols'; +import { + type AutocompleteLiteralValueSuggestion, + type AutocompleteOperatorSuggestion, + type AutocompleteSuggestions, + type AutocompleteSymbolSuggestion, + type DirectLiteralValueSuggestion, + type ExpressionParserResult, + type Logger, + SUPPORTED_BINARY_OPERATORS, + SUPPORTED_CONDITIONAL_OPERATORS, + SUPPORTED_LOGICAL_OPERATORS, +} from './types'; + +interface ExpressionParser { + parse(expr: string, options: { loose?: boolean }): ExpressionParserResult; +} + +export class AutoComplete { + #parser: ExpressionParser; + #logger: Logger; + + constructor(parser: ExpressionParser, logger: Logger = console) { + this.#parser = parser; + this.#logger = logger; + } + + /** + * Generates autocomplete suggestions based on the input expression and cursor offset position. + */ + public getSuggestions( + expr: string, + cursorOffset: number, + context: SymbolsTable + ): AutocompleteSuggestions { + if (!expr.length) { + return []; + } + + try { + const { result, invalidNodes } = this.#parser.parse(expr, { loose: true }); + + // Locate the node at the cursor position. + const nodeAtCursorFound = walk.findNodeAround(result, cursorOffset, (_type, node) => + isNodeAtCursor(node, cursorOffset) + ); + + if (!nodeAtCursorFound) { + // When we can't find one we might be in the boundary of the program/expression. + // We could possibly be in a whitespace at the end of the expression or in a situation where the parsed + // tree may contain 2 top level ExpressionStatement (second one returned as invalid node by the parser). + // + // In this case we want to provide operators as suggestions and refine the search of the "cursor" node to either: + // - using the end position of the whole expression AST (e.g white space at the very end of the expression string) + // - or using the second top level ExpressionStatement node found by the parser as the cursor is at the end of that node. + if (cursorOffset > result.end) { + const ast = invalidNodes.length > 0 ? invalidNodes[0]?.expression : result; + + if (!ast) { + return []; + } + + const lastNodeFound = walk.findNodeAround(ast, ast.end, (_type, node) => + isNodeAtCursor(node, ast.end) + ); + if (!lastNodeFound) { + return []; + } + const { node } = lastNodeFound; + + if (!isAnyNode(node)) { + throw Error(`Unexpected node type ${node.type}`); + } + + return this.getOperatorSuggestionsForNode(ast, node, result.end, context); + } + return []; + } + + const { node } = nodeAtCursorFound; + + if (!isAnyNode(node)) { + throw Error(`Unexpected node type ${node.type}`); + } + + return this.getSuggestionsForNode(result, node, expr, cursorOffset, context); + } catch (error) { + this.#logger.error('Error while computing autocomplete suggestions', error); + return []; + } + } + + /** + * Provides autocomplete suggestions for a specific node in the AST. + */ + private getSuggestionsForNode( + ast: Expression, + node: AnyNode, + expr: string, + cursorOffset: number, + context: SymbolsTable + ): AutocompleteSuggestions { + // When the node is an identifier look up the parent to get more context for the suggestions. + let inferNode: AnyNode = node; + if (node.type === 'Identifier' || node.type === 'Literal') { + const parent = findParentNode(node, ast); + inferNode = + parent && + !['ExpressionStatement', 'LogicalExpression', 'ConditionalExpression'].includes( + parent.type + ) + ? parent + : node; + } + + switch (inferNode.type) { + case 'Identifier': + case 'MemberExpression': { + const pathParts = this.getSymbolsPathPartsForMemberExpressionNode( + inferNode, + cursorOffset + ); + + // Fetch suggestions from the symbol table + const candidatesKeys = context.getMatchingSymbolsKeys(pathParts); + + const suggestions: AutocompleteSymbolSuggestion[] = []; + for (const candidate of candidatesKeys) { + const symbolInfo = context.getSymbolInfo(candidate); + if (symbolInfo) { + suggestions.push({ type: 'symbol', symbol: symbolInfo }); + } + } + + if (suggestions.length === 1) { + const lastPathSegment = pathParts.at(-1)?.replace(/\*$/, ''); + + // Return no suggestion when the only match is an exact match of the + // typed token. + return lastPathSegment !== suggestions[0]?.symbol.definition.name + ? suggestions + : []; + } + + return suggestions; + } + case 'AssignmentExpression': + case 'UnaryExpression': { + // Provide suggestions for binary or logical operators based on the parsed operator + // of the partial expression (e.g suggest "==" when typing "=" (parsed as AssignmentExpression)). + return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context); + } + case 'BinaryExpression': { + const { left, right } = inferNode; + + const isOperatorBinaryOp = SUPPORTED_BINARY_OPERATORS.some( + (op) => op.operator === inferNode.operator + ); + + const operatorIndex = expr.indexOf(inferNode.operator, left.end); + const operatorOffset = operatorIndex + inferNode.operator.length; + const isCursorAfterOperator = cursorOffset > operatorOffset; + + const shouldSuggestValues = + isCursorAfterOperator && + isOperatorBinaryOp && + isNodeAtCursor(right, cursorOffset); + + if (shouldSuggestValues) { + return this.getLiteralValueSuggestionsForNode(inferNode, cursorOffset, context); + } + + // Provide suggestions for binary operators based on the parsed operator + return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context); + } + case 'ConditionalExpression': { + const { consequent, alternate } = inferNode; + + if (isNodeAtCursor(consequent, cursorOffset)) { + return this.getSuggestionsForNode(ast, consequent, expr, cursorOffset, context); + } + + if (isNodeAtCursor(alternate, cursorOffset)) { + return this.getSuggestionsForNode(ast, alternate, expr, cursorOffset, context); + } + } + } + + return []; + } + + /** + * Return a path corresponding to the MemberExpression node that can be used to lookup matching symbols in the symbol table. + */ + private getSymbolsPathPartsForMemberExpressionNode( + node: MemberExpression | Identifier, + cursorOffset: number, + options?: { withWildcardMatches: boolean } + ): string[] { + const withWildcardMatches = options?.withWildcardMatches ?? true; + const pathParts: string[] = []; + + switch (node.type) { + case 'MemberExpression': + { + const memberProperty = node.property; + + // Only support identifier or literal expressions as member properties. + if (!isSupportedMemberProperty(memberProperty)) { + return []; + } + + // Push the path part corresponding to the member property (e.g b in a.b or a['b']) + const propertyPathPart = this.getSymbolsPathPartFromMemberProperty( + memberProperty, + cursorOffset + ); + + if (propertyPathPart) { + pathParts.push( + withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart + ); + } + + // Go through the parent(s) in the chain and add their path parts as well + let parent: Expression | Super | undefined = node.object; + while (parent) { + const parentProperty = 'property' in parent ? parent.property : parent; + + // Only support identifier or literal expressions as parent property. + const parentPropertyPathPart = isSupportedMemberProperty(parentProperty) + ? this.getSymbolsPathPartFromMemberProperty( + parentProperty, + cursorOffset + ) + : undefined; + + if (parentPropertyPathPart) { + pathParts.unshift(parentPropertyPathPart); + } + + parent = 'object' in parent ? parent.object : undefined; + } + } + break; + case 'Identifier': { + const propertyPathPart = this.getSymbolsPathPartFromMemberProperty( + node, + cursorOffset + ); + if (propertyPathPart) { + pathParts.push(withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart); + } + break; + } + default: + assertNever(node); + } + + return pathParts; + } + + /** + * Return a part of a symbol path corresponding to a MemberExpression node property. + */ + private getSymbolsPathPartFromMemberProperty( + node: Identifier | Literal, + cursorOffset: number + ): string { + switch (node.type) { + case 'Identifier': { + if (isDummy(node)) { + return '*'; + } + + return isNodeAtCursor(node, cursorOffset) + ? node.name.slice(0, cursorOffset) + : node.name; + } + case 'Literal': { + if (isDummy(node) || !node.value) { + return '*'; + } + const value = String(node.value); + return isNodeAtCursor(node, cursorOffset) ? value.slice(0, cursorOffset) : value; + } + default: + assertNever(node); + } + } + + /** + * Provides autocomplete literal value suggestions for a specific node in the AST. + */ + private getLiteralValueSuggestionsForNode( + node: BinaryExpression, + cursorOffset: number, + context: SymbolsTable + ): Array { + const { left, right } = node; + + if (left.type !== 'MemberExpression' && left.type !== 'Identifier') { + return []; + } + + if (right.type !== 'Identifier' && right.type !== 'Literal') { + return []; + } + + const leftSymbolPath = this.getSymbolsPathPartsForMemberExpressionNode(left, cursorOffset, { + withWildcardMatches: false, + }); + + const isLeftComputedMember = left.type === 'MemberExpression' && left.computed; + const leftSymbolInfo = context.getSymbolInfo( + isLeftComputedMember ? leftSymbolPath.slice(0, -1) : leftSymbolPath + ); + + if (!leftSymbolInfo) { + return []; + } + + switch (leftSymbolInfo.definition.type) { + case SymbolType.Boolean: { + const suggestions: DirectLiteralValueSuggestion[] = [ + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.Boolean, data: true }, + }, + { + type: 'literal-value', + value: { kind: 'direct', type: SymbolType.Boolean, data: false }, + }, + ]; + + return isDummy(right) + ? suggestions + : suggestions.filter((literalValue) => { + const literalValueString = String(literalValue.value.data); + const rightNodeValue = + right.type === 'Identifier' ? right.name : (right.raw ?? ''); + return ( + literalValueString !== rightNodeValue && + literalValueString.startsWith(rightNodeValue) + ); + }); + } + case SymbolType.String: { + if (!leftSymbolInfo.definition.enum) { + return []; + } + + const rightNodeValue = (() => { + if (right.type === 'Identifier') { + return right.name; + } + + const nodeValue = right.raw ?? ''; + if (/^(['"])(.*)\1$/.test(nodeValue)) { + return null; + } + + return nodeValue.replaceAll(/["']/g, ''); + })(); + + if (rightNodeValue === null) { + return []; + } + + const suggestions = isDummy(right) + ? leftSymbolInfo.definition.enum + : leftSymbolInfo.definition.enum.filter((enumValue) => + enumValue.startsWith(rightNodeValue) + ); + + return suggestions.map((value) => ({ + type: 'literal-value', + value: { + kind: 'direct', + type: SymbolType.String, + data: value, + }, + })); + } + case SymbolType.Array: { + // Only return a literal in array value suggestion when the left hand side of the binary expression + // is computed, e.g myArray[1] + return isLeftComputedMember + ? [ + { + type: 'literal-value', + value: { + kind: 'in-array', + srcSymbol: leftSymbolInfo.definition, + matchedLiteralString: !isDummy(right) + ? right.type === 'Identifier' + ? right.name + : (right.raw ?? '') + : '', + }, + }, + ] + : []; + } + default: + return []; + } + } + + /** + * Provides autocomplete operator suggestions for a specific node in the AST. + */ + private getOperatorSuggestionsForNode( + ast: Expression, + node: AnyNode, + cursorOffset: number, + context: SymbolsTable + ): Array { + if (node.type === 'Literal') { + const parent = findParentNode(node, ast); + + if (parent?.type === 'BinaryExpression') { + return [...SUPPORTED_LOGICAL_OPERATORS, ...SUPPORTED_CONDITIONAL_OPERATORS].map( + (op) => ({ + type: 'operator', + ...op, + }) + ); + } + + const literalSymbol = SymbolsTable.inferSymbolFromValue(node.raw); + return this.getOperatorSuggestionsForSymbol(literalSymbol); + } + + // When the node is an identifier look the parent to get more context for the suggestions + let inferNode: AnyNode = node; + if (node.type === 'Identifier') { + const parent = findParentNode(node, ast); + inferNode = parent && parent.type !== 'ExpressionStatement' ? parent : node; + } + + switch (inferNode.type) { + case 'MemberExpression': { + const pathParts = this.getSymbolsPathPartsForMemberExpressionNode( + inferNode, + cursorOffset, + { + withWildcardMatches: false, + } + ); + const symbolInfo = context.getSymbolInfo(pathParts); + return symbolInfo + ? this.getOperatorSuggestionsForSymbol(symbolInfo.definition) + : []; + } + case 'AssignmentExpression': + case 'BinaryExpression': + case 'UnaryExpression': { + // Starting to write a binary/logical operator so suggest operator matching the already + // typed character as operator. + const operator = inferNode.operator; + return ( + [...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS] + .filter((op) => op.operator.startsWith(operator)) + // No need to include the operator that match exactly + .filter((op) => op.operator !== operator) + .map((op) => ({ + type: 'operator', + ...op, + })) + ); + } + default: + return []; + } + } + + /** + * Provides autocomplete operator suggestions based on the type of a symbol. + */ + private getOperatorSuggestionsForSymbol( + symbol: ExtractSymbolDef + ): Array { + switch (symbol.type) { + case SymbolType.Number: + case SymbolType.Boolean: + case SymbolType.Null: + case SymbolType.Undefined: + case SymbolType.Object: + case SymbolType.Array: { + const equalityOps = SUPPORTED_BINARY_OPERATORS.slice(0, 4); + const finalSuggestions = + symbol.type === SymbolType.Boolean + ? [ + ...equalityOps, + ...SUPPORTED_LOGICAL_OPERATORS, + ...SUPPORTED_CONDITIONAL_OPERATORS, + ] + : equalityOps; + return finalSuggestions.map((op) => ({ + type: 'operator', + ...op, + })); + } + case SymbolType.String: + return SUPPORTED_BINARY_OPERATORS.map((op) => ({ + type: 'operator', + ...op, + })); + case SymbolType.Function: + return this.getOperatorSuggestionsForSymbol(symbol.returns); + case SymbolType.Union: { + return symbol.members.reduce>((prev, cur) => { + prev.push(...this.getOperatorSuggestionsForSymbol(cur)); + return prev; + }, []); + } + default: + assertNever(symbol); + } + } +} + +/** + * Finds the parent of child node in the provided AST. + */ +function findParentNode(child: AnyNode, ast: Expression): AnyNode | undefined { + let foundParent: AnyNode | undefined; + + walk.ancestor(ast, { + [child.type]: (node: AcornNode, _state: undefined, ancestors: AnyNode[]) => { + if (node.start === child.start && node.end === child.end) { + // The parent is the second last ancestor in the stack (last one is the actual node) + foundParent = ancestors[ancestors.length - 2]; + } + }, + }); + + return foundParent; +} + +function isSupportedMemberProperty(node: Expression | PrivateIdentifier | Super) { + return node.type === 'Identifier' || node.type === 'Literal'; +} + +function isNodeAtCursor(node: AcornNode, cursorOffset: number) { + return cursorOffset >= node.start && cursorOffset <= node.end; +} + +function isAnyNode(node: AcornNode): node is AnyNode { + return 'type' in node; +} diff --git a/packages/js-expr/src/errors.ts b/packages/js-expr/src/errors.ts new file mode 100644 index 0000000000..4e41423622 --- /dev/null +++ b/packages/js-expr/src/errors.ts @@ -0,0 +1,24 @@ +import type { Position, Token } from 'acorn'; + +export class ExpressionError extends Error { + /** + * The location of the error in the parsed expression. + */ + public location?: Position | null; + + /** + * The expression token with the error + */ + public token?: Token; + + constructor(message: string, loc?: Position | null, token?: Token) { + super(message); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ExpressionError); + } + this.name = 'ExpressionError'; + this.location = loc; + this.token = token; + } +} diff --git a/packages/js-expr/src/index.ts b/packages/js-expr/src/index.ts new file mode 100644 index 0000000000..3f9368ad48 --- /dev/null +++ b/packages/js-expr/src/index.ts @@ -0,0 +1,8 @@ +export * from './errors'; +export * from './input-values'; +export * from './input-values'; +export * from './runtime'; +export * from './symbols'; +export * from './template'; +export * from './types'; +export * from './utils'; diff --git a/packages/js-expr/src/input-values.ts b/packages/js-expr/src/input-values.ts new file mode 100644 index 0000000000..6aa75cf461 --- /dev/null +++ b/packages/js-expr/src/input-values.ts @@ -0,0 +1,77 @@ +import type { JSONSchema7 } from 'json-schema'; +import { filterOutNullable } from './utils'; + +type InputValuesType = + | null + | string + | number + | boolean + | { [key: string]: InputValuesType } + | InputValuesType[]; + +/** + * Infers a default inputValues object based on the JSON schema of an object. + */ +export function inferDefaultInputValuesFromObjectJSONSchema( + schema: JSONSchema7 +): Record { + if (schema.type !== 'object' || !schema.properties) { + throw new Error(`Expected schema of object to be provided: ${schema.type}`); + } + + const result: Record = {}; + + for (const [key, propertySchema] of Object.entries(schema.properties)) { + if (typeof propertySchema === 'boolean') { + continue; + } + + result[key] = inferDefaultInputValueFromJSONSchema(propertySchema); + } + + return result; +} + +function inferDefaultInputValueFromJSONSchema(schema: JSONSchema7): InputValuesType { + switch (schema.type) { + case 'object': + return inferDefaultInputValuesFromObjectJSONSchema(schema); + case 'array': { + if (schema.items && Array.isArray(schema.items)) { + return schema.items.map((itemSchema) => { + if (typeof itemSchema === 'boolean') { + return false; + } + return inferDefaultInputValueFromJSONSchema(itemSchema); + }); + } + return []; + } + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'null': + return inferDefaultInputValueFromPrimitive(schema); + default: + throw new Error(`Unsupported schema type: ${schema.type}`); + } +} + +function inferDefaultInputValueFromPrimitive(schema: JSONSchema7): InputValuesType { + switch (schema.type) { + case 'boolean': + return true; + case 'number': + case 'integer': + return 1234; + case 'string': { + const enumValues = schema.enum?.filter(filterOutNullable); + return enumValues?.[0] ?? 'default'; + } + case 'null': + return null; + default: + throw new Error(`Unsupported schema type: ${schema.type}`); + } +} diff --git a/packages/js-expr/src/runtime.ts b/packages/js-expr/src/runtime.ts new file mode 100644 index 0000000000..e3a29327dc --- /dev/null +++ b/packages/js-expr/src/runtime.ts @@ -0,0 +1,289 @@ +import { + type Options as AcornOptions, + type Expression, + type ExpressionStatement, + type Position, + type Program, + type Token, + parse, + tokenizer, +} from 'acorn'; +import { parse as parseLoose } from 'acorn-loose'; +// TODO: Explore a better solution for typing this package or find an alternative library +// that is well-typed and meets our evaluation requirements. +// Once resolved, search for TODO-UTIL-EXPR to remove the temporary types added in packages/tsconfig. +import { evaluate } from 'eval-estree-expression'; + +import { AutoComplete } from './autocomplete'; +import { ExpressionError } from './errors'; +import type { SymbolsTable } from './symbols'; +import type { TemplatePart } from './template'; +import { parseTemplate as parseTemplateParts } from './template'; +import type { ExpressionAutocompleteResults, ExpressionParserResult, Logger } from './types'; +import { formatExpressionResult } from './utils'; + +export class ExpressionRuntime { + #parserOptions: AcornOptions; + #autocompleter: AutoComplete; + #logger: Logger; + + constructor(logger: Logger = console) { + this.#parserOptions = { + ecmaVersion: 'latest', + sourceType: 'script', + allowHashBang: false, + locations: true, + }; + this.#autocompleter = new AutoComplete(this, logger); + this.#logger = logger; + } + + /** + * Evaluates an expression based on the given inputs/context. + */ + public evaluate(expr: string, inputs: object): unknown { + try { + const parsed = this.parse(expr); + + if (parsed.invalidNodes.length > 0) { + throw new ExpressionError('Invalid nodes found when parsing'); + } + + return evaluate.sync(parsed.result, inputs, { + functions: true, + withMembers: true, + }); + } catch (error) { + throw error instanceof Error + ? new ExpressionError(error.message) + : new ExpressionError('Unexpected error'); + } + } + + /** + * Evaluates an expression safely by returning the error instead of throwing when invalid. + */ + public safeEvaluate( + expr: string, + inputs: object + ): { value: unknown; error?: undefined } | { value?: undefined; error: ExpressionError } { + try { + const value = this.evaluate(expr, inputs); + return { + value, + }; + } catch (error) { + this.#logger.error(`Error while evaluating expression ${expr}`, error); + + if (error instanceof ExpressionError) { + return { + value: undefined, + error, + }; + } + + return { + value: undefined, + error: + error instanceof Error + ? new ExpressionError(error.message) + : new ExpressionError('Unexpected error'), + }; + } + } + + /** + * Evaluates a condition safely to a boolean. + */ + public evaluateBoolean(expr: string, inputs: object): boolean { + if (expr.trim().length === 0) { + return true; + } + + const evalResult = this.safeEvaluate(expr, inputs); + + if (typeof evalResult.error !== 'undefined') { + return false; + } + + return Boolean(evalResult.value); + } + + /** + * Evaluates an array of conditions as a single logical expression. + * The function treats the conditions as if they were joined by an AND operator, + * meaning the evaluation returns `true` only if all conditions are truthy. + */ + public evaluateBooleanAll(expressions: string[], inputs: object): boolean { + if (expressions.length === 0) { + return true; + } + + return expressions.every((expression) => this.evaluateBoolean(expression, inputs)); + } + + /** + * Parse a template and validate all embedded expressions. + */ + public parseTemplate(template: string): { parts: TemplatePart[]; errors: ExpressionError[] } { + const parts = parseTemplateParts(template); + const errors: ExpressionError[] = []; + + for (const part of parts) { + if (part.type === 'expression') { + try { + const { invalidNodes } = this.parse(part.value); + if (invalidNodes.length > 0) { + errors.push(new ExpressionError('Invalid expression')); + } + } catch (error) { + errors.push(error as ExpressionError); + } + } + } + + return { parts, errors }; + } + + /** + * Evaluate a template string containing `{{ expression }}` placeholders. + */ + public evaluateTemplate(template: string, inputs: object): string { + const { parts } = this.parseTemplate(template); + + return parts + .map((part) => { + if (part.type === 'text') { + return part.value; + } + const result = this.evaluate(part.value, inputs); + return formatExpressionResult(result, ''); + }) + .join(''); + } + + /** + * Parses a binary expression and returns an @ExpressionParserResult. + */ + public parse( + expr: string, + options: { loose?: boolean } = { + loose: false, + } + ): ExpressionParserResult { + try { + const ast = options.loose + ? parseLoose(expr, { ...this.#parserOptions }) + : parse(expr, { ...this.#parserOptions }); + + if (!ast.body || ast.body.length === 0) { + throw new ExpressionError('Empty or invalid expression'); + } + + // Extract the first expression statement that we find + const firstExprIndex = ast.body.findIndex((node) => isParsedExpressionStatement(node)); + const [statement] = ast.body.splice(firstExprIndex, 1); + + if (!statement || !isParsedExpressionStatement(statement)) { + throw new ExpressionError('Empty or invalid expression'); + } + + // Return information on the other nodes as invalid nodes + const invalidNodes = ast.body.filter(filterOutModuleDeclarationStatement); + + return { + result: statement.expression, + invalidNodes, + }; + } catch (error) { + if (error instanceof SyntaxError) { + throw createExpressionErrorFromSyntaxError(expr, error); + } + if (error instanceof ExpressionError) { + throw error; + } + throw new ExpressionError('Unexpected error'); + } + } + + /** + * Provides autocomplete suggestions for the given expression at the provided cursor offset. + */ + public autocomplete( + expr: string, + cursorOffset: number, + context: SymbolsTable + ): ExpressionAutocompleteResults { + const suggestions = this.#autocompleter.getSuggestions(expr, cursorOffset, context); + + return { suggestions }; + } + + public generate(_node: Expression): string { + throw new Error('Not yet implemented'); + } +} + +function createExpressionErrorFromSyntaxError( + code: string, + error: SyntaxError & { loc?: Position } +): ExpressionError { + const loc = error.loc; + + if (!loc) { + return new ExpressionError(error.message); + } + + const errorMessage = `${error.message.replace(/\s*\(\d+:\d+\)$/, '')} at ${code.split('\n').length > 1 ? `line ${loc.line}, ` : ''}char ${loc.column}`; + const token = getTokenAtLoc(code, loc); + + if (!token) { + return new ExpressionError(errorMessage, loc); + } + + return new ExpressionError(errorMessage, loc, token); +} +function getTokenAtLoc(code: string, errorLoc: Position): Token | undefined { + const tokens = tokenizer(code, { + ecmaVersion: 'latest', + locations: true, + }); + + try { + for (const token of tokens) { + if (!token.loc) { + continue; + } + + const { start, end } = token.loc; + + const onSameLine = errorLoc.line === start.line; + const inColumnRange = errorLoc.column >= start.column && errorLoc.column < end.column; + + if (onSameLine && inColumnRange) { + return token; + } + } + } catch (_error) { + return undefined; + } + + return undefined; +} + +function isParsedExpressionStatement( + statement: Program['body'][number] +): statement is ExpressionStatement { + return statement.type === 'ExpressionStatement'; +} + +export function filterOutModuleDeclarationStatement( + statement: Program['body'][number] +): statement is ExpressionStatement { + return ![ + 'ImportDeclaration', + 'ExportNamedDeclaration', + 'ExportDefaultDeclaration', + 'ExportAllDeclaration', + ].includes(statement.type); +} diff --git a/packages/js-expr/src/symbols/__tests__/symbols.test.ts b/packages/js-expr/src/symbols/__tests__/symbols.test.ts new file mode 100644 index 0000000000..94de1623fc --- /dev/null +++ b/packages/js-expr/src/symbols/__tests__/symbols.test.ts @@ -0,0 +1,495 @@ +import { describe, expect, it } from 'bun:test'; + +import { SymbolArray, SymbolObject, SymbolString } from '../symbols'; +import { SymbolsTable } from '../symbols-table'; +import type { SymbolType } from '../types'; + +describe('ExpressionRuntime', () => { + const initialSymbols = { + visitor: SymbolObject({ + name: 'visitor', + properties: { + claims: SymbolObject({ + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: SymbolString({ name: 'key' }), + flags: SymbolObject({ + name: 'flags', + properties: { + FLAG1: SymbolString({ name: 'FLAG1' }), + FLAG2: SymbolString({ name: 'FLAG2' }), + FLAG3: SymbolString({ name: 'FLAG3' }), + }, + methods: [], + }), + hello: SymbolArray({ + name: 'hello', + description: 'An array of string', + items: SymbolString(), + }), + }, + methods: [], + }), + }, + methods: [], + }), + }; + describe('addSymbols', () => { + it('should the symbols matching the provided object to the table', () => { + const symbolsTable = new SymbolsTable(initialSymbols); + + expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({ + definition: { + type: 'object', + name: 'visitor', + properties: { + claims: { + type: 'object', + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: { + type: 'string', + name: 'key', + }, + flags: { + type: 'object', + name: 'flags', + properties: { + FLAG1: { + type: 'string', + name: 'FLAG1', + }, + FLAG2: { + type: 'string', + name: 'FLAG2', + }, + FLAG3: { + type: 'string', + name: 'FLAG3', + }, + }, + methods: [], + }, + hello: { + type: 'array', + name: 'hello', + description: 'An array of string', + items: { + type: 'string', + }, + }, + }, + methods: [], + }, + }, + methods: [], + }, + ref: 'visitor', + childrenRefs: ['visitor.claims'], + }); + + symbolsTable.addSymbols({ + space: SymbolObject({ + name: 'space', + properties: { + id: SymbolString({ name: 'id' }), + title: SymbolString({ name: 'title' }), + }, + methods: [], + }), + }); + + expect(symbolsTable.getSymbolInfo(['space'])).toMatchObject({ + definition: { + type: 'object', + name: 'space', + properties: { + id: { + type: 'string', + name: 'id', + }, + title: { + type: 'string', + name: 'title', + }, + }, + methods: [], + }, + ref: 'space', + childrenRefs: ['space.id', 'space.title'], + }); + }); + }); + + describe('Symbols standard library', () => { + it('should allow to access methods & properties defined as part of the standard library', () => { + const symbolsTable = new SymbolsTable({ + id: SymbolString({ name: 'id' }), + title: SymbolString({ name: 'title' }), + }); + + expect( + symbolsTable.getSymbolInfo('id')?.definition.properties.length + ).toMatchObject({ + type: 'number', + name: 'length', + description: + 'The length data property of a String value contains the length of the string in UTF-16 code units.', + }); + }); + }); + + describe('getSymbolInfo', () => { + it('should add the symbols matching the initial symbol definition passed to the constructor', () => { + const symbolsTable = new SymbolsTable(initialSymbols); + + expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({ + definition: { + type: 'object', + name: 'visitor', + properties: { + claims: { + type: 'object', + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: { + type: 'string', + name: 'key', + }, + flags: { + type: 'object', + name: 'flags', + properties: { + FLAG1: { + type: 'string', + name: 'FLAG1', + }, + FLAG2: { + type: 'string', + name: 'FLAG2', + }, + FLAG3: { + type: 'string', + name: 'FLAG3', + }, + }, + methods: [], + }, + hello: { + type: 'array', + name: 'hello', + description: 'An array of string', + items: { + type: 'string', + }, + }, + }, + methods: [], + }, + }, + methods: [], + }, + ref: 'visitor', + childrenRefs: ['visitor.claims'], + }); + + expect(symbolsTable.getSymbolInfo(['visitor', 'claims'])).toMatchObject({ + definition: { + type: 'object', + name: 'claims', + description: 'The claims contained in the visitor JWT token', + properties: { + key: { + type: 'string', + name: 'key', + }, + flags: { + type: 'object', + name: 'flags', + properties: { + FLAG1: { + type: 'string', + name: 'FLAG1', + }, + FLAG2: { + type: 'string', + name: 'FLAG2', + }, + FLAG3: { + type: 'string', + name: 'FLAG3', + }, + }, + methods: [], + }, + hello: { + type: 'array', + name: 'hello', + description: 'An array of string', + items: { + type: 'string', + }, + }, + }, + methods: [], + }, + ref: 'visitor.claims', + parentRef: 'visitor', + childrenRefs: [ + 'visitor.claims.key', + 'visitor.claims.flags', + 'visitor.claims.hello', + ], + }); + + expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'key'])).toMatchObject({ + definition: { + type: 'string', + name: 'key', + }, + ref: 'visitor.claims.key', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.key.length', + 'visitor.claims.key.at', + 'visitor.claims.key.endsWith', + 'visitor.claims.key.includes', + ], + }); + + expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags'])).toMatchObject({ + definition: { + type: 'object', + name: 'flags', + properties: { + FLAG1: { + type: 'string', + name: 'FLAG1', + }, + FLAG2: { + type: 'string', + name: 'FLAG2', + }, + FLAG3: { + type: 'string', + name: 'FLAG3', + }, + }, + methods: [], + }, + ref: 'visitor.claims.flags', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.flags.FLAG1', + 'visitor.claims.flags.FLAG2', + 'visitor.claims.flags.FLAG3', + ], + }); + + expect( + symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG1']) + ).toMatchObject({ + definition: { + type: 'string', + name: 'FLAG1', + }, + ref: 'visitor.claims.flags.FLAG1', + parentRef: 'visitor.claims.flags', + childrenRefs: [ + 'visitor.claims.flags.FLAG1.length', + 'visitor.claims.flags.FLAG1.at', + 'visitor.claims.flags.FLAG1.endsWith', + 'visitor.claims.flags.FLAG1.includes', + ], + }); + + expect( + symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG2']) + ).toMatchObject({ + definition: { + type: 'string', + name: 'FLAG2', + }, + ref: 'visitor.claims.flags.FLAG2', + parentRef: 'visitor.claims.flags', + childrenRefs: [ + 'visitor.claims.flags.FLAG2.length', + 'visitor.claims.flags.FLAG2.at', + 'visitor.claims.flags.FLAG2.endsWith', + 'visitor.claims.flags.FLAG2.includes', + ], + }); + + expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'hello'])).toMatchObject({ + definition: { + type: 'array', + name: 'hello', + description: 'An array of string', + items: { + type: 'string', + }, + }, + ref: 'visitor.claims.hello', + parentRef: 'visitor.claims', + childrenRefs: [ + 'visitor.claims.hello.length', + 'visitor.claims.hello.at', + 'visitor.claims.hello.includes', + ], + }); + }); + }); + + describe('inferSymbolFromValue', () => { + it('should infer properly a symbol based on a value', () => { + const symbolDef = SymbolsTable.inferSymbolFromValue( + { + visitor: { + claims: { + key: 'test', + flags: { + FLAG1: 'testflag1', + FLAG2: 'testflag2', + FLAG3: 'testflag3', + }, + hello: ['test', 'test1', 'test2'], + }, + }, + }, + 'context' + ); + expect(symbolDef).toMatchObject({ + type: 'object', + name: 'context', + properties: { + visitor: { + type: 'object', + name: 'visitor', + properties: { + claims: { + type: 'object', + name: 'claims', + properties: { + key: { + type: 'string', + name: 'key', + }, + flags: { + type: 'object', + name: 'flags', + properties: { + FLAG1: { + type: 'string', + name: 'FLAG1', + }, + FLAG2: { + type: 'string', + name: 'FLAG2', + }, + FLAG3: { + type: 'string', + name: 'FLAG3', + }, + }, + methods: [], + }, + hello: { + type: 'array', + name: 'hello', + items: { + type: 'string', + }, + }, + }, + methods: [], + }, + }, + methods: [], + }, + }, + methods: [], + }); + }); + }); + + describe('inferSymbolFromJSONSchema', () => { + it('should infer properly a symbol table based on a JSON schema', () => { + const symbolDef = SymbolsTable.inferSymbolFromJSONSchema( + { + type: 'object', + description: `The attributes tied to a site's visitor.`, + properties: { + claims: { + type: 'object', + properties: { + key: { + type: 'string', + }, + flags: { + type: 'object', + description: 'The user feature flags', + properties: { + FLAG1: { type: 'string' }, + FLAG2: { type: 'string' }, + FLAG3: { type: 'string' }, + }, + }, + hello: { + type: 'string', + enum: ['test', 'test1', 'test2'], + }, + }, + }, + }, + }, + 'visitor' + ); + expect(symbolDef).toMatchObject({ + type: 'object', + name: 'visitor', + description: `The attributes tied to a site's visitor.`, + properties: { + claims: { + type: 'object', + name: 'claims', + properties: { + key: { + type: 'string', + name: 'key', + }, + flags: { + type: 'object', + name: 'flags', + description: 'The user feature flags', + properties: { + FLAG1: { + type: 'string', + name: 'FLAG1', + }, + FLAG2: { + type: 'string', + name: 'FLAG2', + }, + FLAG3: { + type: 'string', + name: 'FLAG3', + }, + }, + methods: [], + }, + hello: { + type: 'string', + enum: ['test', 'test1', 'test2'], + }, + }, + methods: [], + }, + }, + methods: [], + }); + }); + }); +}); diff --git a/packages/js-expr/src/symbols/index.ts b/packages/js-expr/src/symbols/index.ts new file mode 100644 index 0000000000..0ad1b5b54b --- /dev/null +++ b/packages/js-expr/src/symbols/index.ts @@ -0,0 +1,3 @@ +export * from './symbols'; +export * from './symbols-table'; +export * from './types'; diff --git a/packages/js-expr/src/symbols/symbols-table.ts b/packages/js-expr/src/symbols/symbols-table.ts new file mode 100644 index 0000000000..baa8bf3191 --- /dev/null +++ b/packages/js-expr/src/symbols/symbols-table.ts @@ -0,0 +1,350 @@ +import type { JSONSchema7 } from 'json-schema'; + +import { filterOutNullable } from '../utils'; +import { + SymbolArray, + SymbolBoolean, + SymbolNull, + SymbolNumber, + SymbolObject, + SymbolString, + SymbolUndefined, +} from './symbols'; +import { + type ExtractSymbolDef, + type GenericSymbolDef, + type ObjectSymbolDef, + SymbolType, + type SymbolWithMethods, + type SymbolWithProperties, + resolveSymbolDef, +} from './types'; + +export interface SymbolInfo { + /** + * Definition of the symbol. + */ + definition: ExtractSymbolDef; + + /** + * Reference of the symbol in the table of symbols. + */ + ref: string; + + /** + * Stores the reference to the parent symbol. + */ + parentRef?: string; + + /** + * Stores the reference to the children symbols. + */ + childrenRefs?: string[]; +} + +export class SymbolError extends Error { + constructor(message: string) { + super(message); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, SymbolError); + } + this.name = 'SymbolError'; + } +} + +export class SymbolsTable { + /** + * Internal table that keeps track of all symbols reference. + */ + #table: Record; + + /** + * Internal table that keep track of the raw symbols definitions. + */ + #rawSymbols: Record; + + constructor(initialContext: Record = {}) { + this.#table = {}; + this.#rawSymbols = {}; + this.addSymbols(initialContext); + } + + /** + * Create a new symbols table by merging the current one with the provided one. + */ + merge(other: SymbolsTable): SymbolsTable { + return new SymbolsTable({ + ...this.#rawSymbols, + ...other.#rawSymbols, + }); + } + + toString() { + return JSON.stringify(this.#table, null, 2); + } + + /** + * Infer the symbol of a value and generate the appropriate symbol definition. + */ + static inferSymbolFromValue(value: unknown, name?: string): ExtractSymbolDef { + if (Array.isArray(value)) { + if (value.length === 0) { + return SymbolArray({ items: SymbolUndefined() }); + } + + const firstItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(0)); + // Check that the array is not a mixin of different items types. + if (value.length > 1) { + const secondItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(1)); + if (firstItemSymbol.type !== secondItemSymbol.type) { + throw new SymbolError('Array with mixin items types are not supported'); + } + } + return SymbolArray({ name, items: firstItemSymbol }); + } + + if (typeof value === 'undefined') { + return SymbolUndefined({ name }); + } + + if (value === null) { + return SymbolNull({ name }); + } + + const valueType = typeof value; + switch (valueType) { + case 'string': + return SymbolString({ name }); + case 'number': + return SymbolNumber({ name }); + case 'boolean': + return SymbolBoolean({ name }); + case 'object': { + const properties = Object.entries(value).reduce>( + (prev, [name, val]) => { + prev[name] = SymbolsTable.inferSymbolFromValue(val, name); + return prev; + }, + {} + ); + return SymbolObject({ name, properties, methods: [] }); + } + default: + throw new SymbolError(`Unsupported symbol type ${valueType}`); + } + } + + /** + * Infer a table of symbol based on a JSON schema object describing it. + */ + static inferSymbolFromJSONSchema( + schema: JSONSchema7, + name?: string + ): ExtractSymbolDef { + switch (schema.type) { + case 'string': + return SymbolString({ + name, + ...(schema.description ? { description: schema.description } : {}), + ...(schema.enum + ? { + enum: schema.enum + .filter(filterOutNullable) + .map((enumValue) => enumValue.toString()), + } + : {}), + }); + case 'number': + case 'integer': + return SymbolNumber({ + name, + ...(schema.description ? { description: schema.description } : {}), + }); + case 'boolean': + return SymbolBoolean({ + name, + ...(schema.description ? { description: schema.description } : {}), + }); + case 'null': + return SymbolNull({ + name, + ...(schema.description ? { description: schema.description } : {}), + }); + case 'object': + return SymbolsTable.#buildObjectSymbolFromJSONSchemaObject(schema, name); + case 'array': + return SymbolsTable.#buildArraySymbolFromJSONSchemaArray(schema, name); + default: + throw new Error(`Unsupported schema type: ${schema.type}`); + } + } + + static #buildObjectSymbolFromJSONSchemaObject( + schema: JSONSchema7, + name?: string + ): ObjectSymbolDef { + const properties: Record = {}; + + if (schema.properties) { + Object.entries(schema.properties).forEach(([propertyName, propertySchema]) => { + properties[propertyName] = SymbolsTable.inferSymbolFromJSONSchema( + propertySchema as JSONSchema7, + propertyName + ); + }); + } + + return SymbolObject({ + name, + properties, + ...(schema.description ? { description: schema.description } : {}), + methods: [], + }); + } + + static #buildArraySymbolFromJSONSchemaArray( + schema: JSONSchema7, + name?: string + ): ExtractSymbolDef { + if (schema.items) { + const itemSymbol = SymbolsTable.inferSymbolFromJSONSchema( + schema.items as JSONSchema7, + `${name || ''}_item` + ); + return SymbolArray({ + name, + ...(schema.description ? { description: schema.description } : {}), + items: itemSymbol, + }); + } + + return SymbolArray({ + name, + ...(schema.description ? { description: schema.description } : {}), + items: SymbolUndefined(), + }); + } + + private generateSymbolRefPath(path: string[]): string { + return path.join('.'); + } + + /** + * Add a single symbol to the table at the provided path. + */ + private addSymbol(path: string[], definition: GenericSymbolDef, raw = false): void { + const fullPath = this.generateSymbolRefPath(path); + const parentPath = path.slice(0, -1).join('.'); + + if (this.#table[fullPath]) { + throw new SymbolError(`Symbol "${fullPath}" already exists.`); + } + + if (raw) { + this.#rawSymbols[fullPath] = definition; + } + + // Add the new symbol linking it to its parent + this.#table[fullPath] = { + definition: resolveSymbolDef(definition), + ref: fullPath, + parentRef: parentPath || undefined, + childrenRefs: [], + }; + + if (parentPath && this.#table[parentPath]) { + if (this.#table[parentPath].childrenRefs) { + this.#table[parentPath].childrenRefs.push(fullPath); + } + } + + // Add any nested symbols if the value is a symbol with properties... + if (isSymbolWithProperties(definition)) { + Object.entries(definition.properties).forEach(([propKey, propSymbol]) => { + if (isObjectSymbol(propSymbol)) { + this.addSymbols({ [propKey]: propSymbol }, path, false); + } else { + this.addSymbol([...path, propKey], propSymbol, false); + } + }); + } + + // ...or a symbol with methods + if (isSymbolWithMethods(definition)) { + definition.methods.forEach((methodSymbol) => { + this.addSymbol([...path, methodSymbol.name], methodSymbol, false); + }); + } + } + + /** + * Add the provided object of symbols definitions to the symbol table. + */ + public addSymbols( + symbols: Record, + prefix: string[] = [], + raw = true + ): void { + for (const [key, symbolDef] of Object.entries(symbols)) { + const path = [...prefix, key]; + + // Add the current symbol to the table. + this.addSymbol(path, symbolDef, raw); + } + } + + /** + * Get a symbol's information using its path in the table. + */ + public getSymbolInfo(path: string | string[]): SymbolInfo | undefined { + const key = Array.isArray(path) ? path.join('.') : path; + const info = this.#table[key]; + return info ? typedSymbolInfo(info) : undefined; + } + + /** + * Get all symbol keys matching the pattern defined by the provided path. + */ + public getMatchingSymbolsKeys(path: string[]): string[] { + const wildcardRegex = new RegExp( + `^${path + .map((segment) => { + if (segment.includes('*')) { + return `${segment.split('*')[0]}([^.]+)?`; + } + return segment; + }) + .join('\\.')}$` + ); + + return Object.keys(this.#table) + .filter((key) => wildcardRegex.test(key)) + .filter(filterOutNullable); + } +} + +function isObjectSymbol(symbol: GenericSymbolDef): symbol is ObjectSymbolDef { + return symbol.type === SymbolType.Object; +} + +function isSymbolWithProperties(symbol: GenericSymbolDef): symbol is SymbolWithProperties { + return ( + symbol.type === SymbolType.Object || + symbol.type === SymbolType.Array || + symbol.type === SymbolType.String + ); +} + +function isSymbolWithMethods(symbol: GenericSymbolDef): symbol is SymbolWithMethods { + return ( + symbol.type === SymbolType.Object || + symbol.type === SymbolType.Array || + symbol.type === SymbolType.String + ); +} + +function typedSymbolInfo(info: SymbolInfo): SymbolInfo { + const definition = resolveSymbolDef(info.definition); + return { ...info, definition } as SymbolInfo; +} diff --git a/packages/js-expr/src/symbols/symbols.ts b/packages/js-expr/src/symbols/symbols.ts new file mode 100644 index 0000000000..7eb196b73c --- /dev/null +++ b/packages/js-expr/src/symbols/symbols.ts @@ -0,0 +1,268 @@ +import { + type ArraySymbolDef, + type BooleanSymbolDef, + type ExtractSymbolDef, + type FunctionSymbolDef, + type GenericSymbolDef, + type NullSymbolDef, + type NumberSymbolDef, + type ObjectSymbolDef, + type StringSymbolDef, + type SymbolsWithPropertiesAndMethods, + SymbolType, + type UndefinedSymbolDef, + type UnionSymbolDef, +} from './types'; + +export function SymbolBoolean(args: Omit = {}): BooleanSymbolDef { + return { + type: SymbolType.Boolean, + ...args, + }; +} + +export function SymbolNumber(args: Omit = {}): NumberSymbolDef { + return { + type: SymbolType.Number, + ...args, + }; +} + +export function SymbolString( + args: Omit = {} +): StringSymbolDef { + return createSymbolWithPropertiesAndMethods(SymbolType.String, args); +} + +export function SymbolObject(args: Omit): ObjectSymbolDef { + return { + type: SymbolType.Object, + ...args, + }; +} + +export function SymbolArray( + args: Omit +): ArraySymbolDef { + return createSymbolWithPropertiesAndMethods(SymbolType.Array, args); +} + +export function SymbolFunction(args: Omit): FunctionSymbolDef { + return { + type: SymbolType.Function, + ...args, + }; +} + +export function OptionalFunctionArg( + optionalArg: ExtractSymbolDef +): ExtractSymbolDef & { optional: true } { + return { + ...optionalArg, + optional: true, + }; +} + +export function SymbolUnion(args: Omit): UnionSymbolDef { + return { + type: SymbolType.Union, + ...args, + }; +} + +export function SymbolUndefined(args: Omit = {}): UndefinedSymbolDef { + return { + type: SymbolType.Undefined, + ...args, + }; +} + +export function SymbolNull(args: Omit = {}): NullSymbolDef { + return { + type: SymbolType.Null, + ...args, + }; +} + +function createSymbolWithPropertiesAndMethods< + T extends SymbolsWithPropertiesAndMethods & { type: SymbolType }, +>(type: T['type'], args: Omit): T { + const symbol = { type, ...args } as T; + + Object.defineProperty(symbol, 'properties', { + get() { + if (symbol.type === SymbolType.Array) { + return isArraySymbol(symbol) + ? StandardLibrary[SymbolType.Array]?.(symbol).properties + : {}; + } + return StandardLibrary[symbol.type]?.properties || {}; + }, + }); + + Object.defineProperty(symbol, 'methods', { + get() { + if (symbol.type === SymbolType.Array) { + return isArraySymbol(symbol) + ? StandardLibrary[SymbolType.Array]?.(symbol).methods + : []; + } + return StandardLibrary[symbol.type]?.methods || []; + }, + }); + + return symbol; +} + +// TODO-ADAPTIVE-CONTENT: extend the definition of the supported standard library methods and properties. + +const StandardLibrary: Partial< + { + [key in Exclude]: { + properties: Record; + methods: FunctionSymbolDef[]; + }; + } & { + [SymbolType.Array]: (symbol: ArraySymbolDef) => { + properties: Record; + methods: FunctionSymbolDef[]; + }; + } +> = { + [SymbolType.String]: { + properties: { + length: SymbolNumber({ + name: 'length', + description: + 'The length data property of a String value contains the length of the string in UTF-16 code units.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length', + }), + }, + methods: [ + SymbolFunction({ + name: 'at', + description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers. + Negative integers count back from the last item in the string.`, + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/at', + args: [ + SymbolNumber({ + name: 'index', + description: 'The index (position) of the string character to be returned', + }), + ], + returns: SymbolUnion({ + description: `A String consisting of the single UTF-16 code unit located at the specified position. + Returns undefined if the given index can not be found.`, + members: [SymbolString(), SymbolUndefined()], + }), + }), + SymbolFunction({ + name: 'endsWith', + description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding + elements of this object (converted to a String) starting at endPosition – length(this). Otherwise returns false.`, + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith', + args: [ + SymbolString({ + name: 'searchString', + description: `The characters to be searched for at the end of str. Cannot be a regex. + All values that are not regexes are coerced to strings, so omitting it or passing undefined causes endsWith() to search for + the string "undefined", which is rarely what you want.`, + }), + OptionalFunctionArg( + SymbolNumber({ + name: 'endPosition', + description: `The end position at which searchString is expected to be found + (the index of searchString's last character plus 1). Defaults to str.length.`, + }) + ), + ], + returns: SymbolBoolean({ + description: `true if the given characters are found at the end of the string, including when searchString is an empty string; + otherwise, false.`, + }), + }), + SymbolFunction({ + name: 'includes', + description: `Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions + that are greater than or equal to position; otherwise, returns false.`, + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes', + args: [ + SymbolString({ + name: 'searchString', + description: `A string to be searched for within str. Cannot be a regex. All values that are not regexes are coerced to strings, so omitting it + or passing undefined causes includes() to search for the string "undefined", which is rarely what you want.`, + }), + OptionalFunctionArg( + SymbolNumber({ + name: 'position', + description: + 'The position within the string at which to begin searching for searchString. (Defaults to 0.)', + }) + ), + ], + returns: SymbolBoolean({ + description: `true if the search string is found anywhere within the given string, including when searchString is an empty string; + otherwise, false.`, + }), + }), + ], + }, + [SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({ + properties: { + length: SymbolNumber({ + name: 'length', + description: `The length data property of an Array instance represents the number of elements in that array. + The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`, + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length', + }), + }, + methods: [ + SymbolFunction({ + name: 'at', + description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers. + Negative integers count back from the last item in the array.`, + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at', + args: [ + SymbolNumber({ + name: 'index', + description: `Zero-based index of the array element to be returned, converted to an integer. + Negative index counts back from the end of the array — if index < 0, index + array.length is accessed.`, + }), + ], + returns: SymbolUnion({ + description: `The element in the array matching the given index. Always returns undefined if index < -array.length or index >= array.length + without attempting to access the corresponding property.`, + members: [arraySymbolDef.items, SymbolUndefined()], + }), + }), + SymbolFunction({ + name: 'includes', + description: + 'Determines whether an array includes a certain value among its entries, returning true or false as appropriate.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes', + args: [ + { + ...arraySymbolDef.items, + name: 'searchElement', + description: 'The value to be searched for within the array.', + }, + OptionalFunctionArg( + SymbolNumber({ + name: 'fromIndex', + description: + 'The position within the string at which to begin searching for searchString. (Defaults to 0.)', + }) + ), + ], + returns: SymbolBoolean({ + description: + 'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).', + }), + }), + ], + }), +}; + +function isArraySymbol(symbol: GenericSymbolDef): symbol is ArraySymbolDef { + return symbol.type === SymbolType.Array; +} diff --git a/packages/js-expr/src/symbols/types.ts b/packages/js-expr/src/symbols/types.ts new file mode 100644 index 0000000000..401f094689 --- /dev/null +++ b/packages/js-expr/src/symbols/types.ts @@ -0,0 +1,193 @@ +import type { MandateProps } from '../utils'; + +export enum SymbolType { + Boolean = 'boolean', + Number = 'number', + String = 'string', + Object = 'object', + Array = 'array', + Function = 'function', + Union = 'union', + Undefined = 'undefined', + Null = 'null', +} + +export interface SymbolMetadata { + /** + * Long description of the symbol. + */ + description?: string; + + /** + * Link to a documentation/manual page. + */ + link?: string; +} + +export interface GenericSymbolDef extends SymbolMetadata { + /** + * Type of the symbol. + */ + type: SymbolType; + + /** + * Name of the symbol. + */ + name?: string; +} + +export interface SymbolWithProperties extends GenericSymbolDef { + /** + * Properties on the symbol type. + */ + properties: Record; +} + +export interface SymbolWithMethods extends GenericSymbolDef { + /** + * Methods that can be called on the symbol type. + */ + methods: FunctionSymbolDef[]; +} + +export interface BooleanSymbolDef extends GenericSymbolDef { + type: SymbolType.Boolean; +} + +export interface NumberSymbolDef extends GenericSymbolDef { + type: SymbolType.Number; +} + +export interface StringSymbolDef extends SymbolWithProperties, SymbolWithMethods { + type: SymbolType.String; + + /** + * Set of enumerated values that the string symbol is retristred to. + */ + enum?: string[]; + + /** + * Properties on strings. + */ + properties: { + length: NumberSymbolDef; + }; +} + +export interface ObjectSymbolDef extends SymbolWithProperties, SymbolWithMethods { + type: SymbolType.Object; +} + +export interface ArraySymbolDef extends SymbolWithProperties, SymbolWithMethods { + type: SymbolType.Array; + + /** + * Symbol representing the type of the items of the array + */ + items: ExtractSymbolDef; + + /** + * Properties on arrays. + */ + properties: { + length: NumberSymbolDef; + }; +} + +export interface FunctionSymbolDef extends MandateProps { + type: SymbolType.Function; + + /** + * Symbols describing the arguments of the function. + */ + args: (ExtractSymbolDef & { optional?: boolean })[]; + + /** + * Symbol describing the returned value of the function. + */ + returns: ExtractSymbolDef; +} + +export interface UnionSymbolDef extends GenericSymbolDef { + type: SymbolType.Union; + + /** + * Symbols composing the union. + */ + members: ExtractSymbolDef[]; +} + +export interface UndefinedSymbolDef extends GenericSymbolDef { + type: SymbolType.Undefined; +} + +export interface NullSymbolDef extends GenericSymbolDef { + type: SymbolType.Null; +} + +export type SymbolsWithPropertiesAndMethods = ArraySymbolDef | ObjectSymbolDef | StringSymbolDef; + +export type ExtractSymbolDef = T extends SymbolType.String + ? StringSymbolDef + : T extends SymbolType.Number + ? NumberSymbolDef + : T extends SymbolType.Boolean + ? BooleanSymbolDef + : T extends SymbolType.Array + ? ArraySymbolDef + : T extends SymbolType.Object + ? ObjectSymbolDef + : T extends SymbolType.Function + ? FunctionSymbolDef + : T extends SymbolType.Union + ? UnionSymbolDef + : T extends SymbolType.Undefined + ? UndefinedSymbolDef + : T extends SymbolType.Null + ? NullSymbolDef + : never; + +export function resolveSymbolDef( + symbol: GenericSymbolDef +): + | StringSymbolDef + | NumberSymbolDef + | BooleanSymbolDef + | ArraySymbolDef + | ObjectSymbolDef + | FunctionSymbolDef + | UnionSymbolDef + | UndefinedSymbolDef + | NullSymbolDef { + switch (symbol.type) { + case SymbolType.String: + return symbol as StringSymbolDef; + + case SymbolType.Number: + return symbol as NumberSymbolDef; + + case SymbolType.Boolean: + return symbol as BooleanSymbolDef; + + case SymbolType.Array: + return symbol as ArraySymbolDef; + + case SymbolType.Object: + return symbol as ObjectSymbolDef; + + case SymbolType.Function: + return symbol as FunctionSymbolDef; + + case SymbolType.Union: + return symbol as UnionSymbolDef; + + case SymbolType.Undefined: + return symbol as UndefinedSymbolDef; + + case SymbolType.Null: + return symbol as NullSymbolDef; + + default: + throw new Error(`Unknown symbol type: ${symbol.type}`); + } +} diff --git a/packages/js-expr/src/template.ts b/packages/js-expr/src/template.ts new file mode 100644 index 0000000000..e71012c2cc --- /dev/null +++ b/packages/js-expr/src/template.ts @@ -0,0 +1,57 @@ +export type TemplateText = { + type: 'text'; + value: string; + start: number; + end: number; +}; + +export type TemplateExpression = { + type: 'expression'; + value: string; + start: number; // Start index of the expression content (after `{{`) + end: number; // End index of the expression content (before `}}`) +}; + +export type TemplatePart = TemplateText | TemplateExpression; + +/** + * Parse a template string containing `{{ expression }}` placeholders. + */ +export function parseTemplate(template: string): TemplatePart[] { + const parts: TemplatePart[] = []; + const regex = /\{\{(.*?)\}\}/gs; + let lastIndex = 0; + + for (const match of template.matchAll(regex)) { + const matchStart = match.index ?? 0; + const matchEnd = matchStart + match[0].length; + + if (matchStart > lastIndex) { + parts.push({ + type: 'text', + value: template.slice(lastIndex, matchStart), + start: lastIndex, + end: matchStart, + }); + } + + parts.push({ + type: 'expression', + value: (match[1] ?? '').trim(), + start: matchStart + 2, + end: matchEnd - 2, + }); + lastIndex = matchEnd; + } + + if (lastIndex < template.length) { + parts.push({ + type: 'text', + value: template.slice(lastIndex), + start: lastIndex, + end: template.length, + }); + } + + return parts; +} diff --git a/packages/js-expr/src/types.ts b/packages/js-expr/src/types.ts new file mode 100644 index 0000000000..7d721480a1 --- /dev/null +++ b/packages/js-expr/src/types.ts @@ -0,0 +1,176 @@ +import type { BinaryOperator, Expression, ExpressionStatement, LogicalOperator } from 'acorn'; + +import type { ArraySymbolDef, SymbolInfo, SymbolType } from './symbols'; + +export interface ExpressionGenerator { + /** + * Converts an ESTree compatible AST node into a string representing the corresponding expression. + */ + generate(node: Expression): string; +} + +export interface ExpressionParserResult { + /** + * The expression statement from the valid portion of the parsed expression. + * + * It is undefined when no valid expression statements could be found. + */ + result: Expression; + + /** + * The information of the invalid (non-expression) nodes found from the other portions of the parsed expression. + */ + invalidNodes: Array; +} + +export interface ExpressionAutocompleteResults { + suggestions: AutocompleteSuggestions; +} + +type ConditionalOperator = '?'; + +export const SUPPORTED_BINARY_OPERATORS = [ + { + operator: '==', + description: 'Checks whether two values are equal.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality', + }, + { + operator: '!=', + description: 'Checks whether two values are unequal.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Inequality', + }, + { + operator: '===', + description: 'Checks whether two values are equal (strict comparison).', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality', + }, + { + operator: '!==', + description: 'Checks whether two values are unequal (strict comparison).', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_inequality', + }, + { + operator: '<', + description: 'Checks if the left value is less than the right value.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than', + }, + { + operator: '<=', + description: 'Checks if the left value is less than or equal to the right value.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than_or_equal', + }, + { + operator: '>', + description: 'Checks if the left value is greater than the right value.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than', + }, + { + operator: '>=', + description: 'Checks if the left value is greater than or equal to the right value.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than_or_equal', + }, + { + operator: 'in', + description: 'Checks if a property exists in an object or if a value is in an array.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/in', + }, +] as const; + +export const SUPPORTED_LOGICAL_OPERATORS = [ + { + operator: '&&', + description: 'Logical AND operator; returns true if both operands are true.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND', + }, + { + operator: '||', + description: 'Logical OR operator; returns true if at least one operand is true.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_OR', + }, +] as const; + +export const SUPPORTED_CONDITIONAL_OPERATORS = [ + { + operator: '?', + description: + 'Conditional (ternary) operator; returns one of two values based on a condition.', + link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator', + }, +] as const; + +type DirectLiteralValue = { + kind: 'direct'; +} & ( + | { + type: SymbolType.Boolean; + data: boolean; + } + | { + type: SymbolType.Number; + data: number; + } + | { + type: SymbolType.String; + data: string; + } + | { + type: SymbolType.Null; + data: null; + } +); + +type InArrayLiteralValue = { + kind: 'in-array'; + srcSymbol: ArraySymbolDef; + matchedLiteralString: string; +}; + +export type DirectLiteralValueSuggestion = { + type: 'literal-value'; + value: DirectLiteralValue; +}; + +export type InArrayLiteralValueSuggestion = { + type: 'literal-value'; + value: InArrayLiteralValue; +}; + +export type AutocompleteLiteralValueSuggestion = + | DirectLiteralValueSuggestion + | InArrayLiteralValueSuggestion; + +export interface AutocompleteOperatorSuggestion { + type: 'operator'; + operator: + | Extract + | Extract + | Extract< + ConditionalOperator, + (typeof SUPPORTED_CONDITIONAL_OPERATORS)[number]['operator'] + >; + description: string; + link: string; +} + +export interface AutocompleteSymbolSuggestion { + type: 'symbol'; + symbol: SymbolInfo; +} + +export type AutocompleteSuggestions = Array< + | AutocompleteSymbolSuggestion + | AutocompleteLiteralValueSuggestion + | AutocompleteOperatorSuggestion +>; + +type LoggerFn = (message: string, ...args: any[]) => void; + +/** + * A logger that can be passed to the runtime. + */ +export interface Logger { + debug: LoggerFn; + info: LoggerFn; + error: LoggerFn; +} diff --git a/packages/js-expr/src/utils.ts b/packages/js-expr/src/utils.ts new file mode 100644 index 0000000000..04a6c2c108 --- /dev/null +++ b/packages/js-expr/src/utils.ts @@ -0,0 +1,40 @@ +/** + * Format the result value of an expression for display as a string. + */ +export function formatExpressionResult(value: any, defaultValue = ''): string { + if (value === undefined || value === null) { + return defaultValue; + } + + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value.toString(); + } + + return defaultValue; +} + +/** + * Filter function to exclude `null` values + */ +export function filterOutNullable(value: T): value is NonNullable { + return !!value; +} + +/** + * Type to make optional properties on a object mandatory. + * + * interface SomeObject { + * uid: string; + * price: number | null; + * location?: string; + * } + * + * type ValuableObject = MandateProps; + */ +export type MandateProps = T & { + [MK in K]-?: NonNullable; +}; diff --git a/packages/js-expr/tsconfig.build.json b/packages/js-expr/tsconfig.build.json new file mode 100644 index 0000000000..ee97ea0d06 --- /dev/null +++ b/packages/js-expr/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["./tsconfig.json"], + "exclude": ["**/*.test.ts"], + "compilerOptions": { + "declaration": true, + "noEmit": false, + "outDir": "dist" + } +} diff --git a/packages/js-expr/tsconfig.json b/packages/js-expr/tsconfig.json new file mode 100644 index 0000000000..53a184c820 --- /dev/null +++ b/packages/js-expr/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node20/tsconfig.json"], + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "isolatedModules": true, + "incremental": true, + "noEmit": true, + "noPropertyAccessFromIndexSignature": false, + "exactOptionalPropertyTypes": false, + "types": [ + "bun-types" // add Bun global + ] + }, + "include": ["types/**/*.d.ts", "src/**/*.ts"] +} diff --git a/packages/js-expr/types/eval-estree-expression.d.ts b/packages/js-expr/types/eval-estree-expression.d.ts new file mode 100644 index 0000000000..af2afb8ed1 --- /dev/null +++ b/packages/js-expr/types/eval-estree-expression.d.ts @@ -0,0 +1,63 @@ +// TODO-UTIL-EXPR: This type is defined here as a workaround for the @gitbook/util-expr package, +// which relies on a JS expression evaluation library that lacks TypeScript typings and has no +// support in DefinitelyTyped. Defining this type directly in @gitbook/util-expr is not feasible +// due to the issue discussed here: +// https://gitbook.slack.com/archives/C01NXGWJELS/p1732880405341719 + +declare module 'eval-estree-expression' { + /** + * Options for evaluation and compilation. + */ + export interface EvalESTreeExpressionOptions { + /** + * Force logical operators to return a boolean result. Default: undefined + */ + booleanLogicalOperators?: boolean; + /** + * Allow function calls to be evaluated. This is unsafe, please enable this option at your own risk. Default: false + */ + functions?: boolean; + /** + * Enable support for function statements and expressions by enabling the functions option AND by passing the .generate() function from the escodegen library. Default: undefined + */ + generate?: boolean; + /** + * Enable the =~ regex operator to support testing values without using functions (example name =~ /^a.*c$/). Default: true + */ + regexOperator?: boolean; + /** + * Throw an error when variables are undefined. Default: false + */ + strict?: boolean; + /** + * Used with the variables method to return nested variables (e.g., variables with dot notation, like foo.bar.baz). Default: undefined + */ + withMembers?: boolean; + } + + /** + * Evaluates an ESTree expression asynchronously against a given context. + * @param expression - An object representing an ESTree-compliant AST node. + * @param context - An object containing variables and values to be used during evaluation. + * @returns A promise resolving to the result of the evaluation. + */ + export function evaluate( + ast: ASTNode, + context: object, + options?: EvalESTreeExpressionOptions + ): Promise; + + /** + * Evaluates an ESTree expression synchronously against a given context. + * @param expression - An object representing an ESTree-compliant AST node. + * @param context - An object containing variables and values to be used during evaluation. + * @returns The result of the evaluation. + */ + export namespace evaluate { + function sync( + expression: ASTNode, + context: object, + options?: EvalESTreeExpressionOptions + ): any; + } +} From 44480f79f1a79332e4056cbb31184a2bb316047c Mon Sep 17 00:00:00 2001 From: Steeve Pastorelli Date: Fri, 22 Aug 2025 14:24:29 +0200 Subject: [PATCH 2/5] Tidy --- packages/js-expr/src/runtime.ts | 3 --- packages/js-expr/types/eval-estree-expression.d.ts | 6 ------ 2 files changed, 9 deletions(-) diff --git a/packages/js-expr/src/runtime.ts b/packages/js-expr/src/runtime.ts index e3a29327dc..d7fe8134e0 100644 --- a/packages/js-expr/src/runtime.ts +++ b/packages/js-expr/src/runtime.ts @@ -9,9 +9,6 @@ import { tokenizer, } from 'acorn'; import { parse as parseLoose } from 'acorn-loose'; -// TODO: Explore a better solution for typing this package or find an alternative library -// that is well-typed and meets our evaluation requirements. -// Once resolved, search for TODO-UTIL-EXPR to remove the temporary types added in packages/tsconfig. import { evaluate } from 'eval-estree-expression'; import { AutoComplete } from './autocomplete'; diff --git a/packages/js-expr/types/eval-estree-expression.d.ts b/packages/js-expr/types/eval-estree-expression.d.ts index af2afb8ed1..ea6ec0cac8 100644 --- a/packages/js-expr/types/eval-estree-expression.d.ts +++ b/packages/js-expr/types/eval-estree-expression.d.ts @@ -1,9 +1,3 @@ -// TODO-UTIL-EXPR: This type is defined here as a workaround for the @gitbook/util-expr package, -// which relies on a JS expression evaluation library that lacks TypeScript typings and has no -// support in DefinitelyTyped. Defining this type directly in @gitbook/util-expr is not feasible -// due to the issue discussed here: -// https://gitbook.slack.com/archives/C01NXGWJELS/p1732880405341719 - declare module 'eval-estree-expression' { /** * Options for evaluation and compilation. From 7b1c364502dedf260ef764081ffc80906bf18ba1 Mon Sep 17 00:00:00 2001 From: Steeve Pastorelli Date: Fri, 22 Aug 2025 14:38:50 +0200 Subject: [PATCH 3/5] Fix format --- packages/js-expr/src/symbols/symbols.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/js-expr/src/symbols/symbols.ts b/packages/js-expr/src/symbols/symbols.ts index 7eb196b73c..ab1b2fbd44 100644 --- a/packages/js-expr/src/symbols/symbols.ts +++ b/packages/js-expr/src/symbols/symbols.ts @@ -8,8 +8,8 @@ import { type NumberSymbolDef, type ObjectSymbolDef, type StringSymbolDef, - type SymbolsWithPropertiesAndMethods, SymbolType, + type SymbolsWithPropertiesAndMethods, type UndefinedSymbolDef, type UnionSymbolDef, } from './types'; From c964b1878485ee783cc9fe5d99081a1f398d0be3 Mon Sep 17 00:00:00 2001 From: Steeve Pastorelli Date: Fri, 22 Aug 2025 14:43:54 +0200 Subject: [PATCH 4/5] Rename package to gitbook/expr --- bun.lock | 38 +++++++++---------- packages/{js-expr => expr}/.gitignore | 0 packages/expr/README.md | 3 ++ packages/{js-expr => expr}/package.json | 4 +- .../src/__tests__/autocomplete.test.ts | 0 .../src/__tests__/input-values.test.ts | 0 .../src/__tests__/runtime.test.ts | 0 .../src/__tests__/template.test.ts | 0 .../{js-expr => expr}/src/autocomplete.ts | 0 packages/{js-expr => expr}/src/errors.ts | 0 packages/{js-expr => expr}/src/index.ts | 0 .../{js-expr => expr}/src/input-values.ts | 0 packages/{js-expr => expr}/src/runtime.ts | 0 .../src/symbols/__tests__/symbols.test.ts | 0 .../{js-expr => expr}/src/symbols/index.ts | 0 .../src/symbols/symbols-table.ts | 0 .../{js-expr => expr}/src/symbols/symbols.ts | 0 .../{js-expr => expr}/src/symbols/types.ts | 0 packages/{js-expr => expr}/src/template.ts | 0 packages/{js-expr => expr}/src/types.ts | 0 packages/{js-expr => expr}/src/utils.ts | 0 .../{js-expr => expr}/tsconfig.build.json | 0 packages/{js-expr => expr}/tsconfig.json | 0 .../types/eval-estree-expression.d.ts | 0 packages/js-expr/README.md | 3 -- 25 files changed, 24 insertions(+), 24 deletions(-) rename packages/{js-expr => expr}/.gitignore (100%) create mode 100644 packages/expr/README.md rename packages/{js-expr => expr}/package.json (88%) rename packages/{js-expr => expr}/src/__tests__/autocomplete.test.ts (100%) rename packages/{js-expr => expr}/src/__tests__/input-values.test.ts (100%) rename packages/{js-expr => expr}/src/__tests__/runtime.test.ts (100%) rename packages/{js-expr => expr}/src/__tests__/template.test.ts (100%) rename packages/{js-expr => expr}/src/autocomplete.ts (100%) rename packages/{js-expr => expr}/src/errors.ts (100%) rename packages/{js-expr => expr}/src/index.ts (100%) rename packages/{js-expr => expr}/src/input-values.ts (100%) rename packages/{js-expr => expr}/src/runtime.ts (100%) rename packages/{js-expr => expr}/src/symbols/__tests__/symbols.test.ts (100%) rename packages/{js-expr => expr}/src/symbols/index.ts (100%) rename packages/{js-expr => expr}/src/symbols/symbols-table.ts (100%) rename packages/{js-expr => expr}/src/symbols/symbols.ts (100%) rename packages/{js-expr => expr}/src/symbols/types.ts (100%) rename packages/{js-expr => expr}/src/template.ts (100%) rename packages/{js-expr => expr}/src/types.ts (100%) rename packages/{js-expr => expr}/src/utils.ts (100%) rename packages/{js-expr => expr}/tsconfig.build.json (100%) rename packages/{js-expr => expr}/tsconfig.json (100%) rename packages/{js-expr => expr}/types/eval-estree-expression.d.ts (100%) delete mode 100644 packages/js-expr/README.md diff --git a/bun.lock b/bun.lock index 9d4888c02f..ee1921a4bb 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,23 @@ "emoji-assets": "^8.0.0", }, }, + "packages/expr": { + "name": "@gitbook/expr", + "version": "0.0.1", + "dependencies": { + "acorn": "^8.14.0", + "acorn-loose": "8.4.0", + "acorn-walk": "^8.3.4", + "assert-never": "^1.2.1", + "eval-estree-expression": "^2.0.3", + }, + "devDependencies": { + "@babel/types": "^7.26.0", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "bun-types": "^1.1.20", + }, + }, "packages/fonts": { "name": "@gitbook/fonts", "version": "0.1.0", @@ -194,23 +211,6 @@ "react": "*", }, }, - "packages/js-expr": { - "name": "@gitbook/js-expr", - "version": "1.0.0", - "dependencies": { - "acorn": "^8.14.0", - "acorn-loose": "8.4.0", - "acorn-walk": "^8.3.4", - "assert-never": "^1.2.1", - "eval-estree-expression": "^2.0.3", - }, - "devDependencies": { - "@babel/types": "^7.26.0", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "bun-types": "^1.1.20", - }, - }, "packages/openapi-parser": { "name": "@gitbook/openapi-parser", "version": "3.0.0", @@ -671,14 +671,14 @@ "@gitbook/emoji-codepoints": ["@gitbook/emoji-codepoints@workspace:packages/emoji-codepoints"], + "@gitbook/expr": ["@gitbook/expr@workspace:packages/expr"], + "@gitbook/fontawesome-pro": ["@gitbook/fontawesome-pro@1.0.11", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "^6.7.2" } }, "sha512-P+GZ/X7b2Uj4uxxFX6xez+QceK211SXgMBj54lXzgo3Py34QhOuYrh/2F3ChB1iK5SXDwjn2xAuQXI7HuM66Mg=="], "@gitbook/fonts": ["@gitbook/fonts@workspace:packages/fonts"], "@gitbook/icons": ["@gitbook/icons@workspace:packages/icons"], - "@gitbook/js-expr": ["@gitbook/js-expr@workspace:packages/js-expr"], - "@gitbook/openapi-parser": ["@gitbook/openapi-parser@workspace:packages/openapi-parser"], "@gitbook/react-contentkit": ["@gitbook/react-contentkit@workspace:packages/react-contentkit"], diff --git a/packages/js-expr/.gitignore b/packages/expr/.gitignore similarity index 100% rename from packages/js-expr/.gitignore rename to packages/expr/.gitignore diff --git a/packages/expr/README.md b/packages/expr/README.md new file mode 100644 index 0000000000..ab6c82baa5 --- /dev/null +++ b/packages/expr/README.md @@ -0,0 +1,3 @@ +# `@gitbook/expr` + +Safely evaluate & parse user-defined GitBook expressions. diff --git a/packages/js-expr/package.json b/packages/expr/package.json similarity index 88% rename from packages/js-expr/package.json rename to packages/expr/package.json index 78a6a60653..d45ba59f63 100644 --- a/packages/js-expr/package.json +++ b/packages/expr/package.json @@ -1,6 +1,6 @@ { - "name": "@gitbook/js-expr", - "description": "Safely evaluate & parse user-defined JS expression.", + "name": "@gitbook/expr", + "description": "Safely evaluate & parse user-defined GitBook expressions.", "version": "0.0.1", "type": "module", "exports": { diff --git a/packages/js-expr/src/__tests__/autocomplete.test.ts b/packages/expr/src/__tests__/autocomplete.test.ts similarity index 100% rename from packages/js-expr/src/__tests__/autocomplete.test.ts rename to packages/expr/src/__tests__/autocomplete.test.ts diff --git a/packages/js-expr/src/__tests__/input-values.test.ts b/packages/expr/src/__tests__/input-values.test.ts similarity index 100% rename from packages/js-expr/src/__tests__/input-values.test.ts rename to packages/expr/src/__tests__/input-values.test.ts diff --git a/packages/js-expr/src/__tests__/runtime.test.ts b/packages/expr/src/__tests__/runtime.test.ts similarity index 100% rename from packages/js-expr/src/__tests__/runtime.test.ts rename to packages/expr/src/__tests__/runtime.test.ts diff --git a/packages/js-expr/src/__tests__/template.test.ts b/packages/expr/src/__tests__/template.test.ts similarity index 100% rename from packages/js-expr/src/__tests__/template.test.ts rename to packages/expr/src/__tests__/template.test.ts diff --git a/packages/js-expr/src/autocomplete.ts b/packages/expr/src/autocomplete.ts similarity index 100% rename from packages/js-expr/src/autocomplete.ts rename to packages/expr/src/autocomplete.ts diff --git a/packages/js-expr/src/errors.ts b/packages/expr/src/errors.ts similarity index 100% rename from packages/js-expr/src/errors.ts rename to packages/expr/src/errors.ts diff --git a/packages/js-expr/src/index.ts b/packages/expr/src/index.ts similarity index 100% rename from packages/js-expr/src/index.ts rename to packages/expr/src/index.ts diff --git a/packages/js-expr/src/input-values.ts b/packages/expr/src/input-values.ts similarity index 100% rename from packages/js-expr/src/input-values.ts rename to packages/expr/src/input-values.ts diff --git a/packages/js-expr/src/runtime.ts b/packages/expr/src/runtime.ts similarity index 100% rename from packages/js-expr/src/runtime.ts rename to packages/expr/src/runtime.ts diff --git a/packages/js-expr/src/symbols/__tests__/symbols.test.ts b/packages/expr/src/symbols/__tests__/symbols.test.ts similarity index 100% rename from packages/js-expr/src/symbols/__tests__/symbols.test.ts rename to packages/expr/src/symbols/__tests__/symbols.test.ts diff --git a/packages/js-expr/src/symbols/index.ts b/packages/expr/src/symbols/index.ts similarity index 100% rename from packages/js-expr/src/symbols/index.ts rename to packages/expr/src/symbols/index.ts diff --git a/packages/js-expr/src/symbols/symbols-table.ts b/packages/expr/src/symbols/symbols-table.ts similarity index 100% rename from packages/js-expr/src/symbols/symbols-table.ts rename to packages/expr/src/symbols/symbols-table.ts diff --git a/packages/js-expr/src/symbols/symbols.ts b/packages/expr/src/symbols/symbols.ts similarity index 100% rename from packages/js-expr/src/symbols/symbols.ts rename to packages/expr/src/symbols/symbols.ts diff --git a/packages/js-expr/src/symbols/types.ts b/packages/expr/src/symbols/types.ts similarity index 100% rename from packages/js-expr/src/symbols/types.ts rename to packages/expr/src/symbols/types.ts diff --git a/packages/js-expr/src/template.ts b/packages/expr/src/template.ts similarity index 100% rename from packages/js-expr/src/template.ts rename to packages/expr/src/template.ts diff --git a/packages/js-expr/src/types.ts b/packages/expr/src/types.ts similarity index 100% rename from packages/js-expr/src/types.ts rename to packages/expr/src/types.ts diff --git a/packages/js-expr/src/utils.ts b/packages/expr/src/utils.ts similarity index 100% rename from packages/js-expr/src/utils.ts rename to packages/expr/src/utils.ts diff --git a/packages/js-expr/tsconfig.build.json b/packages/expr/tsconfig.build.json similarity index 100% rename from packages/js-expr/tsconfig.build.json rename to packages/expr/tsconfig.build.json diff --git a/packages/js-expr/tsconfig.json b/packages/expr/tsconfig.json similarity index 100% rename from packages/js-expr/tsconfig.json rename to packages/expr/tsconfig.json diff --git a/packages/js-expr/types/eval-estree-expression.d.ts b/packages/expr/types/eval-estree-expression.d.ts similarity index 100% rename from packages/js-expr/types/eval-estree-expression.d.ts rename to packages/expr/types/eval-estree-expression.d.ts diff --git a/packages/js-expr/README.md b/packages/js-expr/README.md deleted file mode 100644 index 057c23eef8..0000000000 --- a/packages/js-expr/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# `@gitbook/js-expr` - -Safely evaluate & parse user-defined JS expression. From e8e98a79140f3b5f4ef89b549cc37860ed1c2f1c Mon Sep 17 00:00:00 2001 From: Steeve Pastorelli Date: Mon, 25 Aug 2025 10:31:25 +0200 Subject: [PATCH 5/5] Add changeset --- .changeset/hot-wombats-tap.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-wombats-tap.md diff --git a/.changeset/hot-wombats-tap.md b/.changeset/hot-wombats-tap.md new file mode 100644 index 0000000000..323764fdc4 --- /dev/null +++ b/.changeset/hot-wombats-tap.md @@ -0,0 +1,5 @@ +--- +"@gitbook/expr": major +--- + +Publish gitbook/expr package to help evaluate user defined expressions.