From ec5102c7d1f82c91007c7feef3c39f65f3a6bf0c Mon Sep 17 00:00:00 2001 From: Jordan Santell Date: Wed, 24 Sep 2025 09:41:12 -0700 Subject: [PATCH 1/2] chore: Use provided SPACE_NAME for list-operations.test.ts (#1817) --- packages/patterns/integration/list-operations.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/patterns/integration/list-operations.test.ts b/packages/patterns/integration/list-operations.test.ts index bb3e835da..76f40b073 100644 --- a/packages/patterns/integration/list-operations.test.ts +++ b/packages/patterns/integration/list-operations.test.ts @@ -15,16 +15,11 @@ describe("list-operations simple test", () => { let identity: Identity; let cc: CharmsController; let charm: CharmController; - let spaceName: string; beforeAll(async () => { identity = await Identity.generate({ implementation: "noble" }); - // Use a unique space name to avoid conflicts between test runs - spaceName = `${SPACE_NAME}-${Date.now()}-${ - Math.random().toString(36).slice(2) - }`; cc = await CharmsController.initialize({ - spaceName: spaceName, + spaceName: SPACE_NAME, apiUrl: new URL(API_URL), identity: identity, }); @@ -48,7 +43,7 @@ describe("list-operations simple test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: spaceName, + spaceName: SPACE_NAME, charmId: charm.id, identity, }); From 7e4f729086316766201f7054988074b447c2337e Mon Sep 17 00:00:00 2001 From: gideon Date: Wed, 24 Sep 2025 10:44:48 -0700 Subject: [PATCH 2/2] Refactor/ast transformers part 1 (#1813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New modular transformer stack. * Stand up @commontools/ts-transformers: normalized data-flow analysis, rule-based opaque-ref rewrite, schema-injection rule, import helpers, and comprehensive fixture coverage * Share transformer utilities across the workspace (common-tools-imports, common-tools-symbols, call-kind detection, binding plans) with robust helper reuse and canonical derive substitution * Wire the new package into @commontools/js-runtime: use the modular transformers, tighten virtual FS/module resolution, streamline environment lib handling, and keep compiler tests up to date with explicit CommonTools typings * Document roadmap and open gaps (transformers_notes.md), expand fixtures/tests to guard edge cases, and align schema generation via schema-generator’s createSchemaTransformerV2 --- deno.json | 4 +- deno.lock | 10 + packages/js-runtime/deno.json | 2 +- .../js-runtime/test/fixture-based.test.ts | 341 ----- .../jsx-arithmetic-operations.expected.tsx | 48 - .../jsx-conditional-rendering.expected.tsx | 64 - .../jsx-function-calls.expected.tsx | 82 -- .../jsx-string-operations.expected.tsx | 56 - .../schema-transform/no-directive.expected.ts | 9 - .../schema-transform/no-directive.input.ts | 9 - packages/js-runtime/test/opaque-ref.test.ts | 4 +- ...hema-generator-complex-nested-test.test.ts | 216 ---- .../test/typescript.compiler.test.ts | 30 +- .../typescript/transformer/debug.ts | 30 - .../typescript/transformer/imports.ts | 266 ---- .../js-runtime/typescript/transformer/mod.ts | 40 +- .../typescript/transformer/opaque-ref.ts | 1152 ----------------- .../typescript/transformer/schema.ts | 215 --- .../typescript/transformer/transforms.ts | 999 -------------- .../typescript/transformer/types.ts | 394 ------ packages/patterns/counter-handlers.ts | 2 +- packages/patterns/counter.tsx | 2 +- packages/patterns/instantiate-recipe.tsx | 1 + packages/patterns/nested-counter.tsx | 2 +- packages/schema-generator/notes.md | 94 -- packages/schema-generator/refactor_plan.md | 249 ---- .../root-ref-promotion-change.txt | 184 --- .../test/fixtures-runner.test.ts | 220 ++-- .../test/schema/type-to-schema.test.ts | 30 + packages/test-support/README.md | 35 + packages/test-support/deno.json | 31 + packages/test-support/src/fixture-runner.ts | 357 +++++ packages/test-support/src/mod.ts | 11 + packages/ts-transformers/README.md | 24 + packages/ts-transformers/deno.json | 32 + .../docs/opaque-refs-rewrite-notes.txt | 83 ++ .../docs/transformers_notes.md | 371 ++++++ packages/ts-transformers/src/core/assert.ts | 6 + .../src/core/common-tools-imports.ts | 197 +++ .../src/core/common-tools-symbols.ts | 183 +++ packages/ts-transformers/src/core/context.ts | 139 ++ packages/ts-transformers/src/core/imports.ts | 212 +++ packages/ts-transformers/src/mod.ts | 45 + .../src/opaque-ref/call-kind.ts | 277 ++++ .../src/opaque-ref/dataflow.ts | 774 +++++++++++ .../src/opaque-ref/normalize.ts | 187 +++ .../opaque-ref/rewrite/binary-expression.ts | 35 + .../src/opaque-ref/rewrite/bindings.ts | 95 ++ .../src/opaque-ref/rewrite/call-expression.ts | 117 ++ .../rewrite/conditional-expression.ts | 119 ++ .../rewrite/container-expression.ts | 25 + .../rewrite/element-access-expression.ts | 48 + .../src/opaque-ref/rewrite/event-handlers.ts | 12 + .../src/opaque-ref/rewrite/helpers.ts | 289 +++++ .../src/opaque-ref/rewrite/import-resolver.ts | 51 + .../rewrite/prefix-unary-expression.ts | 55 + .../src/opaque-ref/rewrite/property-access.ts | 44 + .../src/opaque-ref/rewrite/rewrite.ts | 92 ++ .../opaque-ref/rewrite/template-expression.ts | 35 + .../src/opaque-ref/rewrite/types.ts | 37 + .../src/opaque-ref/rules/jsx-expression.ts | 126 ++ .../src/opaque-ref/rules/schema-injection.ts | 143 ++ .../src/opaque-ref/transformer.ts | 64 + .../src/opaque-ref/transforms.ts | 426 ++++++ .../ts-transformers/src/opaque-ref/types.ts | 246 ++++ .../src/schema/schema-transformer.ts | 169 +++ .../test/fixture-based.test.ts | 141 ++ .../builder-conditional.expected.tsx | 27 + .../builder-conditional.input.tsx | 20 + .../ast-transform/counter-recipe.expected.tsx | 3 +- .../ast-transform/counter-recipe.input.tsx | 0 .../event-handler-no-derive.expected.tsx | 3 +- .../event-handler-no-derive.input.tsx | 0 .../handler-object-literal.expected.tsx | 1 - .../handler-object-literal.input.tsx | 0 .../recipe-array-map.expected.tsx | 1 - .../ast-transform/recipe-array-map.input.tsx | 0 .../ast-transform/ternary_derive.expected.tsx | 3 +- .../ast-transform/ternary_derive.input.tsx | 0 ...array-cell-remove-intersection.expected.ts | 0 .../array-cell-remove-intersection.input.ts | 0 .../complex-nested-types.expected.ts | 1 - .../complex-nested-types.input.ts | 0 .../date-and-map-types.expected.ts | 0 .../date-and-map-types.input.ts | 0 .../preserve-explicit-schemas.expected.ts | 2 +- .../preserve-explicit-schemas.input.ts | 0 .../handler-schema/simple-handler.expected.ts | 2 +- .../handler-schema/simple-handler.input.ts | 0 ...unsupported-intersection-index.expected.ts | 0 .../unsupported-intersection-index.input.ts | 0 .../complex-expressions.expected.tsx | 5 +- .../complex-expressions.input.tsx | 0 .../element-access-complex.expected.tsx | 172 +++ .../element-access-complex.input.tsx | 86 ++ .../element-access-simple.expected.tsx | 53 + .../element-access-simple.input.tsx | 28 + .../jsx-arithmetic-operations.expected.tsx | 47 + .../jsx-arithmetic-operations.input.tsx | 0 .../jsx-complex-mixed.expected.tsx | 26 +- .../jsx-complex-mixed.input.tsx | 0 .../jsx-conditional-rendering.expected.tsx | 63 + .../jsx-conditional-rendering.input.tsx | 0 .../jsx-function-calls.expected.tsx | 81 ++ .../jsx-function-calls.input.tsx | 0 .../jsx-property-access.expected.tsx | 36 +- .../jsx-property-access.input.tsx | 0 .../jsx-string-operations.expected.tsx | 55 + .../jsx-string-operations.input.tsx | 0 .../method-chains.expected.tsx | 186 +++ .../jsx-expressions/method-chains.input.tsx | 112 ++ .../no-double-derive.expected.tsx | 22 + .../no-double-derive.input.tsx | 27 + .../no-transform-simple-ref.expected.tsx | 1 - .../no-transform-simple-ref.input.tsx | 0 .../opaque-ref-cell-map.expected.tsx | 116 ++ .../opaque-ref-cell-map.input.tsx | 143 ++ .../opaque-ref-operations.expected.tsx | 7 +- .../opaque-ref-operations.input.tsx | 0 .../parent-suppression-edge.expected.tsx | 394 ++++++ .../parent-suppression-edge.input.tsx | 160 +++ .../recipe-statements-vs-jsx.expected.tsx | 11 +- .../recipe-statements-vs-jsx.input.tsx | 0 .../recipe-with-cells.expected.tsx | 5 +- .../recipe-with-cells.input.tsx | 0 .../opaque-ref-map.expected.ts | 4 +- .../schema-transform/opaque-ref-map.input.ts | 13 +- .../recipe-with-types.expected.tsx | 0 .../recipe-with-types.input.tsx | 0 .../with-opaque-ref.expected.tsx | 3 +- .../with-opaque-ref.input.tsx | 0 .../schema-transform/with-options.expected.ts | 0 .../schema-transform/with-options.input.ts | 0 .../test/opaque-ref/dataflow.test.ts | 54 + .../test/opaque-ref/harness.ts | 88 ++ .../test/opaque-ref/map-callbacks.test.ts | 61 + .../test/opaque-ref/normalize.test.ts | 34 + packages/ts-transformers/test/utils.ts | 252 ++++ 138 files changed, 7841 insertions(+), 4655 deletions(-) delete mode 100644 packages/js-runtime/test/fixture-based.test.ts delete mode 100644 packages/js-runtime/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx delete mode 100644 packages/js-runtime/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx delete mode 100644 packages/js-runtime/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx delete mode 100644 packages/js-runtime/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx delete mode 100644 packages/js-runtime/test/fixtures/schema-transform/no-directive.expected.ts delete mode 100644 packages/js-runtime/test/fixtures/schema-transform/no-directive.input.ts delete mode 100644 packages/js-runtime/test/schema-generator-complex-nested-test.test.ts delete mode 100644 packages/js-runtime/typescript/transformer/debug.ts delete mode 100644 packages/js-runtime/typescript/transformer/imports.ts delete mode 100644 packages/js-runtime/typescript/transformer/opaque-ref.ts delete mode 100644 packages/js-runtime/typescript/transformer/schema.ts delete mode 100644 packages/js-runtime/typescript/transformer/transforms.ts delete mode 100644 packages/js-runtime/typescript/transformer/types.ts delete mode 100644 packages/schema-generator/notes.md delete mode 100644 packages/schema-generator/refactor_plan.md delete mode 100644 packages/schema-generator/root-ref-promotion-change.txt create mode 100644 packages/test-support/README.md create mode 100644 packages/test-support/deno.json create mode 100644 packages/test-support/src/fixture-runner.ts create mode 100644 packages/test-support/src/mod.ts create mode 100644 packages/ts-transformers/README.md create mode 100644 packages/ts-transformers/deno.json create mode 100644 packages/ts-transformers/docs/opaque-refs-rewrite-notes.txt create mode 100644 packages/ts-transformers/docs/transformers_notes.md create mode 100644 packages/ts-transformers/src/core/assert.ts create mode 100644 packages/ts-transformers/src/core/common-tools-imports.ts create mode 100644 packages/ts-transformers/src/core/common-tools-symbols.ts create mode 100644 packages/ts-transformers/src/core/context.ts create mode 100644 packages/ts-transformers/src/core/imports.ts create mode 100644 packages/ts-transformers/src/mod.ts create mode 100644 packages/ts-transformers/src/opaque-ref/call-kind.ts create mode 100644 packages/ts-transformers/src/opaque-ref/dataflow.ts create mode 100644 packages/ts-transformers/src/opaque-ref/normalize.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/binary-expression.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/bindings.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/call-expression.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/conditional-expression.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/container-expression.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/element-access-expression.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/event-handlers.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/helpers.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/import-resolver.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/prefix-unary-expression.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/property-access.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/rewrite.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/template-expression.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rewrite/types.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rules/jsx-expression.ts create mode 100644 packages/ts-transformers/src/opaque-ref/rules/schema-injection.ts create mode 100644 packages/ts-transformers/src/opaque-ref/transformer.ts create mode 100644 packages/ts-transformers/src/opaque-ref/transforms.ts create mode 100644 packages/ts-transformers/src/opaque-ref/types.ts create mode 100644 packages/ts-transformers/src/schema/schema-transformer.ts create mode 100644 packages/ts-transformers/test/fixture-based.test.ts create mode 100644 packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.input.tsx rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/counter-recipe.expected.tsx (91%) rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/counter-recipe.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx (94%) rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/event-handler-no-derive.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/handler-object-literal.expected.tsx (99%) rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/handler-object-literal.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/recipe-array-map.expected.tsx (99%) rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/recipe-array-map.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/ternary_derive.expected.tsx (77%) rename packages/{js-runtime => ts-transformers}/test/fixtures/ast-transform/ternary_derive.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/array-cell-remove-intersection.expected.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/array-cell-remove-intersection.input.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/complex-nested-types.expected.ts (99%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/complex-nested-types.input.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/date-and-map-types.expected.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/date-and-map-types.input.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/preserve-explicit-schemas.expected.ts (94%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/preserve-explicit-schemas.input.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/simple-handler.expected.ts (96%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/simple-handler.input.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/unsupported-intersection-index.expected.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/handler-schema/unsupported-intersection-index.input.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/complex-expressions.expected.tsx (67%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/complex-expressions.input.tsx (100%) create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/jsx-arithmetic-operations.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx (51%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/jsx-complex-mixed.input.tsx (100%) create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/jsx-conditional-rendering.input.tsx (100%) create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/jsx-function-calls.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx (70%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/jsx-property-access.input.tsx (100%) create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/jsx-string-operations.input.tsx (100%) create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/no-double-derive.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/no-double-derive.input.tsx rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/no-transform-simple-ref.expected.tsx (99%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/no-transform-simple-ref.input.tsx (100%) create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.input.tsx rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx (56%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/opaque-ref-operations.input.tsx (100%) create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.input.tsx rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx (84%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx (72%) rename packages/{js-runtime => ts-transformers}/test/fixtures/jsx-expressions/recipe-with-cells.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/schema-transform/opaque-ref-map.expected.ts (92%) rename packages/{js-runtime => ts-transformers}/test/fixtures/schema-transform/opaque-ref-map.input.ts (77%) rename packages/{js-runtime => ts-transformers}/test/fixtures/schema-transform/recipe-with-types.expected.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/schema-transform/recipe-with-types.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/schema-transform/with-opaque-ref.expected.tsx (89%) rename packages/{js-runtime => ts-transformers}/test/fixtures/schema-transform/with-opaque-ref.input.tsx (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/schema-transform/with-options.expected.ts (100%) rename packages/{js-runtime => ts-transformers}/test/fixtures/schema-transform/with-options.input.ts (100%) create mode 100644 packages/ts-transformers/test/opaque-ref/dataflow.test.ts create mode 100644 packages/ts-transformers/test/opaque-ref/harness.ts create mode 100644 packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts create mode 100644 packages/ts-transformers/test/opaque-ref/normalize.test.ts create mode 100644 packages/ts-transformers/test/utils.ts diff --git a/deno.json b/deno.json index 36581581f..09870928f 100644 --- a/deno.json +++ b/deno.json @@ -12,6 +12,8 @@ "./packages/integration", "./packages/js-runtime", "./packages/js-sandbox", + "./packages/ts-transformers", + "./packages/test-support", "./packages/schema-generator", "./packages/llm", "./packages/memory", @@ -86,7 +88,7 @@ "docs/", "packages/seeder/templates/", "packages/static/assets/", - "packages/js-runtime/test/fixtures", + "packages/ts-transformers/test/fixtures", "packages/schema-generator/test/fixtures", "packages/vendor-astral" ] diff --git a/deno.lock b/deno.lock index cf882bfe2..5b75f87d4 100644 --- a/deno.lock +++ b/deno.lock @@ -1926,6 +1926,11 @@ "npm:@lit/task@^1.0.2" ] }, + "packages/test-support": { + "dependencies": [ + "npm:typescript@*" + ] + }, "packages/toolshed": { "dependencies": [ "jsr:@cmd-johnson/oauth2-client@2", @@ -1964,6 +1969,11 @@ "npm:stoker@^1.4.2" ] }, + "packages/ts-transformers": { + "dependencies": [ + "npm:typescript@*" + ] + }, "packages/ui": { "dependencies": [ "npm:@codemirror/autocomplete@^6.15.0", diff --git a/packages/js-runtime/deno.json b/packages/js-runtime/deno.json index 27fe6a1c4..ce836b291 100644 --- a/packages/js-runtime/deno.json +++ b/packages/js-runtime/deno.json @@ -1,7 +1,7 @@ { "name": "@commontools/js-runtime", "tasks": { - "test": "deno test --allow-read --allow-write --allow-run --allow-env=API_URL,\"TSC_*\",NODE_INSPECTOR_IPC,VSCODE_INSPECTOR_OPTIONS,NODE_ENV test/*.test.ts" + "test": "deno test --allow-read --allow-write --allow-run --allow-env=UPDATE_GOLDENS,API_URL,\"TSC_*\",NODE_INSPECTOR_IPC,VSCODE_INSPECTOR_OPTIONS,NODE_ENV test/*.test.ts" }, "imports": { "typescript": "npm:typescript", diff --git a/packages/js-runtime/test/fixture-based.test.ts b/packages/js-runtime/test/fixture-based.test.ts deleted file mode 100644 index ca0699e42..000000000 --- a/packages/js-runtime/test/fixture-based.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; -import { compareFixtureTransformation } from "./test-utils.ts"; -import { StaticCache } from "@commontools/static"; -import { walk } from "@std/fs/walk"; -import { basename, dirname, relative, resolve } from "@std/path"; - -// Helper to create a unified diff-like output -function createUnifiedDiff( - expected: string, - actual: string, - context: number = 3, -): string { - const expectedLines = expected.split("\n"); - const actualLines = actual.split("\n"); - const maxLines = Math.max(expectedLines.length, actualLines.length); - - const diffRanges: Array<{ start: number; end: number }> = []; - - // First, find all ranges with differences - for (let i = 0; i < maxLines; i++) { - const expectedLine = expectedLines[i] ?? ""; - const actualLine = actualLines[i] ?? ""; - - if (expectedLine !== actualLine) { - // Find or extend a range - const lastRange = diffRanges[diffRanges.length - 1]; - if (lastRange && i <= lastRange.end + context * 2) { - // Extend the existing range - lastRange.end = i; - } else { - // Start a new range - diffRanges.push({ start: i, end: i }); - } - } - } - - // Now create diff blocks with context - let diff = ""; - for (const range of diffRanges) { - const blockStart = Math.max(0, range.start - context); - const blockEnd = Math.min(maxLines - 1, range.end + context); - - const lines: string[] = []; - - for (let i = blockStart; i <= blockEnd; i++) { - const expectedLine = expectedLines[i] ?? ""; - const actualLine = actualLines[i] ?? ""; - - if (expectedLine === actualLine) { - lines.push(` ${expectedLine}`); - } else { - if (i < expectedLines.length && expectedLine !== "") { - lines.push(`- ${expectedLine}`); - } - if (i < actualLines.length && actualLine !== "") { - lines.push(`+ ${actualLine}`); - } - } - } - - // Create the hunk header - const expectedCount = lines.filter((l) => !l.startsWith("+")).length; - const actualCount = lines.filter((l) => !l.startsWith("-")).length; - - diff += `@@ -${blockStart + 1},${expectedCount} +${ - blockStart + 1 - },${actualCount} @@\n`; - diff += lines.join("\n") + "\n\n"; - } - - return diff.trim(); -} - -// Configuration for each fixture directory -interface FixtureConfig { - directory: string; - describe: string; - transformerOptions?: any; - // Map file patterns to test group names - groups?: Array<{ - pattern: RegExp; - name: string; - }>; - // Custom test name formatter - formatTestName?: (fileName: string) => string; - // Skip certain files - skip?: string[]; -} - -const configs: FixtureConfig[] = [ - { - directory: "ast-transform", - describe: "AST Transformation", - transformerOptions: { applySchemaTransformer: true }, - formatTestName: (name) => `transforms ${name.replace(/-/g, " ")}`, - }, - { - directory: "handler-schema", - describe: "Handler Schema Transformation", - transformerOptions: { applySchemaTransformer: true }, - formatTestName: (name) => `transforms ${name.replace(/-/g, " ")}`, - }, - { - directory: "jsx-expressions", - describe: "JSX Expression Transformer", - transformerOptions: { applySchemaTransformer: true }, - formatTestName: (name) => { - const formatted = name.replace(/-/g, " "); - if (name.includes("no-transform")) { - return `does not transform ${formatted.replace("no transform ", "")}`; - } - return `transforms ${formatted}`; - }, - }, - { - directory: "schema-transform", - describe: "Schema Transformer", - transformerOptions: { applySchemaTransformer: true }, - formatTestName: (name) => { - const formatted = name.replace(/-/g, " "); - if (name === "no-directive") { - return "skips transformation without /// directive"; - } - if (name === "with-opaque-ref") return "works with OpaqueRef transformer"; - return `transforms ${formatted}`; - }, - skip: ["no-directive"], // no-directive needs special handling - }, -]; - -// Collect all fixtures before generating tests -async function collectFixtures(config: FixtureConfig) { - const inputFiles: string[] = []; - - for await ( - const entry of walk(`test/fixtures/${config.directory}`, { - exts: [".ts", ".tsx"], - match: [/\.input\.(ts|tsx)$/], - }) - ) { - const relativePath = relative( - `test/fixtures/${config.directory}`, - entry.path, - ); - const baseName = basename( - relativePath, - basename(relativePath).includes(".tsx") ? ".input.tsx" : ".input.ts", - ); - - if (config.skip?.includes(baseName)) continue; - - inputFiles.push(baseName); - } - - return inputFiles; -} - -// Determine file extension -async function getFileExtension(basePath: string): Promise { - try { - await Deno.stat(`test/${basePath}.tsx`); - return ".tsx"; - } catch { - return ".ts"; - } -} - -// Get type definitions once -const staticCache = new StaticCache(); -const commontools = await staticCache.getText("types/commontools.d.ts"); - -// Collect all fixtures first -const configsWithFixtures = await Promise.all( - configs.map(async (config) => ({ - ...config, - fixtures: await collectFixtures(config), - })), -); - -// Generate tests for each configuration -for (const config of configsWithFixtures) { - describe(config.describe, () => { - // Group fixtures by pattern if groups are defined - const fixtureGroups = new Map(); - const ungroupedFixtures: string[] = []; - - // Sort files into groups - for (const fileName of config.fixtures) { - let grouped = false; - - if (config.groups) { - for (const group of config.groups) { - if (group.pattern.test(fileName)) { - if (!fixtureGroups.has(group.name)) { - fixtureGroups.set(group.name, []); - } - fixtureGroups.get(group.name)!.push(fileName); - grouped = true; - break; - } - } - } - - if (!grouped) { - ungroupedFixtures.push(fileName); - } - } - - // Generate tests for grouped fixtures - for (const [groupName, fixtures] of fixtureGroups) { - describe(groupName, () => { - for (const fixture of fixtures.sort()) { - const testName = config.formatTestName?.(fixture) || fixture; - - it(testName, async () => { - const inputPath = `${config.directory}/${fixture}`; - const ext = await getFileExtension(`fixtures/${inputPath}.input`); - - const result = await compareFixtureTransformation( - `${inputPath}.input${ext}`, - `${inputPath}.expected${ext}`, - { - types: { "commontools.d.ts": commontools }, - ...config.transformerOptions, - }, - ); - - if (!result.matches) { - // Create a detailed diff message - const unifiedDiff = createUnifiedDiff( - result.expected, - result.actual, - ); - - let diffMessage = - `\n\nTransformation output does not match expected for: ${testName}\n`; - diffMessage += `\nFiles:\n`; - diffMessage += ` Input: ${ - resolve(`test/fixtures/${inputPath}.input${ext}`) - }\n`; - diffMessage += ` Expected: ${ - resolve(`test/fixtures/${inputPath}.expected${ext}`) - }\n`; - diffMessage += `\n${"=".repeat(80)}\n`; - diffMessage += `UNIFIED DIFF (expected vs actual):\n`; - diffMessage += `${"=".repeat(80)}\n`; - diffMessage += unifiedDiff; - diffMessage += `\n${"=".repeat(80)}\n`; - - // Throw an assertion error with our detailed message - throw new Error(diffMessage); - } - }); - } - }); - } - - // Generate tests for ungrouped fixtures - for (const fixture of ungroupedFixtures.sort()) { - const testName = config.formatTestName?.(fixture) || fixture; - - it(testName, async () => { - const inputPath = `${config.directory}/${fixture}`; - const ext = await getFileExtension(`fixtures/${inputPath}.input`); - - const result = await compareFixtureTransformation( - `${inputPath}.input${ext}`, - `${inputPath}.expected${ext}`, - { - types: { "commontools.d.ts": commontools }, - ...config.transformerOptions, - }, - ); - - if (!result.matches) { - // Create a detailed diff message - const unifiedDiff = createUnifiedDiff(result.expected, result.actual); - - let diffMessage = - `\n\nTransformation output does not match expected for: ${testName}\n`; - diffMessage += `\nFiles:\n`; - diffMessage += ` Input: ${ - resolve(`test/fixtures/${inputPath}.input${ext}`) - }\n`; - diffMessage += ` Expected: ${ - resolve(`test/fixtures/${inputPath}.expected${ext}`) - }\n`; - diffMessage += `\n${"=".repeat(80)}\n`; - diffMessage += `UNIFIED DIFF (expected vs actual):\n`; - diffMessage += `${"=".repeat(80)}\n`; - diffMessage += unifiedDiff; - diffMessage += `\n${"=".repeat(80)}\n`; - - // Throw an assertion error with our detailed message - throw new Error(diffMessage); - } - }); - } - }); -} - -// Special handling for tests that need the compiler -describe("Schema Transformer - Compiler Tests", () => { - it("skips transformation without /// directive", async () => { - const { getTypeScriptEnvironmentTypes, TypeScriptCompiler } = await import( - "../mod.ts" - ); - const types = await getTypeScriptEnvironmentTypes(new StaticCache()); - const typeLibs = { ...types, commontools }; - const compiler = new TypeScriptCompiler(typeLibs); - - const inputContent = await Deno.readTextFile( - "test/fixtures/schema-transform/no-directive.input.ts", - ); - - const program = { - main: "/main.ts", - files: [ - { - name: "/main.ts", - contents: inputContent, - }, - { - name: "commontools.d.ts", - contents: commontools, - }, - ], - }; - - const compiled = compiler.compile(program, { - runtimeModules: ["commontools"], - }); - - // Should NOT transform without the directive - expect(compiled.js).toContain("commontools_1.toSchema)("); - expect(compiled.js).not.toContain('"type":"object"'); - expect(compiled.js).not.toContain('"properties"'); - expect(compiled.js).not.toContain("satisfies"); - }); -}); diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx b/packages/js-runtime/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx deleted file mode 100644 index 857fc7e0d..000000000 --- a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/// -import { h, recipe, UI, derive, JSONSchema } from "commontools"; -interface State { - count: number; - price: number; - discount: number; - quantity: number; -} -export default recipe({ - type: "object", - properties: { - count: { - type: "number" - }, - price: { - type: "number" - }, - discount: { - type: "number" - }, - quantity: { - type: "number" - } - }, - required: ["count", "price", "discount", "quantity"] -} as const satisfies JSONSchema, (state) => { - return { - [UI]: (
-

Basic Arithmetic

-

Count + 1: {commontools_1.derive(state.count, _v1 => _v1 + 1)}

-

Count - 1: {commontools_1.derive(state.count, _v1 => _v1 - 1)}

-

Count * 2: {commontools_1.derive(state.count, _v1 => _v1 * 2)}

-

Price / 2: {commontools_1.derive(state.price, _v1 => _v1 / 2)}

-

Count % 3: {commontools_1.derive(state.count, _v1 => _v1 % 3)}

- -

Complex Expressions

-

Discounted Price: {commontools_1.derive({ state_price: state.price, state_discount: state.discount }, ({ state_price: _v1, state_discount: _v2 }) => _v1 - (_v1 * _v2))}

-

Total: {commontools_1.derive({ state_price: state.price, state_quantity: state.quantity }, ({ state_price: _v1, state_quantity: _v2 }) => _v1 * _v2)}

-

With Tax (8%): {commontools_1.derive({ state_price: state.price, state_quantity: state.quantity }, ({ state_price: _v1, state_quantity: _v2 }) => (_v1 * _v2) * 1.08)}

-

Complex: {commontools_1.derive({ state_count: state.count, state_quantity: state.quantity, state_price: state.price, state_discount: state.discount }, ({ state_count: _v1, state_quantity: _v2, state_price: _v3, state_discount: _v4 }) => (_v1 + _v2) * _v3 - (_v3 * _v4))}

- -

Multiple Same Ref

-

Count³: {commontools_1.derive(state.count, _v1 => _v1 * _v1 * _v1)}

-

Price Range: ${commontools_1.derive(state.price, _v1 => _v1 - 10)} - ${commontools_1.derive(state.price, _v1 => _v1 + 10)}

-
), - }; -}); - diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx b/packages/js-runtime/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx deleted file mode 100644 index c1728b8ee..000000000 --- a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/// -import { h, recipe, UI, ifElse, derive, JSONSchema } from "commontools"; -interface State { - isActive: boolean; - count: number; - userType: string; - score: number; - hasPermission: boolean; - isPremium: boolean; -} -export default recipe({ - type: "object", - properties: { - isActive: { - type: "boolean" - }, - count: { - type: "number" - }, - userType: { - type: "string" - }, - score: { - type: "number" - }, - hasPermission: { - type: "boolean" - }, - isPremium: { - type: "boolean" - } - }, - required: ["isActive", "count", "userType", "score", "hasPermission", "isPremium"] -} as const satisfies JSONSchema, (state) => { - return { - [UI]: (
-

Basic Ternary

- {commontools_1.ifElse(state.isActive, "Active", "Inactive")} - {commontools_1.ifElse(state.hasPermission, "Authorized", "Denied")} - -

Ternary with Comparisons

- {commontools_1.ifElse(commontools_1.derive(state.count, _v1 => _v1 > 10), "High", "Low")} - {commontools_1.ifElse(commontools_1.derive(state.score, _v1 => _v1 >= 90), "A", state.score >= 80 ? "B" : "C")} - {commontools_1.ifElse(commontools_1.derive(state.count, _v1 => _v1 === 0), "Empty", state.count === 1 ? "Single" : "Multiple")} - -

Nested Ternary

- {commontools_1.ifElse(state.isActive, state.isPremium ? "Premium Active" : "Regular Active", "Inactive")} - {commontools_1.ifElse(commontools_1.derive(state.userType, _v1 => _v1 === "admin"), "Admin", state.userType === "user" ? "User" : "Guest")} - -

Complex Conditions

- {commontools_1.ifElse(commontools_1.derive({ state_isActive: state.isActive, state_hasPermission: state.hasPermission }, ({ state_isActive: _v1, state_hasPermission: _v2 }) => _v1 && _v2), "Full Access", "Limited Access")} - {commontools_1.ifElse(commontools_1.derive(state.count, _v1 => _v1 > 0 && _v1 < 10), "In Range", "Out of Range")} - {commontools_1.ifElse(commontools_1.derive({ state_isPremium: state.isPremium, state_score: state.score }, ({ state_isPremium: _v1, state_score: _v2 }) => _v1 || _v2 > 100), "Premium Features", "Basic Features")} - -

IfElse Component

- {ifElse(state.isActive,
User is active with {state.count} items
,
User is inactive
)} - - {ifElse(state.count > 5,
    -
  • Many items: {state.count}
  • -
,

Few items: {state.count}

)} -
), - }; -}); - diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx b/packages/js-runtime/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx deleted file mode 100644 index e65b0ac43..000000000 --- a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/// -import { h, recipe, UI, ifElse, derive, JSONSchema } from "commontools"; -interface State { - a: number; - b: number; - price: number; - text: string; - values: number[]; - name: string; - float: string; -} -export default recipe({ - type: "object", - properties: { - a: { - type: "number" - }, - b: { - type: "number" - }, - price: { - type: "number" - }, - text: { - type: "string" - }, - values: { - type: "array", - items: { - type: "number" - } - }, - name: { - type: "string" - }, - float: { - type: "string" - } - }, - required: ["a", "b", "price", "text", "values", "name", "float"] -} as const satisfies JSONSchema, (state) => { - return { - [UI]: (
-

Math Functions

-

Max: {commontools_1.derive({ state_a: state.a, state_b: state.b }, ({ state_a: _v1, state_b: _v2 }) => Math.max(_v1, _v2))}

-

Min: {commontools_1.derive(state.a, _v1 => Math.min(_v1, 10))}

-

Abs: {commontools_1.derive({ state_a: state.a, state_b: state.b }, ({ state_a: _v1, state_b: _v2 }) => Math.abs(_v1 - _v2))}

-

Round: {commontools_1.derive(state.price, _v1 => Math.round(_v1))}

-

Floor: {commontools_1.derive(state.price, _v1 => Math.floor(_v1))}

-

Ceiling: {commontools_1.derive(state.price, _v1 => Math.ceil(_v1))}

-

Square root: {commontools_1.derive(state.a, _v1 => Math.sqrt(_v1))}

- -

String Methods as Function Calls

-

Uppercase: {commontools_1.derive(state.name, _v1 => _v1.toUpperCase())}

-

Lowercase: {commontools_1.derive(state.name, _v1 => _v1.toLowerCase())}

-

Substring: {commontools_1.derive(state.text, _v1 => _v1.substring(0, 5))}

-

Replace: {commontools_1.derive(state.text, _v1 => _v1.replace("old", "new"))}

-

Includes: {commontools_1.ifElse(commontools_1.derive(state.text, _v1 => _v1.includes("test")), "Yes", "No")}

-

Starts with: {commontools_1.ifElse(commontools_1.derive(state.name, _v1 => _v1.startsWith("A")), "Yes", "No")}

- -

Number Methods

-

To Fixed: {commontools_1.derive(state.price, _v1 => _v1.toFixed(2))}

-

To Precision: {commontools_1.derive(state.price, _v1 => _v1.toPrecision(4))}

- -

Parse Functions

-

Parse Int: {commontools_1.derive(state.float, _v1 => parseInt(_v1))}

-

Parse Float: {commontools_1.derive(state.float, _v1 => parseFloat(_v1))}

- -

Array Method Calls

-

Sum: {commontools_1.derive(state.values, _v1 => _v1.reduce((a, b) => a + b, 0))}

-

Max value: {commontools_1.derive(state.values, _v1 => Math.max(..._v1))}

-

Joined: {commontools_1.derive(state.values, _v1 => _v1.join(", "))}

- -

Complex Function Calls

-

Multiple args: {commontools_1.derive(state.a, _v1 => Math.pow(_v1, 2))}

-

Nested calls: {commontools_1.derive(state.a, _v1 => Math.round(Math.sqrt(_v1)))}

-

Chained calls: {commontools_1.derive(state.name, _v1 => _v1.trim().toUpperCase())}

-

With expressions: {commontools_1.derive({ state_a: state.a, state_b: state.b }, ({ state_a: _v1, state_b: _v2 }) => Math.max(_v1 + 1, _v2 * 2))}

-
), - }; -}); - diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx b/packages/js-runtime/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx deleted file mode 100644 index 90e0d0e28..000000000 --- a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/// -import { h, recipe, UI, derive, JSONSchema } from "commontools"; -interface State { - firstName: string; - lastName: string; - title: string; - message: string; - count: number; -} -export default recipe({ - type: "object", - properties: { - firstName: { - type: "string" - }, - lastName: { - type: "string" - }, - title: { - type: "string" - }, - message: { - type: "string" - }, - count: { - type: "number" - } - }, - required: ["firstName", "lastName", "title", "message", "count"] -} as const satisfies JSONSchema, (state) => { - return { - [UI]: (
-

String Concatenation

-

{commontools_1.derive({ state_title: state.title, state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_title: _v1, state_firstName: _v2, state_lastName: _v3 }) => _v1 + ": " + _v2 + " " + _v3)}

-

{commontools_1.derive({ state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_firstName: _v1, state_lastName: _v2 }) => _v1 + _v2)}

-

{commontools_1.derive(state.firstName, _v1 => "Hello, " + _v1 + "!")}

- -

Template Literals

-

{commontools_1.derive(state.firstName, _v1 => `Welcome, ${_v1}!`)}

-

{commontools_1.derive({ state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_firstName: _v1, state_lastName: _v2 }) => `Full name: ${_v1} ${_v2}`)}

-

{commontools_1.derive({ state_title: state.title, state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_title: _v1, state_firstName: _v2, state_lastName: _v3 }) => `${_v1}: ${_v2} ${_v3}`)}

- -

String Methods

-

Uppercase: {commontools_1.derive(state.firstName, _v1 => _v1.toUpperCase())}

-

Lowercase: {commontools_1.derive(state.title, _v1 => _v1.toLowerCase())}

-

Length: {commontools_1.derive(state.message, _v1 => _v1.length)}

-

Substring: {commontools_1.derive(state.message, _v1 => _v1.substring(0, 5))}

- -

Mixed String and Number

-

{commontools_1.derive({ state_firstName: state.firstName, state_count: state.count }, ({ state_firstName: _v1, state_count: _v2 }) => _v1 + " has " + _v2 + " items")}

-

{commontools_1.derive({ state_firstName: state.firstName, state_count: state.count }, ({ state_firstName: _v1, state_count: _v2 }) => `${_v1} has ${_v2} items`)}

-

Count as string: {commontools_1.derive(state.count, _v1 => "Count: " + _v1)}

-
), - }; -}); - diff --git a/packages/js-runtime/test/fixtures/schema-transform/no-directive.expected.ts b/packages/js-runtime/test/fixtures/schema-transform/no-directive.expected.ts deleted file mode 100644 index c3e2da3d9..000000000 --- a/packages/js-runtime/test/fixtures/schema-transform/no-directive.expected.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { } from "commontools"; - -interface User { - name: string; - age: number; -} - -const schema = toSchema(); -export default schema; \ No newline at end of file diff --git a/packages/js-runtime/test/fixtures/schema-transform/no-directive.input.ts b/packages/js-runtime/test/fixtures/schema-transform/no-directive.input.ts deleted file mode 100644 index 4149ae354..000000000 --- a/packages/js-runtime/test/fixtures/schema-transform/no-directive.input.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { toSchema } from "commontools"; - -interface User { - name: string; - age: number; -} - -const schema = toSchema(); -export default schema; \ No newline at end of file diff --git a/packages/js-runtime/test/opaque-ref.test.ts b/packages/js-runtime/test/opaque-ref.test.ts index 30e746f95..d3e51836a 100644 --- a/packages/js-runtime/test/opaque-ref.test.ts +++ b/packages/js-runtime/test/opaque-ref.test.ts @@ -126,8 +126,8 @@ const el =
{count > 5 ? "yes" : "no"} {count + 1}
; `; // Test utility applies transformers regardless of directive const transformed = await transformSource(source, { types }); - expect(transformed).toContain("commontools_1.derive"); - expect(transformed).toContain("commontools_1.ifElse"); + expect(transformed).toContain("derive"); + expect(transformed).toContain("ifElse"); }); }); }); diff --git a/packages/js-runtime/test/schema-generator-complex-nested-test.test.ts b/packages/js-runtime/test/schema-generator-complex-nested-test.test.ts deleted file mode 100644 index 69788b746..000000000 --- a/packages/js-runtime/test/schema-generator-complex-nested-test.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { expect } from "@std/expect"; -import ts from "typescript"; -import { createSchemaTransformerV2 } from "@commontools/schema-generator"; -import { describe, it } from "@std/testing/bdd"; - -/** - * Test our new schema generator against the complex-nested-types fixture - * This will reveal gaps in our formatter coverage and show us what to build next - */ -describe("Schema Generator Complex Nested Types Test", () => { - it("should generate correct schema for UserEvent interface", () => { - const code = ` - interface UserEvent { - user: { - name: string; - email: string; - age?: number; - }; - action: "create" | "update" | "delete"; - } - `; - - const { type, checker } = getTypeFromCode(code, "UserEvent"); - const generator = createSchemaTransformerV2(); - - const result = generator(type, checker); - - // Should be an object with user and action properties - expect(result.type).toBe("object"); - expect(result.properties).toBeDefined(); - expect(result.properties?.user).toBeDefined(); - expect(result.properties?.action).toBeDefined(); - - // User should be an object with name, email, age properties - const userProp = result.properties?.user; - expect(userProp?.type).toBe("object"); - expect(userProp?.properties?.name?.type).toBe("string"); - expect(userProp?.properties?.email?.type).toBe("string"); - expect(userProp?.properties?.age?.type).toBe("number"); - - // age should be optional (not in required array) - expect(userProp?.required).toContain("name"); - expect(userProp?.required).toContain("email"); - expect(userProp?.required).not.toContain("age"); - - // Action should be a union type handled by UnionFormatter - const actionProp = result.properties?.action; - expect(actionProp?.enum).toEqual(["create", "update", "delete"]); - }); - - it("should generate correct schema for UserState interface", () => { - const code = ` - // Mock Cell type for testing - use interface to create proper TypeReference - interface Cell { - get(): T; - set(value: T): void; - } - - interface UserState { - lastAction: Cell; - count: Cell; - } - `; - - const { type, checker } = getTypeFromCode(code, "UserState"); - const generator = createSchemaTransformerV2(); - - const result = generator(type, checker); - - // Remove debug logging since tests should be clean - - // Should be an object with lastAction, count properties - expect(result.type).toBe("object"); - expect(result.properties).toBeDefined(); - expect(result.properties?.lastAction).toBeDefined(); - expect(result.properties?.count).toBeDefined(); - - // lastAction should be Cell - const lastActionProp = result.properties?.lastAction; - expect(lastActionProp?.asCell).toBe(true); - expect(lastActionProp?.type).toBe("string"); - - // count should be Cell - const countProp = result.properties?.count; - expect(countProp?.asCell).toBe(true); - expect(countProp?.type).toBe("number"); - - // All properties should be required - expect(result.required).toContain("lastAction"); - expect(result.required).toContain("count"); - }); - - it("should handle nested generic types correctly", () => { - const code = ` - // Mock Cell type for testing - use interface to create proper TypeReference - interface Cell { - get(): T; - set(value: T): void; - } - - // Define the array type separately to ensure it's preserved - type DataArray = Array<{ - id: string; - value: number; - }>; - - interface NestedGeneric { - data: Cell; - } - `; - - const { type, checker } = getTypeFromCode(code, "NestedGeneric"); - const generator = createSchemaTransformerV2(); - - const result = generator(type, checker); - - // Should handle nested Cell> correctly - const dataProp = result.properties?.data; - expect(dataProp?.asCell).toBe(true); - expect(dataProp?.type).toBe("array"); - expect(dataProp?.items?.type).toBe("object"); - expect(dataProp?.items?.properties?.id?.type).toBe("string"); - expect(dataProp?.items?.properties?.value?.type).toBe("number"); - }); - - it("should handle union types in properties", () => { - const code = ` - interface UnionTest { - status: "active" | "inactive" | "pending"; - priority: 1 | 2 | 3; - } - `; - - const { type, checker } = getTypeFromCode(code, "UnionTest"); - const generator = createSchemaTransformerV2(); - - const result = generator(type, checker); - - // Union types should be handled correctly by UnionFormatter - const statusProp = result.properties?.status; - const priorityProp = result.properties?.priority; - - // Status should be a string enum - expect(statusProp?.enum).toEqual(["active", "inactive", "pending"]); - - // Priority should be a number enum - expect(priorityProp?.enum).toEqual([1, 2, 3]); - }); -}); - -// Helper to create a minimal TypeScript program for testing -function createTestProgram( - code: string, -): { program: ts.Program; checker: ts.TypeChecker; sourceFile: ts.SourceFile } { - const fileName = "test.ts"; - const sourceFile = ts.createSourceFile( - fileName, - code, - ts.ScriptTarget.Latest, - true, - ); - - const compilerHost: ts.CompilerHost = { - getSourceFile: (name) => name === fileName ? sourceFile : undefined, - writeFile: () => {}, - getCurrentDirectory: () => "", - getDirectories: () => [], - fileExists: () => true, - readFile: () => "", - getCanonicalFileName: (fileName) => fileName, - useCaseSensitiveFileNames: () => true, - getNewLine: () => "\n", - getDefaultLibFileName: () => "lib.d.ts", - }; - - const program = ts.createProgram([fileName], { - target: ts.ScriptTarget.Latest, - module: ts.ModuleKind.ESNext, - }, compilerHost); - - return { - program, - checker: program.getTypeChecker(), - sourceFile: sourceFile!, - }; -} - -// Helper to get a type from an interface or type alias declaration -function getTypeFromCode( - code: string, - typeName: string, -): { type: ts.Type; checker: ts.TypeChecker; typeNode?: ts.TypeNode } { - const { program, checker, sourceFile } = createTestProgram(code); - - // Find the interface or type alias declaration - let foundType: ts.Type | undefined; - let foundTypeNode: ts.TypeNode | undefined; - - ts.forEachChild(sourceFile, (node) => { - if (ts.isInterfaceDeclaration(node) && node.name.text === typeName) { - const symbol = checker.getSymbolAtLocation(node.name); - if (symbol) { - foundType = checker.getDeclaredTypeOfSymbol(symbol); - } - } else if (ts.isTypeAliasDeclaration(node) && node.name.text === typeName) { - foundType = checker.getTypeFromTypeNode(node.type); - foundTypeNode = node.type; - } - }); - - if (!foundType) { - throw new Error(`Type ${typeName} not found in code`); - } - - return { type: foundType, checker, typeNode: foundTypeNode }; -} diff --git a/packages/js-runtime/test/typescript.compiler.test.ts b/packages/js-runtime/test/typescript.compiler.test.ts index dc62923b7..f4de3c9d3 100644 --- a/packages/js-runtime/test/typescript.compiler.test.ts +++ b/packages/js-runtime/test/typescript.compiler.test.ts @@ -41,7 +41,11 @@ const TESTS: TestDef[] = [ }, ]; -const types = await getTypeScriptEnvironmentTypes(new StaticCache()); +const staticCache = new StaticCache(); +const types = await getTypeScriptEnvironmentTypes(staticCache); +types["commontools.d.ts"] = await staticCache.getText( + "types/commontools.d.ts", +); describe("TypeScriptCompiler", () => { it("compiles a filesystem graph", async () => { @@ -110,6 +114,30 @@ export default add(5, "5");`, await expect(compiler.resolveAndCompile(program)).rejects.toThrow(expected); }); + it("Skips schema transformation without directive", async () => { + const compiler = new TypeScriptCompiler(types); + const program = new InMemoryProgram("/main.tsx", { + "/main.tsx": ` +import { toSchema } from "commontools"; + +export interface Props { + count: number; +} + +export default toSchema(); +`, + "commontools.d.ts": types["commontools.d.ts"], + }); + + const { js } = await compiler.resolveAndCompile(program, { + bundleExportAll: true, + runtimeModules: ["commontools"], + }); + + expect(js).toContain("toSchema"); + expect(js).not.toContain('"type":"object"'); + }); + for (const { name, source, expectedError, ...options } of TESTS) { it(name, () => { const artifact = { diff --git a/packages/js-runtime/typescript/transformer/debug.ts b/packages/js-runtime/typescript/transformer/debug.ts deleted file mode 100644 index a088195b8..000000000 --- a/packages/js-runtime/typescript/transformer/debug.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Transformation types for type safety and consistency - */ -export const TRANSFORMATION_TYPES = { - OPAQUE_REF: { - TERNARY: "ternary-to-ifelse", - JSX_EXPRESSION: "jsx-expression-wrap", - BINARY_EXPRESSION: "binary-to-derive", - METHOD_CALL: "method-call", - PROPERTY_ACCESS: "property-access", - TEMPLATE_LITERAL: "template-literal", - OBJECT_SPREAD: "object-spread", - ELEMENT_ACCESS: "element-access", - FUNCTION_CALL: "function-call", - }, - SCHEMA: { - TO_SCHEMA_CALL: "to-schema-call", - HANDLER_TYPE_ARGS: "handler-type-args", - RECIPE_TYPE_ARGS: "recipe-type-args", - TYPE_CONVERSION: "type-conversion", - }, -} as const; - -export type TransformationType = - | typeof TRANSFORMATION_TYPES.OPAQUE_REF[ - keyof typeof TRANSFORMATION_TYPES.OPAQUE_REF - ] - | typeof TRANSFORMATION_TYPES.SCHEMA[ - keyof typeof TRANSFORMATION_TYPES.SCHEMA - ]; diff --git a/packages/js-runtime/typescript/transformer/imports.ts b/packages/js-runtime/typescript/transformer/imports.ts deleted file mode 100644 index 4b3edbee0..000000000 --- a/packages/js-runtime/typescript/transformer/imports.ts +++ /dev/null @@ -1,266 +0,0 @@ -import ts from "typescript"; - -/** - * Gets the CommonTools module alias used in AMD output. - * In AMD output, TypeScript transforms module imports to parameters. - * For imports from "commontools", it typically becomes "commontools_1". - */ -export function getCommonToolsModuleAlias( - sourceFile: ts.SourceFile, -): string | null { - // In AMD output, TypeScript transforms module imports to parameters - // For imports from "commontools", it typically becomes "commontools_1" - for (const statement of sourceFile.statements) { - if (ts.isImportDeclaration(statement)) { - const moduleSpecifier = statement.moduleSpecifier; - if ( - ts.isStringLiteral(moduleSpecifier) && - moduleSpecifier.text === "commontools" - ) { - // For named imports in AMD, TypeScript generates a module parameter - // like "commontools_1". Since we're working at the AST level before - // AMD transformation, we need to anticipate this pattern. - // Return the expected AMD module alias - return "commontools_1"; - } - } - } - return null; -} - -/** - * Checks if a specific import exists from commontools. - */ -export function hasCommonToolsImport( - sourceFile: ts.SourceFile, - importName: string, -): boolean { - for (const statement of sourceFile.statements) { - if (ts.isImportDeclaration(statement)) { - const moduleSpecifier = statement.moduleSpecifier; - if ( - ts.isStringLiteral(moduleSpecifier) && - moduleSpecifier.text === "commontools" - ) { - // Check if the specific import is in the import clause - if (statement.importClause && statement.importClause.namedBindings) { - if (ts.isNamedImports(statement.importClause.namedBindings)) { - for ( - const element of statement.importClause.namedBindings.elements - ) { - if (element.name.text === importName) { - return true; - } - } - } - } - } - } - } - return false; -} - -/** - * Adds an import to the commontools module. - */ -export function addCommonToolsImport( - sourceFile: ts.SourceFile, - factory: ts.NodeFactory, - importName: string, -): ts.SourceFile { - let existingImport: ts.ImportDeclaration | undefined; - let existingImportIndex: number = -1; - let moduleSpecifierText: string = ""; - - // Find existing commontools import - sourceFile.statements.forEach((statement, index) => { - if (ts.isImportDeclaration(statement)) { - const moduleSpecifier = statement.moduleSpecifier; - if ( - ts.isStringLiteral(moduleSpecifier) && - moduleSpecifier.text === "commontools" - ) { - existingImport = statement; - existingImportIndex = index; - moduleSpecifierText = moduleSpecifier.text; - } - } - }); - - let newImport: ts.ImportDeclaration; - - if ( - existingImport && existingImport.importClause && - existingImport.importClause.namedBindings && - ts.isNamedImports(existingImport.importClause.namedBindings) - ) { - // Add to existing import if not already present - const existingElements = existingImport.importClause.namedBindings.elements; - const hasImport = existingElements.some((element) => - element.name.text === importName - ); - - if (hasImport) { - return sourceFile; - } - - const newElements = [ - ...existingElements, - factory.createImportSpecifier( - false, - undefined, - factory.createIdentifier(importName), - ), - ]; - - newImport = factory.updateImportDeclaration( - existingImport, - undefined, - factory.createImportClause( - false, - existingImport.importClause.name, - factory.createNamedImports(newElements), - ), - existingImport.moduleSpecifier, - undefined, - ); - } else { - // Create new import declaration for commontools - newImport = factory.createImportDeclaration( - undefined, - factory.createImportClause( - false, - undefined, - factory.createNamedImports([ - factory.createImportSpecifier( - false, - undefined, - factory.createIdentifier(importName), - ), - ]), - ), - factory.createStringLiteral("commontools"), - undefined, - ); - - // Add as first statement or after existing imports - const newStatements = [...sourceFile.statements]; - let insertIndex = 0; - - // Find the position after all import declarations - for (let i = 0; i < newStatements.length; i++) { - if (ts.isImportDeclaration(newStatements[i])) { - insertIndex = i + 1; - } else { - break; - } - } - - newStatements.splice(insertIndex, 0, newImport); - - return factory.updateSourceFile( - sourceFile, - newStatements, - sourceFile.isDeclarationFile, - sourceFile.referencedFiles, - sourceFile.typeReferenceDirectives, - sourceFile.hasNoDefaultLib, - sourceFile.libReferenceDirectives, - ); - } - - // Reconstruct statements with the new import - const newStatements = [...sourceFile.statements]; - newStatements[existingImportIndex] = newImport; - - return factory.updateSourceFile( - sourceFile, - newStatements, - sourceFile.isDeclarationFile, - sourceFile.referencedFiles, - sourceFile.typeReferenceDirectives, - sourceFile.hasNoDefaultLib, - sourceFile.libReferenceDirectives, - ); -} - -/** - * Removes an import from the commontools module. - */ -export function removeCommonToolsImport( - sourceFile: ts.SourceFile, - factory: ts.NodeFactory, - importName: string, -): ts.SourceFile { - let existingImport: ts.ImportDeclaration | undefined; - let existingImportIndex: number = -1; - - // Find existing commontools import - sourceFile.statements.forEach((statement, index) => { - if (ts.isImportDeclaration(statement)) { - const moduleSpecifier = statement.moduleSpecifier; - if ( - ts.isStringLiteral(moduleSpecifier) && - moduleSpecifier.text === "commontools" - ) { - existingImport = statement; - existingImportIndex = index; - } - } - }); - - if ( - !existingImport || !existingImport.importClause || - !existingImport.importClause.namedBindings || - !ts.isNamedImports(existingImport.importClause.namedBindings) - ) { - return sourceFile; - } - - const existingElements = existingImport.importClause.namedBindings.elements; - const newElements = existingElements.filter( - (element) => element.name.text !== importName, - ); - - // If no imports left, remove the entire import statement - if (newElements.length === 0) { - const newStatements = sourceFile.statements.filter( - (_, index) => index !== existingImportIndex, - ); - return factory.updateSourceFile( - sourceFile, - newStatements, - sourceFile.isDeclarationFile, - sourceFile.referencedFiles, - sourceFile.typeReferenceDirectives, - sourceFile.hasNoDefaultLib, - sourceFile.libReferenceDirectives, - ); - } - - // Otherwise, update the import with remaining imports - const newImport = factory.updateImportDeclaration( - existingImport, - undefined, - factory.createImportClause( - false, - existingImport.importClause.name, - factory.createNamedImports(newElements), - ), - existingImport.moduleSpecifier, - undefined, - ); - - const newStatements = [...sourceFile.statements]; - newStatements[existingImportIndex] = newImport; - - return factory.updateSourceFile( - sourceFile, - newStatements, - sourceFile.isDeclarationFile, - sourceFile.referencedFiles, - sourceFile.typeReferenceDirectives, - sourceFile.hasNoDefaultLib, - sourceFile.libReferenceDirectives, - ); -} diff --git a/packages/js-runtime/typescript/transformer/mod.ts b/packages/js-runtime/typescript/transformer/mod.ts index bb8e3a86c..52ebf8790 100644 --- a/packages/js-runtime/typescript/transformer/mod.ts +++ b/packages/js-runtime/typescript/transformer/mod.ts @@ -1,42 +1,10 @@ -// Main exports export { - createOpaqueRefTransformer, - type OpaqueRefTransformerOptions, - type TransformationError, -} from "./opaque-ref.ts"; + createModularOpaqueRefTransformer as createOpaqueRefTransformer, + type ModularOpaqueRefTransformerOptions as OpaqueRefTransformerOptions, +} from "@commontools/ts-transformers"; -// Schema transformer -export { createSchemaTransformer } from "./schema.ts"; +export { createSchemaTransformer } from "@commontools/ts-transformers"; -// Logging transformer export { createLoggingTransformer } from "./logging.ts"; -// Debug utilities -export { TRANSFORMATION_TYPES } from "./debug.ts"; - -// Type checking utilities -export { - collectOpaqueRefs, - containsOpaqueRef, - isOpaqueRefType, - isSimpleOpaqueRefAccess, -} from "./types.ts"; - -// Import management utilities -export { - addCommonToolsImport, - getCommonToolsModuleAlias, - hasCommonToolsImport, -} from "./imports.ts"; - -// Transformation utilities -export { - checkTransformation, - createIfElseCall, - replaceOpaqueRefWithParam, - type TransformationResult, - transformExpressionWithOpaqueRef, -} from "./transforms.ts"; - -// Common utilities export { hasCtsEnableDirective } from "./utils.ts"; diff --git a/packages/js-runtime/typescript/transformer/opaque-ref.ts b/packages/js-runtime/typescript/transformer/opaque-ref.ts deleted file mode 100644 index 6dec94f1a..000000000 --- a/packages/js-runtime/typescript/transformer/opaque-ref.ts +++ /dev/null @@ -1,1152 +0,0 @@ -import ts from "typescript"; -import { getLogger } from "@commontools/utils/logger"; -import { - containsOpaqueRef, - isEventHandlerJsxAttribute, - isOpaqueRefType, - isSimpleOpaqueRefAccess, -} from "./types.ts"; -import { addCommonToolsImport, hasCommonToolsImport } from "./imports.ts"; -import { - addGetCallsToOpaqueRefs, - checkTransformation, - createIfElseCall, - TransformationTypeString, - transformExpressionWithOpaqueRef, -} from "./transforms.ts"; -import { TRANSFORMATION_TYPES } from "./debug.ts"; - -// Create logger for OpaqueRef transformer -const logger = getLogger("opaque-ref-transformer", { - enabled: false, - level: "debug", -}); - -/** - * Options for the OpaqueRef transformer. - */ -export interface OpaqueRefTransformerOptions { - /** - * Mode of operation: - * - 'transform': Transform the code (default) - * - 'error': Report errors instead of transforming - */ - mode?: "transform" | "error"; -} - -/** - * Transformation error that can be reported in error mode. - */ -export interface TransformationError { - file: string; - line: number; - column: number; - message: string; - type: TransformationTypeString; -} - -/** - * Creates a TypeScript transformer that handles OpaqueRef transformations. - * - * Transformations: - * 1. Ternary operators: `opaqueRef ? a : b` → `ifElse(opaqueRef, a, b)` - * 2. JSX expressions: `{opaqueRef + 1}` → `{derive(opaqueRef, _v => _v + 1)}` - * 3. Binary expressions: `opaqueRef + 1` → `derive(opaqueRef, _v => _v + 1)` - */ -export function createOpaqueRefTransformer( - program: ts.Program, - options: OpaqueRefTransformerOptions = {}, -): ts.TransformerFactory { - const checker = program.getTypeChecker(); - const { mode = "transform" } = options; - const errors: TransformationError[] = []; - // Methods explicitly defined on OpaqueRefMethods interface - // These methods should NOT be transformed with .get() - const opaqueRefMethods = [ - "get", - "set", - "key", - "setDefault", - "setName", - "setSchema", - "map", - ]; - - return (context) => { - return (sourceFile) => { - let needsIfElseImport = false; - let needsDeriveImport = false; - let needsToSchemaImport = false; - let hasTransformed = false; - - const reportError = ( - node: ts.Node, - type: TransformationTypeString, - message: string, - ) => { - const { line, character } = sourceFile.getLineAndCharacterOfPosition( - node.getStart(), - ); - errors.push({ - file: sourceFile.fileName, - line: line + 1, - column: character + 1, - message, - type, - }); - }; - - // Helper to check if a node is inside a JSX expression - const isInsideJsxExpression = (node: ts.Node): boolean => { - let current: ts.Node | undefined = node; - while (current) { - if (ts.isJsxExpression(current)) { - // Check if this JSX expression is in an event handler attribute - const parent = current.parent; - if (parent && ts.isJsxAttribute(parent)) { - const attrName = parent.name.getText(); - // Event handlers like onClick expect functions, not derived values - if (attrName.startsWith("on")) { - return false; - } - } - return true; - } - // If we hit a statement boundary, we're definitely not in a JSX expression - if (ts.isStatement(current) || ts.isSourceFile(current)) { - return false; - } - current = current.parent; - } - return false; - }; - - const visit: ts.Visitor = (node) => { - // Handle function calls with OpaqueRef arguments or method calls on OpaqueRef - if (ts.isCallExpression(node)) { - // Check if this is a method call on an OpaqueRef (e.g., values.map(...)) - if (ts.isPropertyAccessExpression(node.expression)) { - const methodName = node.expression.name.text; - const objectType = checker.getTypeAtLocation( - node.expression.expression, - ); - if (isOpaqueRefType(objectType, checker)) { - // Methods explicitly defined on OpaqueRefMethods interface - // These methods should NOT be transformed with .get() - const opaqueRefMethods = [ - "get", - "set", - "key", - "setDefault", - "setName", - "setSchema", - "map", - ]; - - const methodExistsOnOpaqueRef = opaqueRefMethods.includes( - methodName, - ); - - logger.debug(() => { - const { line, character } = sourceFile - .getLineAndCharacterOfPosition(node.getStart()); - return [ - `[OpaqueRefTransformer] ${TRANSFORMATION_TYPES.OPAQUE_REF.METHOD_CALL} at ${sourceFile.fileName}:${line}:${character}`, - ` methodName: ${methodName}`, - ` objectType: ${checker.typeToString(objectType)}`, - ` isOpaqueRefMethod: ${methodExistsOnOpaqueRef}`, - ].join("\n"); - }); - - // Only apply .get() transformation for array methods on OpaqueRef - // when the OpaqueRef is a simple identifier (not a property access) - if ( - !methodExistsOnOpaqueRef && - ts.isIdentifier(node.expression.expression) - ) { - // Check if this is an array type - const typeString = checker.typeToString(objectType); - if ( - typeString.includes("[]") || typeString.includes("Array<") - ) { - // This is an array method on an OpaqueRef, add .get() before the method - const objectWithGet = context.factory.createCallExpression( - context.factory.createPropertyAccessExpression( - node.expression.expression, - context.factory.createIdentifier("get"), - ), - undefined, - [], - ); - - const newMethodCall = context.factory.createCallExpression( - context.factory.createPropertyAccessExpression( - objectWithGet, - node.expression.name, - ), - node.typeArguments, - // Visit arguments to handle any nested transformations - node.arguments.map((arg) => - ts.visitNode(arg, visit) as ts.Expression - ), - ); - - return newMethodCall; - } - } else if (methodExistsOnOpaqueRef) { - // Method exists on OpaqueRef, so just visit children normally - // This handles methods like map() that are defined on OpaqueRef itself - return ts.visitEachChild(node, visit, context); - } - // For other cases (like person.name.toUpperCase()), let it fall through - // to be handled by the general transformation logic - } - } - - // Special case: handler and recipe with type arguments or inline type annotations - const functionName = getFunctionName(node); - if (functionName === "handler" || functionName === "recipe") { - // Handle recipe with type arguments - if ( - functionName === "recipe" && node.typeArguments && - node.typeArguments.length >= 1 - ) { - // Transform recipe(name, fn) to recipe(toSchema(), name, fn) - // Transform recipe(name, fn) to recipe(toSchema(), toSchema(), name, fn) - logger.debug(() => { - const { line } = sourceFile.getLineAndCharacterOfPosition( - node.getStart(), - ); - return `[OpaqueRefTransformer] Found recipe with type arguments at ${sourceFile.fileName}:${ - line + 1 - }`; - }); - - const recipeArgs = node.arguments; - const schemaArgs: ts.Expression[] = []; - - // Create toSchema calls for each type argument - for (const typeArg of node.typeArguments) { - const toSchemaCall = context.factory.createCallExpression( - context.factory.createIdentifier("toSchema"), - [typeArg], - [], - ); - schemaArgs.push(toSchemaCall); - } - - // Skip the first argument (name) if it's a string literal - const argsArray = Array.from(recipeArgs); - let remainingArgs = argsArray; - if (argsArray.length > 0 && ts.isStringLiteral(argsArray[0])) { - // Skip the name parameter - remainingArgs = argsArray.slice(1); - } - - // Create new recipe call without type arguments but with schema arguments prepended - const newRecipeCall = context.factory.createCallExpression( - node.expression, - undefined, // No type arguments - [...schemaArgs, ...remainingArgs], - ); - - // Mark that we need toSchema import - if (!hasCommonToolsImport(sourceFile, "toSchema")) { - needsToSchemaImport = true; - } - - hasTransformed = true; - return ts.visitEachChild(newRecipeCall, visit, context); - } // Case 1: handler with explicit type arguments - else if ( - functionName === "handler" && node.typeArguments && - node.typeArguments.length >= 2 - ) { - // Transform handler(fn) to handler(toSchema(), toSchema(), fn) - logger.debug(() => { - const { line } = sourceFile.getLineAndCharacterOfPosition( - node.getStart(), - ); - return `[OpaqueRefTransformer] Found handler with type arguments at ${sourceFile.fileName}:${ - line + 1 - }`; - }); - - const [eventType, stateType] = node.typeArguments; - const handlerArgs = node.arguments; - - // Create toSchema calls for the type arguments - const toSchemaEventCall = context.factory.createCallExpression( - context.factory.createIdentifier("toSchema"), - [eventType], - [], - ); - - const toSchemaStateCall = context.factory.createCallExpression( - context.factory.createIdentifier("toSchema"), - [stateType], - [], - ); - - // Create new handler call without type arguments but with schema arguments - const newHandlerCall = context.factory.createCallExpression( - node.expression, - undefined, // No type arguments - [toSchemaEventCall, toSchemaStateCall, ...handlerArgs], - ); - - // Mark that we need toSchema import - if (!hasCommonToolsImport(sourceFile, "toSchema")) { - needsToSchemaImport = true; - } - - hasTransformed = true; - return ts.visitEachChild(newHandlerCall, visit, context); - } // Case 2: handler without type arguments but with inline parameter types - else if ( - node.arguments.length === 1 && - (ts.isFunctionExpression(node.arguments[0]) || - ts.isArrowFunction(node.arguments[0])) - ) { - const handlerFn = node.arguments[0] as - | ts.FunctionExpression - | ts.ArrowFunction; - - // Check if the function has parameter type annotations - if (handlerFn.parameters.length >= 2) { - const eventParam = handlerFn.parameters[0]; - const stateParam = handlerFn.parameters[1]; - - // Only transform if we have type annotations - if (eventParam.type || stateParam.type) { - logger.debug(() => { - const { line } = sourceFile.getLineAndCharacterOfPosition( - node.getStart(), - ); - return `[OpaqueRefTransformer] Found handler with inline type annotations at ${sourceFile.fileName}:${ - line + 1 - }`; - }); - - // Create type nodes from the parameter types - const eventTypeNode = eventParam.type || - context.factory.createKeywordTypeNode( - ts.SyntaxKind.UnknownKeyword, - ); - const stateTypeNode = stateParam.type || - context.factory.createKeywordTypeNode( - ts.SyntaxKind.UnknownKeyword, - ); - - // Create toSchema calls - const toSchemaEventCall = context.factory - .createCallExpression( - context.factory.createIdentifier("toSchema"), - [eventTypeNode], - [], - ); - - const toSchemaStateCall = context.factory - .createCallExpression( - context.factory.createIdentifier("toSchema"), - [stateTypeNode], - [], - ); - - // Create new handler call with schema arguments - const newHandlerCall = context.factory.createCallExpression( - node.expression, - undefined, // No type arguments - [toSchemaEventCall, toSchemaStateCall, handlerFn], - ); - - // Mark that we need toSchema import - if (!hasCommonToolsImport(sourceFile, "toSchema")) { - needsToSchemaImport = true; - } - - hasTransformed = true; - return ts.visitEachChild(newHandlerCall, visit, context); - } - } - } - } - - // Check if this is a builder function call - these should not be transformed - const builderFunctions = [ - "recipe", - "lift", - "handler", - "derive", - "compute", - "render", - "ifElse", - "str", - ]; - if (functionName && builderFunctions.includes(functionName)) { - // Just visit children normally for builder function calls - return ts.visitEachChild(node, visit, context); - } - - // Check if this is a call to a ModuleFactory/HandlerFactory/RecipeFactory - // These are functions returned by lift, handler, recipe, etc. - const expressionType = checker.getTypeAtLocation(node.expression); - const expressionTypeString = checker.typeToString(expressionType); - - logger.debug(() => - `[OpaqueRefTransformer] Call expression's function type: ${expressionTypeString}` - ); - - // If we're calling a ModuleFactory, HandlerFactory, or RecipeFactory - // these expect Opaque parameters - if ( - expressionTypeString.includes("ModuleFactory<") || - expressionTypeString.includes("HandlerFactory<") || - expressionTypeString.includes("RecipeFactory<") - ) { - logger.debug(() => - `[OpaqueRefTransformer] Calling a factory function that expects Opaque parameters` - ); - - // Special case: Check if we're passing an object literal that reconstructs - // all properties from a single OpaqueRef source - if ( - node.arguments.length === 1 && - ts.isObjectLiteralExpression(node.arguments[0]) - ) { - const objectLiteral = node.arguments[0]; - const properties = objectLiteral.properties; - - // Track the source OpaqueRef for all properties - let commonSource: ts.Expression | null = null; - let allFromSameSource = true; - const propertyNames = new Set(); - - for (const prop of properties) { - if ( - ts.isPropertyAssignment(prop) && - ts.isPropertyAccessExpression(prop.initializer) - ) { - const propAccess = prop.initializer; - - // Check if this property access is from an OpaqueRef - const objType = checker.getTypeAtLocation( - propAccess.expression, - ); - if (isOpaqueRefType(objType, checker)) { - if (commonSource === null) { - commonSource = propAccess.expression; - } else if ( - propAccess.expression.getText() !== commonSource.getText() - ) { - // Different sources, can't simplify - allFromSameSource = false; - break; - } - - // Check that the property name matches - if ( - ts.isIdentifier(prop.name) && - prop.name.text === propAccess.name.text - ) { - propertyNames.add(prop.name.text); - } else { - // Property name doesn't match the access, can't simplify - allFromSameSource = false; - break; - } - } else { - // Not from OpaqueRef, can't simplify - allFromSameSource = false; - break; - } - } else { - // Not a simple property assignment, can't simplify - allFromSameSource = false; - break; - } - } - - // If all properties come from the same OpaqueRef source - if (allFromSameSource && commonSource) { - // Get the type of the OpaqueRef source to check if we have all properties - const sourceType = checker.getTypeAtLocation(commonSource); - const typeArguments = (sourceType as any).resolvedTypeArguments; - - if (typeArguments && typeArguments.length > 0) { - const innerType = typeArguments[0]; - const sourceProperties = innerType.getProperties(); - const sourcePropertyNames = new Set( - sourceProperties.map((p: ts.Symbol) => p.getName()), - ); - - // Only transform if we have ALL properties from the source - const hasAllProperties = - sourcePropertyNames.size === propertyNames.size && - [...sourcePropertyNames].every((name) => - propertyNames.has(name) - ); - - if (hasAllProperties) { - logger.debug(() => { - const { line } = sourceFile.getLineAndCharacterOfPosition( - node.getStart(), - ); - return `[OpaqueRefTransformer] Simplifying object literal to OpaqueRef source at ${sourceFile.fileName}:${ - line + 1 - }`; - }); - - hasTransformed = true; - return context.factory.updateCallExpression( - node, - ts.visitNode(node.expression, visit) as ts.Expression, - node.typeArguments, - [commonSource], - ); - } - } - } - } - - return ts.visitEachChild(node, visit, context); - } - - // Check if the function expects OpaqueRef parameters - // If it does, we don't need to transform the arguments - // BUT: Skip this check for method calls on OpaqueRef objects - const isMethodCallOnOpaqueRef = - ts.isPropertyAccessExpression(node.expression) && - isOpaqueRefType( - checker.getTypeAtLocation(node.expression.expression), - checker, - ); - - const functionSymbol = checker.getSymbolAtLocation(node.expression); - - // Debug for reduce - - // IMPORTANT: Skip parameter checking for methods called ON OpaqueRef objects - // - // When you call a method on OpaqueRef (like state.values.reduce(...)): - // - TypeScript reports that reduce expects OpaqueRef parameters - // - But in reality, the method operates on the underlying array and receives regular T values - // - If we check these misleading parameter types, we'll return early and skip the JSX transformation - // - This would prevent wrapping the expression in derive(), causing runtime errors - // - // Example: state.values.reduce((a, b) => a + b) - // - TypeScript says reduce expects (OpaqueRef, OpaqueRef) => OpaqueRef - // - But actually reduce gets (number, number) => number when it runs - // - So we skip this check for methods on OpaqueRef to allow proper transformation - if (functionSymbol && !isMethodCallOnOpaqueRef) { - const functionType = checker.getTypeOfSymbolAtLocation( - functionSymbol, - node.expression, - ); - const signatures = checker.getSignaturesOfType( - functionType, - ts.SignatureKind.Call, - ); - - if (signatures.length > 0) { - // Check the first signature (could be improved to check all overloads) - const signature = signatures[0]; - const parameters = signature.getParameters(); - - if (parameters.length > 0) { - // Check if the first parameter expects an OpaqueRef or Opaque type - const paramType = checker.getTypeOfSymbolAtLocation( - parameters[0], - node, - ); - const paramTypeString = checker.typeToString(paramType); - - logger.debug(() => - `[OpaqueRefTransformer] Function ${ - getFunctionName(node) - } parameter type: ${paramTypeString}` - ); - - // If the function expects Opaque or OpaqueRef parameters, don't transform - if ( - paramTypeString.includes("Opaque<") || - paramTypeString.includes("OpaqueRef<") - ) { - logger.debug(() => - `[OpaqueRefTransformer] Function expects Opaque/OpaqueRef parameters, skipping transformation` - ); - return ts.visitEachChild(node, visit, context); - } - } - } - } - - // Also check what the function call returns - // If it returns a Stream or OpaqueRef, we shouldn't transform it - const callType = checker.getTypeAtLocation(node); - const callTypeString = checker.typeToString(callType); - - logger.debug(() => - `[OpaqueRefTransformer] Call expression type: ${callTypeString}` - ); - - // If this call returns a Stream, OpaqueRef, or ModuleFactory don't - // transform it - if ( - callTypeString.includes("Stream<") || - callTypeString.includes("OpaqueRef<") || - callTypeString.includes("ModuleFactory<") - ) { - logger.debug(() => - `[OpaqueRefTransformer] Call returns Stream/OpaqueRef/ModuleFactory, skipping transformation` - ); - return ts.visitEachChild(node, visit, context); - } - - // Check if the entire call expression contains OpaqueRef values - // This handles both arguments and method calls on OpaqueRef objects - // Only transform if we're inside a JSX expression - const hasOpaqueRef = containsOpaqueRef(node, checker); - const isInJsx = isInsideJsxExpression(node); - - // If this CallExpression is ultimately inside a JSX event handler attribute (e.g., onClick), - // do not wrap the entire call in derive. Let child nodes handle OpaqueRefs. - if (isInJsx && isEventHandlerJsxAttribute(node)) { - return ts.visitEachChild(node, visit, context); - } - - if (hasOpaqueRef && isInJsx) { - // log(`Found function call transformation at ${sourceFile.fileName}:${sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1}`); - hasTransformed = true; - - // Wrap the entire function call in derive - const transformedCall = transformExpressionWithOpaqueRef( - node, - checker, - context.factory, - sourceFile, - context, - ); - - if (transformedCall !== node) { - if (!hasCommonToolsImport(sourceFile, "derive")) { - needsDeriveImport = true; - } - return transformedCall; - } - } - - // Otherwise, just visit children normally - return ts.visitEachChild(node, visit, context); - } - - // Handle property access expressions (e.g., person.name.length) - // Skip if it's part of a larger expression that will handle it - // Only transform if we're inside a JSX expression - if ( - ts.isPropertyAccessExpression(node) && - node.parent && - !ts.isCallExpression(node.parent) && - !ts.isPropertyAccessExpression(node.parent) && - isInsideJsxExpression(node) - ) { - // Check if we're accessing a property on an OpaqueRef - // For example: person.name is OpaqueRef, and we're accessing .length - const objectType = checker.getTypeAtLocation(node.expression); - if (isOpaqueRefType(objectType, checker)) { - // Check if this is just passing through the OpaqueRef (e.g., const x = person.name) - // vs accessing a property on it (e.g., const x = person.name.length) - const resultType = checker.getTypeAtLocation(node); - const isPassThrough = isOpaqueRefType(resultType, checker); - - if (!isPassThrough) { - // This is accessing a property on an OpaqueRef, transform it - hasTransformed = true; - const transformedExpression = transformExpressionWithOpaqueRef( - node, - checker, - context.factory, - sourceFile, - context, - ); - - if (transformedExpression !== node) { - if (!hasCommonToolsImport(sourceFile, "derive")) { - needsDeriveImport = true; - } - return transformedExpression; - } - } - } - } - - // Handle element access (array indexing) - // Only transform if we're inside a JSX expression - if ( - ts.isElementAccessExpression(node) && node.argumentExpression && - isInsideJsxExpression(node) - ) { - if (containsOpaqueRef(node.argumentExpression, checker)) { - logger.debug(() => - `[OpaqueRefTransformer] Found element access transformation at ${sourceFile.fileName}:${ - sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + - 1 - }` - ); - hasTransformed = true; - const transformedArgument = addGetCallsToOpaqueRefs( - node.argumentExpression, - checker, - context.factory, - context, - ) as ts.Expression; - return context.factory.updateElementAccessExpression( - node, - ts.visitNode(node.expression, visit) as ts.Expression, - transformedArgument, - ); - } - } - - // Handle tagged template expressions (e.g., str`...`) - if (ts.isTaggedTemplateExpression(node)) { - // Check if this is the 'str' tagged template - const tag = node.tag; - if (ts.isIdentifier(tag) && tag.text === "str") { - // str is a builder function, don't transform it - // Just visit children of the tag, not the template - const visitedTag = ts.visitNode(node.tag, visit) as ts.Expression; - return context.factory.updateTaggedTemplateExpression( - node, - visitedTag, - node.typeArguments, - node.template, - ); - } - - // For other tagged templates, check if they contain OpaqueRef - const template = node.template; - if ( - ts.isTemplateExpression(template) && - containsOpaqueRef(template, checker) - ) { - logger.debug(() => - `[OpaqueRefTransformer] Found tagged template expression transformation at ${sourceFile.fileName}:${ - sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + - 1 - }` - ); - hasTransformed = true; - - // Transform the template part - const transformedTemplate = transformExpressionWithOpaqueRef( - template, - checker, - context.factory, - sourceFile, - context, - ); - - if (transformedTemplate !== template) { - if (!hasCommonToolsImport(sourceFile, "derive")) { - needsDeriveImport = true; - } - return context.factory.updateTaggedTemplateExpression( - node, - node.tag, - node.typeArguments, - transformedTemplate as ts.TemplateLiteral, - ); - } - } - } - - // Handle template expressions - // Only transform if we're inside a JSX expression - if (ts.isTemplateExpression(node) && isInsideJsxExpression(node)) { - // Check if any template span contains OpaqueRef - if (containsOpaqueRef(node, checker)) { - logger.debug(() => - `[OpaqueRefTransformer] Found template expression transformation at ${sourceFile.fileName}:${ - sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + - 1 - }` - ); - hasTransformed = true; - - // Transform the entire template expression using derive - const transformedExpression = transformExpressionWithOpaqueRef( - node, - checker, - context.factory, - sourceFile, - context, - ); - - if (transformedExpression !== node) { - if (!hasCommonToolsImport(sourceFile, "derive")) { - needsDeriveImport = true; - } - return transformedExpression; - } - } - } - - // Handle object literal expressions with spread properties - if (ts.isObjectLiteralExpression(node)) { - // Check if any spread property contains OpaqueRef - const spreadProperties = node.properties.filter( - ts.isSpreadAssignment, - ); - let needsTransformation = false; - - for (const spread of spreadProperties) { - const spreadType = checker.getTypeAtLocation(spread.expression); - if (isOpaqueRefType(spreadType, checker)) { - needsTransformation = true; - break; - } - } - - if (needsTransformation) { - logger.debug(() => - `[OpaqueRefTransformer] Found object spread transformation at ${sourceFile.fileName}:${ - sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + - 1 - }` - ); - hasTransformed = true; - - // Transform spread properties to individual properties - const newProperties: ts.ObjectLiteralElementLike[] = []; - - for (const prop of node.properties) { - if (ts.isSpreadAssignment(prop)) { - const spreadType = checker.getTypeAtLocation(prop.expression); - if (isOpaqueRefType(spreadType, checker)) { - // Handle union types (e.g., OpaqueRef | undefined) - let opaqueRefType = spreadType; - if (spreadType.flags & ts.TypeFlags.Union) { - const unionType = spreadType as ts.UnionType; - // Find the OpaqueRef type in the union - opaqueRefType = unionType.types.find((t) => - isOpaqueRefType(t, checker) - ) || spreadType; - } - - // For intersection types (which OpaqueRef is), we need to find the part with properties - if (opaqueRefType.flags & ts.TypeFlags.Intersection) { - const intersectionType = - opaqueRefType as ts.IntersectionType; - - // Find the object type with properties in the intersection - for (const type of intersectionType.types) { - if (type.flags & ts.TypeFlags.Object) { - const properties = type.getProperties(); - - // Create individual property assignments - for (const property of properties) { - const propName = property.getName(); - // Skip internal OpaqueRef methods - if (opaqueRefMethods.includes(propName)) { - continue; - } - - // Create property access expression - const propertyAccess = context.factory - .createPropertyAccessExpression( - prop.expression, - propName, - ); - - newProperties.push( - context.factory.createPropertyAssignment( - propName, - propertyAccess, - ), - ); - } - } - } - } - } else { - // Keep non-OpaqueRef spreads as-is - newProperties.push(prop); - } - } else { - // Keep non-spread properties as-is - newProperties.push(prop); - } - } - - return context.factory.updateObjectLiteralExpression( - node, - newProperties, - ); - } - } - - // Special handling for ternary expressions - // Only transform if we're inside a JSX expression - if (ts.isConditionalExpression(node) && isInsideJsxExpression(node)) { - // Check if condition contains OpaqueRef (before transformation) - const originalConditionType = checker.getTypeAtLocation( - node.condition, - ); - const conditionContainsOpaqueRef = containsOpaqueRef( - node.condition, - checker, - ); - const conditionIsOpaqueRef = isOpaqueRefType( - originalConditionType, - checker, - ); - - // First, visit all children to transform them - let visitedCondition = ts.visitNode( - node.condition, - visit, - ) as ts.Expression; - - // Transform condition if it contains OpaqueRef (e.g., state.value + 1) - if ( - !isSimpleOpaqueRefAccess(node.condition, checker) && - containsOpaqueRef(node.condition, checker) - ) { - visitedCondition = transformExpressionWithOpaqueRef( - node.condition, - checker, - context.factory, - sourceFile, - context, - ); - if (!hasCommonToolsImport(sourceFile, "derive")) { - needsDeriveImport = true; - } - } - - // Transform whenTrue and whenFalse branches if they contain OpaqueRef - let visitedWhenTrue = ts.visitNode( - node.whenTrue, - visit, - ) as ts.Expression; - let visitedWhenFalse = ts.visitNode( - node.whenFalse, - visit, - ) as ts.Expression; - - // Check if branches need transformation - if ( - !isSimpleOpaqueRefAccess(node.whenTrue, checker) && - containsOpaqueRef(node.whenTrue, checker) - ) { - visitedWhenTrue = transformExpressionWithOpaqueRef( - node.whenTrue, - checker, - context.factory, - sourceFile, - context, - ); - if (!hasCommonToolsImport(sourceFile, "derive")) { - needsDeriveImport = true; - } - } - - if ( - !isSimpleOpaqueRefAccess(node.whenFalse, checker) && - containsOpaqueRef(node.whenFalse, checker) - ) { - visitedWhenFalse = transformExpressionWithOpaqueRef( - node.whenFalse, - checker, - context.factory, - sourceFile, - context, - ); - if (!hasCommonToolsImport(sourceFile, "derive")) { - needsDeriveImport = true; - } - } - - // Create updated node with transformed children - const updatedNode = context.factory.updateConditionalExpression( - node, - visitedCondition, - node.questionToken, - visitedWhenTrue, - node.colonToken, - visitedWhenFalse, - ); - - // If the condition was/contained an OpaqueRef, or if it got transformed to a derive call - if ( - conditionIsOpaqueRef || conditionContainsOpaqueRef || - visitedCondition !== node.condition - ) { - logger.debug(() => - `[OpaqueRefTransformer] Found ternary transformation at ${sourceFile.fileName}:${ - sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + - 1 - }` - ); - - if (mode === "error") { - reportError( - node, - "ternary", - "Ternary operator with OpaqueRef condition should use ifElse()", - ); - return updatedNode; - } - - hasTransformed = true; - if (!hasCommonToolsImport(sourceFile, "ifElse")) { - needsIfElseImport = true; - } - - return createIfElseCall(updatedNode, context.factory, sourceFile); - } - - return updatedNode; - } - - // For other node types, check transformation first - - const result = checkTransformation(node, checker); - - if (result.transformed) { - logger.debug(() => - `[OpaqueRefTransformer] Found ${result.type} transformation at ${sourceFile.fileName}:${ - sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1 - }` - ); - - if (mode === "error") { - // In error mode, report the error but don't transform - let message = ""; - switch (result.type) { - case "jsx": - message = - "JSX expression with OpaqueRef computation should use derive()"; - break; - case "binary": - message = - "Binary expression with OpaqueRef should use derive()"; - break; - case "call": - message = - "Function call with OpaqueRef arguments should use .get()"; - break; - case "element-access": - message = - "Array/object access with OpaqueRef index should use .get()"; - break; - case "template": - message = "Template literal with OpaqueRef should use .get()"; - break; - case "ternary": - message = - "Ternary operator with OpaqueRef condition should use ifElse()"; - break; - } - reportError(node, result.type!, message); - return ts.visitEachChild(node, visit, context); - } - - // In transform mode, apply the transformation - hasTransformed = true; - - switch (result.type) { - case "jsx": { - const jsxNode = node as ts.JsxExpression; - - // Check if this JSX expression is in an event handler attribute - const parent = jsxNode.parent; - if (parent && ts.isJsxAttribute(parent)) { - const attrName = parent.name.getText(); - // Event handlers like onClick expect functions, not derived values - if (attrName.startsWith("on")) { - // Just visit children normally for event handlers - return ts.visitEachChild(node, visit, context); - } - } - - const transformedExpression = transformExpressionWithOpaqueRef( - jsxNode.expression!, - checker, - context.factory, - sourceFile, - context, - ); - if (transformedExpression !== jsxNode.expression) { - if (!hasCommonToolsImport(sourceFile, "derive")) { - needsDeriveImport = true; - } - return context.factory.updateJsxExpression( - jsxNode, - transformedExpression, - ); - } - break; - } - } - } - - return ts.visitEachChild(node, visit, context); - }; - - const visited = ts.visitNode(sourceFile, visit) as ts.SourceFile; - - // In error mode, throw if we found errors - if (mode === "error" && errors.length > 0) { - const errorMessage = errors - .map((e) => `${e.file}:${e.line}:${e.column} - ${e.message}`) - .join("\n"); - throw new Error(`OpaqueRef transformation errors:\n${errorMessage}`); - } - - // Add necessary imports - let result = visited; - if (hasTransformed && mode === "transform") { - if (needsIfElseImport) { - result = addCommonToolsImport(result, context.factory, "ifElse"); - } - if (needsDeriveImport) { - result = addCommonToolsImport(result, context.factory, "derive"); - } - if (needsToSchemaImport) { - result = addCommonToolsImport(result, context.factory, "toSchema"); - } - } - - return result; - }; - }; -} - -/** - * Get the name of the function being called in a CallExpression - */ -function getFunctionName(node: ts.CallExpression): string | undefined { - const expr = node.expression; - - if (ts.isIdentifier(expr)) { - return expr.text; - } - - if (ts.isPropertyAccessExpression(expr)) { - return expr.name.text; - } - - return undefined; -} - -/** - * Gets the list of transformation errors from the last run. - * Only populated when mode is 'error'. - */ -export function getTransformationErrors(): TransformationError[] { - // This would need to be implemented with proper state management - // For now, it's a placeholder - return []; -} diff --git a/packages/js-runtime/typescript/transformer/schema.ts b/packages/js-runtime/typescript/transformer/schema.ts deleted file mode 100644 index e07218bb2..000000000 --- a/packages/js-runtime/typescript/transformer/schema.ts +++ /dev/null @@ -1,215 +0,0 @@ -import ts from "typescript"; -import { - addCommonToolsImport, - hasCommonToolsImport, - removeCommonToolsImport, -} from "./imports.ts"; -import { createSchemaTransformerV2 } from "@commontools/schema-generator"; - -export interface SchemaTransformerOptions { - logger?: (message: string) => void; -} - -/** - * Transformer that converts TypeScript types to JSONSchema objects. - * Transforms `toSchema()` calls into JSONSchema literals. - */ -export function createSchemaTransformer( - program: ts.Program, - options: SchemaTransformerOptions = {}, -): ts.TransformerFactory { - const checker = program.getTypeChecker(); - const logger = options.logger; - // Use the new schema generator (plugin-compatible) implementation - const generateSchema = createSchemaTransformerV2(); - - return (context: ts.TransformationContext) => { - return (sourceFile: ts.SourceFile) => { - let needsJSONSchemaImport = false; - - const visit: ts.Visitor = (node) => { - // Look for toSchema() calls - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === "toSchema" && - node.typeArguments && - node.typeArguments.length === 1 - ) { - const typeArg = node.typeArguments[0]; - const type = checker.getTypeFromTypeNode(typeArg); - - if (logger && typeArg) { - let typeText = "unknown"; - try { - typeText = typeArg.getText(); - } catch { - // getText() fails on synthetic nodes without source file context - } - logger(`[SchemaTransformer] Found toSchema<${typeText}>() call`); - } - - // Extract options from the call arguments - const options = node.arguments[0]; - let optionsObj: any = {}; - if (options && ts.isObjectLiteralExpression(options)) { - optionsObj = evaluateObjectLiteral(options, checker); - } - - // Generate JSONSchema from the type using the new generator - const schema = generateSchema(type, checker, typeArg); - - // Merge with options - const finalSchema = { ...schema, ...optionsObj }; - - // Create the AST for the schema object - const schemaAst = createSchemaAst(finalSchema, context.factory); - - // Add type assertion: as const satisfies JSONSchema - const constAssertion = context.factory.createAsExpression( - schemaAst, - context.factory.createTypeReferenceNode( - context.factory.createIdentifier("const"), - undefined, - ), - ); - - const satisfiesExpression = context.factory.createSatisfiesExpression( - constAssertion, - context.factory.createTypeReferenceNode( - context.factory.createIdentifier("JSONSchema"), - undefined, - ), - ); - - // Mark that we need JSONSchema import - if (!hasCommonToolsImport(sourceFile, "JSONSchema")) { - needsJSONSchemaImport = true; - } - - return satisfiesExpression; - } - - return ts.visitEachChild(node, visit, context); - }; - - let result = ts.visitNode(sourceFile, visit) as ts.SourceFile; - - // Add JSONSchema import if needed - if (needsJSONSchemaImport) { - result = addCommonToolsImport(result, context.factory, "JSONSchema"); - } - - // Always remove toSchema import since it doesn't exist at runtime - // The SchemaTransformer should have transformed all toSchema calls - if (hasCommonToolsImport(result, "toSchema")) { - if (logger) { - logger( - `[SchemaTransformer] Removing toSchema import (not available at runtime)`, - ); - } - result = removeCommonToolsImport(result, context.factory, "toSchema"); - } - - return result; - }; - }; -} - -/** - * Create AST for a schema object - */ -function createSchemaAst(schema: any, factory: ts.NodeFactory): ts.Expression { - if (schema === null) { - return factory.createNull(); - } - - if (typeof schema === "string") { - return factory.createStringLiteral(schema); - } - - if (typeof schema === "number") { - return factory.createNumericLiteral(schema); - } - - if (typeof schema === "boolean") { - return schema ? factory.createTrue() : factory.createFalse(); - } - - if (Array.isArray(schema)) { - return factory.createArrayLiteralExpression( - schema.map((item) => createSchemaAst(item, factory)), - ); - } - - if (typeof schema === "object") { - const properties: ts.PropertyAssignment[] = []; - - for (const [key, value] of Object.entries(schema)) { - properties.push( - factory.createPropertyAssignment( - factory.createIdentifier(key), - createSchemaAst(value, factory), - ), - ); - } - - return factory.createObjectLiteralExpression(properties, true); - } - - return factory.createIdentifier("undefined"); -} - -/** - * Evaluate an object literal to extract its values - */ -function evaluateObjectLiteral( - node: ts.ObjectLiteralExpression, - checker: ts.TypeChecker, -): any { - const result: any = {}; - - for (const prop of node.properties) { - if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { - const key = prop.name.text; - const value = evaluateExpression(prop.initializer, checker); - if (value !== undefined) { - result[key] = value; - } - } - } - - return result; -} - -// Helper function to evaluate any expression to a literal value -function evaluateExpression( - node: ts.Expression, - checker: ts.TypeChecker, -): any { - if (ts.isStringLiteral(node)) { - return node.text; - } else if (ts.isNumericLiteral(node)) { - return Number(node.text); - } else if (node.kind === ts.SyntaxKind.TrueKeyword) { - return true; - } else if (node.kind === ts.SyntaxKind.FalseKeyword) { - return false; - } else if (node.kind === ts.SyntaxKind.NullKeyword) { - return null; - } else if (node.kind === ts.SyntaxKind.UndefinedKeyword) { - return undefined; - } else if (ts.isObjectLiteralExpression(node)) { - return evaluateObjectLiteral(node, checker); - } else if (ts.isArrayLiteralExpression(node)) { - const values: any[] = []; - for (const elem of node.elements) { - const value = evaluateExpression(elem, checker); - // Keep undefined values in arrays - values.push(value); - } - return values; - } - // Return a special marker for unknown expressions - return undefined; -} diff --git a/packages/js-runtime/typescript/transformer/transforms.ts b/packages/js-runtime/typescript/transformer/transforms.ts deleted file mode 100644 index 015d5f139..000000000 --- a/packages/js-runtime/typescript/transformer/transforms.ts +++ /dev/null @@ -1,999 +0,0 @@ -import ts from "typescript"; -import { getCommonToolsModuleAlias } from "./imports.ts"; -import { - collectOpaqueRefs, - containsOpaqueRef, - isOpaqueRefType, - isSimpleOpaqueRefAccess, -} from "./types.ts"; - -/** - * Get the name of the function being called in a CallExpression - */ -function getFunctionName(node: ts.CallExpression): string | undefined { - const expr = node.expression; - - if (ts.isIdentifier(expr)) { - return expr.text; - } - - if (ts.isPropertyAccessExpression(expr)) { - return expr.name.text; - } - - return undefined; -} - -/** - * Replaces an OpaqueRef expression with a parameter in a larger expression. - */ -export function replaceOpaqueRefWithParam( - expression: ts.Expression, - opaqueRef: ts.Expression, - paramName: string, - factory: ts.NodeFactory, - context: ts.TransformationContext, -): ts.Expression { - const visit = (node: ts.Node): ts.Node => { - // If this is the OpaqueRef we're replacing, return the parameter - if (node === opaqueRef) { - return factory.createIdentifier(paramName); - } - - return ts.visitEachChild(node, visit, context); - }; - - return visit(expression) as ts.Expression; -} - -/** - * Replaces multiple OpaqueRef expressions with their corresponding parameters. - */ -export function replaceOpaqueRefsWithParams( - expression: ts.Expression, - refToParamName: Map, - factory: ts.NodeFactory, - context: ts.TransformationContext, -): ts.Expression { - const visit = (node: ts.Node): ts.Node => { - // Check if this node is one of the OpaqueRefs we're replacing - for (const [ref, paramName] of refToParamName) { - if (node === ref) { - return factory.createIdentifier(paramName); - } - } - - return ts.visitEachChild(node, visit, context); - }; - - return visit(expression) as ts.Expression; -} - -/** - * Creates an ifElse call from a ternary expression. - */ -export function createIfElseCall( - ternary: ts.ConditionalExpression, - factory: ts.NodeFactory, - sourceFile: ts.SourceFile, -): ts.CallExpression { - // Check if we're using the old "commontools" import which needs AMD-style module reference - const moduleAlias = getCommonToolsModuleAlias(sourceFile); - const ifElseIdentifier = moduleAlias - ? factory.createPropertyAccessExpression( - factory.createIdentifier(moduleAlias), - factory.createIdentifier("ifElse"), - ) - : factory.createIdentifier("ifElse"); - - // Strip parentheses from whenTrue and whenFalse if they are ParenthesizedExpressions - let whenTrue = ternary.whenTrue; - let whenFalse = ternary.whenFalse; - - while (ts.isParenthesizedExpression(whenTrue)) { - whenTrue = whenTrue.expression; - } - - while (ts.isParenthesizedExpression(whenFalse)) { - whenFalse = whenFalse.expression; - } - - return factory.createCallExpression( - ifElseIdentifier, - undefined, - [ternary.condition, whenTrue, whenFalse], - ); -} - -function getSimpleName(ref: ts.Expression): string | undefined { - // Only use simple identifiers as parameter names - // e.g., "state" or "count" but not "state.count" or complex expressions - if (ts.isIdentifier(ref)) { - return ref.text; - } - return undefined; -} - -/** - * Transforms an expression containing OpaqueRef values. - * Handles binary expressions and call expressions. - */ -export function transformExpressionWithOpaqueRef( - expression: ts.Expression, - checker: ts.TypeChecker, - factory: ts.NodeFactory, - sourceFile: ts.SourceFile, - context: ts.TransformationContext, -): ts.Expression { - // If this expression is part of a JSX event handler attribute, do not transform. - // Event handlers like onClick expect functions, not derived values. - if ( - ts.isJsxExpression(expression) && expression.parent && - ts.isJsxAttribute(expression.parent) - ) { - const attrName = expression.parent.name.getText(); - if (attrName.startsWith("on")) return expression; - } - - // Handle property access expressions (e.g., person.name.length) - if (ts.isPropertyAccessExpression(expression)) { - // Get the OpaqueRef being accessed - const opaqueRefs = collectOpaqueRefs(expression, checker); - - if (opaqueRefs.length === 0) { - return expression; - } - - // Handle multiple OpaqueRefs (e.g., state.items.filter(i => i.name.includes(state.filter)).length) - if (opaqueRefs.length === 1) { - // Special case for single OpaqueRef to produce cleaner output - // Instead of: derive({state_count: state.count}, ({state_count: _v1}) => _v1 + 1) - // We produce: derive(state.count, _v1 => _v1 + 1) - // This is more readable, performant, and maintains backwards compatibility - // Step 1: Extract the single OpaqueRef and assign a parameter name - const ref = opaqueRefs[0]; - const paramName = getSimpleName(ref) ?? "_v1"; - - // Step 2: Replace the OpaqueRef with the parameter in the expression - // Example: state.count + 1 becomes _v1 + 1 - const lambdaBody = replaceOpaqueRefWithParam( - expression, - ref, - paramName, - factory, - context, - ); - - // Step 3: Create the arrow function - // Simple form: _v1 => expression - const arrowFunction = factory.createArrowFunction( - undefined, - undefined, - [factory.createParameterDeclaration( - undefined, - undefined, - factory.createIdentifier(paramName), // Single parameter: _v1 - undefined, - undefined, - undefined, - )], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - lambdaBody, // The transformed expression - ); - - // Step 4: Create the derive identifier - // Handle both named imports (derive) and module imports (CommonTools.derive) - const moduleAlias = getCommonToolsModuleAlias(sourceFile); - const deriveIdentifier = moduleAlias - ? factory.createPropertyAccessExpression( - factory.createIdentifier(moduleAlias), - factory.createIdentifier("derive"), - ) - : factory.createIdentifier("derive"); - - // Step 5: Create the final derive() call - // Simple form: derive(ref, _v1 => expression) - return factory.createCallExpression( - deriveIdentifier, - undefined, - [ref, arrowFunction], // Note: ref directly, not an object - ); - } else { - // Multiple OpaqueRefs: use object form - // Example: state.items.filter(i => i.name.includes(state.filter)).length - // Will transform to: - // derive( - // {state_items: state.items, state_filter: state.filter}, - // ({state_items: _v1, state_filter: _v2}) => _v1.filter(i => i.name.includes(_v2)).length - // ) - - // Step 1: Deduplicate OpaqueRefs (same ref might appear multiple times) - // For example, if state.count appears 3 times, we only want one parameter - const uniqueRefs = new Map(); - const refToParamName = new Map(); - - opaqueRefs.forEach((ref) => { - const refText = ref.getText(); - if (!uniqueRefs.has(refText)) { - // First occurrence of this ref - create a new parameter name - const paramName = `_v${uniqueRefs.size + 1}`; - uniqueRefs.set(refText, ref); - refToParamName.set(ref, paramName); - } else { - // Duplicate ref - reuse the same parameter name - const firstRef = uniqueRefs.get(refText)!; - refToParamName.set(ref, refToParamName.get(firstRef)!); - } - }); - - const uniqueRefArray = Array.from(uniqueRefs.values()); - - // Step 2: Replace all OpaqueRef occurrences with their parameter names - // This transforms the expression body to use _v1, _v2, etc. - const lambdaBody = replaceOpaqueRefsWithParams( - expression, - refToParamName, - factory, - context, - ); - - // Step 3: Create object literal for refs - this will be the first argument to derive() - // We need to create property names that are valid JavaScript identifiers - const refProperties = uniqueRefArray.map((ref) => { - if (ts.isIdentifier(ref)) { - // Simple identifier like 'state' -> use shorthand {state} - return factory.createShorthandPropertyAssignment(ref, undefined); - } else if (ts.isPropertyAccessExpression(ref)) { - // Property access like 'state.items' -> {state_items: state.items} - // Replace dots with underscores to create valid identifier - const propName = ref.getText().replace(/\./g, "_"); - return factory.createPropertyAssignment( - factory.createIdentifier(propName), - ref, - ); - } else { - // Fallback for other expression types - const propName = `ref${uniqueRefArray.indexOf(ref) + 1}`; - return factory.createPropertyAssignment( - factory.createIdentifier(propName), - ref, - ); - } - }); - - const refObject = factory.createObjectLiteralExpression( - refProperties, - false, - ); - - // Step 4: Create object pattern for parameters - this will destructure in the arrow function - // Maps the property names to our parameter names (_v1, _v2, etc.) - const paramProperties = uniqueRefArray.map((ref, index) => { - const paramName = refToParamName.get(ref)!; - let propName: string; - if (ts.isIdentifier(ref)) { - // Simple identifier: {state: _v1} - propName = ref.text; - } else if (ts.isPropertyAccessExpression(ref)) { - // Property access: {state_items: _v1} - propName = ref.getText().replace(/\./g, "_"); - } else { - // Fallback: {ref1: _v1} - propName = `ref${index + 1}`; - } - - // Creates the binding: propName -> paramName - // e.g., state_items: _v1 - return factory.createBindingElement( - undefined, - factory.createIdentifier(propName), - factory.createIdentifier(paramName), - undefined, - ); - }); - - const paramObjectPattern = factory.createObjectBindingPattern( - paramProperties, - ); - - // Step 5: Create the arrow function with object destructuring - // ({state_items: _v1, state_filter: _v2}) => expression - const arrowFunction = factory.createArrowFunction( - undefined, - undefined, - [factory.createParameterDeclaration( - undefined, - undefined, - paramObjectPattern, // The destructuring pattern we created - undefined, - undefined, - undefined, - )], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - lambdaBody, // The expression with refs replaced by _v1, _v2, etc. - ); - - // Step 6: Create the derive() call - // Handle both named imports (derive) and module imports (CommonTools.derive) - const moduleAlias = getCommonToolsModuleAlias(sourceFile); - const deriveIdentifier = moduleAlias - ? factory.createPropertyAccessExpression( - factory.createIdentifier(moduleAlias), - factory.createIdentifier("derive"), - ) - : factory.createIdentifier("derive"); - - // Final result: derive({refs...}, ({destructured...}) => transformedExpression) - return factory.createCallExpression( - deriveIdentifier, - undefined, - [refObject, arrowFunction], // Note: refObject first, not a single ref - ); - } - } - - // Handle call expressions (e.g., someFunction(a + 1, "prefix")) - if (ts.isCallExpression(expression)) { - // Get all OpaqueRef identifiers in the entire call expression - const opaqueRefs = collectOpaqueRefs(expression, checker); - - if (opaqueRefs.length === 0) { - return expression; - } - - // Deduplicate OpaqueRefs (same ref might appear multiple times) - const uniqueRefs = new Map(); - const refToParamName = new Map(); - - opaqueRefs.forEach((ref) => { - const refText = ref.getText(); - if (!uniqueRefs.has(refText)) { - const paramName = getSimpleName(ref) ?? `_v${uniqueRefs.size + 1}`; - uniqueRefs.set(refText, ref); - refToParamName.set(ref, paramName); - } else { - // Map this ref to the same parameter name as the first occurrence - const firstRef = uniqueRefs.get(refText)!; - refToParamName.set(ref, refToParamName.get(firstRef)!); - } - }); - - const uniqueRefArray = Array.from(uniqueRefs.values()); - - // Replace all occurrences of refs with their parameters in the entire call - const lambdaBody = replaceOpaqueRefsWithParams( - expression, - refToParamName, - factory, - context, - ); - - // If there's only one unique ref, use the simple form: derive(ref, _v => ...) - if (uniqueRefArray.length === 1) { - const ref = uniqueRefArray[0]; - const paramName = refToParamName.get(ref)!; - - const arrowFunction = factory.createArrowFunction( - undefined, - undefined, - [factory.createParameterDeclaration( - undefined, - undefined, - factory.createIdentifier(paramName), - undefined, - undefined, - undefined, - )], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - lambdaBody, - ); - - const moduleAlias = getCommonToolsModuleAlias(sourceFile); - const deriveIdentifier = moduleAlias - ? factory.createPropertyAccessExpression( - factory.createIdentifier(moduleAlias), - factory.createIdentifier("derive"), - ) - : factory.createIdentifier("derive"); - - return factory.createCallExpression( - deriveIdentifier, - undefined, - [ref, arrowFunction], - ); - } - - // Multiple unique refs: use object form derive({a, b}, ({a: _v1, b: _v2}) => ...) - const paramNames = uniqueRefArray.map((ref) => refToParamName.get(ref)!); - - // Create object literal for refs: {a, b, c} - const refProperties = uniqueRefArray.map((ref) => { - // For simple identifiers, use shorthand: {a, b} - if (ts.isIdentifier(ref)) { - return factory.createShorthandPropertyAssignment( - ref, - undefined, - ); - } else if (ts.isPropertyAccessExpression(ref)) { - // For property access, use the full property path as the key - // e.g., state.count becomes "state.count": state.count - const propName = ref.getText().replace(/\./g, "_"); // Replace dots with underscores for valid identifiers - return factory.createPropertyAssignment( - factory.createIdentifier(propName), - ref, - ); - } else { - // Fallback to generic name - const propName = `ref${uniqueRefArray.indexOf(ref) + 1}`; - return factory.createPropertyAssignment( - factory.createIdentifier(propName), - ref, - ); - } - }); - - const refObject = factory.createObjectLiteralExpression( - refProperties, - false, - ); - - // Create object pattern for parameters: {a: _v1, b: _v2} - const paramProperties = uniqueRefArray.map((ref, index) => { - const paramName = paramNames[index]; - - // Determine the property name to use in the binding pattern - let propName: string; - if (ts.isIdentifier(ref)) { - propName = ref.text; - } else if (ts.isPropertyAccessExpression(ref)) { - // Use the same naming scheme as above - propName = ref.getText().replace(/\./g, "_"); - } else { - propName = `ref${index + 1}`; - } - - return factory.createBindingElement( - undefined, - factory.createIdentifier(propName), - factory.createIdentifier(paramName), - undefined, - ); - }); - - const paramPattern = factory.createObjectBindingPattern(paramProperties); - - // Create arrow function: ({_v1, _v2}) => expression - const arrowFunction = factory.createArrowFunction( - undefined, - undefined, - [factory.createParameterDeclaration( - undefined, - undefined, - paramPattern, - undefined, - undefined, - undefined, - )], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - lambdaBody, - ); - - // Create derive call - const moduleAlias = getCommonToolsModuleAlias(sourceFile); - const deriveIdentifier = moduleAlias - ? factory.createPropertyAccessExpression( - factory.createIdentifier(moduleAlias), - factory.createIdentifier("derive"), - ) - : factory.createIdentifier("derive"); - - return factory.createCallExpression( - deriveIdentifier, - undefined, - [refObject, arrowFunction], - ); - } - - // Handle template expressions (e.g., `Hello ${firstName} ${lastName}`) - if (ts.isTemplateExpression(expression)) { - // Get all OpaqueRef identifiers in the template expression - const opaqueRefs = collectOpaqueRefs(expression, checker); - - if (opaqueRefs.length === 0) { - return expression; - } - - // Deduplicate OpaqueRefs - const uniqueRefs = new Map(); - const refToParamName = new Map(); - - opaqueRefs.forEach((ref) => { - const refText = ref.getText(); - if (!uniqueRefs.has(refText)) { - const paramName = getSimpleName(ref) ?? `_v${uniqueRefs.size + 1}`; - uniqueRefs.set(refText, ref); - refToParamName.set(ref, paramName); - } else { - // Map this ref to the same parameter name as the first occurrence - const firstRef = uniqueRefs.get(refText)!; - refToParamName.set(ref, refToParamName.get(firstRef)!); - } - }); - - const uniqueRefArray = Array.from(uniqueRefs.values()); - - // Replace all occurrences of refs with their parameters in the template - const lambdaBody = replaceOpaqueRefsWithParams( - expression, - refToParamName, - factory, - context, - ); - - // Create derive call based on number of unique refs - if (uniqueRefArray.length === 1) { - const ref = uniqueRefArray[0]; - const paramName = refToParamName.get(ref)!; - - const arrowFunction = factory.createArrowFunction( - undefined, - undefined, - [factory.createParameterDeclaration( - undefined, - undefined, - factory.createIdentifier(paramName), - undefined, - undefined, - undefined, - )], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - lambdaBody, - ); - - const moduleAlias = getCommonToolsModuleAlias(sourceFile); - const deriveIdentifier = moduleAlias - ? factory.createPropertyAccessExpression( - factory.createIdentifier(moduleAlias), - factory.createIdentifier("derive"), - ) - : factory.createIdentifier("derive"); - - return factory.createCallExpression( - deriveIdentifier, - undefined, - [ref, arrowFunction], - ); - } - - // Multiple unique refs: use object form - const paramNames = uniqueRefArray.map((ref) => refToParamName.get(ref)!); - - // Create object literal for refs - const refProperties = uniqueRefArray.map((ref) => { - if (ts.isIdentifier(ref)) { - return factory.createShorthandPropertyAssignment(ref, undefined); - } else if (ts.isPropertyAccessExpression(ref)) { - const propName = ref.getText().replace(/\./g, "_"); - return factory.createPropertyAssignment( - factory.createIdentifier(propName), - ref, - ); - } else { - const propName = `ref${uniqueRefArray.indexOf(ref) + 1}`; - return factory.createPropertyAssignment( - factory.createIdentifier(propName), - ref, - ); - } - }); - - const refObject = factory.createObjectLiteralExpression( - refProperties, - false, - ); - - // Create object pattern for parameters - const paramProperties = uniqueRefArray.map((ref, index) => { - const paramName = paramNames[index]; - let propName: string; - if (ts.isIdentifier(ref)) { - propName = ref.text; - } else if (ts.isPropertyAccessExpression(ref)) { - propName = ref.getText().replace(/\./g, "_"); - } else { - propName = `ref${index + 1}`; - } - - return factory.createBindingElement( - undefined, - factory.createIdentifier(propName), - factory.createIdentifier(paramName), - undefined, - ); - }); - - const paramPattern = factory.createObjectBindingPattern(paramProperties); - - // Create arrow function - const arrowFunction = factory.createArrowFunction( - undefined, - undefined, - [factory.createParameterDeclaration( - undefined, - undefined, - paramPattern, - undefined, - undefined, - undefined, - )], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - lambdaBody, - ); - - // Create derive call - const moduleAlias = getCommonToolsModuleAlias(sourceFile); - const deriveIdentifier = moduleAlias - ? factory.createPropertyAccessExpression( - factory.createIdentifier(moduleAlias), - factory.createIdentifier("derive"), - ) - : factory.createIdentifier("derive"); - - return factory.createCallExpression( - deriveIdentifier, - undefined, - [refObject, arrowFunction], - ); - } - - // Handle binary expressions (e.g., cell.value + 1, cell.value * 2) - if (ts.isBinaryExpression(expression)) { - // Get all OpaqueRef identifiers in the expression - const opaqueRefs = collectOpaqueRefs(expression, checker); - - if (opaqueRefs.length === 0) { - return expression; - } - - // Deduplicate OpaqueRefs (same ref might appear multiple times) - const uniqueRefs = new Map(); - const refToParamName = new Map(); - - opaqueRefs.forEach((ref) => { - const refText = ref.getText(); - if (!uniqueRefs.has(refText)) { - const paramName = getSimpleName(ref) ?? `_v${uniqueRefs.size + 1}`; - uniqueRefs.set(refText, ref); - refToParamName.set(ref, paramName); - } else { - // Map this ref to the same parameter name as the first occurrence - const firstRef = uniqueRefs.get(refText)!; - refToParamName.set(ref, refToParamName.get(firstRef)!); - } - }); - - const uniqueRefArray = Array.from(uniqueRefs.values()); - - // If there's only one unique ref, use the simple form: derive(ref, _v => ...) - if (uniqueRefArray.length === 1) { - const ref = uniqueRefArray[0]; - const paramName = refToParamName.get(ref)!; - - // Replace all occurrences of this ref with the parameter - const lambdaBody = replaceOpaqueRefsWithParams( - expression, - refToParamName, - factory, - context, - ); - - const arrowFunction = factory.createArrowFunction( - undefined, - undefined, - [factory.createParameterDeclaration( - undefined, - undefined, - factory.createIdentifier(paramName), - undefined, - undefined, - undefined, - )], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - lambdaBody, - ); - - const moduleAlias = getCommonToolsModuleAlias(sourceFile); - const deriveIdentifier = moduleAlias - ? factory.createPropertyAccessExpression( - factory.createIdentifier(moduleAlias), - factory.createIdentifier("derive"), - ) - : factory.createIdentifier("derive"); - - return factory.createCallExpression( - deriveIdentifier, - undefined, - [ref, arrowFunction], - ); - } - - // Multiple unique refs: use object form derive({a, b}, ({a: _v1, b: _v2}) => ...) - const paramNames = uniqueRefArray.map((ref) => refToParamName.get(ref)!); - - // Create object literal for refs: {a, b, c} - const refProperties = uniqueRefArray.map((ref) => { - // For simple identifiers, use shorthand: {a, b} - if (ts.isIdentifier(ref)) { - return factory.createShorthandPropertyAssignment( - ref, - undefined, - ); - } else if (ts.isPropertyAccessExpression(ref)) { - // For property access, use the full property path as the key - // e.g., state.count becomes "state.count": state.count - const propName = ref.getText().replace(/\./g, "_"); // Replace dots with underscores for valid identifiers - return factory.createPropertyAssignment( - factory.createIdentifier(propName), - ref, - ); - } else { - // Fallback to generic name - const propName = `ref${uniqueRefArray.indexOf(ref) + 1}`; - return factory.createPropertyAssignment( - factory.createIdentifier(propName), - ref, - ); - } - }); - - const refObject = factory.createObjectLiteralExpression( - refProperties, - false, - ); - - // Create object pattern for parameters: {a: _v1, b: _v2} - const paramProperties = uniqueRefArray.map((ref, index) => { - const paramName = paramNames[index]; - - // Determine the property name to use in the binding pattern - let propName: string; - if (ts.isIdentifier(ref)) { - propName = ref.text; - } else if (ts.isPropertyAccessExpression(ref)) { - // Use the same naming scheme as above - propName = ref.getText().replace(/\./g, "_"); - } else { - propName = `ref${index + 1}`; - } - - return factory.createBindingElement( - undefined, - factory.createIdentifier(propName), - factory.createIdentifier(paramName), - undefined, - ); - }); - - const paramPattern = factory.createObjectBindingPattern(paramProperties); - - // Replace all refs in the expression with their corresponding parameters - const lambdaBody = replaceOpaqueRefsWithParams( - expression, - refToParamName, - factory, - context, - ); - - // Create arrow function: ({_v1, _v2}) => expression - const arrowFunction = factory.createArrowFunction( - undefined, - undefined, - [factory.createParameterDeclaration( - undefined, - undefined, - paramPattern, - undefined, - undefined, - undefined, - )], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - lambdaBody, - ); - - // Create derive call - const moduleAlias = getCommonToolsModuleAlias(sourceFile); - const deriveIdentifier = moduleAlias - ? factory.createPropertyAccessExpression( - factory.createIdentifier(moduleAlias), - factory.createIdentifier("derive"), - ) - : factory.createIdentifier("derive"); - - return factory.createCallExpression( - deriveIdentifier, - undefined, - [refObject, arrowFunction], - ); - } - - return expression; -} - -/** - * Transforms OpaqueRef values to add .get() calls. - * This is used for function calls, array indexing, and template literals. - */ -export function addGetCallsToOpaqueRefs( - node: ts.Node, - checker: ts.TypeChecker, - factory: ts.NodeFactory, - context: ts.TransformationContext, -): ts.Node { - const visit = (n: ts.Node): ts.Node => { - // Check if this node is an OpaqueRef that needs .get() - if (ts.isExpression(n)) { - // Skip if this is already a .get() call - if ( - ts.isCallExpression(n) && - ts.isPropertyAccessExpression(n.expression) && - n.expression.name.text === "get" && - n.arguments.length === 0 - ) { - // This is already a .get() call, just transform its object - const transformedObject = visit( - n.expression.expression, - ) as ts.Expression; - return factory.updateCallExpression( - n, - factory.updatePropertyAccessExpression( - n.expression, - transformedObject, - n.expression.name, - ), - n.typeArguments, - n.arguments, - ); - } - - const type = checker.getTypeAtLocation(n); - if (isOpaqueRefType(type, checker)) { - // Create a .get() call - return factory.createCallExpression( - factory.createPropertyAccessExpression( - n, - factory.createIdentifier("get"), - ), - undefined, - [], - ); - } - } - - return ts.visitEachChild(n, visit, context); - }; - - return visit(node); -} - -// TODO(@ubik2): Align these types with the TransformationType in debug.ts -export type TransformationTypeString = - | "ternary" - | "jsx" - | "binary" - | "call" - | "element-access" - | "template"; - -/** - * Result of a transformation check. - */ -export interface TransformationResult { - transformed: boolean; - node: ts.Node; - type: TransformationTypeString | null; - error?: string; -} - -/** - * Checks if a node should be transformed and what type of transformation. - */ -export function checkTransformation( - node: ts.Node, - checker: ts.TypeChecker, -): TransformationResult { - // Check if it's a conditional expression - if (ts.isConditionalExpression(node)) { - const conditionType = checker.getTypeAtLocation(node.condition); - - // Check if the type is OpaqueRef - if (isOpaqueRefType(conditionType, checker)) { - return { - transformed: true, - node, - type: "ternary", - }; - } - } - - // Check if it's a JSX expression that contains OpaqueRef values - if (ts.isJsxExpression(node) && node.expression) { - // Check if this JSX expression is in an event handler attribute - const parent = node.parent; - if (parent && ts.isJsxAttribute(parent)) { - const attrName = parent.name.getText(); - // Event handlers like onClick expect functions, not derived values - if (attrName.startsWith("on")) { - // Don't transform event handlers - return { - transformed: false, - node, - type: null, - }; - } - } - - // Check if the expression is a call to a builder function - if (ts.isCallExpression(node.expression)) { - const functionName = getFunctionName(node.expression); - const builderFunctions = [ - "recipe", - "lift", - "handler", - "derive", - "compute", - "render", - "ifElse", - "str", - ]; - if (functionName && builderFunctions.includes(functionName)) { - // Don't transform calls to builder functions - return { - transformed: false, - node, - type: null, - }; - } - - // Also skip if this is a method call (e.g., array.map) - if (ts.isPropertyAccessExpression(node.expression.expression)) { - // This is a method call, it should be handled at the CallExpression level - return { - transformed: false, - node, - type: null, - }; - } - } - - // Skip simple OpaqueRef accesses - if ( - !isSimpleOpaqueRefAccess(node.expression, checker) && - containsOpaqueRef(node.expression, checker) - ) { - return { - transformed: true, - node, - type: "jsx", - }; - } - } - - // Note: Binary expressions, element access, and template expressions - // are no longer transformed at the statement level. - // They are only transformed when they appear inside JSX expressions, - // which is handled by the JSX expression case above. - - return { - transformed: false, - node, - type: null, - }; -} diff --git a/packages/js-runtime/typescript/transformer/types.ts b/packages/js-runtime/typescript/transformer/types.ts deleted file mode 100644 index 3edc3b485..000000000 --- a/packages/js-runtime/typescript/transformer/types.ts +++ /dev/null @@ -1,394 +0,0 @@ -import ts from "typescript"; -import { getLogger } from "@commontools/utils/logger"; - -// Create a debug logger for type checking - disabled by default -const logger = getLogger("transformer-types", { - enabled: false, - level: "debug", -}); - -/** - * Checks if a TypeScript type is an OpaqueRef type. - * Handles intersection types, type references, and type aliases. - */ -export function isOpaqueRefType( - type: ts.Type, - checker: ts.TypeChecker, -): boolean { - // Debug logging with lazy evaluation - logger.debug(() => - `[isOpaqueRefType] Checking type: ${checker.typeToString(type)}` - ); - logger.debug(() => `[isOpaqueRefType] Type flags: ${type.flags}`); - - logger.debug(() => { - if (type.aliasSymbol) { - return [`[isOpaqueRefType] Alias symbol: ${type.aliasSymbol.getName()}`]; - } - return []; - }); - - // Additional debug info - logger.debug(() => { - const symbol = type.getSymbol(); - if (symbol) { - const messages = [`[isOpaqueRefType] Symbol name: ${symbol.getName()}`]; - const declarations = symbol.getDeclarations(); - if (declarations && declarations.length > 0) { - messages.push( - `[isOpaqueRefType] Symbol declared in: ${ - declarations[0].getSourceFile().fileName - }`, - ); - } - return messages; - } - return []; - }); - - // Handle union types (e.g., OpaqueRef | undefined) - if (type.flags & ts.TypeFlags.Union) { - const unionType = type as ts.UnionType; - // Check if any of the constituent types is OpaqueRef - return unionType.types.some((t) => isOpaqueRefType(t, checker)); - } - - // Handle intersection types (OpaqueRef is defined as an intersection) - if (type.flags & ts.TypeFlags.Intersection) { - const intersectionType = type as ts.IntersectionType; - // Check if any of the constituent types is OpaqueRef - return intersectionType.types.some((t) => isOpaqueRefType(t, checker)); - } - - // Check if it's a type reference - if (type.flags & ts.TypeFlags.Object) { - const objectType = type as ts.ObjectType; - - // Check if it's a reference to a generic type - if (objectType.objectFlags & ts.ObjectFlags.Reference) { - const typeRef = objectType as ts.TypeReference; - const target = typeRef.target; - - if (target && target.symbol) { - const symbolName = target.symbol.getName(); - if (symbolName === "OpaqueRef") return true; - - // Also check the fully qualified name - const fullyQualifiedName = checker.getFullyQualifiedName(target.symbol); - if (fullyQualifiedName.includes("OpaqueRef")) return true; - } - } - - // Also check the type's symbol directly - const symbol = type.getSymbol(); - if (symbol) { - if ( - symbol.name === "OpaqueRef" || symbol.name === "OpaqueRefMethods" || - symbol.name === "OpaqueRefBase" - ) { - return true; - } - - const fullyQualifiedName = checker.getFullyQualifiedName(symbol); - if (fullyQualifiedName.includes("OpaqueRef")) return true; - } - } - - // Check type alias - if (type.aliasSymbol) { - const aliasName = type.aliasSymbol.getName(); - if (aliasName === "OpaqueRef" || aliasName === "Opaque") return true; - - const fullyQualifiedName = checker.getFullyQualifiedName(type.aliasSymbol); - if (fullyQualifiedName.includes("OpaqueRef")) return true; - } - - return false; -} - -/** - * Checks if a node contains any OpaqueRef values. - */ -export function containsOpaqueRef( - node: ts.Node, - checker: ts.TypeChecker, -): boolean { - let found = false; - - const visit = (n: ts.Node): void => { - if (found) return; - - // For property access expressions, check if the result is an OpaqueRef - if (ts.isPropertyAccessExpression(n)) { - const type = checker.getTypeAtLocation(n); - logger.debug(() => - `[containsOpaqueRef] Checking PropertyAccess: ${n.getText()}` - ); - if (isOpaqueRefType(type, checker)) { - logger.debug(() => - `[containsOpaqueRef] Found OpaqueRef in PropertyAccess: ${n.getText()}` - ); - found = true; - return; - } - } - - // Skip call expressions with .get() - they return T, not OpaqueRef - if ( - ts.isCallExpression(n) && - ts.isPropertyAccessExpression(n.expression) && - n.expression.name.text === "get" && - n.arguments.length === 0 - ) { - // This is a .get() call, skip checking its children - return; - } - - // Check standalone identifiers - if (ts.isIdentifier(n)) { - // Skip if this identifier is the name part of a property access - const parent = n.parent; - if ( - parent && ts.isPropertyAccessExpression(parent) && parent.name === n - ) { - // This is the property name in a property access (e.g., 'count' in 'state.count') - return; - } - - const type = checker.getTypeAtLocation(n); - logger.debug(() => - `[containsOpaqueRef] Checking Identifier: ${n.getText()}` - ); - if (isOpaqueRefType(type, checker)) { - logger.debug(() => - `[containsOpaqueRef] Found OpaqueRef in Identifier: ${n.getText()}` - ); - found = true; - return; - } - } - - ts.forEachChild(n, visit); - }; - - logger.debug(() => - `[containsOpaqueRef] Starting check for node: ${node.getText()}` - ); - visit(node); - logger.debug(() => `[containsOpaqueRef] Result: ${found}`); - return found; -} - -/** - * Helper function to check if an identifier is a function parameter - * that we should skip (i.e., callback parameters, not recipe/handler parameters) - * - * First attempts to use TypeScript's symbol API for accurate resolution, - * falls back to AST traversal if needed. - */ -function isFunctionParameter( - node: ts.Identifier, - checker: ts.TypeChecker, -): boolean { - // Try using TypeScript's symbol API first - const symbol = checker.getSymbolAtLocation(node); - if (symbol) { - const declarations = symbol.getDeclarations(); - if (declarations && declarations.length > 0) { - // Check if any declaration is a parameter - const isParam = declarations.some((decl) => ts.isParameter(decl)); - if (isParam) { - // Now check if this is a recipe/handler parameter we should NOT skip - for (const decl of declarations) { - if (ts.isParameter(decl)) { - // Find the containing function - const parent = decl.parent; - if ( - ts.isFunctionExpression(parent) || - ts.isArrowFunction(parent) || - ts.isFunctionDeclaration(parent) || - ts.isMethodDeclaration(parent) - ) { - // Check if this function is passed to recipe/handler/lift - let callExpr: ts.Node = parent; - while (callExpr.parent && !ts.isCallExpression(callExpr.parent)) { - callExpr = callExpr.parent; - } - if (callExpr.parent && ts.isCallExpression(callExpr.parent)) { - const funcName = callExpr.parent.expression.getText(); - if ( - funcName.includes("recipe") || funcName.includes("handler") || - funcName.includes("lift") - ) { - return false; - } - } - } - return true; - } - } - } - } - } - - // Fallback to AST traversal (original implementation) - // First check if the identifier itself is in a parameter position - const parent = node.parent; - if (ts.isParameter(parent) && parent.name === node) { - return true; - } - - // For identifiers used in function bodies, we need to check if they - // reference a parameter of the containing function - let current: ts.Node = node; - let containingFunction: ts.FunctionLikeDeclaration | undefined; - - // Find the containing function - while (current.parent) { - current = current.parent; - if ( - ts.isFunctionExpression(current) || - ts.isArrowFunction(current) || - ts.isFunctionDeclaration(current) || - ts.isMethodDeclaration(current) - ) { - containingFunction = current as ts.FunctionLikeDeclaration; - break; - } - } - - if (containingFunction && containingFunction.parameters) { - // Check if this identifier matches any parameter name - for (const param of containingFunction.parameters) { - if ( - param.name && ts.isIdentifier(param.name) && - param.name.text === node.text - ) { - // Special case: Don't skip parameters from recipe/handler functions - // We DO want to transform state.value, state.items, cell.value, etc. - // Check if this is a recipe or handler function by looking at the call expression - let callExpr: ts.Node = containingFunction; - while (callExpr.parent && !ts.isCallExpression(callExpr.parent)) { - callExpr = callExpr.parent; - } - if (callExpr.parent && ts.isCallExpression(callExpr.parent)) { - const funcName = callExpr.parent.expression.getText(); - if ( - funcName.includes("recipe") || funcName.includes("handler") || - funcName.includes("lift") - ) { - return false; - } - } - - return true; - } - } - } - - return false; -} - -/** - * Collects all OpaqueRef expressions in a node. - */ -export function collectOpaqueRefs( - node: ts.Node, - checker: ts.TypeChecker, -): ts.Expression[] { - const refs: ts.Expression[] = []; - const processedNodes = new Set(); - - const visit = (n: ts.Node): void => { - // Do not traverse into JSX event handler attributes like onClick, onChange, etc. - if (ts.isJsxAttribute(n)) { - const name = n.name.getText(); - if (name && name.startsWith("on")) { - return; // Skip children; event handlers shouldn't contribute OpaqueRefs for derive wrapping - } - } - // Skip if already processed - if (processedNodes.has(n)) return; - processedNodes.add(n); - - // For property access expressions, check if the result is an OpaqueRef - if (ts.isPropertyAccessExpression(n) && ts.isExpression(n)) { - // Don't collect property access on function parameters as OpaqueRef dependencies - // Example: state.items.filter(i => i.active) - // - For i.active: n.expression is 'i' (a callback parameter) → don't collect i.active - // - For state.items: n.expression is 'state' (a recipe parameter) → do collect state.items - // This prevents trying to create derive({state_items: state.items, i_active: i.active}, ...) - // which would fail because 'i' only exists inside the callback, not in outer scope - if ( - ts.isIdentifier(n.expression) && - isFunctionParameter(n.expression, checker) - ) { - return; // Don't add this property access to the OpaqueRef list - } - - // Check if the result of the property access is an OpaqueRef - const type = checker.getTypeAtLocation(n); - if (isOpaqueRefType(type, checker)) { - refs.push(n); - return; // Don't visit children - } - } - - // Check standalone identifiers (not part of property access) - if (ts.isIdentifier(n) && ts.isExpression(n)) { - // Skip if this identifier is the name part of a property access - const parent = n.parent; - if (ts.isPropertyAccessExpression(parent) && parent.name === n) { - // This is the property name in a property access (e.g., 'count' in 'state.count') - return; - } - - // Don't collect function parameters as OpaqueRef dependencies - // Example: state.items.filter(i => i) or array.map((item, index) => index) - // - 'i' and 'index' are function parameters → don't collect them - // - 'state' is a recipe parameter → do collect it (if it's an OpaqueRef) - // This prevents treating callback parameters as reactive dependencies - if (isFunctionParameter(n, checker)) { - return; - } - - const type = checker.getTypeAtLocation(n); - if (isOpaqueRefType(type, checker)) { - refs.push(n); - } - } - - ts.forEachChild(n, visit); - }; - - visit(node); - return refs; -} - -/** - * Checks if an expression is a simple OpaqueRef access without any operations. - */ -export function isSimpleOpaqueRefAccess( - expression: ts.Expression, - checker: ts.TypeChecker, -): boolean { - // Check if the expression is just a simple identifier or property access - // that is an OpaqueRef, without any operations on it - if ( - ts.isIdentifier(expression) || ts.isPropertyAccessExpression(expression) - ) { - const type = checker.getTypeAtLocation(expression); - return isOpaqueRefType(type, checker); - } - return false; -} - -export function isEventHandlerJsxAttribute(node: ts.Node): boolean { - if (!node || !node.parent) return false; - const parent = node.parent; - if (ts.isJsxAttribute(parent)) { - const attrName = parent.name.getText(); - return attrName.startsWith("on"); - } - return false; -} diff --git a/packages/patterns/counter-handlers.ts b/packages/patterns/counter-handlers.ts index a612fbf47..a93bfecb4 100644 --- a/packages/patterns/counter-handlers.ts +++ b/packages/patterns/counter-handlers.ts @@ -1,5 +1,5 @@ /// -import { Cell, handler } from "commontools"; +import { Cell, derive, handler } from "commontools"; export const increment = handler }>( (_, state) => { diff --git a/packages/patterns/counter.tsx b/packages/patterns/counter.tsx index dcfd11601..862487882 100644 --- a/packages/patterns/counter.tsx +++ b/packages/patterns/counter.tsx @@ -1,5 +1,5 @@ /// -import { Default, h, NAME, recipe, str, UI } from "commontools"; +import { Default, derive, h, NAME, recipe, str, UI } from "commontools"; import { decrement, increment, nth, previous } from "./counter-handlers.ts"; interface RecipeState { diff --git a/packages/patterns/instantiate-recipe.tsx b/packages/patterns/instantiate-recipe.tsx index 9105a67ce..e5f8e998c 100644 --- a/packages/patterns/instantiate-recipe.tsx +++ b/packages/patterns/instantiate-recipe.tsx @@ -2,6 +2,7 @@ import { Cell, Default, + derive, h, handler, NAME, diff --git a/packages/patterns/nested-counter.tsx b/packages/patterns/nested-counter.tsx index 90e6551fb..9e7caaabe 100644 --- a/packages/patterns/nested-counter.tsx +++ b/packages/patterns/nested-counter.tsx @@ -1,5 +1,5 @@ /// -import { Default, h, NAME, recipe, str, UI } from "commontools"; +import { Default, derive, h, NAME, recipe, str, UI } from "commontools"; import { decrement, increment, nth, previous } from "./counter-handlers.ts"; interface RecipeState { diff --git a/packages/schema-generator/notes.md b/packages/schema-generator/notes.md deleted file mode 100644 index 743425bb9..000000000 --- a/packages/schema-generator/notes.md +++ /dev/null @@ -1,94 +0,0 @@ -# Schema Generator Notes - -## Type System Architecture - -### SchemaDefinition vs JSONSchema - -**Current State:** - -- `SchemaDefinition` (schema-generator): Internal working type, simplified JSON - Schema subset, mutable, includes `[key: string]: any` for flexibility -- `JSONSchema` (packages/api): Complete JSON Schema Draft-07 spec, immutable - with `readonly`, explicitly includes Common Tools extensions - (`asCell?: boolean`, `asStream?: boolean`) - -**The Type Alignment Issue:** Both old and new systems return `any` from schema -generators and rely on compile-time `satisfies JSONSchema` assertions injected -by the AST transformer. Type safety comes from the transformer's promise that -generated objects will satisfy JSONSchema, not from strong typing during -generation. - -**How it worked:** - -```typescript -// Developer writes: -toSchema() - -// Transformer converts to: -{ - type: "object", - properties: { - count: { type: "number", asCell: true } // ✅ asCell is explicitly defined in JSONSchema - } -} as const satisfies JSONSchema -``` - -**Key insight:** The CommonTools `JSONSchema` type was designed from the start -to support `asCell`/`asStream` extensions as explicit optional properties, not -via an index signature escape hatch. No type system workarounds were needed. - ---- - -## Open Questions - -**🤔 Type System Strategy Decision Needed:** - -Should we invest in aligning the type systems as mentioned in refactor_plan.md -lines 184-193? - -**Option A: Status Quo** - -- Keep returning `any` from generators -- Maintain current transformer-based type safety -- ✅ Works, matches old system, no breaking changes -- ❌ Loose internal typing, defers type safety to runtime - -**Option B: Strong Internal Typing** - -- Create proper SchemaDefinition → JSONSchema conversion -- Strongly type generator return values to return actual `JSONSchema` objects -- ✅ Better developer experience, catch bugs earlier, eliminate `any` returns -- ❌ Significant refactoring, potential breaking changes -- ❌ Note: ExtendedJSONSchema is not needed - `asCell`/`asStream` are already in - `JSONSchema` - -**Questions for Manager:** - -1. Is type safety debt worth addressing now or defer to future iteration? -2. Should we prioritize shipping the current refactor vs. perfecting the type - system? -3. Are there concrete pain points from the current `any`-based approach? - ---- - -## Implementation Notes - -### Cycle Detection - -Removed all depth-based tracking (lines 200+ in formatType, depth params, -maxDepth context) in favor of precise identity-based cycle detection using -`definitionStack`, `inProgressNames`, and pre-computed `cyclicTypes` sets. - -### Formatter Chain - -- CommonToolsFormatter: Cell, Stream, Default -- UnionFormatter: Union types and literal unions -- IntersectionFormatter: A & B merging -- ArrayFormatter: Array, T[], ReadonlyArray -- PrimitiveFormatter: string, number, boolean, etc. -- ObjectFormatter: Interfaces, type literals - -### Test Strategy - -Moving toward fixture-based testing with canonical JSON Schema output comparison -for stability guarantees. diff --git a/packages/schema-generator/refactor_plan.md b/packages/schema-generator/refactor_plan.md deleted file mode 100644 index 432983473..000000000 --- a/packages/schema-generator/refactor_plan.md +++ /dev/null @@ -1,249 +0,0 @@ -# Schema Transformer Refactor Plan - -This document defines the implementation plan to complete the schema transformer -rewrite and to migrate tests out of `packages/js-runtime` into dedicated -packages with clear ownership. The plan keeps `@commontools/schema-generator` -focused on the JSON Schema engine and introduces a new package for TypeScript -AST transformers. - -## Goals - -- Single source of truth for JSON Schema generation logic. -- Clear separation between compile‑time AST transforms and schema generation. -- Migrate schema‑related tests from `js-runtime` into - `@commontools/schema-generator` for parity and focus. -- Introduce a dedicated package for AST transformers and move transformer tests - there. -- Keep `js-runtime` focused on runtime/integration tests. - -## Current State (observations) - -- New schema engine exists in `packages/schema-generator` with modular - formatters (primitive, object, array, union, intersection, common‑tools). -- `createSchemaTransformerV2()` wraps the engine with the same signature as the - legacy `typeToJsonSchema`. -- `packages/js-runtime/typescript/transformer/schema.ts` delegates to the new - engine, but other AST transformers still live under `js-runtime`. -- Many js-runtime tests are integration/E2E; several are purely about schema - shape and should move. -- We added alias‑aware array detection to support nested aliases like - `Cell` in minimal compiler hosts. -- Running `deno task test` in `schema-generator` is configured. For now, tests - run with `--no-check` due to `@commontools/api` generic constraints; a - `test:check` task exists to re‑enable strict type‑checking once the API - generics align with the generator’s output. - - Wrapper semantics are centralized in `CommonToolsFormatter`; arrays are - handled by `ArrayFormatter` and a small helper in the Common Tools - formatter. `ObjectFormatter` is intentionally thin and delegates. - -## Target Architecture - -- `@commontools/schema-generator` remains a pure JSON Schema generator. - - Public surface: generator, plugin factory, formatter interfaces/utilities. - - Tests: only schema output assertions. - -- New package: `@commontools/ts-transformers` (TypeScript AST transformers). - - Contains the transformer implementations now in - `packages/js-runtime/typescript/transformer`: - - `schema.ts` (compile‑time `toSchema()` replacement using the new - generator) - - `opaque-ref.ts` - - `imports.ts`, `transforms.ts`, `logging.ts`, `types.ts`, `utils.ts`, - `debug.ts` - - Tests: input/expected fixture pairs for AST changes (Schema Transformer, JSX - Expression Transformer, Handler Schema Transformation, Compiler directive - enablement). - -- `@commontools/js-runtime` consumes transformers from - `@commontools/ts-transformers` and retains only end‑to‑end runtime tests - (build/execute flows, bundling, recipe integration). - -## Test Migration Strategy - -Move tests based on what they validate: - -- Move to `schema-generator` (pure schema output): - - arrays‑optional - - cell‑array‑types - - cell‑type - - default‑type - - complex‑defaults (value extraction, nullability) - - nested‑wrappers - - type‑aliases - - shared‑type - - recursive‑type - - recursive‑type‑nested - - multi‑hop‑circular - - mutually‑recursive - - type‑to‑schema (assert schema shape, not AST code transformation) - -Testing model in schema‑generator: - -- Prefer fixture‑based tests with canonicalization for determinism and semantic - deep‑equality for expected vs actual: - - `test/fixtures/schema/*.input.ts` (root type `SchemaRoot`) → - `*.expected.json`. - - Determinism is checked by generating twice and comparing canonicalized - strings; expected vs actual uses parsed JSON with order‑insensitive - comparison where appropriate (for readability and robustness). -- Keep focused unit tests for formatter behavior where useful. -- We removed the temporary “golden snapshot” tests; fixtures now serve as the - primary stability checks. - -- Keep as transformer/E2E (stay with js‑runtime until moved to the new - transformers package): - - with‑options (compile‑time merge of object literal into schema) - - no‑directive (skips without `/// `) - - recipe‑with‑types (.tsx end‑to‑end recipe/transformer integration) - - with‑opaque‑ref (.tsx; spans OpaqueRef + Schema transformers) - -## Phase 1 — Schema‑Generator Parity - -Scope: Implement focused schema tests in `packages/schema-generator` that cover -the topics listed above. These tests should not rely on AST rewriting or I/O. - -Deliverables: - -1. Test utilities - - File: `packages/schema-generator/test/utils.ts` - - Helpers: - - `createTestProgram(code: string)` → `{ program, checker, sourceFile }` - - `getTypeFromCode(code: string, name: string)` → - `{ type, checker, - typeNode? }` - - `normalizeSchema(schema: unknown)` → stable object for equality - comparisons (strip `$schema`, order `definitions`, optional `$ref` - normalization if needed). - -2. Focused test suites (proposed structure) - - `packages/schema-generator/test/schema/cell-types.test.ts` - - `Cell`, `Cell>`, `Stream`, `Stream>`, - `Default`, `Cell>` - - `packages/schema-generator/test/schema/arrays-and-aliases.test.ts` - - `T[]`, `Array`, alias types, nested generics - - `packages/schema-generator/test/schema/recursion-and-cycles.test.ts` - - recursive‑type, recursive‑type‑nested, multi‑hop‑circular, - mutually‑recursive (assert `$ref` + `definitions`) - - `packages/schema-generator/test/schema/type-aliases-and-shared.test.ts` - - alias re‑use across properties, shared types - - `packages/schema-generator/test/schema/complex-defaults.test.ts` - - defaults from `Default`, `T|null`, tuples/objects where applicable - - `packages/schema-generator/test/schema/type-to-schema.test.ts` - - direct generator output equivalence vs expected structure - -3. Assertions - - Prefer structural equality on normalized objects. - - Allow flexible property order; strip `$schema` where not relevant. - - For cycle heavy outputs, assert: root `$ref` points to a definition, - existence of expected definition names, and core sub‑shapes. - -4. Tasks - - Keep `deno task test` running with `--no-check` until API generics align. - - Provide and maintain `deno task test:check` to run with type checking; run - this in CI (non‑blocking) until generics are aligned, then flip the default - task to type‑checked. - -5. De‑duplicate - - After parity is achieved in `schema-generator`, remove redundant generator - tests from `js-runtime` (keep only integration/E2E there until Phase 3 is - complete). - -## Phase 2 — New Transformers Package - -Create `packages/ts-transformers` with: - -- `deno.json` with a `test` task, exports, and minimal imports. -- `src/` contents migrated from `packages/js-runtime/typescript/transformer/`: - - `schema.ts` - - `opaque-ref.ts` - - `imports.ts`, `transforms.ts`, `logging.ts`, `types.ts`, `utils.ts`, - `debug.ts` -- Public API: - - `createSchemaTransformer(program: ts.Program, options?: {...})` - - `createOpaqueRefTransformer(...)` - - Optional: a small helper to compose/apply transformers in tests. - -Testing in the new package: - -- Recreate fixture‑based tests under `packages/ts-transformers/test/`: - - `fixture-based.test.ts` (AST Transformation) - - `schema-transformer.test.ts` (Schema Transformer, compile‑time injection) - - `jsx-expression-transformer.test.ts` - - `handler-schema-transformer.test.ts` - - `compiler-directive.test.ts` (cts‑enable) -- Copy fixture files from `js-runtime/test/fixtures/schema-transform/` to - `packages/ts-transformers/test/fixtures/schema-transform/`. -- Keep `.tsx` fixtures where needed for JSX tests. - -## Phase 3 — Integrate and Migrate Consumers - -- Update `@commontools/js-runtime` to import transformers from - `@commontools/ts-transformers`. -- Keep only end‑to‑end runtime tests in `js-runtime` (compiles/executes, bundler - wiring, recipe integration). -- Remove redundant transformer tests in `js-runtime` after migration. - -## Phase 4 — CI and Cleanups - -- Ensure the root `deno task test` runs both new packages’ tests. -- When `@commontools/api` generics are aligned with the generator’s runtime - schema shape (`$ref?`, `properties?`, markers via an extended schema type), - remove `--no-check` from `schema-generator`’s default test task and make - type‑checked tests the default locally and in CI. -- Optionally add a minimal compatibility/sanity test in `js-runtime` that - exercises both transformers to reduce regression risk. - -## API Types (to revisit later) - -- Align `@commontools/api` `JSONSchema` generics with the JSON Schema spec and - generator output: - - Make `$ref?: string` optional and ensure - `properties?: - Readonly>`. - - Introduce - `ExtendedJSONSchema = JSONSchema & { asCell?: boolean; - asStream?: boolean }` - for compile‑time utilities. - - Update generic helpers to accept `T extends ExtendedJSONSchema` while - manipulating markers; constrain to base `JSONSchema` after stripping. -- Once updated, flip `schema-generator` tests back to strict type checking and - remove any transitional allowances in the fixture harness. - -## Risks and Considerations - -- Fixture parity: exact textual expectations from legacy tests may require - normalization and looser assertions around ordering/formatting. -- Union semantics: literal unions currently map to arrays for fixture - compatibility; if we decide to pivot to `enum`/`oneOf`, update both engine and - tests together. -- Package boundaries: avoid cross‑package test dependencies; tests in - `schema-generator` should not import js-runtime utilities. - -## Checklist - -Phase 1 (schema-generator): - -- [x] Add test utilities (`test/utils.ts`) and canonicalization helpers. -- [x] Add focused schema tests covering Cell/Stream/Default, arrays/aliases, - recursion/cycles, type aliases/shared types, complex defaults, and - type‑to‑schema parity. -- [x] Add fixture runner with determinism check and golden update support. -- [ ] Remove redundant schema tests from `js-runtime` (after parity is verified - across suites). - -Phase 2 (new package): - -- [ ] Scaffold `packages/ts-transformers` with tasks/exports. -- [ ] Move transformer sources from `js-runtime`. -- [ ] Port fixture tests and utilities. - -Phase 3 (integration): - -- [ ] Update `js-runtime` to use new package. -- [ ] Keep only E2E runtime tests in `js-runtime`. - -Phase 4 (stabilize): - -- [ ] Root CI runs all packages via `deno task test`. -- [ ] Align API types; drop `--no-check` in `schema-generator` and make - type‑checked tests the default. diff --git a/packages/schema-generator/root-ref-promotion-change.txt b/packages/schema-generator/root-ref-promotion-change.txt deleted file mode 100644 index 0b01a8d5e..000000000 --- a/packages/schema-generator/root-ref-promotion-change.txt +++ /dev/null @@ -1,184 +0,0 @@ -================================================================================ -ROOT TYPE $REF PROMOTION BEHAVIOR CHANGE ANALYSIS -================================================================================ - -SUMMARY: -Our schema generator refactoring has improved the logic for when root types get -promoted to $ref definitions. This change results in 5 failing js-runtime tests -because our new output is MORE CORRECT and BETTER than the expected output. - -TECHNICAL CHANGE: -The shouldPromoteToRef() method now only promotes root types to $ref when: -- The root type already exists in definitions AND -- The root type has been referenced elsewhere (emittedRefs.has(namedKey)) - -Previously, root types were over-promoted to $ref even when unnecessary. - -================================================================================ -DETAILED ANALYSIS OF 5 FAILING TESTS -================================================================================ - -1. TEST: "transforms recursive type nested" - INPUT: RootType { list: LinkedList } where LinkedList is self-recursive - - OLD (Expected): - { - "$ref": "#/definitions/RootType", // ← Root unnecessarily promoted - "definitions": { - "RootType": { - "type": "object", - "properties": { "list": { "$ref": "#/definitions/LinkedList" }} - }, - "LinkedList": { /* recursive definition */ } - } - } - - NEW (Our Output): - { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", // ← Root inline (BETTER!) - "properties": { - "list": { "$ref": "#/definitions/LinkedList" } - }, - "required": ["list"], - "definitions": { - "LinkedList": { /* recursive definition */ } - } - } - - WHY NEW IS BETTER: - ✅ RootType isn't recursive - it just contains a recursive type - ✅ RootType isn't reused elsewhere - only appears once as the root - ✅ More readable - you can see the root structure immediately - ✅ LinkedList still gets proper $ref - because it's actually recursive - -2. TEST: "transforms recursive type" - INPUT: LinkedList (directly self-recursive: LinkedList → LinkedList) - - ANALYSIS: LinkedList is the recursive type itself. However, since it's only - used as the root (not reused elsewhere), our new logic keeps it inline for - better readability while still using $ref for internal recursion. - - WHY NEW IS BETTER: - ✅ Root structure immediately visible - ✅ Internal recursion still properly handled via $ref - ✅ Reduces unnecessary indirection - -3. TEST: "transforms mutually recursive" - INPUT: NodeA ↔ NodeB (mutual recursion between two types) - - WHY NEW IS BETTER: - ✅ Root type (NodeA) isn't reused - appears only once at root level - ✅ Internal mutual recursion still handled - NodeA/NodeB get proper $refs - ✅ Cleaner root structure - immediate visibility of NodeA's shape - ✅ Better tool compatibility - many tools prefer inline root schemas - -4. TEST: "transforms nested recursive" - INPUT: TreeNode ↔ TreeBranch (TreeNode.left/right → TreeBranch → TreeNode) - - WHY NEW IS BETTER: - ✅ TreeNode (root) isn't reused - only appears at root level - ✅ Mutual recursion between TreeNode/TreeBranch still properly handled - ✅ More intuitive - you see TreeNode's structure directly - ✅ Reduces schema complexity by eliminating unnecessary root $ref - -5. TEST: "transforms multi hop circular" - INPUT: A → B → C → A (circular chain across multiple types) - - WHY NEW IS BETTER: - ✅ Root type (A) not reused elsewhere - only at root - ✅ Internal cycle properly handled - A/B/C reference each other via $ref - ✅ More intuitive - you see A's structure directly - ✅ Follows JSON Schema best practices - -================================================================================ -WHY THE OLD BEHAVIOR WAS SUBOPTIMAL -================================================================================ - -The previous logic over-promoted root types to $ref when it wasn't necessary. -This created pointless indirection that: - -❌ Made schemas harder to read (requires mental dereferencing) -❌ Reduced tool compatibility (some tools prefer inline root schemas) -❌ Violated JSON Schema best practices (only use $ref when needed) -❌ Added unnecessary complexity to simple schemas - -================================================================================ -WHEN ROOT $REF PROMOTION IS ACTUALLY APPROPRIATE -================================================================================ - -Root $ref promotion SHOULD happen when the root type is reused elsewhere: - -GOOD EXAMPLE: -interface User { - name: string; - friends: User[]; // ← User appears here (reused) -} -const schema = toSchema(); // ← AND here (root) - -In this case, User appears TWICE (root + internal reuse), so promoting to $ref -makes sense and reduces duplication. - -BAD EXAMPLE (what old logic did): -interface Container { - items: RecursiveItem[]; // ← Only RecursiveItem is recursive -} - -Container should stay inline because it's not recursive itself, even though -it contains recursive types. - -================================================================================ -TECHNICAL IMPLEMENTATION DETAILS -================================================================================ - -Our improved shouldPromoteToRef() logic: -```typescript -private shouldPromoteToRef( - namedKey: string | undefined, - context: GenerationContext, -): boolean { - if (!namedKey) return false; - - const { definitions, emittedRefs } = context; - - // Only promote if root type already exists in definitions AND has been referenced - return !!(definitions[namedKey] && emittedRefs.has(namedKey)); -} -``` - -This ensures root promotion only happens when the type is genuinely reused, -not just because it participates in cycles. - -================================================================================ -IMPACT ASSESSMENT -================================================================================ - -POSITIVE IMPACTS: -✅ More readable schemas - root structure immediately visible -✅ Better tool compatibility - inline roots work better with many JSON Schema tools -✅ Cleaner, more intuitive output - follows JSON Schema best practices -✅ Proper $ref usage - only when actually needed for cycles/reuse -✅ Improved schema maintainability - less indirection -✅ Better developer experience - schemas easier to understand - -NO NEGATIVE IMPACTS: -✅ All generated schemas remain valid JSON Schema -✅ Cycle detection still works perfectly - recursive types properly handled -✅ No functional regressions - all schema validation behavior preserved -✅ Performance maintained or improved - fewer $ref resolutions needed - -RECOMMENDATION: -Update the js-runtime test expectations to match our improved behavior. -The failing tests represent a quality improvement, not a regression. - -================================================================================ -CONCLUSION -================================================================================ - -Our schema generator changes represent a significant quality improvement in -JSON Schema generation. The failing tests validate that our improvements work -correctly - we should update the expected outputs to match our better behavior. - -The new logic produces more readable, more intuitive, and more standards-compliant -JSON schemas while preserving all the correctness guarantees of the previous -implementation. \ No newline at end of file diff --git a/packages/schema-generator/test/fixtures-runner.test.ts b/packages/schema-generator/test/fixtures-runner.test.ts index cf982cf8c..bf96a519d 100644 --- a/packages/schema-generator/test/fixtures-runner.test.ts +++ b/packages/schema-generator/test/fixtures-runner.test.ts @@ -1,38 +1,87 @@ -import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; -import { walk } from "@std/fs/walk"; -import { dirname, resolve } from "@std/path"; import { ensureDir } from "@std/fs/ensure-dir"; +import { dirname, resolve } from "@std/path"; +import { + createUnifiedDiff, + defineFixtureSuite, +} from "@commontools/test-support/fixture-runner"; import { createSchemaTransformerV2 } from "../src/plugin.ts"; import { getTypeFromCode, normalizeSchema } from "./utils.ts"; -interface FixtureConfig { - directory: string; // under test/fixtures - describe: string; +interface SchemaResult { + normalized: unknown; + serialized: string; } -const configs: FixtureConfig[] = [ - { directory: "schema", describe: "Schema fixtures" }, -]; +const TYPE_NAME = "SchemaRoot"; + +defineFixtureSuite({ + suiteName: "Schema fixtures", + rootDir: "./test/fixtures/schema", + expectedPath: ({ stem }) => `${stem}.expected.json`, + async execute(fixture) { + return await runSchemaTransform(fixture.inputPath); + }, + async loadExpected(fixture) { + return await Deno.readTextFile(fixture.expectedPath); + }, + async determinismCheck(actual, fixture) { + const rerun = await runSchemaTransform(fixture.inputPath); + expect(actual.serialized).toEqual(rerun.serialized); + }, + compare(actual, expectedText, fixture) { + let actualObj; + let expectedObj; + try { + actualObj = JSON.parse(actual.serialized.trim()); + expectedObj = JSON.parse(expectedText.trim()); + } catch (error) { + throw new Error( + `JSON parsing failed for ${fixture.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } -// Small unified diff string for nicer failure output -function unifiedDiff(expected: string, actual: string): string { - const e = expected.split("\n"); - const a = actual.split("\n"); - const max = Math.max(e.length, a.length); - const lines: string[] = []; - for (let i = 0; i < max; i++) { - const el = e[i] ?? ""; - const al = a[i] ?? ""; - if (el === al) { - lines.push(` ${el}`); - } else { - if (el !== "") lines.push(`- ${el}`); - if (al !== "") lines.push(`+ ${al}`); + const normalizedActual = normalizeArrayOrdering(actualObj); + const normalizedExpected = normalizeArrayOrdering(expectedObj); + + try { + expect(normalizedActual).toEqual(normalizedExpected); + } catch { + const diff = createUnifiedDiff( + expectedText.trim(), + actual.serialized.trim(), + ); + const message = [ + "", + `Fixture semantic mismatch for ${fixture.id}`, + `Input: ${resolve(fixture.inputPath)}`, + `Expected: ${resolve(fixture.expectedPath)}`, + "", + "=== UNIFIED DIFF (expected vs actual) ===", + diff, + "", + "=== PARSED OBJECTS ===", + `Expected: ${JSON.stringify(normalizedExpected, null, 2)}`, + `Actual: ${JSON.stringify(normalizedActual, null, 2)}`, + ].join("\n"); + throw new Error(message); } - } - return lines.join("\n"); + }, + async updateGolden(actual, fixture) { + await writeText(fixture.expectedPath, actual.serialized); + }, +}); + +async function runSchemaTransform(inputPath: string): Promise { + const code = await Deno.readTextFile(inputPath); + const { type, checker, typeNode } = await getTypeFromCode(code, TYPE_NAME); + const transformer = createSchemaTransformerV2(); + const normalized = normalizeSchema(transformer(type, checker, typeNode)); + const serialized = JSON.stringify(normalized, null, 2) + "\n"; + return { normalized, serialized }; } async function writeText(path: string, data: string) { @@ -40,116 +89,19 @@ async function writeText(path: string, data: string) { await Deno.writeTextFile(path, data); } -// Collect fixtures at module load time -const allFixtures: Map< - string, - Array<{ name: string; input: string; expected: string }> -> = new Map(); - -for (const cfg of configs) { - const baseDir = `./test/fixtures/${cfg.directory}`; - const fixtures: Array<{ name: string; input: string; expected: string }> = []; - - try { - for await (const entry of walk(baseDir, { match: [/\.input\.ts$/] })) { - const input = entry.path; - const stem = input.replace(/\.input\.ts$/, ""); - const expected = `${stem}.expected.json`; - const name = input.replace(`${baseDir}/`, "").replace(/\.input\.ts$/, ""); - fixtures.push({ name, input, expected }); - } - } catch { - // Directory might not exist +function normalizeArrayOrdering(obj: unknown): unknown { + if (Array.isArray(obj)) { + return obj.map(normalizeArrayOrdering); } - - allFixtures.set(cfg.directory, fixtures); -} - -// Generate test suites -for (const cfg of configs) { - const fixtures = allFixtures.get(cfg.directory) || []; - - describe(cfg.describe, () => { - it("has fixtures", () => { - expect(fixtures.length).toBeGreaterThan(0); - }); - - for (const fixture of fixtures) { - it(`matches expected for ${fixture.name}`, async () => { - const code = await Deno.readTextFile(fixture.input); - const typeName = "SchemaRoot"; // Convention: root type is named 'SchemaRoot' - - const gen = createSchemaTransformerV2(); - const { type, checker, typeNode } = await getTypeFromCode( - code, - typeName, - ); - const obj1 = normalizeSchema(gen(type, checker, typeNode)); - const obj2 = normalizeSchema(gen(type, checker, typeNode)); - const s1 = JSON.stringify(obj1, null, 2) + "\n"; - const s2 = JSON.stringify(obj2, null, 2) + "\n"; - expect(s1).toEqual(s2); // determinism - - if (Deno.env.get("UPDATE_GOLDENS") === "1") { - await writeText(fixture.expected, s1); - return; - } - - const expectedText = await Deno.readTextFile(fixture.expected); - - // Parse both as JSON objects for semantic comparison - let actualObj, expectedObj; - try { - actualObj = JSON.parse(s1.trim()); - expectedObj = JSON.parse(expectedText.trim()); - } catch (error) { - throw new Error( - `JSON parsing failed for ${fixture.name}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - - // Normalize JSON Schema semantics (sort required arrays) - const normalizeArrayOrdering = (obj: any): any => { - if (Array.isArray(obj)) { - return obj.map(normalizeArrayOrdering); - } else if (obj && typeof obj === "object") { - const normalized: any = {}; - for (const [key, value] of Object.entries(obj)) { - if (key === "required" && Array.isArray(value)) { - // Sort required arrays for semantic equivalence - normalized[key] = [...value].sort(); - } else { - normalized[key] = normalizeArrayOrdering(value); - } - } - return normalized; - } - return obj; - }; - - actualObj = normalizeArrayOrdering(actualObj); - expectedObj = normalizeArrayOrdering(expectedObj); - - // Use deep equality comparison instead of string comparison - try { - expect(actualObj).toEqual(expectedObj); - } catch (error) { - // If semantic comparison fails, provide helpful diff - const diff = unifiedDiff(expectedText.trim(), s1.trim()); - const msg = [ - `\nFixture semantic mismatch for ${fixture.name}`, - `Input: ${resolve(fixture.input)}`, - `Expected: ${resolve(fixture.expected)}`, - "\n=== UNIFIED DIFF (expected vs actual) ===\n" + diff, - "\n=== PARSED OBJECTS ===", - `Expected: ${JSON.stringify(expectedObj, null, 2)}`, - `Actual: ${JSON.stringify(actualObj, null, 2)}`, - ].join("\n"); - throw new Error(msg); + if (obj && typeof obj === "object") { + const entries = Object.entries(obj as Record) + .map(([key, value]) => { + if (key === "required" && Array.isArray(value)) { + return [key, [...value].sort()]; } + return [key, normalizeArrayOrdering(value)]; }); - } - }); + return Object.fromEntries(entries); + } + return obj; } diff --git a/packages/schema-generator/test/schema/type-to-schema.test.ts b/packages/schema-generator/test/schema/type-to-schema.test.ts index 5e9054559..65dcb56fc 100644 --- a/packages/schema-generator/test/schema/type-to-schema.test.ts +++ b/packages/schema-generator/test/schema/type-to-schema.test.ts @@ -42,4 +42,34 @@ describe("Schema: type-to-schema parity", () => { expect(defU.properties?.newValues?.type).toBe("array"); expect(defU.properties?.newValues?.items?.type).toBe("string"); }); + + it("handles nested objects with string and number unions", async () => { + const code = ` + interface UserInfo { + profile: { + name: string; + email?: string; + }; + status: "active" | "inactive" | "pending"; + level: 1 | 2 | 3; + } + `; + + const { type, checker } = await getTypeFromCode(code, "UserInfo"); + const schema = createSchemaTransformerV2()(type, checker); + + expect(schema.type).toBe("object"); + const profile = schema.properties?.profile as Record; + expect(profile?.type).toBe("object"); + const profileProps = profile?.properties as Record; + expect(profileProps?.name?.type).toBe("string"); + expect(profile.required).toContain("name"); + expect(profile.required).not.toContain("email"); + + const status = schema.properties?.status as Record; + expect(status?.enum).toEqual(["active", "inactive", "pending"]); + + const level = schema.properties?.level as Record; + expect(level?.enum).toEqual([1, 2, 3]); + }); }); diff --git a/packages/test-support/README.md b/packages/test-support/README.md new file mode 100644 index 000000000..4e9677835 --- /dev/null +++ b/packages/test-support/README.md @@ -0,0 +1,35 @@ +# @commontools/test-support + +Shared testing infrastructure for CommonTools packages. This package currently +exports a configurable fixture runner that powers schema generator and +transformer tests without duplicating boilerplate code. + +## Usage + +```ts +import { defineFixtureSuite } from "@commontools/test-support/fixture-runner"; + +await defineFixtureSuite({ + suiteName: "Schema fixtures", + rootDir: "./test/fixtures/schema", + expectedPath: (fixture) => `${fixture.stem}.expected.json`, + async execute(fixture) { + // run transformer and return normalized JSON + }, + async loadExpected(fixture) { + const text = await Deno.readTextFile(fixture.expectedPath); + return JSON.parse(text); + }, + compare(actual, expected) { + expect(actual).toEqual(expected); + }, +}); +``` + +The configuration API is flexible enough to handle text-based outputs, +structured comparisons, per-suite warmup hooks, grouped fixture descriptions, +and golden updates controlled via the `UPDATE_GOLDENS` environment variable. + +For additional examples see +`packages/schema-generator/test/fixtures-runner.test.ts` and +`packages/js-runtime/test/fixture-based.test.ts` after migration. diff --git a/packages/test-support/deno.json b/packages/test-support/deno.json new file mode 100644 index 000000000..e9fe05497 --- /dev/null +++ b/packages/test-support/deno.json @@ -0,0 +1,31 @@ +{ + "name": "@commontools/test-support", + "version": "0.1.0", + "exports": { + ".": "./src/mod.ts", + "./fixture-runner": "./src/fixture-runner.ts" + }, + "imports": { + "typescript": "npm:typescript" + }, + "tasks": { + "test": "echo 'No tests defined.'", + "check": "deno check src/**/*.ts", + "fmt": "deno fmt src/", + "lint": "deno lint src/" + }, + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true + } +} diff --git a/packages/test-support/src/fixture-runner.ts b/packages/test-support/src/fixture-runner.ts new file mode 100644 index 000000000..acb1c9490 --- /dev/null +++ b/packages/test-support/src/fixture-runner.ts @@ -0,0 +1,357 @@ +import { beforeAll, describe, it } from "@std/testing/bdd"; +import { walkSync } from "@std/fs/walk"; +import { basename, isAbsolute, relative, resolve, SEPARATOR } from "@std/path"; + +export interface Fixture { + readonly rootDir: string; + readonly inputPath: string; + readonly expectedPath: string; + readonly relativeInputPath: string; + readonly relativeExpectedPath: string; + readonly stem: string; + readonly extension: string; + readonly id: string; + readonly baseName: string; +} + +export interface FixtureContext { + readonly warmup: Warmup; + readonly shouldUpdateGolden: boolean; + readonly rootDir: string; +} + +export interface FixtureGroup { + readonly name: string; + readonly fixtures: Fixture[]; +} + +type MaybePromise = T | Promise; + +export interface FixtureSuiteConfig { + suiteName: string; + /** + * Root directory containing fixture files. Resolved relative to the current + * working directory of the test process. + */ + rootDir: string; + /** + * Pattern for identifying input fixtures. Defaults to `/\.input\.(ts|tsx)$/i`. + */ + inputPattern?: RegExp; + /** + * Computes the path to the expected output relative to the fixture root. + */ + expectedPath: (details: { + stem: string; + extension: string; + relativeInputPath: string; + }) => string; + /** + * Optional filter to skip fixtures. + */ + skip?: (fixture: Fixture) => boolean; + /** + * Optional grouping for nested describe blocks. + */ + groupBy?: (fixture: Fixture) => string | undefined; + /** + * Custom comparator for fixture ordering. + */ + sortFixtures?: (a: Fixture, b: Fixture) => number; + /** + * Friendly test name formatter. Defaults to the fixture stem. + */ + formatTestName?: (fixture: Fixture) => string; + /** + * Optional warmup hook executed once before tests run. + */ + warmup?: () => MaybePromise; + /** + * Optional hook executed before each fixture test. + */ + beforeEach?: ( + fixture: Fixture, + ctx: FixtureContext, + ) => MaybePromise; + /** + * Produce the actual output for a fixture. + */ + execute: ( + fixture: Fixture, + ctx: FixtureContext, + ) => MaybePromise; + /** + * Load the expected output for a fixture. + */ + loadExpected: ( + fixture: Fixture, + ctx: FixtureContext, + ) => MaybePromise; + /** + * Compare actual vs expected values. + */ + compare: ( + actual: Actual, + expected: Expected, + fixture: Fixture, + ctx: FixtureContext, + ) => MaybePromise; + /** + * Optional determinism check for executions that should be stable across + * multiple invocations (e.g., schema generation). + */ + determinismCheck?: ( + actual: Actual, + fixture: Fixture, + ctx: FixtureContext, + ) => MaybePromise; + /** + * Handle golden updates when `UPDATE_GOLDENS=1`. + */ + updateGolden?: ( + actual: Actual, + fixture: Fixture, + ctx: FixtureContext, + ) => MaybePromise; +} + +export function shouldUpdateGoldens(): boolean { + return Deno.env.get("UPDATE_GOLDENS") === "1"; +} + +export function createUnifiedDiff( + expected: string, + actual: string, + context = 3, +): string { + const expectedLines = expected.split("\n"); + const actualLines = actual.split("\n"); + const maxLines = Math.max(expectedLines.length, actualLines.length); + const diffRanges: Array<{ start: number; end: number }> = []; + + for (let i = 0; i < maxLines; i++) { + const e = expectedLines[i] ?? ""; + const a = actualLines[i] ?? ""; + if (e === a) continue; + const lastRange = diffRanges[diffRanges.length - 1]; + if (lastRange && i <= lastRange.end + context * 2) { + lastRange.end = i; + } else { + diffRanges.push({ start: i, end: i }); + } + } + + if (diffRanges.length === 0) return ""; + + let diff = ""; + for (const range of diffRanges) { + const blockStart = Math.max(0, range.start - context); + const blockEnd = Math.min(maxLines - 1, range.end + context); + const lines: string[] = []; + + for (let i = blockStart; i <= blockEnd; i++) { + const e = expectedLines[i] ?? ""; + const a = actualLines[i] ?? ""; + if (e === a) { + lines.push(` ${e}`); + } else { + if (i < expectedLines.length && e !== "") lines.push(`- ${e}`); + if (i < actualLines.length && a !== "") lines.push(`+ ${a}`); + } + } + + const expectedCount = lines.filter((line) => !line.startsWith("+")).length; + const actualCount = lines.filter((line) => !line.startsWith("-")).length; + diff += `@@ -${blockStart + 1},${expectedCount} +${ + blockStart + 1 + },${actualCount} @@\n`; + diff += `${lines.join("\n")}\n\n`; + } + + return diff.trimEnd(); +} + +export function defineFixtureSuite( + config: FixtureSuiteConfig, +): void { + const { + suiteName, + rootDir, + expectedPath, + inputPattern = /\.input\.(ts|tsx)$/i, + skip, + groupBy, + sortFixtures, + formatTestName, + warmup, + beforeEach, + execute, + loadExpected, + compare, + determinismCheck, + updateGolden, + } = config; + + const absoluteRoot = resolve(rootDir); + const fixtures = collectFixtures(absoluteRoot, inputPattern, expectedPath) + .filter((fixture) => !skip || !skip(fixture)); + + const sorter = sortFixtures ?? defaultSort; + fixtures.sort(sorter); + + const shouldUpdate = shouldUpdateGoldens(); + + describe(suiteName, () => { + let warmupValue: Warmup; + + beforeAll(async () => { + warmupValue = (warmup ? await warmup() : undefined) as Warmup; + }); + + const registerTest = (fixture: Fixture) => { + const testName = formatTestName ? formatTestName(fixture) : fixture.id; + it(testName, async () => { + const ctx: FixtureContext = { + warmup: warmupValue, + shouldUpdateGolden: shouldUpdate, + rootDir: absoluteRoot, + }; + + if (beforeEach) { + await beforeEach(fixture, ctx); + } + + const actual = await execute(fixture, ctx); + + if (determinismCheck) { + await determinismCheck(actual, fixture, ctx); + } + + if (ctx.shouldUpdateGolden) { + if (!updateGolden) { + throw new Error( + `UPDATE_GOLDENS=1, but no updateGolden handler provided for fixture suite "${suiteName}".`, + ); + } + await updateGolden(actual, fixture, ctx); + return; + } + + const expected = await loadExpected(fixture, ctx); + await compare(actual, expected, fixture, ctx); + }); + }; + + if (groupBy) { + const groups = new Map(); + const ungrouped: Fixture[] = []; + + for (const fixture of fixtures) { + const groupName = groupBy(fixture); + if (groupName) { + const bucket = groups.get(groupName) ?? []; + bucket.push(fixture); + groups.set(groupName, bucket); + } else { + ungrouped.push(fixture); + } + } + + for (const [name, groupFixtures] of groups) { + describe(name, () => { + groupFixtures.sort(sorter); + for (const fixture of groupFixtures) { + registerTest(fixture); + } + }); + } + + for (const fixture of ungrouped) { + registerTest(fixture); + } + } else { + for (const fixture of fixtures) { + registerTest(fixture); + } + } + }); +} + +function defaultSort(a: Fixture, b: Fixture): number { + return a.id.localeCompare(b.id); +} + +function collectFixtures( + absoluteRoot: string, + inputPattern: RegExp, + expectedPath: FixtureSuiteConfig["expectedPath"], +): Fixture[] { + const fixtures: Fixture[] = []; + const normalizedRoot = absoluteRoot.endsWith(SEPARATOR) + ? absoluteRoot.slice(0, -1) + : absoluteRoot; + + try { + for (const entry of walkSync(absoluteRoot, { includeDirs: false })) { + const relativeInputPath = relative(normalizedRoot, entry.path); + if (!matchesPattern(inputPattern, normalizeId(relativeInputPath))) { + continue; + } + + const stem = stripInputSuffix(relativeInputPath); + const extension = extractExtension(relativeInputPath); + const expectedRelative = expectedPath({ + stem, + extension, + relativeInputPath, + }); + const absoluteExpected = isAbsolute(expectedRelative) + ? expectedRelative + : resolve(absoluteRoot, expectedRelative); + const relativeExpectedPath = relative(normalizedRoot, absoluteExpected); + + fixtures.push({ + rootDir: absoluteRoot, + inputPath: entry.path, + expectedPath: absoluteExpected, + relativeInputPath, + relativeExpectedPath, + stem, + extension, + id: normalizeId(stem), + baseName: basename(stem), + }); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return fixtures; + } + throw error; + } + + return fixtures; +} + +function matchesPattern(pattern: RegExp, value: string): boolean { + if (pattern.global || pattern.sticky) pattern.lastIndex = 0; + return pattern.test(value); +} + +function stripInputSuffix(relativePath: string): string { + const marker = ".input."; + const index = relativePath.lastIndexOf(marker); + if (index === -1) return normalizeId(relativePath); + return normalizeId(relativePath.slice(0, index)); +} + +function extractExtension(relativePath: string): string { + const marker = ".input."; + const index = relativePath.lastIndexOf(marker); + if (index === -1) return ""; + const suffix = relativePath.slice(index + marker.length); + return suffix ? `.${suffix}` : ""; +} + +function normalizeId(value: string): string { + return value.split(SEPARATOR).join("/"); +} diff --git a/packages/test-support/src/mod.ts b/packages/test-support/src/mod.ts new file mode 100644 index 000000000..1c06f39ec --- /dev/null +++ b/packages/test-support/src/mod.ts @@ -0,0 +1,11 @@ +export { + createUnifiedDiff, + defineFixtureSuite, + shouldUpdateGoldens, +} from "./fixture-runner.ts"; +export type { + Fixture, + FixtureContext, + FixtureGroup, + FixtureSuiteConfig, +} from "./fixture-runner.ts"; diff --git a/packages/ts-transformers/README.md b/packages/ts-transformers/README.md new file mode 100644 index 000000000..b1d1fbeb2 --- /dev/null +++ b/packages/ts-transformers/README.md @@ -0,0 +1,24 @@ +# @commontools/ts-transformers + +This package hosts CommonTools TypeScript AST transformers. It provides a shared +transformation context, import management utilities, and modular rule +implementations that can be reused by the runtime and tooling layers. + +The initial scaffold focuses on core infrastructure. Transformers from +`@commontools/js-runtime` will migrate here incrementally alongside new +architecture work (e.g. OpaqueRef parity and closures support). + +## Scripts + +All commands are driven via `deno task`: + +- `deno task test` — run transformer tests (placeholder until suites land) +- `deno task check` — type-check sources under `src/` +- `deno task fmt` — format source and future test files +- `deno task lint` — lint source and future test files + +## Status + +The package currently exports core context and import management helpers used to +compose future transformer rule sets. Production transformers will be ported in +subsequent phases. diff --git a/packages/ts-transformers/deno.json b/packages/ts-transformers/deno.json new file mode 100644 index 000000000..8eca6a0c6 --- /dev/null +++ b/packages/ts-transformers/deno.json @@ -0,0 +1,32 @@ +{ + "name": "@commontools/ts-transformers", + "version": "0.1.0", + "exports": { + ".": "./src/mod.ts", + "./core/context": "./src/core/context.ts", + "./core/imports": "./src/core/imports.ts" + }, + "imports": { + "typescript": "npm:typescript" + }, + "tasks": { + "test": "deno test --allow-read --allow-write --allow-env test/**/*.test.ts", + "check": "deno check src/**/*.ts", + "fmt": "deno fmt src/ test/**/*.test.ts", + "lint": "deno lint src/ test/" + }, + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true + } +} diff --git a/packages/ts-transformers/docs/opaque-refs-rewrite-notes.txt b/packages/ts-transformers/docs/opaque-refs-rewrite-notes.txt new file mode 100644 index 000000000..ff31ba300 --- /dev/null +++ b/packages/ts-transformers/docs/opaque-refs-rewrite-notes.txt @@ -0,0 +1,83 @@ +// the programmer writes: +lift(fn: (a: I) => O) +// the compiler will infer the types I and O if you don't specify them + +// eventually we want to generate: +lift(inputSchema, outputSchema, ...fn) +// by the way we want the schema transformer to do this for lift and not just recipe + +// this is not true yet but this is where we're going in the runtime: +recipe(arg) = lift(Opaque, O)(arg) + +// right now, we basically go from the recipe declaration to the version of +// recipe that uses the inputSchema/outputSchema, via the opaque ref transformer +recipe(name, (Opaque => Opaque)) // Opaque = literal | OpaqueRef +--> +recipe(, toSchema, ...?) // 'name' should just go into 'description' of inputSchema + +// let's see if we can make the current pipeline work for 'lift' the way it works for 'recipe' (and 'handler') +// this should be fast and if it's not then i'll re-prioritize + +// so yeah that would be: +lift(fn: (a: I) => O) +// becomes --> +lift, toSchema, ...) +// where IT/OT are input transformed and output transformed, as per the following approach: +// data analysis on inputs; if there are no reads, x becomes Opaque +// allowed: x.y, IF we aren't reading the value of x.y but merely passing it along to a lift/handler/etc +// allowed: .map, eventually .filter when it works, because they're exposed on Cell, SOMETIMES; + they have return value Opaque<...> so we need to see if we don't read the returned values + +// In JSX, tags transform expressions to make things Opaque +// example: `x+1` +// example: `<... class = { enabled ? ... : ... }>` +// enabled &&
...
+// ...these should be transformed into a combination of derive/lift and ifElse +// for now, we'll expect to output: +// lift(toSchema,toSchema,x=>x+1)(x) +// <... class = {ifElse(enabled, ..., ...)}> // NOTE: we need to add toSchema support for ifElse +// ifElse(enabled,
...
, null) + +// Optional extension: +// example: <... onClick={ (ev) => { ... lst ... inc ... }} > // <-- handler closes over 'lst' and 'inc' +// currently, you have to define a handler-type function outside the JSX and pass it 'lst' and 'inc' explicitly +// what if our transformer turned that into a handler((ev, {lst, inc}) => ... original function ..., {lst, inc}) +// Fly in the ointment: what if we want to modify lst by pushing something into it? Then we need to make it a Cell; +// this will need to happen after the OpaqueRef/Cell implementation merge, otherwise this will compile but not do +// the right thing at runtime. Eventually you need your outer recipe to declare that 'lst' is Mutable; then the +// transformer will make it Opaque everywhere except the actual handler that needs it to be Mutable. + +// QUESTIONS +* We might treat x?.y as not reading x either because the runtime will + just treat undefined values specially there anyway and allow x?.y --> undefined + +// NOTES +* derive(x, fn) <=> lift(fn)(x) + +// CLOSURES +A main goal of this project is to enable closures support. + +The main case where we use closures right now is in `map`. The function that `map` takes + right now is a classic map function. Internally, it transforms the three map parameters + into a single object containing each one as a property. In any event, it's common to + want to close over values from the context in writing a map. + +OpaqueRef.map(fn: (elem: T, index, list: T[]) => OpaqueRef) +// Internally, this creates a Node whose parameter is: recipe(({elem, index, list}) => fn(elem, index, list)) +// Basically it wraps the given mapping function in a recipe and then calls the built-in map on that recipe, ie: +// built-in-map({list, op: recipe(...)}) + +// How do we close over values from the context when we pass in our fn to the OpaqueRef map? + +// One long-term path would be to do all the heavy lifting in the AST transformer, so that the original +// map call would get transformed into a built-in map where we are passing in the closed-over value + +// In the future we could write our own `curry` function. +Opaque.map(lift(({closed_over_1, closed_over_2}, elem, index, list) => ...original_function...).\ + curry({closed_over_1, closed_over_2})) + +// A thing we can do in the shorter term, lacking a rigorous `curry` function, would be: +OpaqueRef.map({list, op:recipe(({elem, index, list, params: {closed_over_1, closed_over_2}}) => ...original_function...), + params: {closed_over_1, closed_over_2}}) +// Probably best to overload the OpaqueRef map function to accept that signature as well as the +// version that takes just a function diff --git a/packages/ts-transformers/docs/transformers_notes.md b/packages/ts-transformers/docs/transformers_notes.md new file mode 100644 index 000000000..1f66e21da --- /dev/null +++ b/packages/ts-transformers/docs/transformers_notes.md @@ -0,0 +1,371 @@ +# OpaqueRef Transformer Status & Roadmap + +_Last updated: 2025-09-23_ + +## Overview + +`@commontools/ts-transformers` now houses our TypeScript AST transformers. The +package exposes the modular OpaqueRef rewrite we ship to recipe authors (via +`createModularOpaqueRefTransformer`), and `@commontools/js-runtime` now consumes +that implementation directly. This document captures the current implementation, +outstanding gaps, and the focused roadmap we intend to pursue. + +## Current Implementation + +### Architecture Snapshot + +- **Rule-based passes** – We run a small, ordered array of rule modules + (currently JSX expressions and schema injection) over each source file. Each + rule performs targeted rewrites and requests imports through a shared + transformation context. +- **Shared context** – `core/context.ts` centralises the TypeScript checker, + cached type lookups, flag tracking (e.g., JSX depth), diagnostic reporting, + and import management. +- **Data flow analysis** – `opaque-ref/dataflow.ts` walks expressions to collect + reactive data flows, handles most scope boundaries, and records provenance so + map callbacks typed as `any` can still be derived. +- **Rewrite helpers** – `opaque-ref/rewrite/**` modules handle property access, + binary/call/template expressions, ternaries, unary `!`, and container rewrites + using a common binding plan. Import requests are applied at the end of the + pass. +- **Tests** – Fixture-based suites in `packages/ts-transformers/test` cover AST + parity for JSX, schema integration, handler schema transforms, and the new map + callback regression test. + +### Recent Improvements + +- **Map callback parity** – parameters annotated as `any`/`number` now derive + correctly inside `.map` callbacks. +- **Unary `!`** – predicates like `!flag` wrap into `derive` where necessary. +- **Docs fixture** – `opaque-ref-cell-map` fixture reproduces the ct-891 case so + we keep coverage on the new transformer. +- **Optional import handling** – the modular path defers import insertion until + all rules run, preventing duplicate helper imports. +- **Double-derive prevention** – User-written `derive` calls are no longer + wrapped in additional `derive` calls. +- **AMD module qualification** – Injected `derive` and `ifElse` calls now + properly reuse existing import identifiers for correct AMD output. +- **Import resolver** – New system to find and reuse existing CommonTools + imports rather than creating bare identifiers. +- **Normalization refactor** – Implemented explicit dependency tracking with + `isExplicit` flag, removing text-matching workarounds and fixing parent + suppression issues. + +## Known Gaps + +| Area | Impact | Notes | +| ----------------------------------------------------------------------- | -------------------------------------------------------- | ----- | +| **Optional-chain predicates** | `!cellRef?.length` still bypasses `derive`, so | | +| runtime may read a `Cell` eagerly. | Need data flow support for | | +| `PropertyAccessChain` and a rewrite that preserves optional semantics. | | | +| **Closures** | Functions that capture reactive values (e.g. | | +| `() => count + 1`) aren’t rewritten, so callbacks read opaque values at | | | +| runtime. | Requires capture analysis and a closure rewrite rule. | | +| **Destructuring / spread** | Patterns like `const { name } = user` or | | +| `{ ...config, count: count + 1 }` still operate on raw refs. | Object spread not yet supported. | | +| **Async/await & template literals** | Reactive identifiers inside | | +| `await` expressions or template strings aren't wrapped automatically. | | | +| **Function body analysis** | Only `return` statements analyzed in functions. | | +| Side effects and assignments are missed. | Causes reactive data flows to be overlooked. | | +| **Postfix unary operations** | `x++` and `x--` not handled by emitters. | | +| **Testing depth** | No unit or perf suites beyond fixtures; closure/optional | | +| scenarios lack runtime integration coverage. | | | + +## Near-Term Roadmap + +### Phase 1: Foundation Cleanup + +1. **Data structure consolidation** + - Merge internal/external scope representations + - Create single canonical DataFlowAnalysis result + - Eliminate duplication between nodes/dataFlows/graph + +2. **Context type consolidation** + - Create base TransformContext interface + - Minimize context variants + - Unify shared functionality + +### Phase 2: Correctness Improvements + +1. **Fix function body analysis** + - Analyze all statements, not just returns + - Handle side effects and assignments + - Track data flows in intermediate computations + +2. **Improve parameter detection** + - Build parameter metadata once during analysis + - Reuse metadata throughout pipeline + - Eliminate redundant AST walks + +3. **Test enhancements** + - Add unit tests for data flow analysis + - Expand fixtures with edge cases + - Add runtime integration tests + - Test each improvement as it's implemented + +### Phase 3: Architecture Extensions + +1. **Optional-chain predicate support** + - Extend data flow normalisation to recognise optional chains + - Update unary rule to emit `derive(cellRef, ref => !(ref?.length))` + - Add fixtures and unit coverage + +2. **Closure capture rewriting** + - Introduce capture-aware data flow walk + - Add closure rule for wrapped reactive values + - Cover map callbacks, inline handlers, nested closures + +### Future Extensions + +- **Proactive OpaqueRef conversion**: Add new rule to automatically wrap + non-OpaqueRef values that should be reactive, leveraging the cleaned-up + architecture to identify candidates and apply appropriate transformations +- **Import flexibility**: Expand helper resolution so the rewrite pipeline works + when authors: + - Glob-import (`import * as ct from "commontools"`) and access helpers via + namespace members. + - Rename specific imports + (`import { derive as ctDerive } from + "commontools"`) without breaking + helper detection. + - Omit any CommonTools import in `cts`-enabled files, by inserting a safe + namespace import during a preprocessing pass if none is detected. + +## Longer-Term Considerations + +- **Async transformations** – Once closures are handled, assess whether wrapping + reactive values inside template literals and `await` chains is still a blocker + for recipes. +- **Performance & diagnostics** – If rule count grows, revisit lightweight + instrumentation (timing, rule-level debug logging) rather than the heavy + “transformation engine” originally proposed. +- **Legacy transformer sunset** – Done. `js-runtime` now imports the modular + transformer directly; no separate legacy copy remains in that package. + +## Normalization Design & Current Issues + +### Problem Statement + +The normalization phase (`opaque-ref/normalize.ts`) is responsible for: + +1. Deduplicating expressions that refer to the same reactive value +2. Suppressing parent expressions when child expressions are more specific + +However, the current implementation conflates several concerns and relies on +indirect information flow. + +### Current Implementation (2025-09-19) + +#### Data Flow + +1. **Analysis Phase** (`dataflow.ts`): + - Traverses AST and creates nodes for every expression encountered + - Builds a graph with parent-child relationships + - Separately tracks `dataFlows` array of expressions that should become + dependencies + +2. **Normalization Phase** (`normalize.ts`): + - Groups nodes by normalized expression text + - Applies parent suppression: if `state.items.length` exists, suppress + `state.items` + - **Problem**: This incorrectly suppresses needed dependencies in cases like: + ```typescript + state.items[state.items.length - 1]; + // Need both state.items (array access) AND state.items.length (index computation) + ``` + +3. **Current Workaround**: + - Pass `explicitDataFlows` array to normalization + - Build set of expression texts from explicit data flows + - Don't suppress parents that match these texts + - **Issue**: Relies on text matching between different normalization contexts + +### Architectural Problems + +1. **Conceptual Confusion**: + - Mixes graph traversal nodes with explicit dependency requirements + - Parent suppression logic doesn't distinguish between: + - Traversal artifacts (intermediate nodes created while walking down) + - Explicit dependencies (values actually needed for computation) + +2. **Implementation Fragility**: + ```typescript + // Creating fake nodes just for text comparison + const normalized = normalizeExpression({ expression: expr } as DataFlowNode); + const text = normalized.getText(normalized.getSourceFile()); + ``` + +3. **Information Loss**: + - By the time we normalize, we've lost the connection between: + - Which nodes were explicitly added to `dataFlows` + - Which nodes were just traversal artifacts + +### Specific Issues Fixed + +1. **Element Access with Dynamic Indices**: + - Expression: `state.items[state.items.length - 1]` + - Problem: Parent suppression removed `state.items` + - Fix: Don't suppress expressions explicitly in `dataFlows` + +2. **Method Calls on Computed Expressions**: + - Expression: `(item.price * (1 - state.discount)).toFixed(2)` + - Problem: Binary expression was being added as dependency + - Fix: Don't treat property access as data flow when it's a method call + +### Recommended Refactor + +#### Option A: Mark at Creation + +- Add `isExplicit` flag to DataFlowNode +- Set during node creation based on whether it's added to `dataFlows` +- Parent suppression only suppresses non-explicit parents + +#### Option B: Separate Tracking + +- Keep `dataFlows` and graph nodes completely separate +- Use graph only for understanding relationships +- Use `dataFlows` directly for dependencies + +#### Option C: Rethink Parent Suppression + +- Instead of suppressing, track "access paths" +- Know that `state.items.length` implies traversal through `state.items` +- But distinguish traversal from actual dependency needs + +### Test Coverage Recommendations + +See "Test Coverage Analysis" section below for comprehensive testing strategy. + +## Test Coverage Analysis + +### Current Coverage + +#### Fixture-Based Tests (`test/fixtures/`) + +**Strengths**: + +- Good coverage of common patterns +- Tests full transformation pipeline +- Easy to add new cases + +**Weaknesses**: + +- Black-box testing only +- Hard to test specific edge cases +- No visibility into intermediate states + +#### Specific Test Files: + +1. `jsx-property-access.expected.tsx` - Tests element access, property chains +2. `jsx-complex-mixed.expected.tsx` - Tests method calls, array operations +3. `map-callbacks.test.ts` - Tests callback parameter handling + +### Critical Gaps in Coverage + +1. **Parent Suppression Edge Cases**: + - Multiple uses of same base with different property accesses + - Deeply nested property chains with multiple references + - Mixed computed and static accesses + +2. **Method Call Variations**: + - Chained method calls: `expr.method1().method2()` + - Methods returning reactive values + - Methods with reactive arguments + +3. **Complex Element Access**: + - Nested element access: `arr[arr[0]]` + - Multiple dynamic indices: `matrix[i][j]` + - Mixed property and element: `obj.arr[obj.index]` + +4. **Normalization Edge Cases**: + - Parenthesized expressions at different levels + - Type assertions in various positions + - Non-null assertions combined with other operations + +### Recommended Test Additions + +#### 1. Unit Tests for Normalization + +Create `test/opaque-ref/normalize.test.ts`: + +```typescript +// Test parent suppression logic directly +// Test expression normalization rules +// Test explicit data flow preservation +``` + +#### 2. New Fixtures + +**`test/fixtures/jsx-expressions/element-access-complex.input.tsx`**: + +```typescript +// Nested element access +state.matrix[state.row][state.col]; +// Multiple references to same array +state.items[0] + state.items[state.items.length - 1]; +// Computed index from multiple sources +state.arr[state.a + state.b]; +``` + +**`test/fixtures/jsx-expressions/method-chains.input.tsx`**: + +```typescript +// Chained methods +state.text.trim().toLowerCase().includes("test"); +// Methods with reactive args +state.items.slice(state.start, state.end); +// Array method chains +state.items.filter((x) => x > state.threshold).map((x) => x * state.factor); +``` + +**`test/fixtures/jsx-expressions/parent-suppression-edge.input.tsx`**: + +```typescript +// Same base, different properties in one expression +state.user.name + " (age: " + state.user.age + ")"; +// Deeply nested with multiple refs +state.config.theme.colors.primary + state.config.theme.fonts.body; +``` + +#### 3. Integration Tests + +Create `test/integration/`: + +- Test that runtime correctly handles transformed code +- Verify reactivity works as expected +- Test performance characteristics + +#### 4. Property-Based Tests + +Use a property-based testing framework to: + +- Generate random valid TypeScript expressions +- Verify transformations preserve semantics +- Check invariants (no duplicates, all deps captured) + +### Testing Strategy for Refactor + +1. **Before Refactor**: + - Add all recommended tests + - Ensure they pass with current implementation + - Document expected behaviors + +2. **During Refactor**: + - Use tests as safety net + - Add tests for any bugs discovered + - Keep all tests passing + +3. **After Refactor**: + - Verify no regressions + - Add tests for new architecture + - Document new invariants + +## References + +- `packages/ts-transformers/src/opaque-ref/transformer.ts` +- `packages/ts-transformers/src/opaque-ref/dataflow.ts` +- `packages/ts-transformers/src/opaque-ref/rewrite/**` +- `packages/ts-transformers/src/opaque-ref/normalize.ts` (needs refactor) +- `packages/ts-transformers/test/fixtures` +- `packages/schema-generator/docs/refactor_plan.md` (historical context; see + Linear tickets for remaining work) diff --git a/packages/ts-transformers/src/core/assert.ts b/packages/ts-transformers/src/core/assert.ts new file mode 100644 index 000000000..08d087d60 --- /dev/null +++ b/packages/ts-transformers/src/core/assert.ts @@ -0,0 +1,6 @@ +export function assertDefined(value: T | undefined, message: string): T { + if (value === undefined) { + throw new Error(message); + } + return value; +} diff --git a/packages/ts-transformers/src/core/common-tools-imports.ts b/packages/ts-transformers/src/core/common-tools-imports.ts new file mode 100644 index 000000000..faa7da800 --- /dev/null +++ b/packages/ts-transformers/src/core/common-tools-imports.ts @@ -0,0 +1,197 @@ +import ts from "typescript"; + +export function hasCommonToolsImport( + sourceFile: ts.SourceFile, + importName: string, +): boolean { + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + if (!ts.isStringLiteral(statement.moduleSpecifier)) continue; + if (statement.moduleSpecifier.text !== "commontools") continue; + + const clause = statement.importClause; + if (!clause || !clause.namedBindings) continue; + if (!ts.isNamedImports(clause.namedBindings)) continue; + + for (const element of clause.namedBindings.elements) { + if (element.name.text === importName) { + return true; + } + } + } + return false; +} + +export function addCommonToolsImport( + sourceFile: ts.SourceFile, + factory: ts.NodeFactory, + importName: string, +): ts.SourceFile { + let existingImport: ts.ImportDeclaration | undefined; + let existingIndex = -1; + + sourceFile.statements.forEach((statement, index) => { + if (!ts.isImportDeclaration(statement)) return; + if (!ts.isStringLiteral(statement.moduleSpecifier)) return; + if (statement.moduleSpecifier.text !== "commontools") return; + existingImport = statement; + existingIndex = index; + }); + + if ( + existingImport && + existingImport.importClause && + existingImport.importClause.namedBindings && + ts.isNamedImports(existingImport.importClause.namedBindings) + ) { + const existingElements = existingImport.importClause.namedBindings.elements; + const alreadyPresent = existingElements.some((element) => + element.name.text === importName + ); + if (alreadyPresent) { + return sourceFile; + } + + const newElements = [ + ...existingElements, + factory.createImportSpecifier( + false, + undefined, + factory.createIdentifier(importName), + ), + ]; + + const newImport = factory.updateImportDeclaration( + existingImport, + existingImport.modifiers, + factory.createImportClause( + false, + existingImport.importClause.name, + factory.createNamedImports(newElements), + ), + existingImport.moduleSpecifier, + existingImport.assertClause, + ); + + const statements = [...sourceFile.statements]; + statements[existingIndex] = newImport; + return factory.updateSourceFile( + sourceFile, + statements, + sourceFile.isDeclarationFile, + sourceFile.referencedFiles, + sourceFile.typeReferenceDirectives, + sourceFile.hasNoDefaultLib, + sourceFile.libReferenceDirectives, + ); + } + + const newImport = factory.createImportDeclaration( + undefined, + factory.createImportClause( + false, + undefined, + factory.createNamedImports([ + factory.createImportSpecifier( + false, + undefined, + factory.createIdentifier(importName), + ), + ]), + ), + factory.createStringLiteral("commontools"), + undefined, + ); + + const statements = [...sourceFile.statements]; + let insertIndex = 0; + for (const statement of statements) { + if (ts.isImportDeclaration(statement)) { + insertIndex += 1; + } else { + break; + } + } + statements.splice(insertIndex, 0, newImport); + + return factory.updateSourceFile( + sourceFile, + statements, + sourceFile.isDeclarationFile, + sourceFile.referencedFiles, + sourceFile.typeReferenceDirectives, + sourceFile.hasNoDefaultLib, + sourceFile.libReferenceDirectives, + ); +} + +export function removeCommonToolsImport( + sourceFile: ts.SourceFile, + factory: ts.NodeFactory, + importName: string, +): ts.SourceFile { + let existingImport: ts.ImportDeclaration | undefined; + let existingIndex = -1; + + sourceFile.statements.forEach((statement, index) => { + if (!ts.isImportDeclaration(statement)) return; + if (!ts.isStringLiteral(statement.moduleSpecifier)) return; + if (statement.moduleSpecifier.text !== "commontools") return; + existingImport = statement; + existingIndex = index; + }); + + if ( + !existingImport || + !existingImport.importClause || + !existingImport.importClause.namedBindings || + !ts.isNamedImports(existingImport.importClause.namedBindings) + ) { + return sourceFile; + } + + const existingElements = existingImport.importClause.namedBindings.elements; + const remaining = existingElements.filter((element) => + element.name.text !== importName + ); + + if (remaining.length === 0) { + const statements = sourceFile.statements.filter((_, index) => + index !== existingIndex + ); + return factory.updateSourceFile( + sourceFile, + statements, + sourceFile.isDeclarationFile, + sourceFile.referencedFiles, + sourceFile.typeReferenceDirectives, + sourceFile.hasNoDefaultLib, + sourceFile.libReferenceDirectives, + ); + } + + const newImport = factory.updateImportDeclaration( + existingImport, + existingImport.modifiers, + factory.createImportClause( + false, + existingImport.importClause.name, + factory.createNamedImports(remaining), + ), + existingImport.moduleSpecifier, + existingImport.assertClause, + ); + + const statements = [...sourceFile.statements]; + statements[existingIndex] = newImport; + + return factory.updateSourceFile( + sourceFile, + statements, + sourceFile.isDeclarationFile, + sourceFile.referencedFiles, + sourceFile.typeReferenceDirectives, + sourceFile.hasNoDefaultLib, + sourceFile.libReferenceDirectives, + ); +} diff --git a/packages/ts-transformers/src/core/common-tools-symbols.ts b/packages/ts-transformers/src/core/common-tools-symbols.ts new file mode 100644 index 000000000..4fef68e13 --- /dev/null +++ b/packages/ts-transformers/src/core/common-tools-symbols.ts @@ -0,0 +1,183 @@ +import ts from "typescript"; + +// Constant for identifying CommonTools declarations +const COMMONTOOLS_DECLARATION = "commontools.d.ts"; + +/** + * Checks if a declaration comes from the CommonTools library + */ +export function isCommonToolsDeclaration( + declaration: ts.Declaration, +): boolean { + const fileName = declaration.getSourceFile().fileName.replace(/\\/g, "/"); + return fileName.endsWith(COMMONTOOLS_DECLARATION); +} + +/** + * Resolves a symbol to check if it represents a CommonTools export + */ +export function resolvesToCommonToolsSymbol( + symbol: ts.Symbol | undefined, + checker: ts.TypeChecker, + targetName: string, + seen: Set = new Set(), +): boolean { + if (!symbol || seen.has(symbol)) return false; + seen.add(symbol); + + if (symbol.getName() === targetName) { + const declarations = symbol.getDeclarations(); + if (declarations && declarations.some(isCommonToolsDeclaration)) { + return true; + } + } + + if (symbol.flags & ts.SymbolFlags.Alias) { + const aliased = checker.getAliasedSymbol(symbol); + if ( + aliased && + resolvesToCommonToolsSymbol(aliased, checker, targetName, seen) + ) { + return true; + } + } + + const declarations = symbol.getDeclarations(); + if (declarations) { + for (const declaration of declarations) { + if (ts.isTypeAliasDeclaration(declaration)) { + const aliasType = declaration.type; + if (aliasType && ts.isTypeReferenceNode(aliasType)) { + const referenced = checker.getSymbolAtLocation(aliasType.typeName); + if ( + resolvesToCommonToolsSymbol( + referenced, + checker, + targetName, + seen, + ) + ) { + return true; + } + } + } + } + } + + return false; +} + +/** + * Helper to resolve the base type of an expression + */ +function resolveBaseType( + expression: ts.Expression, + checker: ts.TypeChecker, +): ts.Type | undefined { + let baseType = checker.getTypeAtLocation(expression); + if (baseType.flags & ts.TypeFlags.Any) { + const baseSymbol = checker.getSymbolAtLocation(expression); + if (baseSymbol) { + const resolved = checker.getTypeOfSymbolAtLocation( + baseSymbol, + expression, + ); + if (resolved) { + baseType = resolved; + } + } + } + return baseType; +} + +/** + * Gets the symbol for a property or element access expression + */ +export function getMemberSymbol( + expression: ts.PropertyAccessExpression | ts.ElementAccessExpression, + checker: ts.TypeChecker, +): ts.Symbol | undefined { + if (ts.isPropertyAccessExpression(expression)) { + const direct = checker.getSymbolAtLocation(expression.name); + if (direct) return direct; + const baseType = resolveBaseType(expression.expression, checker); + if (!baseType) return undefined; + return baseType.getProperty(expression.name.text); + } + + if ( + ts.isElementAccessExpression(expression) && + expression.argumentExpression && + ts.isStringLiteralLike(expression.argumentExpression) + ) { + const baseType = resolveBaseType(expression.expression, checker); + if (!baseType) return undefined; + return baseType.getProperty(expression.argumentExpression.text); + } + + return checker.getSymbolAtLocation(expression) ?? undefined; +} + +/** + * Checks if a type node represents a CommonTools Default type + */ +function isCommonToolsDefaultTypeNode( + typeNode: ts.TypeNode | undefined, + checker: ts.TypeChecker, + visited: Set = new Set(), +): boolean { + if (!typeNode || !ts.isTypeReferenceNode(typeNode)) return false; + if ( + ts.isIdentifier(typeNode.typeName) && typeNode.typeName.text === "Default" + ) { + return true; + } + const symbol = checker.getSymbolAtLocation(typeNode.typeName); + if (!symbol || visited.has(symbol)) return false; + visited.add(symbol); + if (resolvesToCommonToolsSymbol(symbol, checker, "Default")) { + return true; + } + const declarations = symbol.getDeclarations(); + if (!declarations) return false; + for (const declaration of declarations) { + if (ts.isTypeAliasDeclaration(declaration)) { + const aliased = declaration.type; + if ( + aliased && ts.isTypeReferenceNode(aliased) && + isCommonToolsDefaultTypeNode(aliased, checker, visited) + ) { + return true; + } + } + } + return false; +} + +/** + * Checks if a symbol declares a CommonTools Default type + */ +export function symbolDeclaresCommonToolsDefault( + symbol: ts.Symbol | undefined, + checker: ts.TypeChecker, +): boolean { + if (!symbol) return false; + const declarations = symbol.getDeclarations(); + if (!declarations) return false; + return declarations.some((declaration) => { + const nodeWithType = declaration as { type?: ts.TypeNode }; + if ( + nodeWithType.type && + isCommonToolsDefaultTypeNode(nodeWithType.type, checker) + ) { + return true; + } + if (ts.isPropertySignature(declaration) && declaration.type) { + return isCommonToolsDefaultTypeNode(declaration.type, checker); + } + if (ts.isTypeAliasDeclaration(declaration) && declaration.type) { + return isCommonToolsDefaultTypeNode(declaration.type, checker); + } + return false; + }); +} diff --git a/packages/ts-transformers/src/core/context.ts b/packages/ts-transformers/src/core/context.ts new file mode 100644 index 000000000..47b26cf30 --- /dev/null +++ b/packages/ts-transformers/src/core/context.ts @@ -0,0 +1,139 @@ +import ts from "typescript"; +import { createImportManager } from "./imports.ts"; +import type { ImportManager } from "./imports.ts"; + +export type TransformMode = "transform" | "error"; + +export interface TransformationOptions { + readonly mode?: TransformMode; + readonly debug?: boolean; +} + +export interface TransformationFlags { + jsxExpressionDepth: number; + inJsxAttribute: boolean; +} + +export interface TransformationDiagnostic { + readonly type: string; + readonly message: string; + readonly fileName: string; + readonly line: number; + readonly column: number; +} + +export interface DiagnosticInput { + readonly type: string; + readonly message: string; + readonly node: ts.Node; +} + +type FlagValue = TransformationFlags[keyof TransformationFlags]; + +export interface TransformationContext { + readonly program: ts.Program; + readonly checker: ts.TypeChecker; + readonly factory: ts.NodeFactory; + readonly sourceFile: ts.SourceFile; + readonly options: Required; + readonly imports: ImportManager; + readonly diagnostics: TransformationDiagnostic[]; + readonly flags: TransformationFlags; + getType(node: ts.Node): ts.Type; + reportDiagnostic(input: DiagnosticInput): void; + withFlag( + flag: keyof TransformationFlags, + value: FlagValue, + fn: () => T, + ): T; +} + +const DEFAULT_OPTIONS: Required = { + mode: "transform", + debug: false, +}; + +function createInitialFlags(): TransformationFlags { + return { + jsxExpressionDepth: 0, + inJsxAttribute: false, + }; +} + +export function createTransformationContext( + program: ts.Program, + sourceFile: ts.SourceFile, + transformation: ts.TransformationContext, + options: TransformationOptions = {}, + imports: ImportManager = createImportManager(), +): TransformationContext { + const checker = program.getTypeChecker(); + const factory = transformation.factory; + const mergedOptions: Required = { + ...DEFAULT_OPTIONS, + ...options, + }; + + const typeCache = new Map(); + const diagnostics: TransformationDiagnostic[] = []; + const flags = createInitialFlags(); + + const context: TransformationContext = { + program, + checker, + factory, + sourceFile, + options: mergedOptions, + imports, + diagnostics, + flags, + getType(node: ts.Node): ts.Type { + const cached = typeCache.get(node); + if (cached) { + return cached; + } + const type = checker.getTypeAtLocation(node); + typeCache.set(node, type); + return type; + }, + reportDiagnostic(input: DiagnosticInput): void { + const location = sourceFile.getLineAndCharacterOfPosition( + input.node.getStart(), + ); + diagnostics.push({ + type: input.type, + message: input.message, + fileName: sourceFile.fileName, + line: location.line + 1, + column: location.character + 1, + }); + }, + withFlag( + flag: keyof TransformationFlags, + value: FlagValue, + fn: () => T, + ): T { + const key = flag as keyof TransformationFlags; + const previous = flags[key] as FlagValue; + // deno-lint-ignore no-explicit-any + (flags as any)[key] = value; + try { + return fn(); + } finally { + // deno-lint-ignore no-explicit-any + (flags as any)[key] = previous; + } + }, + }; + + return context; +} + +export function withFlag( + context: TransformationContext, + flag: keyof TransformationFlags, + value: FlagValue, + fn: () => T, +): T { + return context.withFlag(flag, value, fn); +} diff --git a/packages/ts-transformers/src/core/imports.ts b/packages/ts-transformers/src/core/imports.ts new file mode 100644 index 000000000..315bc9387 --- /dev/null +++ b/packages/ts-transformers/src/core/imports.ts @@ -0,0 +1,212 @@ +import ts from "typescript"; + +export interface ImportSpec { + readonly module: string; + readonly typeOnly: boolean; +} + +export interface ImportRequest extends Partial { + readonly name: string; +} + +interface ImportRecord extends ImportSpec { + readonly names: Set; +} + +export interface ImportManager { + request(request: ImportRequest): void; + has(request: ImportRequest): boolean; + entries(): Iterable; + clear(): void; +} + +const DEFAULT_MODULE = "commontools"; + +class SimpleImportManager implements ImportManager { + #records = new Map(); + + request(request: ImportRequest): void { + const module = request.module ?? DEFAULT_MODULE; + const key = `${module}|${request.typeOnly ? "t" : "v"}`; + const existing = this.#records.get(key); + if (existing) { + existing.names.add(request.name); + return; + } + this.#records.set(key, { + module, + typeOnly: request.typeOnly ?? false, + names: new Set([request.name]), + }); + } + + has(request: ImportRequest): boolean { + const module = request.module ?? DEFAULT_MODULE; + const key = `${module}|${request.typeOnly ? "t" : "v"}`; + const existing = this.#records.get(key); + return existing ? existing.names.has(request.name) : false; + } + + *entries(): Iterable { + for (const record of this.#records.values()) { + yield record; + } + } + + clear(): void { + this.#records.clear(); + } +} + +export function createImportManager(): ImportManager { + return new SimpleImportManager(); +} + +function ensureImport( + sourceFile: ts.SourceFile, + factory: ts.NodeFactory, + record: ImportRecord, +): ts.SourceFile { + let existing: ts.ImportDeclaration | undefined; + let existingIndex = -1; + + sourceFile.statements.forEach((statement, index) => { + if (!ts.isImportDeclaration(statement)) { + return; + } + if (!ts.isStringLiteral(statement.moduleSpecifier)) { + return; + } + if (statement.moduleSpecifier.text !== record.module) { + return; + } + const clause = statement.importClause; + if (!clause || !clause.namedBindings) { + return; + } + if (!ts.isNamedImports(clause.namedBindings)) { + return; + } + if (record.typeOnly && !clause.isTypeOnly) { + return; + } + existing = statement; + existingIndex = index; + }); + + if (existing) { + return updateImport(existing, existingIndex, sourceFile, factory, record); + } + return insertImport(sourceFile, factory, record); +} + +function updateImport( + existing: ts.ImportDeclaration, + index: number, + sourceFile: ts.SourceFile, + factory: ts.NodeFactory, + record: ImportRecord, +): ts.SourceFile { + const clause = existing.importClause; + if (!clause || !clause.namedBindings) { + return sourceFile; + } + + const named = clause.namedBindings; + if (!ts.isNamedImports(named)) { + return sourceFile; + } + + const existingNames = new Set( + named.elements.map((element) => element.name.text), + ); + const nextElements = [...named.elements]; + + for (const name of record.names) { + if (existingNames.has(name)) continue; + nextElements.push( + factory.createImportSpecifier( + false, + undefined, + factory.createIdentifier(name), + ), + ); + } + + if (nextElements.length === named.elements.length) { + return sourceFile; + } + + const updated = factory.updateImportDeclaration( + existing, + existing.modifiers, + factory.updateImportClause( + clause, + record.typeOnly || clause.isTypeOnly, + clause.name, + factory.createNamedImports(nextElements), + ), + existing.moduleSpecifier, + existing.assertClause, + ); + + const statements = [...sourceFile.statements]; + statements[index] = updated; + return factory.updateSourceFile(sourceFile, statements); +} + +function insertImport( + sourceFile: ts.SourceFile, + factory: ts.NodeFactory, + record: ImportRecord, +): ts.SourceFile { + const elements = Array.from(record.names).map((name) => + factory.createImportSpecifier( + false, + undefined, + factory.createIdentifier(name), + ) + ); + + const clause = factory.createImportClause( + record.typeOnly, + undefined, + factory.createNamedImports(elements), + ); + + const declaration = factory.createImportDeclaration( + undefined, + clause, + factory.createStringLiteral(record.module), + undefined, + ); + + const statements = [...sourceFile.statements]; + let insertIndex = 0; + for (let i = 0; i < statements.length; i++) { + const statement = statements[i]; + if (!statement) { + break; + } + if (ts.isImportDeclaration(statement)) { + insertIndex = i + 1; + continue; + } + break; + } + statements.splice(insertIndex, 0, declaration); + return factory.updateSourceFile(sourceFile, statements); +} + +export function applyPendingImports( + sourceFile: ts.SourceFile, + factory: ts.NodeFactory, + manager: ImportManager, +): ts.SourceFile { + let updated = sourceFile; + for (const record of manager.entries()) { + updated = ensureImport(updated, factory, record); + } + manager.clear(); + return updated; +} diff --git a/packages/ts-transformers/src/mod.ts b/packages/ts-transformers/src/mod.ts new file mode 100644 index 000000000..5641a78c6 --- /dev/null +++ b/packages/ts-transformers/src/mod.ts @@ -0,0 +1,45 @@ +export { applyPendingImports, createImportManager } from "./core/imports.ts"; +export type { + ImportManager, + ImportRequest, + ImportSpec, +} from "./core/imports.ts"; + +export { createTransformationContext, withFlag } from "./core/context.ts"; +export type { + TransformationContext, + TransformationDiagnostic, + TransformationFlags, + TransformationOptions, + TransformMode, +} from "./core/context.ts"; + +export { + createSchemaTransformer, + type SchemaTransformerOptions, +} from "./schema/schema-transformer.ts"; + +export { collectOpaqueRefs } from "./opaque-ref/dataflow.ts"; + +export { + containsOpaqueRef, + isOpaqueRefType, + isSimpleOpaqueRefAccess, +} from "./opaque-ref/types.ts"; + +export { + createIfElseCall, + replaceOpaqueRefsWithParams, + replaceOpaqueRefWithParam, + transformExpressionWithOpaqueRef, +} from "./opaque-ref/transforms.ts"; + +export { + createJsxExpressionRule, + type OpaqueRefRule, +} from "./opaque-ref/rules/jsx-expression.ts"; + +export { + createModularOpaqueRefTransformer, + type ModularOpaqueRefTransformerOptions, +} from "./opaque-ref/transformer.ts"; diff --git a/packages/ts-transformers/src/opaque-ref/call-kind.ts b/packages/ts-transformers/src/opaque-ref/call-kind.ts new file mode 100644 index 000000000..861c06790 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/call-kind.ts @@ -0,0 +1,277 @@ +import ts from "typescript"; + +import { isCommonToolsDeclaration } from "../core/common-tools-symbols.ts"; + +const BUILDER_SYMBOL_NAMES = new Set([ + "recipe", + "handler", + "lift", + "compute", + "render", +]); + +const ARRAY_OWNER_NAMES = new Set([ + "Array", + "ReadonlyArray", +]); + +const OPAQUE_REF_OWNER_NAMES = new Set([ + "OpaqueRefMethods", + "OpaqueRef", +]); + +export type CallKind = + | { kind: "ifElse"; symbol?: ts.Symbol } + | { kind: "builder"; symbol?: ts.Symbol; builderName: string } + | { kind: "array-map"; symbol?: ts.Symbol } + | { kind: "derive"; symbol?: ts.Symbol }; + +export function detectCallKind( + call: ts.CallExpression, + checker: ts.TypeChecker, +): CallKind | undefined { + return resolveExpressionKind(call.expression, checker, new Set()); +} + +function resolveExpressionKind( + expression: ts.Expression, + checker: ts.TypeChecker, + seen: Set, +): CallKind | undefined { + const target = stripWrappers(expression); + + // Check for simple identifier names first (for cases where symbol resolution might fail) + if (ts.isIdentifier(target)) { + const name = target.text; + if (name === "derive") { + return { kind: "derive" }; + } + if (name === "ifElse") { + return { kind: "ifElse" }; + } + if (BUILDER_SYMBOL_NAMES.has(name)) { + return { kind: "builder", builderName: name }; + } + } + + if (ts.isCallExpression(target)) { + return resolveExpressionKind(target.expression, checker, seen); + } + + let symbol: ts.Symbol | undefined; + if (ts.isPropertyAccessExpression(target)) { + symbol = checker.getSymbolAtLocation(target.name); + } else if (ts.isElementAccessExpression(target)) { + const argument = target.argumentExpression; + if (argument && ts.isExpression(argument)) { + symbol = checker.getSymbolAtLocation(argument); + } + } else if (ts.isIdentifier(target)) { + symbol = checker.getSymbolAtLocation(target); + } else { + symbol = checker.getSymbolAtLocation(target); + } + + if (symbol) { + const kind = resolveSymbolKind(symbol, checker, seen); + if (kind) return kind; + } + + if ( + ts.isPropertyAccessExpression(target) && + target.name.text === "map" + ) { + return { kind: "array-map" }; + } + + const type = checker.getTypeAtLocation(target); + const signatures = checker.getSignaturesOfType(type, ts.SignatureKind.Call); + for (const signature of signatures) { + const signatureSymbol = getSignatureSymbol(signature); + if (!signatureSymbol) continue; + const kind = resolveSymbolKind(signatureSymbol, checker, seen); + if (kind) return kind; + } + + return undefined; +} + +function stripWrappers(expression: ts.Expression): ts.Expression { + let current: ts.Expression = expression; + + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + break; + } + + return current; +} + +function resolveSymbolKind( + symbol: ts.Symbol, + checker: ts.TypeChecker, + seen: Set, +): CallKind | undefined { + const resolved = resolveAlias(symbol, checker, seen); + if (!resolved) return undefined; + if (seen.has(resolved)) return undefined; + seen.add(resolved); + + const declarations = resolved.declarations ?? []; + const name = resolved.getName(); + + for (const declaration of declarations) { + const builderKind = detectBuilderFromDeclaration(resolved, declaration); + if (builderKind) return builderKind; + if ( + isArrayMapDeclaration(declaration) || + isOpaqueRefMapDeclaration(declaration) + ) { + return { kind: "array-map", symbol: resolved }; + } + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isExpression(declaration.initializer) + ) { + const nested = resolveExpressionKind( + declaration.initializer, + checker, + seen, + ); + if (nested) return nested; + } + } + + if (name === "ifElse" && symbolIsCommonTools(resolved)) { + return { kind: "ifElse", symbol: resolved }; + } + + if (name === "derive" && symbolIsCommonTools(resolved)) { + return { kind: "derive", symbol: resolved }; + } + + if (BUILDER_SYMBOL_NAMES.has(name) && symbolIsCommonTools(resolved)) { + return { kind: "builder", symbol: resolved, builderName: name }; + } + + if (name === "ifElse") { + return { kind: "ifElse", symbol: resolved }; + } + + if (name === "derive") { + return { kind: "derive", symbol: resolved }; + } + + if (BUILDER_SYMBOL_NAMES.has(name)) { + return { kind: "builder", symbol: resolved, builderName: name }; + } + + if (name === "map") { + return { kind: "array-map", symbol: resolved }; + } + + return undefined; +} + +function resolveAlias( + symbol: ts.Symbol, + checker: ts.TypeChecker, + seen: Set, +): ts.Symbol | undefined { + let current = symbol; + while (true) { + if (seen.has(current)) return current; + if (!(current.flags & ts.SymbolFlags.Alias)) break; + const aliased = checker.getAliasedSymbol(current); + if (!aliased) break; + current = aliased; + } + return current; +} + +function detectBuilderFromDeclaration( + symbol: ts.Symbol, + declaration: ts.Declaration, +): CallKind | undefined { + if (!hasIdentifierName(declaration)) return undefined; + + const name = declaration.name.text; + if (!BUILDER_SYMBOL_NAMES.has(name)) return undefined; + + return { + kind: "builder", + symbol, + builderName: name, + }; +} + +function isArrayMapDeclaration(declaration: ts.Declaration): boolean { + if (!hasIdentifierName(declaration)) return false; + if (declaration.name.text !== "map") return false; + + const owner = findOwnerName(declaration); + if (!owner) return false; + return ARRAY_OWNER_NAMES.has(owner); +} + +function isOpaqueRefMapDeclaration(declaration: ts.Declaration): boolean { + if (!hasIdentifierName(declaration)) return false; + if (declaration.name.text !== "map") return false; + + const owner = findOwnerName(declaration); + if (!owner) return false; + return OPAQUE_REF_OWNER_NAMES.has(owner); +} + +function findOwnerName(node: ts.Node): string | undefined { + let current: ts.Node | undefined = node.parent; + while (current) { + if ( + ts.isInterfaceDeclaration(current) || + ts.isClassDeclaration(current) || + ts.isTypeAliasDeclaration(current) + ) { + if (current.name) return current.name.text; + } + if (ts.isSourceFile(current)) break; + current = current.parent; + } + return undefined; +} + +function hasIdentifierName( + declaration: ts.Declaration, +): declaration is ts.Declaration & { readonly name: ts.Identifier } { + const { name } = declaration as { name?: ts.Node }; + return !!name && ts.isIdentifier(name); +} + +function getSignatureSymbol(signature: ts.Signature): ts.Symbol | undefined { + // deno-lint-ignore no-explicit-any + const sigWithSymbol = signature as any; + if (sigWithSymbol.symbol) { + return sigWithSymbol.symbol as ts.Symbol; + } + const declaration = signature.declaration; + if (!declaration) return undefined; + // deno-lint-ignore no-explicit-any + const declWithSymbol = declaration as any; + return declWithSymbol.symbol as ts.Symbol | undefined; +} + +function symbolIsCommonTools(symbol: ts.Symbol): boolean { + const declarations = symbol.declarations ?? []; + return declarations.some(isCommonToolsDeclaration); +} diff --git a/packages/ts-transformers/src/opaque-ref/dataflow.ts b/packages/ts-transformers/src/opaque-ref/dataflow.ts new file mode 100644 index 000000000..ee67199e5 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/dataflow.ts @@ -0,0 +1,774 @@ +import ts from "typescript"; + +import { + getMemberSymbol, + isFunctionParameter, + isOpaqueRefType, + symbolDeclaresCommonToolsDefault, +} from "./types.ts"; +import { detectCallKind } from "./call-kind.ts"; + +export interface DataFlowScopeParameter { + readonly name: string; + readonly symbol: ts.Symbol; + readonly declaration?: ts.ParameterDeclaration; +} + +export interface DataFlowScope { + readonly id: number; + readonly parentId: number | null; + readonly parameters: readonly DataFlowScopeParameter[]; +} + +export interface DataFlowNode { + readonly id: number; + readonly expression: ts.Expression; + readonly canonicalKey: string; + readonly parentId: number | null; + readonly scopeId: number; + readonly isExplicit: boolean; // True if this node represents an actual dependency, not a traversal artifact +} + +export interface DataFlowGraph { + readonly nodes: readonly DataFlowNode[]; + readonly scopes: readonly DataFlowScope[]; + readonly rootScopeId: number; +} + +export type RewriteHint = + | { kind: "call-if-else"; predicate: ts.Expression } + | { kind: "skip-call-rewrite"; reason: "array-map" | "builder" } + | undefined; + +export interface DataFlowAnalysis { + containsOpaqueRef: boolean; + requiresRewrite: boolean; + dataFlows: ts.Expression[]; + graph: DataFlowGraph; + rewriteHint?: RewriteHint; +} + +export function dedupeExpressions( + expressions: ts.Expression[], + sourceFile: ts.SourceFile, +): ts.Expression[] { + const seen = new Map(); + for (const expr of expressions) { + const key = expr.getText(sourceFile); + if (!seen.has(key)) { + seen.set(key, expr); + } + } + return Array.from(seen.values()); +} + +interface DataFlowScopeInternal { + readonly id: number; + readonly parentId: number | null; + readonly parameterSymbols: ts.Symbol[]; + readonly aggregated: Set; +} + +interface AnalyzerContext { + nextNodeId: number; + nextScopeId: number; + readonly collectedNodes: DataFlowNode[]; // All nodes collected during analysis + readonly scopes: Map; +} + +interface InternalAnalysis { + containsOpaqueRef: boolean; + requiresRewrite: boolean; + dataFlows: ts.Expression[]; + localNodes: DataFlowNode[]; // Nodes from this expression subtree only + rewriteHint?: RewriteHint; +} + +const emptyAnalysis = (): InternalAnalysis => ({ + containsOpaqueRef: false, + requiresRewrite: false, + dataFlows: [], + localNodes: [], + rewriteHint: undefined, +}); + +const mergeAnalyses = (...analyses: InternalAnalysis[]): InternalAnalysis => { + let contains = false; + let requires = false; + const dataFlows: ts.Expression[] = []; + const localNodes: DataFlowNode[] = []; + for (const analysis of analyses) { + if (!analysis) continue; + contains ||= analysis.containsOpaqueRef; + requires ||= analysis.requiresRewrite; + dataFlows.push(...analysis.dataFlows); + localNodes.push(...analysis.localNodes); + } + return { + containsOpaqueRef: contains, + requiresRewrite: requires, + dataFlows, + localNodes, + rewriteHint: undefined, + }; +}; + +export function createDataFlowAnalyzer( + checker: ts.TypeChecker, +): (expression: ts.Expression) => DataFlowAnalysis { + const createScope = ( + context: AnalyzerContext, + parent: DataFlowScopeInternal | null, + parameterSymbols: ts.Symbol[], + ): DataFlowScopeInternal => { + const aggregated = parent + ? new Set(parent.aggregated) + : new Set(); + for (const symbol of parameterSymbols) aggregated.add(symbol); + const scope: DataFlowScopeInternal = { + id: context.nextScopeId++, + parentId: parent ? parent.id : null, + parameterSymbols, + aggregated, + }; + context.scopes.set(scope.id, scope); + return scope; + }; + + const createCanonicalKey = ( + expression: ts.Expression, + scope: DataFlowScopeInternal, + ): string => { + const sourceFile = expression.getSourceFile(); + const text = expression.getText(sourceFile); + return `${scope.id}:${text}`; + }; + + const toDataFlowScope = ( + scope: DataFlowScopeInternal, + ): DataFlowScope => ({ + id: scope.id, + parentId: scope.parentId, + parameters: scope.parameterSymbols.map((symbol) => { + const declarations = symbol.getDeclarations(); + const parameterDecl = declarations?.find(( + decl, + ): decl is ts.ParameterDeclaration => ts.isParameter(decl)); + if (parameterDecl) { + return { + name: symbol.getName(), + symbol, + declaration: parameterDecl, + } satisfies DataFlowScopeParameter; + } + return { + name: symbol.getName(), + symbol, + } satisfies DataFlowScopeParameter; + }), + }); + + const analyzeExpression = ( + expression: ts.Expression, + scope: DataFlowScopeInternal, + context: AnalyzerContext, + ): InternalAnalysis => { + const isSymbolIgnored = (symbol: ts.Symbol | undefined): boolean => { + if (!symbol) return false; + if (scope.aggregated.has(symbol) && isRootOpaqueParameter(symbol)) { + return false; + } + return scope.aggregated.has(symbol); + }; + + const originatesFromIgnored = (expr: ts.Expression): boolean => { + if (ts.isIdentifier(expr)) { + const symbol = checker.getSymbolAtLocation(expr); + return isSymbolIgnored(symbol); + } + if ( + ts.isPropertyAccessExpression(expr) || + ts.isElementAccessExpression(expr) + ) { + return originatesFromIgnored(expr.expression); + } + if (ts.isCallExpression(expr)) { + return originatesFromIgnored(expr.expression); + } + return false; + }; + + const recordDataFlow = ( + expr: ts.Expression, + ownerScope: DataFlowScopeInternal, + parentId: number | null = null, + isExplicit: boolean = false, + ): DataFlowNode => { + const node: DataFlowNode = { + id: context.nextNodeId++, + expression: expr, + canonicalKey: createCanonicalKey(expr, ownerScope), + parentId, + scopeId: ownerScope.id, + isExplicit, + }; + context.collectedNodes.push(node); + return node; + }; + + const findRootIdentifier = ( + expr: ts.Expression, + ): ts.Identifier | undefined => { + let current: ts.Expression = expr; + while (true) { + if (ts.isIdentifier(current)) return current; + if (ts.isPropertyAccessExpression(current)) { + current = current.expression; + continue; + } + if (ts.isElementAccessExpression(current)) { + current = current.expression; + continue; + } + if ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + continue; + } + if (ts.isCallExpression(current)) { + current = current.expression; + continue; + } + return undefined; + } + }; + + const getOpaqueParameterCallKind = ( + symbol: ts.Symbol | undefined, + ): "builder" | "array-map" | undefined => { + if (!symbol) return undefined; + const declarations = symbol.getDeclarations(); + if (!declarations) return undefined; + for (const declaration of declarations) { + if (!ts.isParameter(declaration)) continue; + let functionNode: ts.Node | undefined = declaration.parent; + while (functionNode && !ts.isFunctionLike(functionNode)) { + functionNode = functionNode.parent; + } + if (!functionNode) continue; + let candidate: ts.Node | undefined = functionNode.parent; + while (candidate && !ts.isCallExpression(candidate)) { + candidate = candidate.parent; + } + if (!candidate) continue; + const callExpression = candidate as ts.CallExpression; + const callKind = detectCallKind(callExpression, checker); + if (callKind?.kind === "builder" || callKind?.kind === "array-map") { + return callKind.kind; + } + } + return undefined; + }; + + const isRootOpaqueParameter = (symbol: ts.Symbol | undefined): boolean => + getOpaqueParameterCallKind(symbol) !== undefined; + + const isImplicitOpaqueRefExpression = ( + expr: ts.Expression, + ): boolean => { + const root = findRootIdentifier(expr); + if (!root) return false; + const symbol = checker.getSymbolAtLocation(root); + return isRootOpaqueParameter(symbol); + }; + + if (ts.isIdentifier(expression)) { + const symbol = checker.getSymbolAtLocation(expression); + if (isSymbolIgnored(symbol)) { + return emptyAnalysis(); + } + const type = checker.getTypeAtLocation(expression); + const parameterCallKind = getOpaqueParameterCallKind(symbol); + if (parameterCallKind === "array-map") { + const node = recordDataFlow(expression, scope, null, true); // Explicit: parameter is a dependency + return { + containsOpaqueRef: true, + requiresRewrite: false, // Map parameters themselves don't need wrapping + dataFlows: [expression], + localNodes: [node], + }; + } + if (isOpaqueRefType(type, checker)) { + const node = recordDataFlow(expression, scope, null, true); // Explicit: direct OpaqueRef + return { + containsOpaqueRef: true, + requiresRewrite: false, + dataFlows: [expression], + localNodes: [node], + }; + } + if (symbolDeclaresCommonToolsDefault(symbol, checker)) { + const node = recordDataFlow(expression, scope, null, true); // Explicit: CommonTools default + return { + containsOpaqueRef: true, + requiresRewrite: false, + dataFlows: [expression], + localNodes: [node], + }; + } + return emptyAnalysis(); + } + + if (ts.isPropertyAccessExpression(expression)) { + const target = analyzeExpression(expression.expression, scope, context); + const propertyType = checker.getTypeAtLocation(expression); + + if (isOpaqueRefType(propertyType, checker)) { + if (originatesFromIgnored(expression.expression)) { + return emptyAnalysis(); + } + const parentId = + findParentNodeId(target.localNodes, expression.expression) ?? + null; + const node = recordDataFlow(expression, scope, parentId, true); // Explicit: OpaqueRef property + + // Special case: property access on map callback parameters should be treated as OpaqueRef + // but not require rewrite (they're handled by the map transformation) + const isMapParameter = target.dataFlows.length === 1 && + target.dataFlows[0] && + ts.isIdentifier(target.dataFlows[0]) && + getOpaqueParameterCallKind( + checker.getSymbolAtLocation(target.dataFlows[0]), + ) === "array-map"; + + if (isMapParameter) { + return { + containsOpaqueRef: true, + requiresRewrite: false, // Don't wrap simple property access on map params + dataFlows: [expression], + localNodes: [node], + }; + } + + // If the target is a complex expression requiring rewrite (like ElementAccess), + // propagate its dataFlows. Otherwise, add this property access as a dataFlow. + if (target.requiresRewrite && target.dataFlows.length > 0) { + return { + containsOpaqueRef: true, + requiresRewrite: target.requiresRewrite, + dataFlows: target.dataFlows, + localNodes: [node], + }; + } else { + return { + containsOpaqueRef: true, + requiresRewrite: target.requiresRewrite, + dataFlows: [expression], + localNodes: [node], + }; + } + } + const propertySymbol = getMemberSymbol(expression, checker); + if (symbolDeclaresCommonToolsDefault(propertySymbol, checker)) { + if (originatesFromIgnored(expression.expression)) { + return emptyAnalysis(); + } + const parentId = + findParentNodeId(target.localNodes, expression.expression) ?? null; + const node = recordDataFlow(expression, scope, parentId, true); // Explicit: CommonTools property + return { + containsOpaqueRef: true, + requiresRewrite: true, + dataFlows: [expression], + localNodes: [node], + }; + } + if (isImplicitOpaqueRefExpression(expression)) { + if (originatesFromIgnored(expression.expression)) { + return emptyAnalysis(); + } + + // Check if this is a computed expression (property access on call result, etc.) + const isPropertyOnCall = ts.isCallExpression(expression.expression); + + const parentId = + findParentNodeId(target.localNodes, expression.expression) ?? null; + + // If the target is a complex expression requiring rewrite (ElementAccess or Call), + // propagate its dataFlows. Otherwise add this property access as a dataFlow. + if ( + isPropertyOnCall || + (target.requiresRewrite && target.dataFlows.length > 0) + ) { + // This is a computed expression - use the dependencies from the target + const node = recordDataFlow(expression, scope, parentId, false); + return { + containsOpaqueRef: true, + requiresRewrite: true, + dataFlows: target.dataFlows, + localNodes: [node], + }; + } + + // This is a direct property access on an OpaqueRef (like state.charms.length) + // It should be its own explicit dependency + const node = recordDataFlow(expression, scope, parentId, true); + return { + containsOpaqueRef: true, + requiresRewrite: true, + dataFlows: [expression], + localNodes: [node], + }; + } + return { + containsOpaqueRef: target.containsOpaqueRef, + requiresRewrite: target.requiresRewrite || target.containsOpaqueRef, + dataFlows: target.dataFlows, + localNodes: target.localNodes, + }; + } + + if (ts.isElementAccessExpression(expression)) { + const target = analyzeExpression(expression.expression, scope, context); + const argumentExpression = expression.argumentExpression; + const argument = argumentExpression && + ts.isExpression(argumentExpression) + ? analyzeExpression(argumentExpression, scope, context) + : emptyAnalysis(); + + const isStaticIndex = argumentExpression && + ts.isExpression(argumentExpression) && + (ts.isLiteralExpression(argumentExpression) || + ts.isNoSubstitutionTemplateLiteral(argumentExpression)); + + if (isStaticIndex) { + const result = mergeAnalyses(target, argument); + return result; + } + + if ( + isImplicitOpaqueRefExpression(expression.expression) && + target.dataFlows.length === 0 + ) { + if (originatesFromIgnored(expression.expression)) { + return emptyAnalysis(); + } + const parentId = + findParentNodeId(target.localNodes, expression.expression) ?? null; + // Element access on implicit opaque ref - this is likely an explicit dependency + const node = recordDataFlow(expression, scope, parentId, true); + return { + containsOpaqueRef: true, + requiresRewrite: true, + dataFlows: [expression], + localNodes: [node], + }; + } + return { + containsOpaqueRef: target.containsOpaqueRef || + argument.containsOpaqueRef, + requiresRewrite: true, + dataFlows: [...target.dataFlows, ...argument.dataFlows], + localNodes: [...target.localNodes, ...argument.localNodes], + }; + } + + if (ts.isParenthesizedExpression(expression)) { + return analyzeExpression(expression.expression, scope, context); + } + + if (ts.isAsExpression(expression)) { + return analyzeExpression(expression.expression, scope, context); + } + + if (ts.isTypeAssertionExpression(expression)) { + return analyzeExpression(expression.expression, scope, context); + } + + if (ts.isNonNullExpression(expression)) { + return analyzeExpression(expression.expression, scope, context); + } + + if (ts.isConditionalExpression(expression)) { + const condition = analyzeExpression(expression.condition, scope, context); + const whenTrue = analyzeExpression(expression.whenTrue, scope, context); + const whenFalse = analyzeExpression(expression.whenFalse, scope, context); + return { + containsOpaqueRef: condition.containsOpaqueRef || + whenTrue.containsOpaqueRef || + whenFalse.containsOpaqueRef, + requiresRewrite: true, + dataFlows: [ + ...condition.dataFlows, + ...whenTrue.dataFlows, + ...whenFalse.dataFlows, + ], + localNodes: [ + ...condition.localNodes, + ...whenTrue.localNodes, + ...whenFalse.localNodes, + ], + }; + } + + if (ts.isBinaryExpression(expression)) { + const left = analyzeExpression(expression.left, scope, context); + const right = analyzeExpression(expression.right, scope, context); + const merged = mergeAnalyses(left, right); + return { + ...merged, + requiresRewrite: left.containsOpaqueRef || right.containsOpaqueRef, + }; + } + + if ( + ts.isPrefixUnaryExpression(expression) || + ts.isPostfixUnaryExpression(expression) + ) { + const operand = analyzeExpression(expression.operand, scope, context); + return { + containsOpaqueRef: operand.containsOpaqueRef, + requiresRewrite: operand.containsOpaqueRef, + dataFlows: operand.dataFlows, + localNodes: operand.localNodes, + }; + } + + if (ts.isTemplateExpression(expression)) { + const parts = expression.templateSpans.map((span) => + analyzeExpression(span.expression, scope, context) + ); + const merged = mergeAnalyses(...parts); + return { + ...merged, + requiresRewrite: parts.some((part) => part.containsOpaqueRef), + }; + } + + if (ts.isTaggedTemplateExpression(expression)) { + if (ts.isTemplateExpression(expression.template)) { + return analyzeExpression(expression.template, scope, context); + } + return emptyAnalysis(); + } + + if (ts.isCallExpression(expression)) { + const callee = analyzeExpression(expression.expression, scope, context); + const analyses: InternalAnalysis[] = [callee]; + for (const arg of expression.arguments) { + if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) { + const parameterSymbols: ts.Symbol[] = []; + for (const parameter of arg.parameters) { + const symbol = checker.getSymbolAtLocation(parameter.name); + if (symbol) { + parameterSymbols.push(symbol); + } + } + const childScope = createScope(context, scope, parameterSymbols); + if (ts.isBlock(arg.body)) { + const blockAnalyses: InternalAnalysis[] = []; + for (const statement of arg.body.statements) { + if (ts.isReturnStatement(statement) && statement.expression) { + blockAnalyses.push( + analyzeExpression(statement.expression, childScope, context), + ); + } + } + analyses.push(mergeAnalyses(...blockAnalyses)); + } else { + analyses.push(analyzeExpression(arg.body, childScope, context)); + } + } else if (ts.isExpression(arg)) { + analyses.push(analyzeExpression(arg, scope, context)); + } + } + + const combined = mergeAnalyses(...analyses); + const callKind = detectCallKind(expression, checker); + const rewriteHint: RewriteHint | undefined = (() => { + if (callKind?.kind === "ifElse" && expression.arguments.length > 0) { + const predicate = expression.arguments[0]; + if (predicate) { + return { kind: "call-if-else", predicate }; + } + } + if (callKind?.kind === "builder") { + return { kind: "skip-call-rewrite", reason: "builder" }; + } + if (callKind?.kind === "array-map") { + return { kind: "skip-call-rewrite", reason: "array-map" }; + } + return undefined; + })(); + + if (callKind?.kind === "builder") { + return { + containsOpaqueRef: combined.containsOpaqueRef, + requiresRewrite: false, + dataFlows: combined.dataFlows, + localNodes: combined.localNodes, + rewriteHint, + }; + } + + return { + containsOpaqueRef: combined.containsOpaqueRef, + requiresRewrite: combined.containsOpaqueRef || + combined.requiresRewrite, + dataFlows: combined.dataFlows, + localNodes: combined.localNodes, + rewriteHint, + }; + } + + if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) { + const parameterSymbols: ts.Symbol[] = []; + for (const parameter of expression.parameters) { + const symbol = checker.getSymbolAtLocation(parameter.name); + if (symbol) parameterSymbols.push(symbol); + } + const childScope = createScope(context, scope, parameterSymbols); + if (ts.isBlock(expression.body)) { + const analyses: InternalAnalysis[] = []; + for (const statement of expression.body.statements) { + if (ts.isReturnStatement(statement) && statement.expression) { + analyses.push( + analyzeExpression(statement.expression, childScope, context), + ); + } + } + return mergeAnalyses(...analyses); + } + return analyzeExpression(expression.body, childScope, context); + } + + if (ts.isObjectLiteralExpression(expression)) { + const analyses = expression.properties.map((prop) => { + if ( + ts.isPropertyAssignment(prop) && ts.isExpression(prop.initializer) + ) { + return analyzeExpression(prop.initializer, scope, context); + } + if (ts.isShorthandPropertyAssignment(prop)) { + return analyzeExpression(prop.name, scope, context); + } + return emptyAnalysis(); + }); + return mergeAnalyses(...analyses); + } + + if (ts.isArrayLiteralExpression(expression)) { + const analyses = expression.elements.map((element) => { + if (ts.isExpression(element)) { + return analyzeExpression(element, scope, context); + } + return emptyAnalysis(); + }); + return mergeAnalyses(...analyses); + } + + const analyses: InternalAnalysis[] = []; + expression.forEachChild((child) => { + if (ts.isExpression(child)) { + analyses.push(analyzeExpression(child, scope, context)); + } + }); + if (analyses.length === 0) { + return emptyAnalysis(); + } + return mergeAnalyses(...analyses); + }; + + return (expression: ts.Expression) => { + const context: AnalyzerContext = { + nextNodeId: 0, + nextScopeId: 0, + collectedNodes: [], + scopes: new Map(), + }; + const rootScope = createScope(context, null, []); + const result = analyzeExpression(expression, rootScope, context); + const scopes = Array.from(context.scopes.values()).map(toDataFlowScope); + const { localNodes: _, ...resultWithoutNodes } = result; + return { + ...resultWithoutNodes, + graph: { + nodes: context.collectedNodes, + scopes, + rootScopeId: rootScope.id, + }, + }; + }; +} + +export function collectOpaqueRefs( + node: ts.Node, + checker: ts.TypeChecker, +): ts.Expression[] { + const refs: ts.Expression[] = []; + const processedNodes = new Set(); + + const visit = (n: ts.Node): void => { + if (ts.isJsxAttribute(n)) { + const name = n.name.getText(); + if (name && name.startsWith("on")) { + return; + } + } + + if (processedNodes.has(n)) return; + processedNodes.add(n); + + if (ts.isPropertyAccessExpression(n) && ts.isExpression(n)) { + if ( + ts.isIdentifier(n.expression) && + isFunctionParameter(n.expression, checker) + ) { + return; + } + + const type = checker.getTypeAtLocation(n); + if (isOpaqueRefType(type, checker)) { + refs.push(n); + return; + } + } + + if (ts.isIdentifier(n) && ts.isExpression(n)) { + const parent = n.parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === n) { + return; + } + + if (isFunctionParameter(n, checker)) { + return; + } + + const type = checker.getTypeAtLocation(n); + if (isOpaqueRefType(type, checker)) { + refs.push(n); + } + } + + ts.forEachChild(n, visit); + }; + + visit(node); + return refs; +} +const findParentNodeId = ( + nodes: DataFlowNode[], + target: ts.Expression, +): number | null => { + for (let index = nodes.length - 1; index >= 0; index--) { + const node = nodes[index]; + if (node && node.expression === target) { + return node.id; + } + } + return null; +}; diff --git a/packages/ts-transformers/src/opaque-ref/normalize.ts b/packages/ts-transformers/src/opaque-ref/normalize.ts new file mode 100644 index 000000000..9abb04e42 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/normalize.ts @@ -0,0 +1,187 @@ +import ts from "typescript"; + +import type { DataFlowGraph, DataFlowNode } from "./dataflow.ts"; + +export interface NormalizedDataFlow { + readonly canonicalKey: string; + readonly expression: ts.Expression; + readonly occurrences: readonly DataFlowNode[]; + readonly scopeId: number; +} + +export interface NormalizedDataFlowSet { + readonly all: readonly NormalizedDataFlow[]; + readonly byCanonicalKey: ReadonlyMap; +} + +export function normalizeDataFlows( + graph: DataFlowGraph, + requestedDataFlows?: ts.Expression[], +): NormalizedDataFlowSet { + const nodesById = new Map(); + for (const node of graph.nodes) nodesById.set(node.id, node); + + // If specific dataFlows were requested, only process nodes corresponding to those expressions + // This prevents suppressing nodes that are explicitly needed as dependencies + let nodesToProcess = graph.nodes; + if (requestedDataFlows && requestedDataFlows.length > 0) { + const requestedTexts = new Set( + requestedDataFlows.map((expr) => expr.getText(expr.getSourceFile())), + ); + nodesToProcess = graph.nodes.filter((node) => + requestedTexts.has( + node.expression.getText(node.expression.getSourceFile()), + ) + ); + } + + const grouped = new Map(); + const nodeToGroup = new Map(); + + const normalizeExpression = (node: DataFlowNode): ts.Expression => { + let current: ts.Expression = node.expression; + + // Only normalize away truly meaningless wrappers that don't change semantics + while (true) { + // Remove parentheses - purely syntactic, no semantic difference + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + + // Remove type assertions - don't affect runtime behavior + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + + // Remove non-null assertions - don't affect runtime behavior + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + + // Special case: for method calls like obj.method(), we need to normalize + // back to the object so the transformation can wrap it properly + // e.g., state.user.name.toUpperCase() -> state.user.name + if (ts.isCallExpression(current)) { + const callee = current.expression; + if (ts.isPropertyAccessExpression(callee)) { + // This is a method call - normalize to the object + current = callee.expression; + continue; + } + } + + // Also handle property access when it's being called as a method + // e.g., when we see state.user.name.toUpperCase (without the call), + // but it's the callee of a call expression + if (ts.isPropertyAccessExpression(current)) { + if ( + current.parent && + ts.isCallExpression(current.parent) && + current.parent.expression === current + ) { + // This property is being called as a method + current = current.expression; + continue; + } + } + + // That's it! Keep all other meaningful distinctions: + // - state.items vs state.items.length (different reactive dependencies) + // - array[0] vs array (different values) + break; + } + + return current; + }; + + for (const node of nodesToProcess) { + const expression = normalizeExpression(node); + const sourceFile = expression.getSourceFile(); + const key = `${node.scopeId}:${expression.getText(sourceFile)}`; + let group = grouped.get(key); + if (!group) { + group = { + expression, + nodes: [], + scopeId: node.scopeId, + }; + grouped.set(key, group); + } + group.nodes.push(node); + nodeToGroup.set(node.id, key); + } + + const suppressed = new Set(); + + // Parent suppression: suppress parents that have more specific children + // BUT: If we're working with explicitly requested dataFlows, don't suppress any of them + // They were all explicitly requested as dependencies + if (!requestedDataFlows || requestedDataFlows.length === 0) { + for (const [canonicalKey, group] of grouped.entries()) { + // Check if any node in this group has an explicit child + // If so, this parent should be suppressed in favor of the more specific child + for (const node of group.nodes) { + let hasExplicitChild = false; + + // Check all nodes to see if any child is explicit + for (const potentialChild of graph.nodes) { + if ( + potentialChild.parentId === node.id && potentialChild.isExplicit + ) { + hasExplicitChild = true; + break; + } + } + + if (hasExplicitChild) { + suppressed.add(canonicalKey); + break; + } + } + } + } + + const filtered = Array.from(grouped.entries()) + .filter(([canonicalKey]) => !suppressed.has(canonicalKey)); + + const all: NormalizedDataFlow[] = filtered.map(([canonicalKey, value]) => ({ + canonicalKey, + expression: value.expression, + occurrences: value.nodes, + scopeId: value.scopeId, + })).sort((a, b) => { + const aId = a.occurrences[0]?.id ?? -1; + const bId = b.occurrences[0]?.id ?? -1; + return aId - bId; + }); + + return { + all, + byCanonicalKey: new Map(all.map((dependency) => [ + dependency.canonicalKey, + dependency, + ])), + }; +} + +const isWithin = (outer: ts.Node, inner: ts.Node): boolean => { + return inner.pos >= outer.pos && inner.end <= outer.end; +}; + +export function selectDataFlowsWithin( + set: NormalizedDataFlowSet, + node: ts.Node, +): NormalizedDataFlow[] { + return set.all.filter((dataFlow) => + dataFlow.occurrences.some((occurrence) => + isWithin(node, occurrence.expression) + ) + ); +} diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/binary-expression.ts b/packages/ts-transformers/src/opaque-ref/rewrite/binary-expression.ts new file mode 100644 index 000000000..8110d9223 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/binary-expression.ts @@ -0,0 +1,35 @@ +import ts from "typescript"; + +import type { OpaqueRefHelperName } from "../transforms.ts"; +import type { Emitter, EmitterResult } from "./types.ts"; +import { createBindingPlan } from "./bindings.ts"; +import { + createDeriveCallForExpression, + filterRelevantDataFlows, +} from "./helpers.ts"; + +export const emitBinaryExpression: Emitter = ({ + expression, + dataFlows, + analysis, + context, +}) => { + if (!ts.isBinaryExpression(expression)) return undefined; + if (dataFlows.all.length === 0) return undefined; + + const relevantDataFlows = filterRelevantDataFlows( + dataFlows.all, + analysis, + context, + ); + if (relevantDataFlows.length === 0) return undefined; + + const plan = createBindingPlan(relevantDataFlows); + const rewritten = createDeriveCallForExpression(expression, plan, context); + if (rewritten === expression) return undefined; + + return { + expression: rewritten, + helpers: new Set(["derive"]), + }; +}; diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/bindings.ts b/packages/ts-transformers/src/opaque-ref/rewrite/bindings.ts new file mode 100644 index 000000000..8cd16805c --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/bindings.ts @@ -0,0 +1,95 @@ +import ts from "typescript"; + +import type { NormalizedDataFlow } from "../normalize.ts"; + +export interface BindingPlanEntry { + readonly dataFlow: NormalizedDataFlow; + readonly propertyName: string; + readonly paramName: string; +} + +export interface BindingPlan { + readonly entries: readonly BindingPlanEntry[]; + readonly usesObjectBinding: boolean; +} + +function deriveBaseName( + expression: ts.Expression, + index: number, +): string { + if (ts.isIdentifier(expression)) { + return expression.text; + } + if (ts.isPropertyAccessExpression(expression)) { + return expression.getText().replace(/\./g, "_"); + } + return `ref${index + 1}`; +} + +interface UniqueNameOptions { + readonly trimLeadingUnderscores?: boolean; +} + +function createUniqueIdentifier( + candidate: string, + fallback: string, + used: Set, + options: UniqueNameOptions = {}, +): string { + let base = candidate.replace(/[^A-Za-z0-9_]/g, "_"); + if (options.trimLeadingUnderscores) { + base = base.replace(/^_+/, ""); + } + if (base.length === 0) { + base = fallback; + } + if (!/^[A-Za-z_]/.test(base.charAt(0))) { + base = fallback; + } + + let name = base; + let suffix = 1; + while (used.has(name)) { + name = `${base}_${suffix++}`; + } + used.add(name); + return name; +} + +export function createBindingPlan( + dataFlows: readonly NormalizedDataFlow[], +): BindingPlan { + const usedPropertyNames = new Set(); + const usedParamNames = new Set(); + const entries: BindingPlanEntry[] = []; + + dataFlows.forEach((dataFlow, index) => { + const base = deriveBaseName(dataFlow.expression, index); + const fallback = `ref${index + 1}`; + const propertyName = createUniqueIdentifier( + base, + fallback, + usedPropertyNames, + { trimLeadingUnderscores: true }, + ); + + const paramName = ts.isIdentifier(dataFlow.expression) + ? createUniqueIdentifier( + dataFlow.expression.text, + `_v${index + 1}`, + usedParamNames, + ) + : createUniqueIdentifier( + `_v${index + 1}`, + `_v${index + 1}`, + usedParamNames, + ); + + entries.push({ dataFlow, propertyName, paramName }); + }); + + return { + entries, + usesObjectBinding: entries.length > 1, + }; +} diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/call-expression.ts b/packages/ts-transformers/src/opaque-ref/rewrite/call-expression.ts new file mode 100644 index 000000000..a9b0ad635 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/call-expression.ts @@ -0,0 +1,117 @@ +import ts from "typescript"; + +import type { OpaqueRefHelperName } from "../transforms.ts"; +import type { Emitter } from "./types.ts"; +import { createBindingPlan } from "./bindings.ts"; +import { + createDeriveCallForExpression, + filterRelevantDataFlows, +} from "./helpers.ts"; +import { selectDataFlowsWithin } from "../normalize.ts"; + +export const emitCallExpression: Emitter = ({ + expression, + dataFlows, + context, + analysis, +}) => { + if (!ts.isCallExpression(expression)) return undefined; + if (dataFlows.all.length === 0) return undefined; + + const hint = analysis.rewriteHint; + + if (hint?.kind === "skip-call-rewrite") { + if (hint.reason === "array-map") { + const rewritten = context.rewriteChildren(expression); + if (rewritten !== expression) { + return { + expression: rewritten, + helpers: new Set(), + }; + } + return undefined; + } + return undefined; + } + + if (hint?.kind === "call-if-else") { + const helpers = new Set(); + + const predicateDataFlows = selectDataFlowsWithin( + dataFlows, + hint.predicate, + ); + const relevantPredicateDataFlows = filterRelevantDataFlows( + predicateDataFlows, + analysis, + context, + ); + + let rewrittenPredicate: ts.Expression = hint.predicate; + if (relevantPredicateDataFlows.length > 0) { + const plan = createBindingPlan(relevantPredicateDataFlows); + const derivedPredicate = createDeriveCallForExpression( + hint.predicate, + plan, + context, + ); + if (derivedPredicate !== hint.predicate) { + rewrittenPredicate = derivedPredicate; + helpers.add("derive"); + } + } else { + const child = context.rewriteChildren(hint.predicate); + if (child !== hint.predicate) { + rewrittenPredicate = child; + } + } + + const rewrittenCallee = context.rewriteChildren(expression.expression); + const rewrittenArgs: ts.Expression[] = []; + let changed = rewrittenCallee !== expression.expression; + + expression.arguments.forEach((argument, index) => { + let updated: ts.Expression = argument; + if (index === 0) { + updated = rewrittenPredicate; + } else { + const child = context.rewriteChildren(argument); + if (child !== argument) { + updated = child; + } + } + if (updated !== argument) changed = true; + rewrittenArgs.push(updated); + }); + + if (!changed) return undefined; + + const updatedCall = context.factory.updateCallExpression( + expression, + rewrittenCallee, + expression.typeArguments, + rewrittenArgs, + ); + + return { + expression: updatedCall, + helpers, + }; + } + + const relevantDataFlows = filterRelevantDataFlows( + dataFlows.all, + analysis, + context, + ); + if (relevantDataFlows.length === 0) return undefined; + + const plan = createBindingPlan(relevantDataFlows); + const rewritten = createDeriveCallForExpression(expression, plan, context); + if (rewritten === expression) return undefined; + + return { + expression: rewritten, + helpers: new Set(["derive"]), + }; +}; diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/conditional-expression.ts b/packages/ts-transformers/src/opaque-ref/rewrite/conditional-expression.ts new file mode 100644 index 000000000..d630dd1d3 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/conditional-expression.ts @@ -0,0 +1,119 @@ +import ts from "typescript"; + +import type { OpaqueRefHelperName } from "../transforms.ts"; +import { createIfElseCall } from "../transforms.ts"; +import { selectDataFlowsWithin } from "../normalize.ts"; +import { isSimpleOpaqueRefAccess } from "../types.ts"; +import type { Emitter } from "./types.ts"; +import { createBindingPlan } from "./bindings.ts"; +import { + createDeriveCallForExpression, + filterRelevantDataFlows, +} from "./helpers.ts"; + +export const emitConditionalExpression: Emitter = ({ + expression, + dataFlows, + analysis, + context, +}) => { + if (!ts.isConditionalExpression(expression)) return undefined; + if (dataFlows.all.length === 0) return undefined; + + const predicateDataFlows = selectDataFlowsWithin( + dataFlows, + expression.condition, + ); + const shouldDerivePredicate = predicateDataFlows.length > 0 && + !isSimpleOpaqueRefAccess(expression.condition, context.checker); + + const helpers = new Set(["ifElse"]); + let predicate: ts.Expression = expression.condition; + let whenTrue: ts.Expression = expression.whenTrue; + let whenFalse: ts.Expression = expression.whenFalse; + + if (shouldDerivePredicate) { + const plan = createBindingPlan(predicateDataFlows); + const derivedPredicate = createDeriveCallForExpression( + expression.condition, + plan, + context, + ); + if (derivedPredicate !== expression.condition) { + predicate = derivedPredicate; + helpers.add("derive"); + } + } + + const whenTrueDataFlows = filterRelevantDataFlows( + selectDataFlowsWithin(dataFlows, expression.whenTrue), + analysis, + context, + ); + + // Check if the whenTrue branch actually requires rewriting + const whenTrueAnalysis = context.analyze(expression.whenTrue); + + if (whenTrueDataFlows.length > 0 && whenTrueAnalysis.requiresRewrite) { + const plan = createBindingPlan(whenTrueDataFlows); + const derivedWhenTrue = createDeriveCallForExpression( + expression.whenTrue, + plan, + context, + ); + if (derivedWhenTrue !== expression.whenTrue) { + whenTrue = derivedWhenTrue; + helpers.add("derive"); + } else { + const rewritten = context.rewriteChildren(expression.whenTrue); + if (rewritten !== expression.whenTrue) whenTrue = rewritten; + } + } else { + const rewritten = context.rewriteChildren(expression.whenTrue); + if (rewritten !== expression.whenTrue) whenTrue = rewritten; + } + + const whenFalseDataFlows = filterRelevantDataFlows( + selectDataFlowsWithin(dataFlows, expression.whenFalse), + analysis, + context, + ); + + // Check if the whenFalse branch actually requires rewriting + const whenFalseAnalysis = context.analyze(expression.whenFalse); + + if (whenFalseDataFlows.length > 0 && whenFalseAnalysis.requiresRewrite) { + const plan = createBindingPlan(whenFalseDataFlows); + const derivedWhenFalse = createDeriveCallForExpression( + expression.whenFalse, + plan, + context, + ); + if (derivedWhenFalse !== expression.whenFalse) { + whenFalse = derivedWhenFalse; + helpers.add("derive"); + } else { + const rewritten = context.rewriteChildren(expression.whenFalse); + if (rewritten !== expression.whenFalse) whenFalse = rewritten; + } + } else { + const rewritten = context.rewriteChildren(expression.whenFalse); + if (rewritten !== expression.whenFalse) whenFalse = rewritten; + } + + const rewritten = createIfElseCall( + expression, + context.factory, + context.sourceFile, + { + predicate, + whenTrue, + whenFalse, + }, + ); + + return { + expression: rewritten, + helpers, + }; +}; diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/container-expression.ts b/packages/ts-transformers/src/opaque-ref/rewrite/container-expression.ts new file mode 100644 index 000000000..1a7b01517 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/container-expression.ts @@ -0,0 +1,25 @@ +import ts from "typescript"; + +import type { Emitter } from "./types.ts"; + +const isContainerExpression = (expression: ts.Expression): boolean => { + return ts.isObjectLiteralExpression(expression) || + ts.isArrayLiteralExpression(expression) || + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isTypeAssertionExpression(expression) || + ts.isNonNullExpression(expression); +}; + +export const emitContainerExpression: Emitter = ({ + expression, + context, +}) => { + if (!isContainerExpression(expression)) return undefined; + const rewritten = context.rewriteChildren(expression); + if (rewritten === expression) return undefined; + return { + expression: rewritten, + helpers: new Set(), + }; +}; diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/element-access-expression.ts b/packages/ts-transformers/src/opaque-ref/rewrite/element-access-expression.ts new file mode 100644 index 000000000..07e26b0cb --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/element-access-expression.ts @@ -0,0 +1,48 @@ +import ts from "typescript"; + +import type { OpaqueRefHelperName } from "../transforms.ts"; +import type { Emitter } from "./types.ts"; +import { createBindingPlan } from "./bindings.ts"; +import { + createDeriveCallForExpression, + filterRelevantDataFlows, +} from "./helpers.ts"; + +export const emitElementAccessExpression: Emitter = ({ + expression, + dataFlows, + analysis, + context, +}) => { + if (!ts.isElementAccessExpression(expression)) return undefined; + if (dataFlows.all.length === 0) return undefined; + + const relevantDataFlows = filterRelevantDataFlows( + dataFlows.all, + analysis, + context, + ); + + if (relevantDataFlows.length === 0) return undefined; + + // Check if this is a static index access + const argumentExpression = expression.argumentExpression; + const isStaticIndex = argumentExpression && + ts.isExpression(argumentExpression) && + (ts.isLiteralExpression(argumentExpression) || + ts.isNoSubstitutionTemplateLiteral(argumentExpression)); + + // If it's a static index and doesn't require rewrite, don't wrap it + if (isStaticIndex && !analysis.requiresRewrite) { + return undefined; + } + + const plan = createBindingPlan(relevantDataFlows); + const rewritten = createDeriveCallForExpression(expression, plan, context); + if (rewritten === expression) return undefined; + + return { + expression: rewritten, + helpers: new Set(["derive"]), + }; +}; diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/event-handlers.ts b/packages/ts-transformers/src/opaque-ref/rewrite/event-handlers.ts new file mode 100644 index 000000000..3d838860b --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/event-handlers.ts @@ -0,0 +1,12 @@ +import ts from "typescript"; + +export function isSafeEventHandlerCall(node: ts.CallExpression): boolean { + const expression = node.expression; + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text.startsWith("on"); + } + if (ts.isIdentifier(expression)) { + return expression.text.startsWith("on"); + } + return false; +} diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/helpers.ts b/packages/ts-transformers/src/opaque-ref/rewrite/helpers.ts new file mode 100644 index 000000000..e4868aaa2 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/helpers.ts @@ -0,0 +1,289 @@ +import ts from "typescript"; + +import { createDeriveCall } from "../transforms.ts"; +import { detectCallKind } from "../call-kind.ts"; +import type { BindingPlan } from "./bindings.ts"; +import type { RewriteContext } from "./types.ts"; +import type { NormalizedDataFlow } from "../normalize.ts"; +import type { DataFlowAnalysis } from "../dataflow.ts"; +import { isFunctionParameter } from "../types.ts"; + +function originatesFromIgnoredParameter( + expression: ts.Expression, + scopeId: number, + analysis: DataFlowAnalysis, + checker: ts.TypeChecker, +): boolean { + const scope = analysis.graph.scopes.find((candidate) => + candidate.id === scopeId + ); + if (!scope) return false; + + const isIgnoredSymbol = (symbol: ts.Symbol | undefined): boolean => { + if (!symbol) return false; + const symbolName = symbol.getName(); + return scope.parameters.some((parameter) => { + if (parameter.symbol === symbol || parameter.name === symbolName) { + if ( + parameter.declaration && + getOpaqueCallKindForParameter(parameter.declaration, checker) + ) { + return false; + } + return true; + } + return false; + }); + }; + + const inner = (expr: ts.Expression): boolean => { + if (ts.isIdentifier(expr)) { + const symbol = checker.getSymbolAtLocation(expr); + return isIgnoredSymbol(symbol); + } + if ( + ts.isPropertyAccessExpression(expr) || ts.isElementAccessExpression(expr) + ) { + return inner(expr.expression); + } + if (ts.isCallExpression(expr)) { + return inner(expr.expression); + } + return false; + }; + + return inner(expression); +} + +function getOpaqueCallKindForParameter( + declaration: ts.ParameterDeclaration, + checker: ts.TypeChecker, +): "builder" | "array-map" | undefined { + let functionNode: ts.Node | undefined = declaration.parent; + while (functionNode && !ts.isFunctionLike(functionNode)) { + functionNode = functionNode.parent; + } + if (!functionNode) return undefined; + + let candidate: ts.Node | undefined = functionNode.parent; + while (candidate && !ts.isCallExpression(candidate)) { + candidate = candidate.parent; + } + if (!candidate) return undefined; + + const callKind = detectCallKind(candidate, checker); + if (callKind?.kind === "builder" || callKind?.kind === "array-map") { + return callKind.kind; + } + return undefined; +} + +function resolvesToParameterOfKind( + expression: ts.Expression, + checker: ts.TypeChecker, + kind: "array-map" | "builder", +): boolean { + let current: ts.Expression = expression; + let symbol: ts.Symbol | undefined; + let isRootIdentifierOnly = true; + const allowPropertyTraversal = kind === "array-map"; + while (true) { + if (ts.isIdentifier(current)) { + symbol = checker.getSymbolAtLocation(current); + break; + } + if ( + ts.isPropertyAccessExpression(current) || + ts.isElementAccessExpression(current) || + ts.isCallExpression(current) + ) { + if (!allowPropertyTraversal) { + isRootIdentifierOnly = false; + } + current = current.expression; + continue; + } + if ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + continue; + } + break; + } + + if (!symbol) return false; + const declarations = symbol.getDeclarations(); + if (!declarations) return false; + return declarations.some((declaration) => + ts.isParameter(declaration) && + getOpaqueCallKindForParameter(declaration, checker) === kind && + (kind === "array-map" || isRootIdentifierOnly) + ); +} + +function resolvesToMapParameter( + expression: ts.Expression, + checker: ts.TypeChecker, +): boolean { + return resolvesToParameterOfKind(expression, checker, "array-map"); +} + +function resolvesToBuilderParameter( + expression: ts.Expression, + checker: ts.TypeChecker, +): boolean { + return resolvesToParameterOfKind(expression, checker, "builder"); +} + +export function filterRelevantDataFlows( + dataFlows: readonly NormalizedDataFlow[], + analysis: DataFlowAnalysis, + context: RewriteContext, +): NormalizedDataFlow[] { + const isParameterExpression = (expression: ts.Expression): boolean => { + let current: ts.Expression = expression; + while (true) { + if (ts.isIdentifier(current)) { + return isFunctionParameter(current, context.checker); + } + if ( + ts.isPropertyAccessExpression(current) || + ts.isElementAccessExpression(current) || + ts.isCallExpression(current) + ) { + current = current.expression; + continue; + } + if ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + continue; + } + return false; + } + }; + + return dataFlows.filter((dataFlow) => { + if ( + originatesFromIgnoredParameter( + dataFlow.expression, + dataFlow.scopeId, + analysis, + context.checker, + ) + ) { + return false; + } + if (isParameterExpression(dataFlow.expression)) { + if (resolvesToMapParameter(dataFlow.expression, context.checker)) { + return true; + } + if (resolvesToBuilderParameter(dataFlow.expression, context.checker)) { + return false; + } + return false; + } + if (resolvesToBuilderParameter(dataFlow.expression, context.checker)) { + return false; + } + return true; + }); +} + +export function createDeriveCallForExpression( + expression: ts.Expression, + plan: BindingPlan, + context: RewriteContext, +): ts.Expression { + if (plan.entries.length === 0) return expression; + + // Don't wrap expressions that are already derive calls + if (ts.isCallExpression(expression)) { + const callKind = detectCallKind(expression, context.checker); + if (callKind?.kind === "derive") { + return expression; + } + } + + if (!plan.usesObjectBinding && plan.entries.length === 1) { + const [entry] = plan.entries; + if (entry && entry.dataFlow.expression === expression) { + return expression; + } + } + + const refs: ts.Expression[] = []; + const seen = new Set(); + const addRef = (expr: ts.Expression): void => { + if (seen.has(expr)) return; + seen.add(expr); + refs.push(expr); + }; + const normalizeForCanonical = (expr: ts.Expression): ts.Expression => { + let current: ts.Expression = expr; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if ( + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + continue; + } + if (ts.isCallExpression(current)) { + const callee = current.expression; + if ( + ts.isPropertyAccessExpression(callee) || + ts.isElementAccessExpression(callee) + ) { + current = callee.expression; + continue; + } + } + if ( + ts.isPropertyAccessExpression(current) && + current.parent && + ts.isCallExpression(current.parent) && + current.parent.expression === current + ) { + current = current.expression; + continue; + } + break; + } + return current; + }; + for (const entry of plan.entries) { + const canonical = entry.dataFlow.expression; + const canonicalText = canonical.getText(canonical.getSourceFile()); + addRef(canonical); + for (const occurrence of entry.dataFlow.occurrences) { + const normalized = normalizeForCanonical(occurrence.expression); + if ( + normalized.getText(normalized.getSourceFile()) === canonicalText + ) { + addRef(normalized); + } + } + } + + const deriveCall = createDeriveCall(expression, refs, { + factory: context.factory, + sourceFile: context.sourceFile, + context: context.transformation, + }); + + return deriveCall ?? expression; +} diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/import-resolver.ts b/packages/ts-transformers/src/opaque-ref/rewrite/import-resolver.ts new file mode 100644 index 000000000..176c79b58 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/import-resolver.ts @@ -0,0 +1,51 @@ +import ts from "typescript"; + +/** + * Finds an existing import identifier for a given name from a module + */ +export function findImportedIdentifier( + sourceFile: ts.SourceFile, + importName: string, + moduleName: string = "commontools", +): ts.Identifier | undefined { + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) { + const moduleSpecifier = statement.moduleSpecifier; + if ( + ts.isStringLiteral(moduleSpecifier) && + moduleSpecifier.text === moduleName + ) { + const namedBindings = statement.importClause?.namedBindings; + if (namedBindings && ts.isNamedImports(namedBindings)) { + for (const element of namedBindings.elements) { + const name = element.propertyName?.text || element.name.text; + if (name === importName) { + return element.name; // This is the local identifier + } + } + } + } + } + } + return undefined; +} + +/** + * Gets or creates an identifier for a commontools helper function + */ +export function getHelperIdentifier( + factory: ts.NodeFactory, + sourceFile: ts.SourceFile, + helperName: string, +): ts.Identifier { + // First try to find if it's already imported + const existing = findImportedIdentifier(sourceFile, helperName); + if (existing) { + // Return the existing identifier (reuse the same node) + return existing; + } + + // If not imported, create a bare identifier + // (The import manager will handle adding the import) + return factory.createIdentifier(helperName); +} diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/prefix-unary-expression.ts b/packages/ts-transformers/src/opaque-ref/rewrite/prefix-unary-expression.ts new file mode 100644 index 000000000..baac07241 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/prefix-unary-expression.ts @@ -0,0 +1,55 @@ +import ts from "typescript"; + +import type { OpaqueRefHelperName } from "../transforms.ts"; +import type { Emitter } from "./types.ts"; +import { createBindingPlan } from "./bindings.ts"; +import { + createDeriveCallForExpression, + filterRelevantDataFlows, +} from "./helpers.ts"; +import { normalizeDataFlows } from "../normalize.ts"; + +export const emitPrefixUnaryExpression: Emitter = ({ + expression, + dataFlows, + context, + analysis, +}) => { + if (!ts.isPrefixUnaryExpression(expression)) return undefined; + if (expression.operator !== ts.SyntaxKind.ExclamationToken) { + return undefined; + } + if (dataFlows.all.length === 0) return undefined; + + let relevantDataFlows = filterRelevantDataFlows( + dataFlows.all, + analysis, + context, + ); + + if (relevantDataFlows.length === 0 && analysis.containsOpaqueRef) { + const fallbackAnalysis = context.analyze(expression.operand); + const fallbackDataFlows = normalizeDataFlows( + fallbackAnalysis.graph, + fallbackAnalysis.dataFlows, + ); + relevantDataFlows = filterRelevantDataFlows( + fallbackDataFlows.all, + fallbackAnalysis, + context, + ); + + if (relevantDataFlows.length === 0) return undefined; + } else if (relevantDataFlows.length === 0) { + return undefined; + } + + const plan = createBindingPlan(relevantDataFlows); + const rewritten = createDeriveCallForExpression(expression, plan, context); + if (rewritten === expression) return undefined; + + return { + expression: rewritten, + helpers: new Set(["derive"]), + }; +}; diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/property-access.ts b/packages/ts-transformers/src/opaque-ref/rewrite/property-access.ts new file mode 100644 index 000000000..a17f506c1 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/property-access.ts @@ -0,0 +1,44 @@ +import ts from "typescript"; + +import type { OpaqueRefHelperName } from "../transforms.ts"; +import type { EmitterParams, EmitterResult } from "./types.ts"; +import { createBindingPlan } from "./bindings.ts"; +import { + createDeriveCallForExpression, + filterRelevantDataFlows, +} from "./helpers.ts"; +import { isSafeEventHandlerCall } from "./event-handlers.ts"; + +export function emitPropertyAccess( + params: EmitterParams, +): EmitterResult | undefined { + const { expression, dataFlows, context } = params; + if (!ts.isPropertyAccessExpression(expression)) return undefined; + + if (dataFlows.all.length === 0) return undefined; + if ( + expression.parent && + ts.isCallExpression(expression.parent) && + expression.parent.expression === expression + ) { + if (!isSafeEventHandlerCall(expression.parent)) return undefined; + } + + const relevantDataFlows = filterRelevantDataFlows( + dataFlows.all, + params.analysis, + context, + ); + if (relevantDataFlows.length === 0) { + return undefined; + } + + const plan = createBindingPlan(relevantDataFlows); + const rewritten = createDeriveCallForExpression(expression, plan, context); + if (rewritten === expression) return undefined; + + return { + expression: rewritten, + helpers: new Set(["derive"]), + }; +} diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/rewrite.ts b/packages/ts-transformers/src/opaque-ref/rewrite/rewrite.ts new file mode 100644 index 000000000..cf8ab9f43 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/rewrite.ts @@ -0,0 +1,92 @@ +import ts from "typescript"; + +import { normalizeDataFlows } from "../normalize.ts"; +import type { + Emitter, + EmitterContext, + EmitterResult, + RewriteParams, +} from "./types.ts"; +import { emitPropertyAccess } from "./property-access.ts"; +import { emitBinaryExpression } from "./binary-expression.ts"; +import { emitCallExpression } from "./call-expression.ts"; +import { emitTemplateExpression } from "./template-expression.ts"; +import { emitConditionalExpression } from "./conditional-expression.ts"; +import { emitElementAccessExpression } from "./element-access-expression.ts"; +import { emitContainerExpression } from "./container-expression.ts"; +import { emitPrefixUnaryExpression } from "./prefix-unary-expression.ts"; +import type { OpaqueRefHelperName } from "../transforms.ts"; + +const EMITTERS: readonly Emitter[] = [ + emitPropertyAccess, + emitBinaryExpression, + emitCallExpression, + emitTemplateExpression, + emitConditionalExpression, + emitElementAccessExpression, + emitPrefixUnaryExpression, + emitContainerExpression, +]; + +function rewriteChildExpressions( + node: ts.Expression, + context: RewriteParams["context"], + helpers: Set, +): ts.Expression { + const visitor = (child: ts.Node): ts.Node => { + if (ts.isExpression(child)) { + const analysis = context.analyze(child); + if (analysis.containsOpaqueRef && analysis.requiresRewrite) { + const result = rewriteExpression({ + expression: child, + analysis, + context, + }); + if (result) { + for (const helper of result.helpers) helpers.add(helper); + return result.expression; + } + } + } + return ts.visitEachChild(child, visitor, context.transformation); + }; + + return ts.visitEachChild( + node, + visitor, + context.transformation, + ) as ts.Expression; +} + +export function rewriteExpression( + params: RewriteParams, +): EmitterResult | undefined { + const dataFlows = normalizeDataFlows( + params.analysis.graph, + params.analysis.dataFlows, + ); + + const helperSet = new Set(); + const emitterContext: EmitterContext = { + ...params.context, + rewriteChildren(node: ts.Expression): ts.Expression { + return rewriteChildExpressions(node, params.context, helperSet); + }, + }; + for (const emitter of EMITTERS) { + const result = emitter({ + expression: params.expression, + dataFlows, + analysis: params.analysis, + context: emitterContext, + }); + if (result) { + for (const helper of result.helpers) helperSet.add(helper); + return { + expression: result.expression, + helpers: new Set(helperSet), + }; + } + } + return undefined; +} diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/template-expression.ts b/packages/ts-transformers/src/opaque-ref/rewrite/template-expression.ts new file mode 100644 index 000000000..10f921d07 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/template-expression.ts @@ -0,0 +1,35 @@ +import ts from "typescript"; + +import type { OpaqueRefHelperName } from "../transforms.ts"; +import type { Emitter } from "./types.ts"; +import { createBindingPlan } from "./bindings.ts"; +import { + createDeriveCallForExpression, + filterRelevantDataFlows, +} from "./helpers.ts"; + +export const emitTemplateExpression: Emitter = ({ + expression, + dataFlows, + analysis, + context, +}) => { + if (!ts.isTemplateExpression(expression)) return undefined; + if (dataFlows.all.length === 0) return undefined; + + const relevantDataFlows = filterRelevantDataFlows( + dataFlows.all, + analysis, + context, + ); + if (relevantDataFlows.length === 0) return undefined; + + const plan = createBindingPlan(relevantDataFlows); + const rewritten = createDeriveCallForExpression(expression, plan, context); + if (rewritten === expression) return undefined; + + return { + expression: rewritten, + helpers: new Set(["derive"]), + }; +}; diff --git a/packages/ts-transformers/src/opaque-ref/rewrite/types.ts b/packages/ts-transformers/src/opaque-ref/rewrite/types.ts new file mode 100644 index 000000000..f2c55cd8b --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rewrite/types.ts @@ -0,0 +1,37 @@ +import ts from "typescript"; + +import type { DataFlowAnalysis } from "../dataflow.ts"; +import type { NormalizedDataFlowSet } from "../normalize.ts"; +import type { OpaqueRefHelperName } from "../transforms.ts"; + +export interface RewriteContext { + readonly factory: ts.NodeFactory; + readonly checker: ts.TypeChecker; + readonly sourceFile: ts.SourceFile; + readonly transformation: ts.TransformationContext; + readonly analyze: (expression: ts.Expression) => DataFlowAnalysis; +} + +export interface EmitterContext extends RewriteContext { + rewriteChildren(node: ts.Expression): ts.Expression; +} + +export interface RewriteParams { + readonly expression: ts.Expression; + readonly analysis: DataFlowAnalysis; + readonly context: RewriteContext; +} + +export interface EmitterParams { + readonly expression: ts.Expression; + readonly dataFlows: NormalizedDataFlowSet; + readonly analysis: DataFlowAnalysis; + readonly context: EmitterContext; +} + +export interface EmitterResult { + readonly expression: ts.Expression; + readonly helpers: ReadonlySet; +} + +export type Emitter = (params: EmitterParams) => EmitterResult | undefined; diff --git a/packages/ts-transformers/src/opaque-ref/rules/jsx-expression.ts b/packages/ts-transformers/src/opaque-ref/rules/jsx-expression.ts new file mode 100644 index 000000000..d613781db --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rules/jsx-expression.ts @@ -0,0 +1,126 @@ +import ts from "typescript"; + +import type { TransformationContext } from "../../core/context.ts"; +import { isEventHandlerJsxAttribute } from "../types.ts"; +import type { OpaqueRefHelperName } from "../transforms.ts"; +import { createDataFlowAnalyzer } from "../dataflow.ts"; +import { rewriteExpression } from "../rewrite/rewrite.ts"; +import { detectCallKind } from "../call-kind.ts"; + +export interface OpaqueRefRule { + readonly name: string; + transform( + sourceFile: ts.SourceFile, + context: TransformationContext, + transformation: ts.TransformationContext, + ): ts.SourceFile; +} + +function isInsideDeriveCallback( + node: ts.Node, + checker: ts.TypeChecker, +): boolean { + let current: ts.Node | undefined = node.parent; + + while (current) { + // Check if we're inside an arrow function or function expression + if ( + ts.isArrowFunction(current) || + ts.isFunctionExpression(current) + ) { + // Check if this function is an argument to a derive call + const functionParent = current.parent; + if ( + functionParent && + ts.isCallExpression(functionParent) && + functionParent.arguments.includes(current as ts.Expression) + ) { + const callKind = detectCallKind(functionParent, checker); + if (callKind?.kind === "derive") { + return true; + } + } + } + current = current.parent; + } + + return false; +} + +export function createJsxExpressionRule(): OpaqueRefRule { + return { + name: "jsx-expression", + transform( + sourceFile, + context, + transformation, + ): ts.SourceFile { + const checker = context.checker; + const analyze = createDataFlowAnalyzer(checker); + const helpers = new Set(); + + const visit: ts.Visitor = (node) => { + if (ts.isJsxExpression(node) && node.expression) { + if (isEventHandlerJsxAttribute(node)) { + return ts.visitEachChild(node, visit, transformation); + } + + // Skip if inside a derive callback + const insideDeriveCallback = isInsideDeriveCallback(node, checker); + if (insideDeriveCallback) { + return ts.visitEachChild(node, visit, transformation); + } + + const analysis = analyze(node.expression); + + // Skip if doesn't require rewriting + if (!analysis.requiresRewrite) { + return ts.visitEachChild(node, visit, transformation); + } + + if (context.options.mode === "error") { + context.reportDiagnostic({ + type: "opaque-ref:jsx-expression", + message: + "JSX expression with OpaqueRef computation should use derive", + node: node.expression, + }); + return node; + } + + const rewriteResult = rewriteExpression({ + expression: node.expression, + analysis, + context: { + factory: context.factory, + checker, + sourceFile, + transformation, + analyze, + }, + }); + + if (rewriteResult) { + for (const helper of rewriteResult.helpers) { + helpers.add(helper); + } + return context.factory.createJsxExpression( + node.dotDotDotToken, + rewriteResult.expression, + ); + } + } + + return ts.visitEachChild(node, visit, transformation); + }; + + const updated = ts.visitEachChild(sourceFile, visit, transformation); + + for (const helper of helpers) { + context.imports.request({ name: helper }); + } + + return updated; + }, + }; +} diff --git a/packages/ts-transformers/src/opaque-ref/rules/schema-injection.ts b/packages/ts-transformers/src/opaque-ref/rules/schema-injection.ts new file mode 100644 index 000000000..fa7fa8e21 --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/rules/schema-injection.ts @@ -0,0 +1,143 @@ +import ts from "typescript"; + +import type { TransformationContext } from "../../core/context.ts"; +import type { OpaqueRefRule } from "./jsx-expression.ts"; +import { detectCallKind } from "../call-kind.ts"; + +export function createSchemaInjectionRule(): OpaqueRefRule { + return { + name: "schema-injection", + transform(sourceFile, context, transformation) { + let requestedToSchema = false; + + const ensureToSchemaImport = (): void => { + if (requestedToSchema) return; + requestedToSchema = true; + context.imports.request({ name: "toSchema" }); + }; + + const visit = (node: ts.Node): ts.Node => { + if (!ts.isCallExpression(node)) { + return ts.visitEachChild(node, visit, transformation); + } + + const callKind = detectCallKind(node, context.checker); + + if (callKind?.kind === "builder" && callKind.builderName === "recipe") { + const typeArgs = node.typeArguments; + if (typeArgs && typeArgs.length >= 1) { + const factory = transformation.factory; + const schemaArgs = typeArgs.map((typeArg) => typeArg).map(( + typeArg, + ) => + factory.createCallExpression( + factory.createIdentifier("toSchema"), + [typeArg], + [], + ) + ); + + const argsArray = Array.from(node.arguments); + let remainingArgs = argsArray; + if ( + argsArray.length > 0 && + argsArray[0] && ts.isStringLiteral(argsArray[0]) + ) { + remainingArgs = argsArray.slice(1); + } + + ensureToSchemaImport(); + + const updated = factory.createCallExpression( + node.expression, + undefined, + [...schemaArgs, ...remainingArgs], + ); + + return ts.visitEachChild(updated, visit, transformation); + } + } + + if ( + callKind?.kind === "builder" && callKind.builderName === "handler" + ) { + const factory = transformation.factory; + + if (node.typeArguments && node.typeArguments.length >= 2) { + const eventType = node.typeArguments[0]; + const stateType = node.typeArguments[1]; + if (!eventType || !stateType) { + return ts.visitEachChild(node, visit, transformation); + } + const toSchemaEvent = factory.createCallExpression( + factory.createIdentifier("toSchema"), + [eventType], + [], + ); + const toSchemaState = factory.createCallExpression( + factory.createIdentifier("toSchema"), + [stateType], + [], + ); + + ensureToSchemaImport(); + + const updated = factory.createCallExpression( + node.expression, + undefined, + [toSchemaEvent, toSchemaState, ...node.arguments], + ); + + return ts.visitEachChild(updated, visit, transformation); + } + + if (node.arguments.length === 1) { + const handlerCandidate = node.arguments[0]; + if ( + handlerCandidate && + (ts.isFunctionExpression(handlerCandidate) || + ts.isArrowFunction(handlerCandidate)) + ) { + const handlerFn = handlerCandidate; + if (handlerFn.parameters.length >= 2) { + const eventParam = handlerFn.parameters[0]; + const stateParam = handlerFn.parameters[1]; + const eventType = eventParam?.type ?? + factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); + const stateType = stateParam?.type ?? + factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); + + if (eventParam || stateParam) { + const toSchemaEvent = factory.createCallExpression( + factory.createIdentifier("toSchema"), + [eventType], + [], + ); + const toSchemaState = factory.createCallExpression( + factory.createIdentifier("toSchema"), + [stateType], + [], + ); + + ensureToSchemaImport(); + + const updated = factory.createCallExpression( + node.expression, + undefined, + [toSchemaEvent, toSchemaState, handlerFn], + ); + + return ts.visitEachChild(updated, visit, transformation); + } + } + } + } + } + + return ts.visitEachChild(node, visit, transformation); + }; + + return ts.visitEachChild(sourceFile, visit, transformation); + }, + }; +} diff --git a/packages/ts-transformers/src/opaque-ref/transformer.ts b/packages/ts-transformers/src/opaque-ref/transformer.ts new file mode 100644 index 000000000..b0ec07d1d --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/transformer.ts @@ -0,0 +1,64 @@ +import ts from "typescript"; + +import { applyPendingImports, createImportManager } from "../core/imports.ts"; +import { + createTransformationContext, + type TransformationOptions, +} from "../core/context.ts"; +import { createJsxExpressionRule } from "./rules/jsx-expression.ts"; +import type { OpaqueRefRule } from "./rules/jsx-expression.ts"; +import { createSchemaInjectionRule } from "./rules/schema-injection.ts"; + +export type ModularOpaqueRefTransformerOptions = TransformationOptions; + +function createRules(): OpaqueRefRule[] { + return [ + createJsxExpressionRule(), + createSchemaInjectionRule(), + ]; +} + +export function createModularOpaqueRefTransformer( + program: ts.Program, + options: ModularOpaqueRefTransformerOptions = {}, +): ts.TransformerFactory { + const rules = createRules(); + + return (transformation) => (sourceFile) => { + const imports = createImportManager(); + const context = createTransformationContext( + program, + sourceFile, + transformation, + options, + imports, + ); + + let current = sourceFile; + + for (const rule of rules) { + const next = rule.transform(current, context, transformation); + if (next !== current) { + current = next; + (context as { sourceFile: ts.SourceFile }).sourceFile = current; + } + } + + current = applyPendingImports(current, transformation.factory, imports); + (context as { sourceFile: ts.SourceFile }).sourceFile = current; + + if ( + context.options.mode === "error" && + context.diagnostics.length > 0 + ) { + const message = context.diagnostics + .map((diagnostic) => + `${diagnostic.fileName}:${diagnostic.line}:${diagnostic.column} - ${diagnostic.message}` + ) + .join("\n"); + throw new Error(`OpaqueRef transformation errors:\n${message}`); + } + + return current; + }; +} diff --git a/packages/ts-transformers/src/opaque-ref/transforms.ts b/packages/ts-transformers/src/opaque-ref/transforms.ts new file mode 100644 index 000000000..5e8bcfb3b --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/transforms.ts @@ -0,0 +1,426 @@ +import ts from "typescript"; +import { + containsOpaqueRef, + isOpaqueRefType, + isSimpleOpaqueRefAccess, +} from "./types.ts"; +import { + createDataFlowAnalyzer, + type DataFlowAnalysis, + dedupeExpressions, +} from "./dataflow.ts"; +import { getHelperIdentifier } from "./rewrite/import-resolver.ts"; + +export type OpaqueRefHelperName = "derive" | "ifElse" | "toSchema"; + +export function replaceOpaqueRefWithParam( + expression: ts.Expression, + opaqueRef: ts.Expression, + paramName: string, + factory: ts.NodeFactory, + context: ts.TransformationContext, +): ts.Expression { + const visit = (node: ts.Node): ts.Node => { + if (node === opaqueRef) { + return factory.createIdentifier(paramName); + } + return ts.visitEachChild(node, visit, context); + }; + return visit(expression) as ts.Expression; +} + +export function replaceOpaqueRefsWithParams( + expression: ts.Expression, + refToParamName: Map, + factory: ts.NodeFactory, + context: ts.TransformationContext, +): ts.Expression { + const visit = (node: ts.Node): ts.Node => { + for (const [ref, paramName] of refToParamName) { + if (node === ref) { + return factory.createIdentifier(paramName); + } + } + return ts.visitEachChild(node, visit, context); + }; + return visit(expression) as ts.Expression; +} + +export interface IfElseOverrides { + readonly predicate?: ts.Expression; + readonly whenTrue?: ts.Expression; + readonly whenFalse?: ts.Expression; +} + +export function createIfElseCall( + ternary: ts.ConditionalExpression, + factory: ts.NodeFactory, + sourceFile: ts.SourceFile, + overrides: IfElseOverrides = {}, +): ts.CallExpression { + const ifElseIdentifier = getHelperIdentifier(factory, sourceFile, "ifElse"); + + let predicate = overrides.predicate ?? ternary.condition; + let whenTrue = overrides.whenTrue ?? ternary.whenTrue; + let whenFalse = overrides.whenFalse ?? ternary.whenFalse; + while (ts.isParenthesizedExpression(predicate)) { + predicate = predicate.expression; + } + while (ts.isParenthesizedExpression(whenTrue)) whenTrue = whenTrue.expression; + while (ts.isParenthesizedExpression(whenFalse)) { + whenFalse = whenFalse.expression; + } + + return factory.createCallExpression( + ifElseIdentifier, + undefined, + [predicate, whenTrue, whenFalse], + ); +} + +function getSimpleName(ref: ts.Expression): string | undefined { + return ts.isIdentifier(ref) ? ref.text : undefined; +} + +interface DeriveEntry { + readonly ref: ts.Expression; + readonly paramName: string; + readonly propertyName: string; +} + +interface DeriveCallOptions { + readonly factory: ts.NodeFactory; + readonly sourceFile: ts.SourceFile; + readonly context: ts.TransformationContext; + readonly registerHelper?: (helper: OpaqueRefHelperName) => void; +} + +function createDeriveCallOptions( + factory: ts.NodeFactory, + sourceFile: ts.SourceFile, + context: ts.TransformationContext, + registerHelper?: (helper: OpaqueRefHelperName) => void, +): DeriveCallOptions { + return registerHelper + ? { factory, sourceFile, context, registerHelper } + : { factory, sourceFile, context }; +} + +function createPropertyName( + ref: ts.Expression, + index: number, +): string { + if (ts.isIdentifier(ref)) { + return ref.text; + } + if (ts.isPropertyAccessExpression(ref)) { + return ref.getText().replace(/\./g, "_"); + } + return `ref${index + 1}`; +} + +function planDeriveEntries( + refs: readonly ts.Expression[], +): { + readonly entries: readonly DeriveEntry[]; + readonly refToParamName: Map; +} { + const entries: DeriveEntry[] = []; + const refToParamName = new Map(); + const seen = new Map(); + + refs.forEach((ref) => { + const key = ref.getText(); + let entry = seen.get(key); + if (!entry) { + const paramName = getSimpleName(ref) ?? `_v${entries.length + 1}`; + entry = { + ref, + paramName, + propertyName: createPropertyName(ref, entries.length), + }; + seen.set(key, entry); + entries.push(entry); + } + refToParamName.set(ref, entry.paramName); + }); + + return { entries, refToParamName }; +} + +function createParameterForEntries( + factory: ts.NodeFactory, + entries: readonly DeriveEntry[], +): ts.ParameterDeclaration { + if (entries.length === 1) { + const entry = entries[0]!; + return factory.createParameterDeclaration( + undefined, + undefined, + factory.createIdentifier(entry.paramName), + undefined, + undefined, + undefined, + ); + } + + const bindings = entries.map((entry) => + factory.createBindingElement( + undefined, + factory.createIdentifier(entry.propertyName), + factory.createIdentifier(entry.paramName), + undefined, + ) + ); + + return factory.createParameterDeclaration( + undefined, + undefined, + factory.createObjectBindingPattern(bindings), + undefined, + undefined, + undefined, + ); +} + +function createDeriveArgs( + factory: ts.NodeFactory, + entries: readonly DeriveEntry[], +): readonly ts.Expression[] { + if (entries.length === 1) { + return [entries[0]!.ref]; + } + + const properties = entries.map((entry) => { + if (ts.isIdentifier(entry.ref)) { + return factory.createShorthandPropertyAssignment(entry.ref, undefined); + } + return factory.createPropertyAssignment( + factory.createIdentifier(entry.propertyName), + entry.ref, + ); + }); + + return [factory.createObjectLiteralExpression(properties, false)]; +} + +export function createDeriveCall( + expression: ts.Expression, + refs: readonly ts.Expression[], + options: DeriveCallOptions, +): ts.Expression | undefined { + if (refs.length === 0) return undefined; + + const { factory, sourceFile, context, registerHelper } = options; + const { entries, refToParamName } = planDeriveEntries(refs); + if (entries.length === 0) return undefined; + + const lambdaBody = replaceOpaqueRefsWithParams( + expression, + refToParamName, + factory, + context, + ); + + const arrowFunction = factory.createArrowFunction( + undefined, + undefined, + [createParameterForEntries(factory, entries)], + undefined, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + lambdaBody, + ); + + registerHelper?.("derive"); + const deriveIdentifier = getHelperIdentifier(factory, sourceFile, "derive"); + const deriveArgs = [ + ...createDeriveArgs(factory, entries), + arrowFunction, + ]; + + return factory.createCallExpression( + deriveIdentifier, + undefined, + deriveArgs, + ); +} + +function deriveWhenNecessary( + expression: ts.Expression, + analyzeDataFlows: (expr: ts.Expression) => { + analysis: DataFlowAnalysis; + dataFlows: ts.Expression[]; + }, + options: DeriveCallOptions, + precomputed?: { + analysis: DataFlowAnalysis; + dataFlows: ts.Expression[]; + }, +): ts.Expression | undefined { + const { dataFlows: opaqueRefs } = precomputed ?? analyzeDataFlows(expression); + if (opaqueRefs.length === 0) return undefined; + return createDeriveCall(expression, opaqueRefs, options); +} + +function createDataFlowResolver( + analyzer: (expr: ts.Expression) => DataFlowAnalysis, + sourceFile: ts.SourceFile, +): (expr: ts.Expression) => { + analysis: DataFlowAnalysis; + dataFlows: ts.Expression[]; +} { + return (expr) => { + const analysis = analyzer(expr); + const deduped = dedupeExpressions(analysis.dataFlows, sourceFile); + const texts = deduped.map((dep) => dep.getText(sourceFile)); + const filtered = deduped.filter((dep, index) => { + if (!ts.isIdentifier(dep)) return true; + const text = texts[index]; + return !texts.some((other, otherIndex) => + otherIndex !== index && other.startsWith(`${text}.`) + ); + }); + return { analysis, dataFlows: filtered }; + }; +} + +export function transformExpressionWithOpaqueRef( + expression: ts.Expression, + checker: ts.TypeChecker, + factory: ts.NodeFactory, + sourceFile: ts.SourceFile, + context: ts.TransformationContext, + registerHelper?: (helper: OpaqueRefHelperName) => void, + analyzer?: (expression: ts.Expression) => DataFlowAnalysis, +): ts.Expression { + if ( + ts.isJsxExpression(expression) && + expression.parent && + ts.isJsxAttribute(expression.parent) + ) { + const attrName = expression.parent.name.getText(); + if (attrName.startsWith("on")) return expression; + } + + const dataFlowAnalyzer = analyzer ?? createDataFlowAnalyzer(checker); + const resolveDataFlows = createDataFlowResolver( + dataFlowAnalyzer, + sourceFile, + ); + + const deriveOptions = createDeriveCallOptions( + factory, + sourceFile, + context, + registerHelper, + ); + + if (ts.isConditionalExpression(expression)) { + const conditionType = checker.getTypeAtLocation(expression.condition); + const conditionContainsOpaqueRef = containsOpaqueRef( + expression.condition, + checker, + ); + const conditionIsOpaqueRef = isOpaqueRefType(conditionType, checker); + + const transformBranch = (expr: ts.Expression): ts.Expression => { + if (ts.isConditionalExpression(expr)) { + return expr; + } + if ( + !isSimpleOpaqueRefAccess(expr, checker) && + containsOpaqueRef(expr, checker) + ) { + return transformExpressionWithOpaqueRef( + expr, + checker, + factory, + sourceFile, + context, + registerHelper, + dataFlowAnalyzer, + ); + } + return expr; + }; + + const visitedCondition = transformBranch(expression.condition); + const visitedWhenTrue = transformBranch(expression.whenTrue); + const visitedWhenFalse = transformBranch(expression.whenFalse); + + const updated = factory.updateConditionalExpression( + expression, + visitedCondition, + expression.questionToken, + visitedWhenTrue, + expression.colonToken, + visitedWhenFalse, + ); + + if ( + conditionIsOpaqueRef || + conditionContainsOpaqueRef || + visitedCondition !== expression.condition + ) { + registerHelper?.("ifElse"); + return createIfElseCall(updated, factory, sourceFile); + } + + return updated; + } + + if ( + ts.isPrefixUnaryExpression(expression) && + expression.operator === ts.SyntaxKind.ExclamationToken + ) { + const deriveCall = deriveWhenNecessary( + expression, + resolveDataFlows, + deriveOptions, + ); + return deriveCall ?? expression; + } + + if (ts.isPropertyAccessExpression(expression)) { + const deriveCall = deriveWhenNecessary( + expression, + resolveDataFlows, + deriveOptions, + ); + return deriveCall ?? expression; + } + + if (ts.isCallExpression(expression)) { + const analysisResult = resolveDataFlows(expression); + if (analysisResult.analysis.rewriteHint?.kind === "call-if-else") { + return expression; + } + const deriveCall = deriveWhenNecessary( + expression, + resolveDataFlows, + deriveOptions, + analysisResult, + ); + return deriveCall ?? expression; + } + + if (ts.isTemplateExpression(expression)) { + const deriveCall = deriveWhenNecessary( + expression, + resolveDataFlows, + deriveOptions, + ); + return deriveCall ?? expression; + } + + if (ts.isBinaryExpression(expression)) { + const deriveCall = deriveWhenNecessary( + expression, + resolveDataFlows, + deriveOptions, + ); + return deriveCall ?? expression; + } + + return expression; +} diff --git a/packages/ts-transformers/src/opaque-ref/types.ts b/packages/ts-transformers/src/opaque-ref/types.ts new file mode 100644 index 000000000..0f244130f --- /dev/null +++ b/packages/ts-transformers/src/opaque-ref/types.ts @@ -0,0 +1,246 @@ +import ts from "typescript"; +import { + getMemberSymbol, + resolvesToCommonToolsSymbol, + symbolDeclaresCommonToolsDefault, +} from "../core/common-tools-symbols.ts"; + +// Re-export commonly used functions +export { + getMemberSymbol, + symbolDeclaresCommonToolsDefault, +} from "../core/common-tools-symbols.ts"; + +export function isOpaqueRefType( + type: ts.Type, + checker: ts.TypeChecker, +): boolean { + if (type.getCallSignatures().length > 0) { + return false; + } + if (type.flags & ts.TypeFlags.Union) { + return (type as ts.UnionType).types.some((t) => + isOpaqueRefType(t, checker) + ); + } + if (type.flags & ts.TypeFlags.Intersection) { + return (type as ts.IntersectionType).types.some((t) => + isOpaqueRefType(t, checker) + ); + } + if (type.flags & ts.TypeFlags.Object) { + const objectType = type as ts.ObjectType; + if (objectType.objectFlags & ts.ObjectFlags.Reference) { + const typeRef = objectType as ts.TypeReference; + const target = typeRef.target; + if (target && target.symbol) { + const symbolName = target.symbol.getName(); + if (symbolName === "OpaqueRef" || symbolName === "Cell") return true; + if ( + resolvesToCommonToolsSymbol(target.symbol, checker, "Default") + ) { + return true; + } + const qualified = checker.getFullyQualifiedName(target.symbol); + if (qualified.includes("OpaqueRef") || qualified.includes("Cell")) { + return true; + } + } + } + const symbol = type.getSymbol(); + if (symbol) { + if ( + symbol.name === "OpaqueRef" || + symbol.name === "OpaqueRefMethods" || + symbol.name === "OpaqueRefBase" || + symbol.name === "Cell" + ) { + return true; + } + if (resolvesToCommonToolsSymbol(symbol, checker, "Default")) { + return true; + } + const qualified = checker.getFullyQualifiedName(symbol); + if (qualified.includes("OpaqueRef") || qualified.includes("Cell")) { + return true; + } + } + } + if (type.aliasSymbol) { + const aliasName = type.aliasSymbol.getName(); + if ( + aliasName === "OpaqueRef" || + aliasName === "Opaque" || + aliasName === "Cell" + ) { + return true; + } + if (resolvesToCommonToolsSymbol(type.aliasSymbol, checker, "Default")) { + return true; + } + const qualified = checker.getFullyQualifiedName(type.aliasSymbol); + if (qualified.includes("OpaqueRef") || qualified.includes("Cell")) { + return true; + } + } + return false; +} + +export function containsOpaqueRef( + node: ts.Node, + checker: ts.TypeChecker, +): boolean { + let found = false; + const visit = (n: ts.Node): void => { + if (found) return; + if (ts.isPropertyAccessExpression(n)) { + const type = checker.getTypeAtLocation(n); + if (isOpaqueRefType(type, checker)) { + found = true; + return; + } + const propertySymbol = getMemberSymbol(n, checker); + if (symbolDeclaresCommonToolsDefault(propertySymbol, checker)) { + found = true; + return; + } + } + if ( + ts.isCallExpression(n) && + ts.isPropertyAccessExpression(n.expression) && + n.expression.name.text === "get" && + n.arguments.length === 0 + ) { + return; + } + if (ts.isIdentifier(n)) { + const parent = n.parent; + if ( + parent && ts.isPropertyAccessExpression(parent) && parent.name === n + ) { + return; + } + const type = checker.getTypeAtLocation(n); + if (isOpaqueRefType(type, checker)) { + found = true; + return; + } + const symbol = checker.getSymbolAtLocation(n); + if (symbolDeclaresCommonToolsDefault(symbol, checker)) { + found = true; + return; + } + } + ts.forEachChild(n, visit); + }; + visit(node); + return found; +} + +export function isSimpleOpaqueRefAccess( + expression: ts.Expression, + checker: ts.TypeChecker, +): boolean { + if ( + ts.isIdentifier(expression) || ts.isPropertyAccessExpression(expression) + ) { + const type = checker.getTypeAtLocation(expression); + return isOpaqueRefType(type, checker); + } + return false; +} + +export function isFunctionParameter( + node: ts.Identifier, + checker: ts.TypeChecker, +): boolean { + const symbol = checker.getSymbolAtLocation(node); + if (symbol) { + const declarations = symbol.getDeclarations(); + if (declarations && declarations.some((decl) => ts.isParameter(decl))) { + for (const decl of declarations) { + if (!ts.isParameter(decl)) continue; + const parent = decl.parent; + if ( + ts.isFunctionExpression(parent) || + ts.isArrowFunction(parent) || + ts.isFunctionDeclaration(parent) || + ts.isMethodDeclaration(parent) + ) { + let callExpr: ts.Node = parent; + while (callExpr.parent && !ts.isCallExpression(callExpr.parent)) { + callExpr = callExpr.parent; + } + if (callExpr.parent && ts.isCallExpression(callExpr.parent)) { + const funcName = callExpr.parent.expression.getText(); + if ( + funcName.includes("recipe") || + funcName.includes("handler") || + funcName.includes("lift") + ) { + return false; + } + } + } + return true; + } + } + } + + const parent = node.parent; + if (ts.isParameter(parent) && parent.name === node) { + return true; + } + + let current: ts.Node = node; + let containingFunction: ts.FunctionLikeDeclaration | undefined; + while (current.parent) { + current = current.parent; + if ( + ts.isFunctionExpression(current) || + ts.isArrowFunction(current) || + ts.isFunctionDeclaration(current) || + ts.isMethodDeclaration(current) + ) { + containingFunction = current as ts.FunctionLikeDeclaration; + break; + } + } + + if (containingFunction && containingFunction.parameters) { + for (const param of containingFunction.parameters) { + if ( + param.name && ts.isIdentifier(param.name) && + param.name.text === node.text + ) { + let callExpr: ts.Node = containingFunction; + while (callExpr.parent && !ts.isCallExpression(callExpr.parent)) { + callExpr = callExpr.parent; + } + if (callExpr.parent && ts.isCallExpression(callExpr.parent)) { + const funcName = callExpr.parent.expression.getText(); + if ( + funcName.includes("recipe") || + funcName.includes("handler") || + funcName.includes("lift") + ) { + return false; + } + } + return true; + } + } + } + + return false; +} + +export function isEventHandlerJsxAttribute(node: ts.Node): boolean { + if (!node || !node.parent) return false; + const parent = node.parent; + if (ts.isJsxAttribute(parent)) { + const attrName = parent.name.getText(); + return attrName.startsWith("on"); + } + return false; +} diff --git a/packages/ts-transformers/src/schema/schema-transformer.ts b/packages/ts-transformers/src/schema/schema-transformer.ts new file mode 100644 index 000000000..fc288f2fd --- /dev/null +++ b/packages/ts-transformers/src/schema/schema-transformer.ts @@ -0,0 +1,169 @@ +import ts from "typescript"; +import { + addCommonToolsImport, + hasCommonToolsImport, + removeCommonToolsImport, +} from "../core/common-tools-imports.ts"; +import { createSchemaTransformerV2 } from "@commontools/schema-generator"; + +export interface SchemaTransformerOptions { + logger?: (message: string) => void; +} + +export function createSchemaTransformer( + program: ts.Program, + options: SchemaTransformerOptions = {}, +): ts.TransformerFactory { + const checker = program.getTypeChecker(); + const logger = options.logger; + const generateSchema = createSchemaTransformerV2(); + + return (context: ts.TransformationContext) => (sourceFile: ts.SourceFile) => { + let needsJSONSchemaImport = false; + + const visit: ts.Visitor = (node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "toSchema" && + node.typeArguments && + node.typeArguments.length === 1 + ) { + const typeArg = node.typeArguments[0]; + if (!typeArg) { + return ts.visitEachChild(node, visit, context); + } + const type = checker.getTypeFromTypeNode(typeArg); + + if (logger) { + let typeText = "unknown"; + try { + typeText = typeArg.getText(); + } catch { + // synthetic nodes may not support getText(); ignore + } + logger(`[SchemaTransformer] Found toSchema<${typeText}>() call`); + } + + const arg0 = node.arguments[0]; + let optionsObj: Record = {}; + if (arg0 && ts.isObjectLiteralExpression(arg0)) { + optionsObj = evaluateObjectLiteral(arg0, checker); + } + + const schema = generateSchema(type, checker, typeArg); + const finalSchema = { ...schema, ...optionsObj }; + const schemaAst = createSchemaAst(finalSchema, context.factory); + + const constAssertion = context.factory.createAsExpression( + schemaAst, + context.factory.createTypeReferenceNode( + context.factory.createIdentifier("const"), + undefined, + ), + ); + + const satisfiesExpression = context.factory.createSatisfiesExpression( + constAssertion, + context.factory.createTypeReferenceNode( + context.factory.createIdentifier("JSONSchema"), + undefined, + ), + ); + + if (!hasCommonToolsImport(sourceFile, "JSONSchema")) { + needsJSONSchemaImport = true; + } + + return satisfiesExpression; + } + + return ts.visitEachChild(node, visit, context); + }; + + let result = ts.visitNode(sourceFile, visit) as ts.SourceFile; + + if (needsJSONSchemaImport) { + result = addCommonToolsImport(result, context.factory, "JSONSchema"); + } + + if (hasCommonToolsImport(result, "toSchema")) { + if (logger) { + logger( + `[SchemaTransformer] Removing toSchema import (not available at runtime)`, + ); + } + result = removeCommonToolsImport(result, context.factory, "toSchema"); + } + + return result; + }; +} + +function createSchemaAst( + schema: unknown, + factory: ts.NodeFactory, +): ts.Expression { + if (schema === null) return factory.createNull(); + if (typeof schema === "string") return factory.createStringLiteral(schema); + if (typeof schema === "number") return factory.createNumericLiteral(schema); + if (typeof schema === "boolean") { + return schema ? factory.createTrue() : factory.createFalse(); + } + if (Array.isArray(schema)) { + return factory.createArrayLiteralExpression( + schema.map((item) => createSchemaAst(item, factory)), + ); + } + if (typeof schema === "object") { + const properties = Object.entries(schema as Record).map(( + [key, value], + ) => + factory.createPropertyAssignment( + factory.createIdentifier(key), + createSchemaAst(value, factory), + ) + ); + return factory.createObjectLiteralExpression(properties, true); + } + return factory.createIdentifier("undefined"); +} + +function evaluateObjectLiteral( + node: ts.ObjectLiteralExpression, + checker: ts.TypeChecker, +): Record { + const result: Record = {}; + for (const prop of node.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + const value = evaluateExpression(prop.initializer, checker); + if (value !== undefined) { + result[prop.name.text] = value; + } + } + } + return result; +} + +function evaluateExpression( + node: ts.Expression, + checker: ts.TypeChecker, +): unknown { + if (ts.isStringLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (node.kind === ts.SyntaxKind.NullKeyword) return null; + if (node.kind === ts.SyntaxKind.UndefinedKeyword) return undefined; + if (ts.isObjectLiteralExpression(node)) { + return evaluateObjectLiteral(node, checker); + } + if (ts.isArrayLiteralExpression(node)) { + return node.elements.map((element) => evaluateExpression(element, checker)); + } + const constantValue = checker.getConstantValue( + node as ts.PropertyAccessExpression, + ); + if (constantValue !== undefined) return constantValue; + return undefined; +} diff --git a/packages/ts-transformers/test/fixture-based.test.ts b/packages/ts-transformers/test/fixture-based.test.ts new file mode 100644 index 000000000..ec3f9a68e --- /dev/null +++ b/packages/ts-transformers/test/fixture-based.test.ts @@ -0,0 +1,141 @@ +import { + createUnifiedDiff, + defineFixtureSuite, +} from "@commontools/test-support/fixture-runner"; +import { StaticCache } from "@commontools/static"; +import { resolve } from "@std/path"; + +import { loadFixture, transformFixture } from "./utils.ts"; + +interface FixtureConfig { + directory: string; + describe: string; + transformerOptions?: Record; + groups?: Array<{ pattern: RegExp; name: string }>; + formatTestName?: (fileName: string) => string; +} + +const configs: FixtureConfig[] = [ + { + directory: "ast-transform", + describe: "AST Transformation", + transformerOptions: { applySchemaTransformer: true }, + formatTestName: (name) => `transforms ${name.replace(/-/g, " ")}`, + }, + { + directory: "handler-schema", + describe: "Handler Schema Transformation", + transformerOptions: { applySchemaTransformer: true }, + formatTestName: (name) => `transforms ${name.replace(/-/g, " ")}`, + }, + { + directory: "jsx-expressions", + describe: "JSX Expression Transformer", + transformerOptions: { + applySchemaTransformer: true, + }, + formatTestName: (name) => { + const formatted = name.replace(/-/g, " "); + if (name.includes("no-transform")) { + return `does not transform ${formatted.replace("no transform ", "")}`; + } + return `transforms ${formatted}`; + }, + }, + { + directory: "schema-transform", + describe: "Schema Transformer", + transformerOptions: { applySchemaTransformer: true }, + formatTestName: (name) => { + const formatted = name.replace(/-/g, " "); + if (name === "with-opaque-ref") return "works with OpaqueRef transformer"; + return `transforms ${formatted}`; + }, + groups: [ + { pattern: /with-opaque-ref/, name: "OpaqueRef integration" }, + ], + }, +]; + +const staticCache = new StaticCache(); +const commontools = await staticCache.getText("types/commontools.d.ts"); +const FIXTURES_ROOT = "./test/fixtures"; + +for (const config of configs) { + const suiteConfig = { + suiteName: config.describe, + rootDir: `${FIXTURES_ROOT}/${config.directory}`, + expectedPath: ({ stem, extension }: { stem: string; extension: string }) => + `${stem}.expected${extension}`, + async execute(fixture: { relativeInputPath: string }) { + return await transformFixture( + `${config.directory}/${fixture.relativeInputPath}`, + { + types: { "commontools.d.ts": commontools }, + ...config.transformerOptions, + }, + ); + }, + async loadExpected(fixture: { relativeExpectedPath: string }) { + return await loadFixture( + `${config.directory}/${fixture.relativeExpectedPath}`, + ); + }, + compare(actual: string, expected: string, fixture: { + baseName: string; + relativeInputPath: string; + relativeExpectedPath: string; + }) { + if (actual === expected) return; + const diff = createUnifiedDiff(expected, actual); + let message = + `\n\nTransformation output does not match expected for: ${fixture.baseName}\n`; + message += `\nFiles:\n`; + message += ` Input: ${ + resolve( + `${FIXTURES_ROOT}/${config.directory}/${fixture.relativeInputPath}`, + ) + }\n`; + message += ` Expected: ${ + resolve( + `${FIXTURES_ROOT}/${config.directory}/${fixture.relativeExpectedPath}`, + ) + }\n`; + message += `\n${"=".repeat(80)}\n`; + message += `UNIFIED DIFF (expected vs actual):\n`; + message += `${"=".repeat(80)}\n`; + message += diff; + message += `\n${"=".repeat(80)}\n`; + throw new Error(message); + }, + updateGolden(actual: string, fixture: { relativeExpectedPath: string }) { + const normalized = `${actual}\n`; + return Deno.writeTextFile( + `${FIXTURES_ROOT}/${config.directory}/${fixture.relativeExpectedPath}`, + normalized, + ); + }, + }; + + if (config.formatTestName) { + Object.assign(suiteConfig, { + formatTestName: (fixture: { baseName: string }) => + config.formatTestName!(fixture.baseName), + }); + } + + if (config.groups) { + Object.assign(suiteConfig, { + groupBy: (fixture: { baseName: string }) => { + for (const group of config.groups!) { + if (group.pattern.test(fixture.baseName)) { + return group.name; + } + } + return undefined; + }, + }); + } + + defineFixtureSuite(suiteConfig); +} diff --git a/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx new file mode 100644 index 000000000..356fb1b4e --- /dev/null +++ b/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx @@ -0,0 +1,27 @@ +/// +import { Default, h, NAME, recipe, UI, ifElse, derive, JSONSchema } from "commontools"; +interface RecipeState { + count: Default; + label: Default; +} +export default recipe({ + type: "object", + properties: { + count: { + type: "number", + default: 0 + }, + label: { + type: "string", + default: "" + } + }, + required: ["count", "label"] +} as const satisfies JSONSchema, (state) => { + return { + [NAME]: state.label, + [UI]: (
+ {ifElse(derive({ state, state_count: state.count }, ({ state: state, state_count: _v2 }) => state && _v2 > 0),

Positive

,

Non-positive

)} +
), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.input.tsx new file mode 100644 index 000000000..7fe52a8e2 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.input.tsx @@ -0,0 +1,20 @@ +/// +import { Default, h, NAME, recipe, UI } from "commontools"; + +interface RecipeState { + count: Default; + label: Default; +} + +export default recipe("ConditionalRecipe", (state) => { + return { + [NAME]: state.label, + [UI]: ( +
+ {state && state.count > 0 + ?

Positive

+ :

Non-positive

} +
+ ), + }; +}); diff --git a/packages/js-runtime/test/fixtures/ast-transform/counter-recipe.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe.expected.tsx similarity index 91% rename from packages/js-runtime/test/fixtures/ast-transform/counter-recipe.expected.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/counter-recipe.expected.tsx index 2754a34ae..88a3c0b0e 100644 --- a/packages/js-runtime/test/fixtures/ast-transform/counter-recipe.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe.expected.tsx @@ -47,11 +47,10 @@ export default recipe({ [UI]: (
-
    -
  • next number: {commontools_1.ifElse(state.value, commontools_1.derive(state.value, _v1 => _v1 + 1), "unknown")}
  • +
  • next number: {ifElse(state.value, derive(state.value, _v1 => _v1 + 1), "unknown")}
+
), value: state.value, }; }); - diff --git a/packages/js-runtime/test/fixtures/ast-transform/counter-recipe.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/ast-transform/counter-recipe.input.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/counter-recipe.input.tsx diff --git a/packages/js-runtime/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx similarity index 94% rename from packages/js-runtime/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx index d385f709b..96e076ed8 100644 --- a/packages/js-runtime/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx @@ -32,7 +32,7 @@ export default recipe({ return { [UI]: (
{/* Regular JSX expression - should be wrapped in derive */} - Count: {commontools_1.derive(count, count => count + 1)} + Count: {derive(count, count => count + 1)} {/* Event handler with OpaqueRef - should NOT be wrapped in derive */} @@ -47,4 +47,3 @@ export default recipe({ count, }; }); - diff --git a/packages/js-runtime/test/fixtures/ast-transform/event-handler-no-derive.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/ast-transform/event-handler-no-derive.input.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.input.tsx diff --git a/packages/js-runtime/test/fixtures/ast-transform/handler-object-literal.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/handler-object-literal.expected.tsx similarity index 99% rename from packages/js-runtime/test/fixtures/ast-transform/handler-object-literal.expected.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/handler-object-literal.expected.tsx index 88617a6b2..e961d54bc 100644 --- a/packages/js-runtime/test/fixtures/ast-transform/handler-object-literal.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/handler-object-literal.expected.tsx @@ -30,4 +30,3 @@ export default recipe({ type: "object", properties: { value: { type: "number" }, onClick3: myHandler(state), }; }); - diff --git a/packages/js-runtime/test/fixtures/ast-transform/handler-object-literal.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/handler-object-literal.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/ast-transform/handler-object-literal.input.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/handler-object-literal.input.tsx diff --git a/packages/js-runtime/test/fixtures/ast-transform/recipe-array-map.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.expected.tsx similarity index 99% rename from packages/js-runtime/test/fixtures/ast-transform/recipe-array-map.expected.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.expected.tsx index d6d1a0386..23c02b929 100644 --- a/packages/js-runtime/test/fixtures/ast-transform/recipe-array-map.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.expected.tsx @@ -45,4 +45,3 @@ export default recipe({ values, }; }); - diff --git a/packages/js-runtime/test/fixtures/ast-transform/recipe-array-map.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/ast-transform/recipe-array-map.input.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/recipe-array-map.input.tsx diff --git a/packages/js-runtime/test/fixtures/ast-transform/ternary_derive.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.expected.tsx similarity index 77% rename from packages/js-runtime/test/fixtures/ast-transform/ternary_derive.expected.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.expected.tsx index fc94f405a..8fbb795c9 100644 --- a/packages/js-runtime/test/fixtures/ast-transform/ternary_derive.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.expected.tsx @@ -16,8 +16,7 @@ export default recipe({ return { [NAME]: "test ternary with derive", [UI]: (
- {commontools_1.ifElse(commontools_1.derive(state.value, _v1 => _v1 + 1), commontools_1.derive(state.value, _v1 => _v1 + 2), "undefined")} + {ifElse(derive(state.value, _v1 => _v1 + 1), derive(state.value, _v1 => _v1 + 2), "undefined")}
), }; }); - diff --git a/packages/js-runtime/test/fixtures/ast-transform/ternary_derive.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/ast-transform/ternary_derive.input.tsx rename to packages/ts-transformers/test/fixtures/ast-transform/ternary_derive.input.tsx diff --git a/packages/js-runtime/test/fixtures/handler-schema/array-cell-remove-intersection.expected.ts b/packages/ts-transformers/test/fixtures/handler-schema/array-cell-remove-intersection.expected.ts similarity index 100% rename from packages/js-runtime/test/fixtures/handler-schema/array-cell-remove-intersection.expected.ts rename to packages/ts-transformers/test/fixtures/handler-schema/array-cell-remove-intersection.expected.ts diff --git a/packages/js-runtime/test/fixtures/handler-schema/array-cell-remove-intersection.input.ts b/packages/ts-transformers/test/fixtures/handler-schema/array-cell-remove-intersection.input.ts similarity index 100% rename from packages/js-runtime/test/fixtures/handler-schema/array-cell-remove-intersection.input.ts rename to packages/ts-transformers/test/fixtures/handler-schema/array-cell-remove-intersection.input.ts diff --git a/packages/js-runtime/test/fixtures/handler-schema/complex-nested-types.expected.ts b/packages/ts-transformers/test/fixtures/handler-schema/complex-nested-types.expected.ts similarity index 99% rename from packages/js-runtime/test/fixtures/handler-schema/complex-nested-types.expected.ts rename to packages/ts-transformers/test/fixtures/handler-schema/complex-nested-types.expected.ts index 9e5df7ddc..446c75ec2 100644 --- a/packages/js-runtime/test/fixtures/handler-schema/complex-nested-types.expected.ts +++ b/packages/ts-transformers/test/fixtures/handler-schema/complex-nested-types.expected.ts @@ -121,4 +121,3 @@ export { userHandler }; export default recipe("complex-nested-types test", () => { return { userHandler }; }); - diff --git a/packages/js-runtime/test/fixtures/handler-schema/complex-nested-types.input.ts b/packages/ts-transformers/test/fixtures/handler-schema/complex-nested-types.input.ts similarity index 100% rename from packages/js-runtime/test/fixtures/handler-schema/complex-nested-types.input.ts rename to packages/ts-transformers/test/fixtures/handler-schema/complex-nested-types.input.ts diff --git a/packages/js-runtime/test/fixtures/handler-schema/date-and-map-types.expected.ts b/packages/ts-transformers/test/fixtures/handler-schema/date-and-map-types.expected.ts similarity index 100% rename from packages/js-runtime/test/fixtures/handler-schema/date-and-map-types.expected.ts rename to packages/ts-transformers/test/fixtures/handler-schema/date-and-map-types.expected.ts diff --git a/packages/js-runtime/test/fixtures/handler-schema/date-and-map-types.input.ts b/packages/ts-transformers/test/fixtures/handler-schema/date-and-map-types.input.ts similarity index 100% rename from packages/js-runtime/test/fixtures/handler-schema/date-and-map-types.input.ts rename to packages/ts-transformers/test/fixtures/handler-schema/date-and-map-types.input.ts diff --git a/packages/js-runtime/test/fixtures/handler-schema/preserve-explicit-schemas.expected.ts b/packages/ts-transformers/test/fixtures/handler-schema/preserve-explicit-schemas.expected.ts similarity index 94% rename from packages/js-runtime/test/fixtures/handler-schema/preserve-explicit-schemas.expected.ts rename to packages/ts-transformers/test/fixtures/handler-schema/preserve-explicit-schemas.expected.ts index 3292c12e2..68120dd47 100644 --- a/packages/js-runtime/test/fixtures/handler-schema/preserve-explicit-schemas.expected.ts +++ b/packages/ts-transformers/test/fixtures/handler-schema/preserve-explicit-schemas.expected.ts @@ -15,4 +15,4 @@ const stateSchema = { const logHandler = handler(eventSchema, stateSchema, (event, state) => { state.log.push(event.message); }); -export { logHandler }; \ No newline at end of file +export { logHandler }; diff --git a/packages/js-runtime/test/fixtures/handler-schema/preserve-explicit-schemas.input.ts b/packages/ts-transformers/test/fixtures/handler-schema/preserve-explicit-schemas.input.ts similarity index 100% rename from packages/js-runtime/test/fixtures/handler-schema/preserve-explicit-schemas.input.ts rename to packages/ts-transformers/test/fixtures/handler-schema/preserve-explicit-schemas.input.ts diff --git a/packages/js-runtime/test/fixtures/handler-schema/simple-handler.expected.ts b/packages/ts-transformers/test/fixtures/handler-schema/simple-handler.expected.ts similarity index 96% rename from packages/js-runtime/test/fixtures/handler-schema/simple-handler.expected.ts rename to packages/ts-transformers/test/fixtures/handler-schema/simple-handler.expected.ts index 7560198a3..29a326c2d 100644 --- a/packages/js-runtime/test/fixtures/handler-schema/simple-handler.expected.ts +++ b/packages/ts-transformers/test/fixtures/handler-schema/simple-handler.expected.ts @@ -25,4 +25,4 @@ const myHandler = handler({ } as const satisfies JSONSchema, (event, state) => { state.value = state.value + event.increment; }); -export { myHandler }; \ No newline at end of file +export { myHandler }; diff --git a/packages/js-runtime/test/fixtures/handler-schema/simple-handler.input.ts b/packages/ts-transformers/test/fixtures/handler-schema/simple-handler.input.ts similarity index 100% rename from packages/js-runtime/test/fixtures/handler-schema/simple-handler.input.ts rename to packages/ts-transformers/test/fixtures/handler-schema/simple-handler.input.ts diff --git a/packages/js-runtime/test/fixtures/handler-schema/unsupported-intersection-index.expected.ts b/packages/ts-transformers/test/fixtures/handler-schema/unsupported-intersection-index.expected.ts similarity index 100% rename from packages/js-runtime/test/fixtures/handler-schema/unsupported-intersection-index.expected.ts rename to packages/ts-transformers/test/fixtures/handler-schema/unsupported-intersection-index.expected.ts diff --git a/packages/js-runtime/test/fixtures/handler-schema/unsupported-intersection-index.input.ts b/packages/ts-transformers/test/fixtures/handler-schema/unsupported-intersection-index.input.ts similarity index 100% rename from packages/js-runtime/test/fixtures/handler-schema/unsupported-intersection-index.input.ts rename to packages/ts-transformers/test/fixtures/handler-schema/unsupported-intersection-index.input.ts diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/complex-expressions.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/complex-expressions.expected.tsx similarity index 67% rename from packages/js-runtime/test/fixtures/jsx-expressions/complex-expressions.expected.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/complex-expressions.expected.tsx index cb53fce19..b03dcac07 100644 --- a/packages/js-runtime/test/fixtures/jsx-expressions/complex-expressions.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/complex-expressions.expected.tsx @@ -23,9 +23,8 @@ export default recipe({ return { [UI]: (

Price: {price}

-

Discount: {commontools_1.derive({ price, discount }, ({ price: price, discount: discount }) => price - discount)}

-

With tax: {commontools_1.derive({ price, discount, tax }, ({ price: price, discount: discount, tax: tax }) => (price - discount) * (1 + tax))}

+

Discount: {derive({ price, discount }, ({ price: price, discount: discount }) => price - discount)}

+

With tax: {derive({ price, discount, tax }, ({ price: price, discount: discount, tax: tax }) => (price - discount) * (1 + tax))}

) }; }); - diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/complex-expressions.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/complex-expressions.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/complex-expressions.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/complex-expressions.input.tsx diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.expected.tsx new file mode 100644 index 000000000..b9b51089b --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.expected.tsx @@ -0,0 +1,172 @@ +/// +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; +interface State { + matrix: number[][]; + row: number; + col: number; + items: string[]; + arr: number[]; + a: number; + b: number; + indices: number[]; + nested: { + arrays: string[][]; + index: number; + }; + users: Array<{ + name: string; + scores: number[]; + }>; + selectedUser: number; + selectedScore: number; +} +export default recipe({ + type: "object", + properties: { + matrix: { + type: "array", + items: { + type: "array", + items: { + type: "number" + } + } + }, + row: { + type: "number" + }, + col: { + type: "number" + }, + items: { + type: "array", + items: { + type: "string" + } + }, + arr: { + type: "array", + items: { + type: "number" + } + }, + a: { + type: "number" + }, + b: { + type: "number" + }, + indices: { + type: "array", + items: { + type: "number" + } + }, + nested: { + type: "object", + properties: { + arrays: { + type: "array", + items: { + type: "array", + items: { + type: "string" + } + } + }, + index: { + type: "number" + } + }, + required: ["arrays", "index"] + }, + users: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string" + }, + scores: { + type: "array", + items: { + type: "number" + } + } + }, + required: ["name", "scores"] + } + }, + selectedUser: { + type: "number" + }, + selectedScore: { + type: "number" + } + }, + required: ["matrix", "row", "col", "items", "arr", "a", "b", "indices", "nested", "users", "selectedUser", "selectedScore"] +} as const satisfies JSONSchema, (state) => { + return { + [UI]: (
+

Nested Element Access

+ {/* Double indexing into matrix */} +

Matrix value: {derive({ state_matrix: state.matrix, state_row: state.row, state_col: state.col }, ({ state_matrix: _v1, state_row: _v2, state_col: _v3 }) => _v1[_v2][_v3])}

+ + {/* Triple nested access */} +

Deep nested: {derive({ state_nested_arrays: state.nested.arrays, state_nested_index: state.nested.index, state_row: state.row }, ({ state_nested_arrays: _v1, state_nested_index: _v2, state_row: _v3 }) => _v1[_v2][_v3])}

+ +

Multiple References to Same Array

+ {/* Same array accessed multiple times with different indices */} +

First and last: {state.items[0]} and {derive({ state_items: state.items, state_items_length: state.items.length }, ({ state_items: _v1, state_items_length: _v2 }) => _v1[_v2 - 1])}

+ + {/* Array used in computation and access */} +

Sum of ends: {derive({ state_arr: state.arr, state_arr_length: state.arr.length }, ({ state_arr: _v1, state_arr_length: _v2 }) => _v1[0] + _v1[_v2 - 1])}

+ +

Computed Indices

+ {/* Index from multiple state values */} +

Computed index: {derive({ state_arr: state.arr, state_a: state.a, state_b: state.b }, ({ state_arr: _v1, state_a: _v2, state_b: _v3 }) => _v1[_v2 + _v3])}

+ + {/* Index from computation involving array */} +

Modulo index: {derive({ state_items: state.items, state_row: state.row, state_items_length: state.items.length }, ({ state_items: _v1, state_row: _v2, state_items_length: _v3 }) => _v1[_v2 % _v3])}

+ + {/* Complex index expression */} +

Complex: {derive({ state_arr: state.arr, state_a: state.a, state_arr_length: state.arr.length }, ({ state_arr: _v1, state_a: _v2, state_arr_length: _v3 }) => _v1[Math.min(_v2 * 2, _v3 - 1)])}

+ +

Chained Element Access

+ {/* Element access returning array, then accessing that */} +

User score: {derive({ state_users: state.users, state_selectedUser: state.selectedUser, state_selectedScore: state.selectedScore }, ({ state_users: _v1, state_selectedUser: _v2, state_selectedScore: _v3 }) => _v1[_v2].scores[_v3])}

+ + {/* Using one array element as index for another */} +

Indirect: {derive({ state_items: state.items, state_indices: state.indices }, ({ state_items: _v1, state_indices: _v2 }) => _v1[_v2[0]])}

+ + {/* Array element used as index for same array */} +

Self reference: {derive(state.arr, _v1 => _v1[_v1[0]])}

+ +

Mixed Property and Element Access

+ {/* Property access followed by element access with computed index */} +

Mixed: {derive({ state_nested_arrays: state.nested.arrays, state_nested_index: state.nested.index }, ({ state_nested_arrays: _v1, state_nested_index: _v2 }) => _v1[_v2].length)}

+ + {/* Element access followed by property access */} +

User name length: {derive({ state_users: state.users, state_selectedUser: state.selectedUser }, ({ state_users: _v1, state_selectedUser: _v2 }) => _v1[_v2].name.length)}

+ +

Element Access in Conditions

+ {/* Element access in ternary */} +

Conditional: {ifElse(derive({ state_arr: state.arr, state_a: state.a }, ({ state_arr: _v1, state_a: _v2 }) => _v1[_v2] > 10), derive({ state_items: state.items, state_b: state.b }, ({ state_items: _v1, state_b: _v2 }) => _v1[_v2]), state.items[0])}

+ + {/* Element access in boolean expression */} +

Has value: {ifElse(derive({ state_matrix: state.matrix, state_row: state.row, state_col: state.col }, ({ state_matrix: _v1, state_row: _v2, state_col: _v3 }) => _v1[_v2][_v3] > 0), "positive", "non-positive")}

+ +

Element Access with Operators

+ {/* Element access with arithmetic */} +

Product: {derive({ state_arr: state.arr, state_a: state.a, state_b: state.b }, ({ state_arr: _v1, state_a: _v2, state_b: _v3 }) => _v1[_v2] * _v1[_v3])}

+ + {/* Element access with string concatenation */} +

Concat: {derive({ state_items: state.items, state_indices: state.indices }, ({ state_items: _v1, state_indices: _v2 }) => _v1[0] + " - " + _v1[_v2[0]])}

+ + {/* Multiple element accesses in single expression */} +

Sum: {derive(state.arr, _v1 => _v1[0] + _v1[1] + _v1[2])}

+
), + }; +}); + diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.input.tsx new file mode 100644 index 000000000..f5f3b0a35 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-complex.input.tsx @@ -0,0 +1,86 @@ +/// +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; + +interface State { + matrix: number[][]; + row: number; + col: number; + items: string[]; + arr: number[]; + a: number; + b: number; + indices: number[]; + nested: { + arrays: string[][]; + index: number; + }; + users: Array<{ name: string; scores: number[] }>; + selectedUser: number; + selectedScore: number; +} + +export default recipe("ElementAccessComplex", (state) => { + return { + [UI]: ( +
+

Nested Element Access

+ {/* Double indexing into matrix */} +

Matrix value: {state.matrix[state.row][state.col]}

+ + {/* Triple nested access */} +

Deep nested: {state.nested.arrays[state.nested.index][state.row]}

+ +

Multiple References to Same Array

+ {/* Same array accessed multiple times with different indices */} +

First and last: {state.items[0]} and {state.items[state.items.length - 1]}

+ + {/* Array used in computation and access */} +

Sum of ends: {state.arr[0] + state.arr[state.arr.length - 1]}

+ +

Computed Indices

+ {/* Index from multiple state values */} +

Computed index: {state.arr[state.a + state.b]}

+ + {/* Index from computation involving array */} +

Modulo index: {state.items[state.row % state.items.length]}

+ + {/* Complex index expression */} +

Complex: {state.arr[Math.min(state.a * 2, state.arr.length - 1)]}

+ +

Chained Element Access

+ {/* Element access returning array, then accessing that */} +

User score: {state.users[state.selectedUser].scores[state.selectedScore]}

+ + {/* Using one array element as index for another */} +

Indirect: {state.items[state.indices[0]]}

+ + {/* Array element used as index for same array */} +

Self reference: {state.arr[state.arr[0]]}

+ +

Mixed Property and Element Access

+ {/* Property access followed by element access with computed index */} +

Mixed: {state.nested.arrays[state.nested.index].length}

+ + {/* Element access followed by property access */} +

User name length: {state.users[state.selectedUser].name.length}

+ +

Element Access in Conditions

+ {/* Element access in ternary */} +

Conditional: {state.arr[state.a] > 10 ? state.items[state.b] : state.items[0]}

+ + {/* Element access in boolean expression */} +

Has value: {ifElse(state.matrix[state.row][state.col] > 0, "positive", "non-positive")}

+ +

Element Access with Operators

+ {/* Element access with arithmetic */} +

Product: {state.arr[state.a] * state.arr[state.b]}

+ + {/* Element access with string concatenation */} +

Concat: {state.items[0] + " - " + state.items[state.indices[0]]}

+ + {/* Multiple element accesses in single expression */} +

Sum: {state.arr[0] + state.arr[1] + state.arr[2]}

+
+ ), + }; +}); \ No newline at end of file diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.expected.tsx new file mode 100644 index 000000000..f31801200 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.expected.tsx @@ -0,0 +1,53 @@ +/// +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; +interface State { + items: string[]; + index: number; + matrix: number[][]; + row: number; + col: number; +} +export default recipe({ + type: "object", + properties: { + items: { + type: "array", + items: { + type: "string" + } + }, + index: { + type: "number" + }, + matrix: { + type: "array", + items: { + type: "array", + items: { + type: "number" + } + } + }, + row: { + type: "number" + }, + col: { + type: "number" + } + }, + required: ["items", "index", "matrix", "row", "col"] +} as const satisfies JSONSchema, (state) => { + return { + [UI]: (
+

Dynamic Element Access

+ {/* Basic dynamic index */} +

Item: {derive({ state_items: state.items, state_index: state.index }, ({ state_items: _v1, state_index: _v2 }) => _v1[_v2])}

+ + {/* Computed index */} +

Last: {derive({ state_items: state.items, state_items_length: state.items.length }, ({ state_items: _v1, state_items_length: _v2 }) => _v1[_v2 - 1])}

+ + {/* Double indexing */} +

Matrix: {derive({ state_matrix: state.matrix, state_row: state.row, state_col: state.col }, ({ state_matrix: _v1, state_row: _v2, state_col: _v3 }) => _v1[_v2][_v3])}

+
), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.input.tsx new file mode 100644 index 000000000..14c23b21a --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-simple.input.tsx @@ -0,0 +1,28 @@ +/// +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; + +interface State { + items: string[]; + index: number; + matrix: number[][]; + row: number; + col: number; +} + +export default recipe("ElementAccessSimple", (state) => { + return { + [UI]: ( +
+

Dynamic Element Access

+ {/* Basic dynamic index */} +

Item: {state.items[state.index]}

+ + {/* Computed index */} +

Last: {state.items[state.items.length - 1]}

+ + {/* Double indexing */} +

Matrix: {state.matrix[state.row][state.col]}

+
+ ), + }; +}); \ No newline at end of file diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx new file mode 100644 index 000000000..e6639be7f --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.expected.tsx @@ -0,0 +1,47 @@ +/// +import { h, recipe, UI, derive, JSONSchema } from "commontools"; +interface State { + count: number; + price: number; + discount: number; + quantity: number; +} +export default recipe({ + type: "object", + properties: { + count: { + type: "number" + }, + price: { + type: "number" + }, + discount: { + type: "number" + }, + quantity: { + type: "number" + } + }, + required: ["count", "price", "discount", "quantity"] +} as const satisfies JSONSchema, (state) => { + return { + [UI]: (
+

Basic Arithmetic

+

Count + 1: {derive(state.count, _v1 => _v1 + 1)}

+

Count - 1: {derive(state.count, _v1 => _v1 - 1)}

+

Count * 2: {derive(state.count, _v1 => _v1 * 2)}

+

Price / 2: {derive(state.price, _v1 => _v1 / 2)}

+

Count % 3: {derive(state.count, _v1 => _v1 % 3)}

+ +

Complex Expressions

+

Discounted Price: {derive({ state_price: state.price, state_discount: state.discount }, ({ state_price: _v1, state_discount: _v2 }) => _v1 - (_v1 * _v2))}

+

Total: {derive({ state_price: state.price, state_quantity: state.quantity }, ({ state_price: _v1, state_quantity: _v2 }) => _v1 * _v2)}

+

With Tax (8%): {derive({ state_price: state.price, state_quantity: state.quantity }, ({ state_price: _v1, state_quantity: _v2 }) => (_v1 * _v2) * 1.08)}

+

Complex: {derive({ state_count: state.count, state_quantity: state.quantity, state_price: state.price, state_discount: state.discount }, ({ state_count: _v1, state_quantity: _v2, state_price: _v3, state_discount: _v4 }) => (_v1 + _v2) * _v3 - (_v3 * _v4))}

+ +

Multiple Same Ref

+

Count³: {derive(state.count, _v1 => _v1 * _v1 * _v1)}

+

Price Range: ${derive(state.price, _v1 => _v1 - 10)} - ${derive(state.price, _v1 => _v1 + 10)}

+
), + }; +}); diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-arithmetic-operations.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/jsx-arithmetic-operations.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/jsx-arithmetic-operations.input.tsx diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx similarity index 51% rename from packages/js-runtime/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx index 48f2ca530..5f5da3932 100644 --- a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-complex-mixed.expected.tsx @@ -1,5 +1,5 @@ /// -import { h, recipe, UI, ifElse, derive, JSONSchema } from "commontools"; +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; interface Item { id: number; name: string; @@ -57,34 +57,34 @@ export default recipe({ return { [UI]: (

Array Operations

-

Total items: {commontools_1.derive(state.items, _v1 => _v1.length)}

-

Filtered count: {commontools_1.derive({ state_items: state.items, state_filter: state.filter }, ({ state_items: _v1, state_filter: _v2 }) => _v1.filter(i => i.name.includes(_v2)).length)}

+

Total items: {state.items.length}

+

Filtered count: {derive({ state_items: state.items, state_filter: state.filter }, ({ state_items: _v1, state_filter: _v2 }) => _v1.filter(i => i.name.includes(_v2)).length)}

Array with Complex Expressions

    {state.items.map(item => (
  • {item.name} - Original: ${item.price} - - Discounted: ${commontools_1.derive(state.discount, _v1 => (item.price * (1 - _v1)).toFixed(2))} - - With tax: ${commontools_1.derive({ state_discount: state.discount, state_taxRate: state.taxRate }, ({ state_discount: _v1, state_taxRate: _v2 }) => (item.price * (1 - _v1) * (1 + _v2)).toFixed(2))} + - Discounted: ${derive({ item_price: item.price, state_discount: state.discount }, ({ item_price: _v1, state_discount: _v2 }) => (_v1 * (1 - _v2)).toFixed(2))} + - With tax: ${derive({ item_price: item.price, state_discount: state.discount, state_taxRate: state.taxRate }, ({ item_price: _v1, state_discount: _v2, state_taxRate: _v3 }) => (_v1 * (1 - _v2) * (1 + _v3)).toFixed(2))}
  • ))}

Array Methods

-

Item count: {commontools_1.derive(state.items, _v1 => _v1.length)}

-

Active items: {commontools_1.derive(state.items, _v1 => _v1.filter(i => i.active).length)}

+

Item count: {state.items.length}

+

Active items: {derive(state.items, _v1 => _v1.filter(i => i.active).length)}

Simple Operations

-

Discount percent: {commontools_1.derive(state.discount, _v1 => _v1 * 100)}%

-

Tax percent: {commontools_1.derive(state.taxRate, _v1 => _v1 * 100)}%

+

Discount percent: {derive(state.discount, _v1 => _v1 * 100)}%

+

Tax percent: {derive(state.taxRate, _v1 => _v1 * 100)}%

Array Predicates

-

All active: {commontools_1.ifElse(commontools_1.derive(state.items, _v1 => _v1.every(i => i.active)), "Yes", "No")}

-

Any active: {commontools_1.ifElse(commontools_1.derive(state.items, _v1 => _v1.some(i => i.active)), "Yes", "No")}

-

Has expensive (gt 100): {commontools_1.ifElse(commontools_1.derive(state.items, _v1 => _v1.some(i => i.price > 100)), "Yes", "No")}

+

All active: {ifElse(derive(state.items, _v1 => _v1.every(i => i.active)), "Yes", "No")}

+

Any active: {ifElse(derive(state.items, _v1 => _v1.some(i => i.active)), "Yes", "No")}

+

Has expensive (gt 100): {ifElse(derive(state.items, _v1 => _v1.some(i => i.price > 100)), "Yes", "No")}

Object Operations

-
_v1.length)} data-has-filter={commontools_1.derive(state.filter, _v1 => _v1.length > 0)} data-discount={state.discount}> +
_v1 > 0)} data-discount={state.discount}> Object attributes
), diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-complex-mixed.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-complex-mixed.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/jsx-complex-mixed.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/jsx-complex-mixed.input.tsx diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx new file mode 100644 index 000000000..28ddfb3ad --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.expected.tsx @@ -0,0 +1,63 @@ +/// +import { h, recipe, UI, ifElse, derive, JSONSchema } from "commontools"; +interface State { + isActive: boolean; + count: number; + userType: string; + score: number; + hasPermission: boolean; + isPremium: boolean; +} +export default recipe({ + type: "object", + properties: { + isActive: { + type: "boolean" + }, + count: { + type: "number" + }, + userType: { + type: "string" + }, + score: { + type: "number" + }, + hasPermission: { + type: "boolean" + }, + isPremium: { + type: "boolean" + } + }, + required: ["isActive", "count", "userType", "score", "hasPermission", "isPremium"] +} as const satisfies JSONSchema, (state) => { + return { + [UI]: (
+

Basic Ternary

+ {ifElse(state.isActive, "Active", "Inactive")} + {ifElse(state.hasPermission, "Authorized", "Denied")} + +

Ternary with Comparisons

+ {ifElse(derive(state.count, _v1 => _v1 > 10), "High", "Low")} + {ifElse(derive(state.score, _v1 => _v1 >= 90), "A", derive(state.score, _v1 => _v1 >= 80 ? "B" : "C"))} + {ifElse(derive(state.count, _v1 => _v1 === 0), "Empty", derive(state.count, _v1 => _v1 === 1 ? "Single" : "Multiple"))} + +

Nested Ternary

+ {ifElse(state.isActive, derive(state.isPremium, _v1 => (_v1 ? "Premium Active" : "Regular Active")), "Inactive")} + {ifElse(derive(state.userType, _v1 => _v1 === "admin"), "Admin", derive(state.userType, _v1 => _v1 === "user" ? "User" : "Guest"))} + +

Complex Conditions

+ {ifElse(derive({ state_isActive: state.isActive, state_hasPermission: state.hasPermission }, ({ state_isActive: _v1, state_hasPermission: _v2 }) => _v1 && _v2), "Full Access", "Limited Access")} + {ifElse(derive(state.count, _v1 => _v1 > 0 && _v1 < 10), "In Range", "Out of Range")} + {ifElse(derive({ state_isPremium: state.isPremium, state_score: state.score }, ({ state_isPremium: _v1, state_score: _v2 }) => _v1 || _v2 > 100), "Premium Features", "Basic Features")} + +

IfElse Component

+ {ifElse(state.isActive,
User is active with {state.count} items
,
User is inactive
)} + + {ifElse(derive(state.count, _v1 => _v1 > 5),
    +
  • Many items: {state.count}
  • +
,

Few items: {state.count}

)} +
), + }; +}); diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-conditional-rendering.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/jsx-conditional-rendering.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering.input.tsx diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx new file mode 100644 index 000000000..4f804c453 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.expected.tsx @@ -0,0 +1,81 @@ +/// +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; +interface State { + a: number; + b: number; + price: number; + text: string; + values: number[]; + name: string; + float: string; +} +export default recipe({ + type: "object", + properties: { + a: { + type: "number" + }, + b: { + type: "number" + }, + price: { + type: "number" + }, + text: { + type: "string" + }, + values: { + type: "array", + items: { + type: "number" + } + }, + name: { + type: "string" + }, + float: { + type: "string" + } + }, + required: ["a", "b", "price", "text", "values", "name", "float"] +} as const satisfies JSONSchema, (state) => { + return { + [UI]: (
+

Math Functions

+

Max: {derive({ state_a: state.a, state_b: state.b }, ({ state_a: _v1, state_b: _v2 }) => Math.max(_v1, _v2))}

+

Min: {derive(state.a, _v1 => Math.min(_v1, 10))}

+

Abs: {derive({ state_a: state.a, state_b: state.b }, ({ state_a: _v1, state_b: _v2 }) => Math.abs(_v1 - _v2))}

+

Round: {derive(state.price, _v1 => Math.round(_v1))}

+

Floor: {derive(state.price, _v1 => Math.floor(_v1))}

+

Ceiling: {derive(state.price, _v1 => Math.ceil(_v1))}

+

Square root: {derive(state.a, _v1 => Math.sqrt(_v1))}

+ +

String Methods as Function Calls

+

Uppercase: {derive(state.name, _v1 => _v1.toUpperCase())}

+

Lowercase: {derive(state.name, _v1 => _v1.toLowerCase())}

+

Substring: {derive(state.text, _v1 => _v1.substring(0, 5))}

+

Replace: {derive(state.text, _v1 => _v1.replace("old", "new"))}

+

Includes: {ifElse(derive(state.text, _v1 => _v1.includes("test")), "Yes", "No")}

+

Starts with: {ifElse(derive(state.name, _v1 => _v1.startsWith("A")), "Yes", "No")}

+ +

Number Methods

+

To Fixed: {derive(state.price, _v1 => _v1.toFixed(2))}

+

To Precision: {derive(state.price, _v1 => _v1.toPrecision(4))}

+ +

Parse Functions

+

Parse Int: {derive(state.float, _v1 => parseInt(_v1))}

+

Parse Float: {derive(state.float, _v1 => parseFloat(_v1))}

+ +

Array Method Calls

+

Sum: {derive(state.values, _v1 => _v1.reduce((a, b) => a + b, 0))}

+

Max value: {derive(state.values, _v1 => Math.max(..._v1))}

+

Joined: {derive(state.values, _v1 => _v1.join(", "))}

+ +

Complex Function Calls

+

Multiple args: {derive(state.a, _v1 => Math.pow(_v1, 2))}

+

Nested calls: {derive(state.a, _v1 => Math.round(Math.sqrt(_v1)))}

+

Chained calls: {derive(state.name, _v1 => _v1.trim().toUpperCase())}

+

With expressions: {derive({ state_a: state.a, state_b: state.b }, ({ state_a: _v1, state_b: _v2 }) => Math.max(_v1 + 1, _v2 * 2))}

+
), + }; +}); diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-function-calls.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/jsx-function-calls.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/jsx-function-calls.input.tsx diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx similarity index 70% rename from packages/js-runtime/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx index 18a056eee..e9ed79c8b 100644 --- a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-property-access.expected.tsx @@ -138,44 +138,44 @@ export default recipe({

Basic Property Access

{state.user.name}

Age: {state.user.age}

-

Active: {commontools_1.ifElse(state.user.active, "Yes", "No")}

+

Active: {ifElse(state.user.active, "Yes", "No")}

Nested Property Access

Bio: {state.user.profile.bio}

Location: {state.user.profile.location}

Theme: {state.user.profile.settings.theme}

-

Notifications: {commontools_1.ifElse(state.user.profile.settings.notifications, "On", "Off")}

+

Notifications: {ifElse(state.user.profile.settings.notifications, "On", "Off")}

Property Access with Operations

-

Age + 1: {commontools_1.derive(state.user.age, _v1 => _v1 + 1)}

-

Name length: {commontools_1.derive(state.user.name, _v1 => _v1.length)}

-

Uppercase name: {commontools_1.derive(state.user.name, _v1 => _v1.toUpperCase())}

-

Location includes city: {commontools_1.ifElse(commontools_1.derive(state.user.profile.location, _v1 => _v1.includes("City")), "Yes", "No")}

+

Age + 1: {derive(state.user.age, _v1 => _v1 + 1)}

+

Name length: {state.user.name.length}

+

Uppercase name: {derive(state.user.name, _v1 => _v1.toUpperCase())}

+

Location includes city: {ifElse(derive(state.user.profile.location, _v1 => _v1.includes("City")), "Yes", "No")}

Array Element Access

-

Item at index: {state.items[state.index.get()]}

+

Item at index: {derive({ state_items: state.items, state_index: state.index }, ({ state_items: _v1, state_index: _v2 }) => _v1[_v2])}

First item: {state.items[0]}

-

Last item: {state.items[state.items.get().length - 1]}

-

Number at index: {state.numbers[state.index.get()]}

+

Last item: {derive({ state_items: state.items, state_items_length: state.items.length }, ({ state_items: _v1, state_items_length: _v2 }) => _v1[_v2 - 1])}

+

Number at index: {derive({ state_numbers: state.numbers, state_index: state.index }, ({ state_numbers: _v1, state_index: _v2 }) => _v1[_v2])}

Config Access with Styles

+ color: state.config.theme.primaryColor, + fontSize: derive(state.config.theme.fontSize, _v1 => _v1 + "px") + }}> Styled text

+ backgroundColor: ifElse(state.config.features.darkMode, "#333", "#fff"), + borderColor: state.config.theme.secondaryColor + }}> Theme-aware box

Complex Property Chains

-

{commontools_1.derive({ state_user_name: state.user.name, state_user_profile_location: state.user.profile.location }, ({ state_user_name: _v1, state_user_profile_location: _v2 }) => _v1 + " from " + _v2)}

-

Font size + 2: {commontools_1.derive(state.config.theme.fontSize, _v1 => _v1 + 2)}px

-

Has beta and dark mode: {commontools_1.ifElse(commontools_1.derive({ state_config_features_beta: state.config.features.beta, state_config_features_darkMode: state.config.features.darkMode }, ({ state_config_features_beta: _v1, state_config_features_darkMode: _v2 }) => _v1 && _v2), "Yes", "No")}

+

{derive({ state_user_name: state.user.name, state_user_profile_location: state.user.profile.location }, ({ state_user_name: _v1, state_user_profile_location: _v2 }) => _v1 + " from " + _v2)}

+

Font size + 2: {derive(state.config.theme.fontSize, _v1 => _v1 + 2)}px

+

Has beta and dark mode: {ifElse(derive({ state_config_features_beta: state.config.features.beta, state_config_features_darkMode: state.config.features.darkMode }, ({ state_config_features_beta: _v1, state_config_features_darkMode: _v2 }) => _v1 && _v2), "Yes", "No")}

), }; }); diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-property-access.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-property-access.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/jsx-property-access.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/jsx-property-access.input.tsx diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx new file mode 100644 index 000000000..178b01a32 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.expected.tsx @@ -0,0 +1,55 @@ +/// +import { h, recipe, UI, derive, JSONSchema } from "commontools"; +interface State { + firstName: string; + lastName: string; + title: string; + message: string; + count: number; +} +export default recipe({ + type: "object", + properties: { + firstName: { + type: "string" + }, + lastName: { + type: "string" + }, + title: { + type: "string" + }, + message: { + type: "string" + }, + count: { + type: "number" + } + }, + required: ["firstName", "lastName", "title", "message", "count"] +} as const satisfies JSONSchema, (state) => { + return { + [UI]: (
+

String Concatenation

+

{derive({ state_title: state.title, state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_title: _v1, state_firstName: _v2, state_lastName: _v3 }) => _v1 + ": " + _v2 + " " + _v3)}

+

{derive({ state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_firstName: _v1, state_lastName: _v2 }) => _v1 + _v2)}

+

{derive(state.firstName, _v1 => "Hello, " + _v1 + "!")}

+ +

Template Literals

+

{derive(state.firstName, _v1 => `Welcome, ${_v1}!`)}

+

{derive({ state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_firstName: _v1, state_lastName: _v2 }) => `Full name: ${_v1} ${_v2}`)}

+

{derive({ state_title: state.title, state_firstName: state.firstName, state_lastName: state.lastName }, ({ state_title: _v1, state_firstName: _v2, state_lastName: _v3 }) => `${_v1}: ${_v2} ${_v3}`)}

+ +

String Methods

+

Uppercase: {derive(state.firstName, _v1 => _v1.toUpperCase())}

+

Lowercase: {derive(state.title, _v1 => _v1.toLowerCase())}

+

Length: {state.message.length}

+

Substring: {derive(state.message, _v1 => _v1.substring(0, 5))}

+ +

Mixed String and Number

+

{derive({ state_firstName: state.firstName, state_count: state.count }, ({ state_firstName: _v1, state_count: _v2 }) => _v1 + " has " + _v2 + " items")}

+

{derive({ state_firstName: state.firstName, state_count: state.count }, ({ state_firstName: _v1, state_count: _v2 }) => `${_v1} has ${_v2} items`)}

+

Count as string: {derive(state.count, _v1 => "Count: " + _v1)}

+
), + }; +}); diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/jsx-string-operations.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/jsx-string-operations.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/jsx-string-operations.input.tsx diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx new file mode 100644 index 000000000..8d5672328 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx @@ -0,0 +1,186 @@ +/// +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; +interface State { + text: string; + searchTerm: string; + items: number[]; + start: number; + end: number; + threshold: number; + factor: number; + names: string[]; + prefix: string; + prices: number[]; + discount: number; + taxRate: number; + users: Array<{ + name: string; + age: number; + active: boolean; + }>; + minAge: number; + words: string[]; + separator: string; +} +export default recipe({ + type: "object", + properties: { + text: { + type: "string" + }, + searchTerm: { + type: "string" + }, + items: { + type: "array", + items: { + type: "number" + } + }, + start: { + type: "number" + }, + end: { + type: "number" + }, + threshold: { + type: "number" + }, + factor: { + type: "number" + }, + names: { + type: "array", + items: { + type: "string" + } + }, + prefix: { + type: "string" + }, + prices: { + type: "array", + items: { + type: "number" + } + }, + discount: { + type: "number" + }, + taxRate: { + type: "number" + }, + users: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string" + }, + age: { + type: "number" + }, + active: { + type: "boolean" + } + }, + required: ["name", "age", "active"] + } + }, + minAge: { + type: "number" + }, + words: { + type: "array", + items: { + type: "string" + } + }, + separator: { + type: "string" + } + }, + required: ["text", "searchTerm", "items", "start", "end", "threshold", "factor", "names", "prefix", "prices", "discount", "taxRate", "users", "minAge", "words", "separator"] +} as const satisfies JSONSchema, (state) => { + return { + [UI]: (
+

Chained String Methods

+ {/* Simple chain */} +

Trimmed lower: {derive(state.text, _v1 => _v1.trim().toLowerCase())}

+ + {/* Chain with reactive argument */} +

Contains search: {derive({ state_text: state.text, state_searchTerm: state.searchTerm }, ({ state_text: _v1, state_searchTerm: _v2 }) => _v1.toLowerCase().includes(_v2.toLowerCase()))}

+ + {/* Longer chain */} +

Processed: {derive(state.text, _v1 => _v1.trim().toLowerCase().replace("old", "new").toUpperCase())}

+ +

Array Method Chains

+ {/* Filter then length */} +

Count above threshold: {derive({ state_items: state.items, state_threshold: state.threshold }, ({ state_items: _v1, state_threshold: _v2 }) => _v1.filter(x => x > _v2).length)}

+ + {/* Filter then map */} +
    + {derive({ state_items: state.items, state_threshold: state.threshold }, ({ state_items: _v1, state_threshold: _v2 }) => _v1.filter(x => x > _v2)).map(x => (
  • Value: {derive({ x, state_factor: state.factor }, ({ x: x, state_factor: _v2 }) => x * _v2)}
  • ))} +
+ + {/* Multiple filters */} +

Double filter count: {derive({ state_items: state.items, state_start: state.start, state_end: state.end }, ({ state_items: _v1, state_start: _v2, state_end: _v3 }) => _v1.filter(x => x > _v2).filter(x => x < _v3).length)}

+ +

Methods with Reactive Arguments

+ {/* Slice with reactive indices */} +

Sliced items: {derive({ state_items: state.items, state_start: state.start, state_end: state.end }, ({ state_items: _v1, state_start: _v2, state_end: _v3 }) => _v1.slice(_v2, _v3).join(", "))}

+ + {/* String methods with reactive args */} +

Starts with: {derive({ state_names: state.names, state_prefix: state.prefix }, ({ state_names: _v1, state_prefix: _v2 }) => _v1.filter(n => n.startsWith(_v2)).join(", "))}

+ + {/* Array find with reactive predicate */} +

First match: {derive({ state_names: state.names, state_searchTerm: state.searchTerm }, ({ state_names: _v1, state_searchTerm: _v2 }) => _v1.find(n => n.includes(_v2)))}

+ +

Complex Method Combinations

+ {/* Map with chained operations inside */} +
    + {state.names.map(name => (
  • {derive(name, name => name.trim().toLowerCase().replace(" ", "-"))}
  • ))} +
+ + {/* Reduce with reactive accumulator */} +

Total with discount: {derive({ state_prices: state.prices, state_discount: state.discount }, ({ state_prices: _v1, state_discount: _v2 }) => _v1.reduce((sum, price) => sum + price * (1 - _v2), 0))}

+ + {/* Method result used in computation */} +

Average * factor: {derive({ state_items: state.items, state_items_length: state.items.length, state_factor: state.factor }, ({ state_items: _v1, state_items_length: _v2, state_factor: _v3 }) => (_v1.reduce((a, b) => a + b, 0) / _v2) * _v3)}

+ +

Methods on Computed Values

+ {/* Method on binary expression result */} +

Formatted price: {derive({ state_prices: state.prices, state_discount: state.discount }, ({ state_prices: _v1, state_discount: _v2 }) => (_v1[0] * (1 - _v2)).toFixed(2))}

+ + {/* Method on conditional result */} +

Conditional trim: {derive({ state_text: state.text, state_text_length: state.text.length, state_prefix: state.prefix }, ({ state_text: _v1, state_text_length: _v2, state_prefix: _v3 }) => (_v2 > 10 ? _v1 : _v3).trim())}

+ + {/* Method chain on computed value */} +

Complex: {derive({ state_text: state.text, state_prefix: state.prefix }, ({ state_text: _v1, state_prefix: _v2 }) => (_v1 + " " + _v2).trim().toLowerCase().split(" ").join("-"))}

+ +

Array Methods with Complex Predicates

+ {/* Filter with multiple conditions */} +

Active adults: {derive({ state_users: state.users, state_minAge: state.minAge }, ({ state_users: _v1, state_minAge: _v2 }) => _v1.filter(u => u.age >= _v2 && u.active).length)}

+ + {/* Map with conditional logic */} +
    + {state.users.map(u => (
  • {ifElse(u.active, derive(u.name, _v1 => _v1.toUpperCase()), derive(u.name, _v1 => _v1.toLowerCase()))}
  • ))} +
+ + {/* Some/every with reactive predicates */} +

Has adults: {ifElse(derive({ state_users: state.users, state_minAge: state.minAge }, ({ state_users: _v1, state_minAge: _v2 }) => _v1.some(u => u.age >= _v2)), "Yes", "No")}

+

All active: {ifElse(derive(state.users, _v1 => _v1.every(u => u.active)), "Yes", "No")}

+ +

Method Calls in Expressions

+ {/* Method result in arithmetic */} +

Length sum: {derive({ state_text: state.text, state_prefix: state.prefix }, ({ state_text: _v1, state_prefix: _v2 }) => _v1.trim().length + _v2.trim().length)}

+ + {/* Method result in comparison */} +

Is long: {ifElse(derive({ state_text: state.text, state_threshold: state.threshold }, ({ state_text: _v1, state_threshold: _v2 }) => _v1.trim().length > _v2), "Yes", "No")}

+ + {/* Multiple method results combined */} +

Joined: {derive({ state_words: state.words, state_separator: state.separator }, ({ state_words: _v1, state_separator: _v2 }) => _v1.join(_v2).toUpperCase())}

+
), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.input.tsx new file mode 100644 index 000000000..711045632 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.input.tsx @@ -0,0 +1,112 @@ +/// +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; + +interface State { + text: string; + searchTerm: string; + items: number[]; + start: number; + end: number; + threshold: number; + factor: number; + names: string[]; + prefix: string; + prices: number[]; + discount: number; + taxRate: number; + users: Array<{ name: string; age: number; active: boolean }>; + minAge: number; + words: string[]; + separator: string; +} + +export default recipe("MethodChains", (state) => { + return { + [UI]: ( +
+

Chained String Methods

+ {/* Simple chain */} +

Trimmed lower: {state.text.trim().toLowerCase()}

+ + {/* Chain with reactive argument */} +

Contains search: {state.text.toLowerCase().includes(state.searchTerm.toLowerCase())}

+ + {/* Longer chain */} +

Processed: {state.text.trim().toLowerCase().replace("old", "new").toUpperCase()}

+ +

Array Method Chains

+ {/* Filter then length */} +

Count above threshold: {state.items.filter(x => x > state.threshold).length}

+ + {/* Filter then map */} +
    + {state.items.filter(x => x > state.threshold).map(x => ( +
  • Value: {x * state.factor}
  • + ))} +
+ + {/* Multiple filters */} +

Double filter count: {state.items.filter(x => x > state.start).filter(x => x < state.end).length}

+ +

Methods with Reactive Arguments

+ {/* Slice with reactive indices */} +

Sliced items: {state.items.slice(state.start, state.end).join(", ")}

+ + {/* String methods with reactive args */} +

Starts with: {state.names.filter(n => n.startsWith(state.prefix)).join(", ")}

+ + {/* Array find with reactive predicate */} +

First match: {state.names.find(n => n.includes(state.searchTerm))}

+ +

Complex Method Combinations

+ {/* Map with chained operations inside */} +
    + {state.names.map(name => ( +
  • {name.trim().toLowerCase().replace(" ", "-")}
  • + ))} +
+ + {/* Reduce with reactive accumulator */} +

Total with discount: {state.prices.reduce((sum, price) => sum + price * (1 - state.discount), 0)}

+ + {/* Method result used in computation */} +

Average * factor: {(state.items.reduce((a, b) => a + b, 0) / state.items.length) * state.factor}

+ +

Methods on Computed Values

+ {/* Method on binary expression result */} +

Formatted price: {(state.prices[0] * (1 - state.discount)).toFixed(2)}

+ + {/* Method on conditional result */} +

Conditional trim: {(state.text.length > 10 ? state.text : state.prefix).trim()}

+ + {/* Method chain on computed value */} +

Complex: {(state.text + " " + state.prefix).trim().toLowerCase().split(" ").join("-")}

+ +

Array Methods with Complex Predicates

+ {/* Filter with multiple conditions */} +

Active adults: {state.users.filter(u => u.age >= state.minAge && u.active).length}

+ + {/* Map with conditional logic */} +
    + {state.users.map(u => ( +
  • {u.active ? u.name.toUpperCase() : u.name.toLowerCase()}
  • + ))} +
+ + {/* Some/every with reactive predicates */} +

Has adults: {state.users.some(u => u.age >= state.minAge) ? "Yes" : "No"}

+

All active: {state.users.every(u => u.active) ? "Yes" : "No"}

+ +

Method Calls in Expressions

+ {/* Method result in arithmetic */} +

Length sum: {state.text.trim().length + state.prefix.trim().length}

+ + {/* Method result in comparison */} +

Is long: {state.text.trim().length > state.threshold ? "Yes" : "No"}

+ + {/* Multiple method results combined */} +

Joined: {state.words.join(state.separator).toUpperCase()}

+
+ ), + }; +}); \ No newline at end of file diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/no-double-derive.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/no-double-derive.expected.tsx new file mode 100644 index 000000000..96b036003 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/no-double-derive.expected.tsx @@ -0,0 +1,22 @@ +/// +import { derive, h } from "commontools"; +// Test case: User-written derive calls should not be double-wrapped +// This tests that derive(index, (i) => i + 1) doesn't become derive(index, index => derive(index, (i) => i + 1)) +export default function TestComponent({ items, cellRef }) { + return (
+ {/* User-written derive with simple parameter transformation - should NOT be double-wrapped */} + Count: {derive(items.length, (n) => n + 1)} + + {/* User-written derive accessing opaque ref property - should NOT be double-wrapped */} + Name: {derive(cellRef, (ref) => ref.name || "Unknown")} + + {/* Nested in map with user-written derive - derives should NOT be double-wrapped */} + {items.map((item, index) => (
  • + {/* These user-written derives should remain as-is, not wrapped in another derive */} + Item {derive(index, (i) => i + 1)}: {derive(item, (it) => it.title)} +
  • ))} + + {/* Simple property access - should NOT be transformed */} + Direct access: {cellRef.value} +
    ); +} \ No newline at end of file diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/no-double-derive.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/no-double-derive.input.tsx new file mode 100644 index 000000000..383de7e7d --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/no-double-derive.input.tsx @@ -0,0 +1,27 @@ +/// +import { derive, h } from "commontools"; + +// Test case: User-written derive calls should not be double-wrapped +// This tests that derive(index, (i) => i + 1) doesn't become derive(index, index => derive(index, (i) => i + 1)) +export default function TestComponent({ items, cellRef }) { + return ( +
    + {/* User-written derive with simple parameter transformation - should NOT be double-wrapped */} + Count: {derive(items.length, (n) => n + 1)} + + {/* User-written derive accessing opaque ref property - should NOT be double-wrapped */} + Name: {derive(cellRef, (ref) => ref.name || "Unknown")} + + {/* Nested in map with user-written derive - derives should NOT be double-wrapped */} + {items.map((item, index) => ( +
  • + {/* These user-written derives should remain as-is, not wrapped in another derive */} + Item {derive(index, (i) => i + 1)}: {derive(item, (it) => it.title)} +
  • + ))} + + {/* Simple property access - should NOT be transformed */} + Direct access: {cellRef.value} +
    + ); +} \ No newline at end of file diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/no-transform-simple-ref.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/no-transform-simple-ref.expected.tsx similarity index 99% rename from packages/js-runtime/test/fixtures/jsx-expressions/no-transform-simple-ref.expected.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/no-transform-simple-ref.expected.tsx index 2a358958a..e83e3b0eb 100644 --- a/packages/js-runtime/test/fixtures/jsx-expressions/no-transform-simple-ref.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/no-transform-simple-ref.expected.tsx @@ -7,4 +7,3 @@ export default recipe("test", (state) => { [NAME]: "test", }; }); - diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/no-transform-simple-ref.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/no-transform-simple-ref.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/no-transform-simple-ref.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/no-transform-simple-ref.input.tsx diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx new file mode 100644 index 000000000..4d75a5bfc --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx @@ -0,0 +1,116 @@ +/// +import { Cell, cell, createCell, derive, h, handler, ifElse, lift, NAME, navigateTo, recipe, UI, JSONSchema } from "commontools"; +// the simple charm (to which we'll store references within a cell) +const SimpleRecipe = recipe("Simple Recipe", () => ({ + [NAME]: "Some Simple Recipe", + [UI]:
    Some Simple Recipe
    , +})); +// Create a cell to store an array of charms +const createCellRef = lift({ + type: "object", + properties: { + isInitialized: { type: "boolean", default: false, asCell: true }, + storedCellRef: { type: "object", asCell: true }, + }, +}, undefined, ({ isInitialized, storedCellRef }) => { + if (!isInitialized.get()) { + console.log("Creating cellRef - first time"); + const newCellRef = createCell(undefined, "charmsArray"); + newCellRef.set([]); + storedCellRef.set(newCellRef); + isInitialized.set(true); + return { + cellRef: newCellRef, + }; + } + else { + console.log("cellRef already initialized"); + } + // If already initialized, return the stored cellRef + return { + cellRef: storedCellRef, + }; +}); +// Add a charm to the array and navigate to it +// we get a new isInitialized passed in for each +// charm we add to the list. this makes sure +// we only try to add the charm once to the list +// and we only call navigateTo once +const addCharmAndNavigate = lift({ + type: "object", + properties: { + charm: { type: "object" }, + cellRef: { type: "array", asCell: true }, + isInitialized: { type: "boolean", asCell: true }, + }, +}, undefined, ({ charm, cellRef, isInitialized }) => { + if (!isInitialized.get()) { + if (cellRef) { + cellRef.push(charm); + isInitialized.set(true); + return navigateTo(charm); + } + else { + console.log("addCharmAndNavigate undefined cellRef"); + } + } + return undefined; +}); +// Create a new SimpleRecipe and add it to the array +const createSimpleRecipe = handler({} as const satisfies JSONSchema, { + type: "object", + properties: { + cellRef: { + type: "array", + items: true, + asCell: true + } + }, + required: ["cellRef"] +} as const satisfies JSONSchema, (_, { cellRef }) => { + // Create isInitialized cell for this charm addition + const isInitialized = cell(false); + // Create the charm + const charm = SimpleRecipe({}); + // Store the charm in the array and navigate + return addCharmAndNavigate({ charm, cellRef, isInitialized }); +}); +// Handler to navigate to a specific charm from the list +const goToCharm = handler({} as const satisfies JSONSchema, { + type: "object", + properties: { + charm: {} + }, + required: ["charm"] +} as const satisfies JSONSchema, (_, { charm }) => { + console.log("goToCharm clicked"); + return navigateTo(charm); +}); +// create the named cell inside the recipe body, so we do it just once +export default recipe("Charms Launcher", () => { + // cell to store array of charms we created + const { cellRef } = createCellRef({ + isInitialized: cell(false), + storedCellRef: cell(), + }); + return { + [NAME]: "Charms Launcher", + [UI]: (
    +

    Stored Charms:

    + {ifElse(!cellRef?.length,
    No charms created yet
    ,
      + {cellRef.map((charm: any, index: number) => (
    • + + Go to Charm {derive(index, index => index + 1)} + + Charm {derive(index, index => index + 1)}: {derive(charm, charm => charm[NAME] || "Unnamed")} +
    • ))} +
    )} + + + Create New Charm + +
    ), + cellRef, + }; +}); + diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.input.tsx new file mode 100644 index 000000000..3f35a33ef --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.input.tsx @@ -0,0 +1,143 @@ +/// +import { + Cell, + cell, + createCell, + derive, + h, + handler, + ifElse, + lift, + NAME, + navigateTo, + recipe, + UI, +} from "commontools"; + +// the simple charm (to which we'll store references within a cell) +const SimpleRecipe = recipe("Simple Recipe", () => ({ + [NAME]: "Some Simple Recipe", + [UI]:
    Some Simple Recipe
    , +})); + +// Create a cell to store an array of charms +const createCellRef = lift( + { + type: "object", + properties: { + isInitialized: { type: "boolean", default: false, asCell: true }, + storedCellRef: { type: "object", asCell: true }, + }, + }, + undefined, + ({ isInitialized, storedCellRef }) => { + if (!isInitialized.get()) { + console.log("Creating cellRef - first time"); + const newCellRef = createCell(undefined, "charmsArray"); + newCellRef.set([]); + storedCellRef.set(newCellRef); + isInitialized.set(true); + return { + cellRef: newCellRef, + }; + } else { + console.log("cellRef already initialized"); + } + // If already initialized, return the stored cellRef + return { + cellRef: storedCellRef, + }; + }, +); + +// Add a charm to the array and navigate to it +// we get a new isInitialized passed in for each +// charm we add to the list. this makes sure +// we only try to add the charm once to the list +// and we only call navigateTo once +const addCharmAndNavigate = lift( + { + type: "object", + properties: { + charm: { type: "object" }, + cellRef: { type: "array", asCell: true }, + isInitialized: { type: "boolean", asCell: true }, + }, + }, + undefined, + ({ charm, cellRef, isInitialized }) => { + if (!isInitialized.get()) { + if (cellRef) { + cellRef.push(charm); + isInitialized.set(true); + return navigateTo(charm); + } else { + console.log("addCharmAndNavigate undefined cellRef"); + } + } + return undefined; + }, +); + +// Create a new SimpleRecipe and add it to the array +const createSimpleRecipe = handler }>( + (_, { cellRef }) => { + // Create isInitialized cell for this charm addition + const isInitialized = cell(false); + + // Create the charm + const charm = SimpleRecipe({}); + + // Store the charm in the array and navigate + return addCharmAndNavigate({ charm, cellRef, isInitialized }); + }, +); + +// Handler to navigate to a specific charm from the list +const goToCharm = handler( + (_, { charm }) => { + console.log("goToCharm clicked"); + return navigateTo(charm); + }, +); + +// create the named cell inside the recipe body, so we do it just once +export default recipe("Charms Launcher", () => { + // cell to store array of charms we created + const { cellRef } = createCellRef({ + isInitialized: cell(false), + storedCellRef: cell(), + }); + + return { + [NAME]: "Charms Launcher", + [UI]: ( +
    +

    Stored Charms:

    + {ifElse( + !cellRef?.length, +
    No charms created yet
    , +
      + {cellRef.map((charm: any, index: number) => ( +
    • + + Go to Charm {index + 1} + + Charm {index + 1}: {charm[NAME] || "Unnamed"} +
    • + ))} +
    , + )} + + + Create New Charm + +
    + ), + cellRef, + }; +}); diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx similarity index 56% rename from packages/js-runtime/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx index bc85d20ee..2959e6869 100644 --- a/packages/js-runtime/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx @@ -6,10 +6,9 @@ export default recipe("OpaqueRefOperations", (state) => { return { [UI]: (

    Count: {count}

    -

    Next: {commontools_1.derive(count, count => count + 1)}

    -

    Double: {commontools_1.derive(count, count => count * 2)}

    -

    Total: {commontools_1.derive(price, price => price * 1.1)}

    +

    Next: {derive(count, count => count + 1)}

    +

    Double: {derive(count, count => count * 2)}

    +

    Total: {derive(price, price => price * 1.1)}

    ) }; }); - diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/opaque-ref-operations.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/opaque-ref-operations.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.input.tsx diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.expected.tsx new file mode 100644 index 000000000..121aca65d --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.expected.tsx @@ -0,0 +1,394 @@ +/// +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; +interface State { + user: { + name: string; + age: number; + email: string; + profile: { + bio: string; + location: string; + website: string; + }; + settings: { + theme: string; + notifications: boolean; + privacy: string; + }; + }; + config: { + theme: { + colors: { + primary: string; + secondary: string; + background: string; + }; + fonts: { + heading: string; + body: string; + mono: string; + }; + spacing: { + small: number; + medium: number; + large: number; + }; + }; + features: { + darkMode: boolean; + animations: boolean; + betaFeatures: boolean; + }; + }; + data: { + items: Array<{ + id: number; + name: string; + value: number; + }>; + totals: { + count: number; + sum: number; + average: number; + }; + }; + deeply: { + nested: { + structure: { + with: { + many: { + levels: { + value: string; + count: number; + }; + }; + }; + }; + }; + }; + arrays: { + first: string[]; + second: number[]; + nested: Array<{ + items: string[]; + count: number; + }>; + }; +} +export default recipe({ + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { + type: "string" + }, + age: { + type: "number" + }, + email: { + type: "string" + }, + profile: { + type: "object", + properties: { + bio: { + type: "string" + }, + location: { + type: "string" + }, + website: { + type: "string" + } + }, + required: ["bio", "location", "website"] + }, + settings: { + type: "object", + properties: { + theme: { + type: "string" + }, + notifications: { + type: "boolean" + }, + privacy: { + type: "string" + } + }, + required: ["theme", "notifications", "privacy"] + } + }, + required: ["name", "age", "email", "profile", "settings"] + }, + config: { + type: "object", + properties: { + theme: { + type: "object", + properties: { + colors: { + type: "object", + properties: { + primary: { + type: "string" + }, + secondary: { + type: "string" + }, + background: { + type: "string" + } + }, + required: ["primary", "secondary", "background"] + }, + fonts: { + type: "object", + properties: { + heading: { + type: "string" + }, + body: { + type: "string" + }, + mono: { + type: "string" + } + }, + required: ["heading", "body", "mono"] + }, + spacing: { + type: "object", + properties: { + small: { + type: "number" + }, + medium: { + type: "number" + }, + large: { + type: "number" + } + }, + required: ["small", "medium", "large"] + } + }, + required: ["colors", "fonts", "spacing"] + }, + features: { + type: "object", + properties: { + darkMode: { + type: "boolean" + }, + animations: { + type: "boolean" + }, + betaFeatures: { + type: "boolean" + } + }, + required: ["darkMode", "animations", "betaFeatures"] + } + }, + required: ["theme", "features"] + }, + data: { + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: { + id: { + type: "number" + }, + name: { + type: "string" + }, + value: { + type: "number" + } + }, + required: ["id", "name", "value"] + } + }, + totals: { + type: "object", + properties: { + count: { + type: "number" + }, + sum: { + type: "number" + }, + average: { + type: "number" + } + }, + required: ["count", "sum", "average"] + } + }, + required: ["items", "totals"] + }, + deeply: { + type: "object", + properties: { + nested: { + type: "object", + properties: { + structure: { + type: "object", + properties: { + with: { + type: "object", + properties: { + many: { + type: "object", + properties: { + levels: { + type: "object", + properties: { + value: { + type: "string" + }, + count: { + type: "number" + } + }, + required: ["value", "count"] + } + }, + required: ["levels"] + } + }, + required: ["many"] + } + }, + required: ["with"] + } + }, + required: ["structure"] + } + }, + required: ["nested"] + }, + arrays: { + type: "object", + properties: { + first: { + type: "array", + items: { + type: "string" + } + }, + second: { + type: "array", + items: { + type: "number" + } + }, + nested: { + type: "array", + items: { + type: "object", + properties: { + items: { + type: "array", + items: { + type: "string" + } + }, + count: { + type: "number" + } + }, + required: ["items", "count"] + } + } + }, + required: ["first", "second", "nested"] + } + }, + required: ["user", "config", "data", "deeply", "arrays"] +} as const satisfies JSONSchema, (state) => { + return { + [UI]: (
    +

    Same Base, Different Properties

    + {/* Multiple accesses to same object in one expression */} +

    User info: {state.user.name} (age: {state.user.age}, email: {state.user.email})

    + + {/* String concatenation with multiple property accesses */} +

    Full profile: {derive({ state_user_name: state.user.name, state_user_profile_location: state.user.profile.location, state_user_profile_bio: state.user.profile.bio }, ({ state_user_name: _v1, state_user_profile_location: _v2, state_user_profile_bio: _v3 }) => _v1 + " from " + _v2 + " - " + _v3)}

    + + {/* Arithmetic with multiple properties from same base */} +

    Age calculation: {derive(state.user.age, _v1 => _v1 * 12)} months, or {derive(state.user.age, _v1 => _v1 * 365)} days

    + +

    Deeply Nested Property Chains

    + {/* Multiple references to deeply nested object */} +

    Theme: {state.config.theme.colors.primary} / {state.config.theme.colors.secondary} on {state.config.theme.colors.background}

    + + {/* Fonts from same nested structure */} +

    Typography: Headings in {state.config.theme.fonts.heading}, body in {state.config.theme.fonts.body}, code in {state.config.theme.fonts.mono}

    + + {/* Mixed depth accesses */} +

    Config summary: Dark mode {ifElse(state.config.features.darkMode, "enabled", "disabled")} with {state.config.theme.colors.primary} primary color

    + +

    Very Deep Nesting with Multiple References

    + {/* Accessing different properties at same deep level */} +

    Deep value: {state.deeply.nested.structure.with.many.levels.value} (count: {state.deeply.nested.structure.with.many.levels.count})

    + + {/* Mixed depth from same root */} +

    Mixed depths: {state.deeply.nested.structure.with.many.levels.value} in {state.deeply.nested.structure.with.many.levels.count} items

    + +

    Arrays with Shared Base

    + {/* Multiple array properties */} +

    Array info: First has {state.arrays.first.length} items, second has {state.arrays.second.length} items

    + + {/* Nested array access with shared base */} +

    Nested: {state.arrays.nested[0].items.length} items in first, count is {state.arrays.nested[0].count}

    + + {/* Array and property access mixed */} +

    First item: {state.arrays.first[0]} (total: {state.arrays.first.length})

    + +

    Complex Expressions with Shared Bases

    + {/* Conditional with multiple property accesses */} +

    Status: {ifElse(state.user.settings.notifications, derive({ state_user_name: state.user.name, state_user_settings_theme: state.user.settings.theme }, ({ state_user_name: _v1, state_user_settings_theme: _v2 }) => _v1 + " has notifications on with " + _v2 + " theme"), derive(state.user.name, _v1 => _v1 + " has notifications off"))}

    + + {/* Computed expression with shared base */} +

    Spacing calc: {derive({ state_config_theme_spacing_small: state.config.theme.spacing.small, state_config_theme_spacing_medium: state.config.theme.spacing.medium, state_config_theme_spacing_large: state.config.theme.spacing.large }, ({ state_config_theme_spacing_small: _v1, state_config_theme_spacing_medium: _v2, state_config_theme_spacing_large: _v3 }) => _v1 + _v2 + _v3)} total

    + + {/* Boolean expressions with multiple properties */} +

    Features: {ifElse(derive({ state_config_features_darkMode: state.config.features.darkMode, state_config_features_animations: state.config.features.animations }, ({ state_config_features_darkMode: _v1, state_config_features_animations: _v2 }) => _v1 && _v2), "Full features", "Limited features")}

    + +

    Method Calls on Shared Bases

    + {/* Multiple method calls on properties from same base */} +

    Formatted: {derive(state.user.name, _v1 => _v1.toUpperCase())} - {derive(state.user.email, _v1 => _v1.toLowerCase())}

    + + {/* Property access and method calls mixed */} +

    Profile length: {state.user.profile.bio.length} chars in bio, {state.user.profile.location.length} chars in location

    + +

    Edge Cases for Parent Suppression

    + {/* Same intermediate parent used differently */} +

    User settings: Theme is {state.user.settings.theme} with privacy {state.user.settings.privacy}

    + + {/* Parent and child both used */} +

    Data summary: {state.data.items.length} items with average {state.data.totals.average}

    + + {/* Multiple levels of the same chain */} +

    Nested refs: {state.config.theme.colors.primary} in {state.config.theme.fonts.body} with {ifElse(state.config.features.animations, "animations", "no animations")}

    + +

    Extreme Parent Suppression Test

    + {/* Using every level of a deep chain */} +

    All levels: + Root: {ifElse(state.deeply, "exists", "missing")}, + Nested: {ifElse(state.deeply.nested, "exists", "missing")}, + Value: {state.deeply.nested.structure.with.many.levels.value} +

    +
    ), + }; +}); + diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.input.tsx new file mode 100644 index 000000000..95bf627c4 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/parent-suppression-edge.input.tsx @@ -0,0 +1,160 @@ +/// +import { h, recipe, UI, derive, ifElse, JSONSchema } from "commontools"; + +interface State { + user: { + name: string; + age: number; + email: string; + profile: { + bio: string; + location: string; + website: string; + }; + settings: { + theme: string; + notifications: boolean; + privacy: string; + }; + }; + config: { + theme: { + colors: { + primary: string; + secondary: string; + background: string; + }; + fonts: { + heading: string; + body: string; + mono: string; + }; + spacing: { + small: number; + medium: number; + large: number; + }; + }; + features: { + darkMode: boolean; + animations: boolean; + betaFeatures: boolean; + }; + }; + data: { + items: Array<{ + id: number; + name: string; + value: number; + }>; + totals: { + count: number; + sum: number; + average: number; + }; + }; + deeply: { + nested: { + structure: { + with: { + many: { + levels: { + value: string; + count: number; + }; + }; + }; + }; + }; + }; + arrays: { + first: string[]; + second: number[]; + nested: Array<{ + items: string[]; + count: number; + }>; + }; +} + +export default recipe("ParentSuppressionEdge", (state) => { + return { + [UI]: ( +
    +

    Same Base, Different Properties

    + {/* Multiple accesses to same object in one expression */} +

    User info: {state.user.name} (age: {state.user.age}, email: {state.user.email})

    + + {/* String concatenation with multiple property accesses */} +

    Full profile: {state.user.name + " from " + state.user.profile.location + " - " + state.user.profile.bio}

    + + {/* Arithmetic with multiple properties from same base */} +

    Age calculation: {state.user.age * 12} months, or {state.user.age * 365} days

    + +

    Deeply Nested Property Chains

    + {/* Multiple references to deeply nested object */} +

    Theme: {state.config.theme.colors.primary} / {state.config.theme.colors.secondary} on {state.config.theme.colors.background}

    + + {/* Fonts from same nested structure */} +

    Typography: Headings in {state.config.theme.fonts.heading}, body in {state.config.theme.fonts.body}, code in {state.config.theme.fonts.mono}

    + + {/* Mixed depth accesses */} +

    Config summary: Dark mode {state.config.features.darkMode ? "enabled" : "disabled"} with {state.config.theme.colors.primary} primary color

    + +

    Very Deep Nesting with Multiple References

    + {/* Accessing different properties at same deep level */} +

    Deep value: {state.deeply.nested.structure.with.many.levels.value} (count: {state.deeply.nested.structure.with.many.levels.count})

    + + {/* Mixed depth from same root */} +

    Mixed depths: {state.deeply.nested.structure.with.many.levels.value} in {state.deeply.nested.structure.with.many.levels.count} items

    + +

    Arrays with Shared Base

    + {/* Multiple array properties */} +

    Array info: First has {state.arrays.first.length} items, second has {state.arrays.second.length} items

    + + {/* Nested array access with shared base */} +

    Nested: {state.arrays.nested[0].items.length} items in first, count is {state.arrays.nested[0].count}

    + + {/* Array and property access mixed */} +

    First item: {state.arrays.first[0]} (total: {state.arrays.first.length})

    + +

    Complex Expressions with Shared Bases

    + {/* Conditional with multiple property accesses */} +

    Status: {state.user.settings.notifications ? + state.user.name + " has notifications on with " + state.user.settings.theme + " theme" : + state.user.name + " has notifications off"}

    + + {/* Computed expression with shared base */} +

    Spacing calc: {state.config.theme.spacing.small + state.config.theme.spacing.medium + state.config.theme.spacing.large} total

    + + {/* Boolean expressions with multiple properties */} +

    Features: {state.config.features.darkMode && state.config.features.animations ? "Full features" : "Limited features"}

    + +

    Method Calls on Shared Bases

    + {/* Multiple method calls on properties from same base */} +

    Formatted: {state.user.name.toUpperCase()} - {state.user.email.toLowerCase()}

    + + {/* Property access and method calls mixed */} +

    Profile length: {state.user.profile.bio.length} chars in bio, {state.user.profile.location.length} chars in location

    + +

    Edge Cases for Parent Suppression

    + {/* Same intermediate parent used differently */} +

    User settings: Theme is {state.user.settings.theme} with privacy {state.user.settings.privacy}

    + + {/* Parent and child both used */} +

    Data summary: {state.data.items.length} items with average {state.data.totals.average}

    + + {/* Multiple levels of the same chain */} +

    Nested refs: {state.config.theme.colors.primary} in {state.config.theme.fonts.body} with {state.config.features.animations ? "animations" : "no animations"}

    + +

    Extreme Parent Suppression Test

    + {/* Using every level of a deep chain */} +

    All levels: + Root: {state.deeply ? "exists" : "missing"}, + Nested: {state.deeply.nested ? "exists" : "missing"}, + Value: {state.deeply.nested.structure.with.many.levels.value} +

    +
    + ), + }; +}); \ No newline at end of file diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx similarity index 84% rename from packages/js-runtime/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx index 7068889a6..56d8ebf19 100644 --- a/packages/js-runtime/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.expected.tsx @@ -1,5 +1,5 @@ /// -import { recipe, UI, NAME, str, handler, h, Cell, ifElse, derive, JSONSchema } from "commontools"; +import { recipe, UI, NAME, str, handler, h, Cell, derive, ifElse, JSONSchema } from "commontools"; interface RecipeState { value: number; } @@ -58,13 +58,13 @@ export default recipe({ {/* These SHOULD be transformed (JSX expression context) */} Current: {state.value}
    - Next number: {commontools_1.derive(state.value, _v1 => _v1 + 1)} + Next number: {derive(state.value, _v1 => _v1 + 1)}
    - Previous: {commontools_1.derive(state.value, _v1 => _v1 - 1)} + Previous: {derive(state.value, _v1 => _v1 - 1)}
    - Doubled: {commontools_1.derive(state.value, _v1 => _v1 * 2)} + Doubled: {derive(state.value, _v1 => _v1 * 2)}
    - Status: {commontools_1.ifElse(commontools_1.derive(state.value, _v1 => _v1 > 10), "High", "Low")} + Status: {ifElse(derive(state.value, _v1 => _v1 > 10), "High", "Low")}

    +
    ), @@ -78,4 +78,3 @@ export default recipe({ } }; }); - diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/recipe-statements-vs-jsx.input.tsx diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx similarity index 72% rename from packages/js-runtime/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx index e36336dda..c7c9415eb 100644 --- a/packages/js-runtime/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-with-cells.expected.tsx @@ -12,10 +12,9 @@ export default recipe({ return { [UI]: (

    Current value: {cell.value}

    -

    Next value: {commontools_1.derive(cell.value, _v1 => _v1 + 1)}

    -

    Double: {commontools_1.derive(cell.value, _v1 => _v1 * 2)}

    +

    Next value: {derive(cell.value, _v1 => _v1 + 1)}

    +

    Double: {derive(cell.value, _v1 => _v1 * 2)}

    ), value: cell.value, }; }); - diff --git a/packages/js-runtime/test/fixtures/jsx-expressions/recipe-with-cells.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/recipe-with-cells.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/jsx-expressions/recipe-with-cells.input.tsx rename to packages/ts-transformers/test/fixtures/jsx-expressions/recipe-with-cells.input.tsx diff --git a/packages/js-runtime/test/fixtures/schema-transform/opaque-ref-map.expected.ts b/packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.expected.ts similarity index 92% rename from packages/js-runtime/test/fixtures/schema-transform/opaque-ref-map.expected.ts rename to packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.expected.ts index 861461698..b462e412d 100644 --- a/packages/js-runtime/test/fixtures/schema-transform/opaque-ref-map.expected.ts +++ b/packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.expected.ts @@ -1,4 +1,4 @@ -import { recipe, OpaqueRef, JSONSchema } from "commontools"; +import { OpaqueRef, recipe, JSONSchema } from "commontools"; interface TodoItem { title: string; done: boolean; @@ -37,7 +37,7 @@ export default recipe({ const filtered = items.map((item, index) => ({ title: item.title, done: item.done, - position: index + position: index, })); return { mapped, filtered }; }); diff --git a/packages/js-runtime/test/fixtures/schema-transform/opaque-ref-map.input.ts b/packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.input.ts similarity index 77% rename from packages/js-runtime/test/fixtures/schema-transform/opaque-ref-map.input.ts rename to packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.input.ts index a55e48faa..25fe89c5e 100644 --- a/packages/js-runtime/test/fixtures/schema-transform/opaque-ref-map.input.ts +++ b/packages/ts-transformers/test/fixtures/schema-transform/opaque-ref-map.input.ts @@ -1,4 +1,4 @@ -import { recipe, OpaqueRef } from "commontools"; +import { OpaqueRef, recipe } from "commontools"; interface TodoItem { title: string; @@ -9,12 +9,13 @@ export default recipe<{ items: TodoItem[] }>("Test Map", ({ items }) => { // This should NOT be transformed to items.get().map() // because OpaqueRef has its own map method const mapped = items.map((item) => item.title); - + // This should also work without transformation const filtered = items.map((item, index) => ({ - ...item, - position: index + title: item.title, + done: item.done, + position: index, })); - + return { mapped, filtered }; -}); \ No newline at end of file +}); diff --git a/packages/js-runtime/test/fixtures/schema-transform/recipe-with-types.expected.tsx b/packages/ts-transformers/test/fixtures/schema-transform/recipe-with-types.expected.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/schema-transform/recipe-with-types.expected.tsx rename to packages/ts-transformers/test/fixtures/schema-transform/recipe-with-types.expected.tsx diff --git a/packages/js-runtime/test/fixtures/schema-transform/recipe-with-types.input.tsx b/packages/ts-transformers/test/fixtures/schema-transform/recipe-with-types.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/schema-transform/recipe-with-types.input.tsx rename to packages/ts-transformers/test/fixtures/schema-transform/recipe-with-types.input.tsx diff --git a/packages/js-runtime/test/fixtures/schema-transform/with-opaque-ref.expected.tsx b/packages/ts-transformers/test/fixtures/schema-transform/with-opaque-ref.expected.tsx similarity index 89% rename from packages/js-runtime/test/fixtures/schema-transform/with-opaque-ref.expected.tsx rename to packages/ts-transformers/test/fixtures/schema-transform/with-opaque-ref.expected.tsx index 9c7d8dd66..4b439a6e4 100644 --- a/packages/js-runtime/test/fixtures/schema-transform/with-opaque-ref.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-transform/with-opaque-ref.expected.tsx @@ -20,10 +20,9 @@ export default recipe(model, model, (cell) => { const doubled = derive(cell.value, (v) => v * 2); return { [UI]: (
    -

    Value: {commontools_1.derive(cell, cell => cell.value)}

    +

    Value: {cell.value}

    Doubled: {doubled}

    ), value: cell.value, }; }); - diff --git a/packages/js-runtime/test/fixtures/schema-transform/with-opaque-ref.input.tsx b/packages/ts-transformers/test/fixtures/schema-transform/with-opaque-ref.input.tsx similarity index 100% rename from packages/js-runtime/test/fixtures/schema-transform/with-opaque-ref.input.tsx rename to packages/ts-transformers/test/fixtures/schema-transform/with-opaque-ref.input.tsx diff --git a/packages/js-runtime/test/fixtures/schema-transform/with-options.expected.ts b/packages/ts-transformers/test/fixtures/schema-transform/with-options.expected.ts similarity index 100% rename from packages/js-runtime/test/fixtures/schema-transform/with-options.expected.ts rename to packages/ts-transformers/test/fixtures/schema-transform/with-options.expected.ts diff --git a/packages/js-runtime/test/fixtures/schema-transform/with-options.input.ts b/packages/ts-transformers/test/fixtures/schema-transform/with-options.input.ts similarity index 100% rename from packages/js-runtime/test/fixtures/schema-transform/with-options.input.ts rename to packages/ts-transformers/test/fixtures/schema-transform/with-options.input.ts diff --git a/packages/ts-transformers/test/opaque-ref/dataflow.test.ts b/packages/ts-transformers/test/opaque-ref/dataflow.test.ts new file mode 100644 index 000000000..3f6307848 --- /dev/null +++ b/packages/ts-transformers/test/opaque-ref/dataflow.test.ts @@ -0,0 +1,54 @@ +import { describe, it } from "@std/testing/bdd"; +import { assert, assertEquals } from "@std/assert"; + +import { analyzeExpression } from "./harness.ts"; + +describe("data flow analyzer", () => { + it("marks ifElse predicate for selective rewriting", () => { + const { analysis } = analyzeExpression( + "ifElse(state.count > 3, 'hi', 'bye')", + ); + + assert( + analysis.rewriteHint && analysis.rewriteHint.kind === "call-if-else", + ); + assertEquals( + analysis.rewriteHint.predicate.getText(), + "state.count > 3", + ); + }); + + it("identifies array map calls that should skip wrapping", () => { + const { analysis } = analyzeExpression( + "state.items.map(item => item + state.count)", + ); + + assert( + analysis.rewriteHint && analysis.rewriteHint.kind === "skip-call-rewrite", + ); + assertEquals(analysis.rewriteHint.reason, "array-map"); + }); + + it("recognises ifElse when called via alias", () => { + const { analysis } = analyzeExpression( + "aliasIfElse(state.count > 3, 'hi', 'bye')", + { prelude: "declare const aliasIfElse: typeof ifElse;" }, + ); + + assert( + analysis.rewriteHint && analysis.rewriteHint.kind === "call-if-else", + ); + }); + + it("recognises builders when called via alias", () => { + const { analysis } = analyzeExpression( + "aliasRecipe(() => state.count)", + { prelude: "declare const aliasRecipe: typeof recipe;" }, + ); + + assert( + analysis.rewriteHint && analysis.rewriteHint.kind === "skip-call-rewrite", + ); + assertEquals(analysis.rewriteHint.reason, "builder"); + }); +}); diff --git a/packages/ts-transformers/test/opaque-ref/harness.ts b/packages/ts-transformers/test/opaque-ref/harness.ts new file mode 100644 index 000000000..cc0805600 --- /dev/null +++ b/packages/ts-transformers/test/opaque-ref/harness.ts @@ -0,0 +1,88 @@ +import ts from "typescript"; + +import { createDataFlowAnalyzer } from "../../src/opaque-ref/dataflow.ts"; + +export interface AnalysisHarnessResult { + readonly sourceFile: ts.SourceFile; + readonly checker: ts.TypeChecker; + readonly expression: ts.Expression; + readonly analysis: ReturnType>; +} + +interface AnalyzeOptions { + readonly prelude?: string; +} + +export function analyzeExpression( + source: string, + options: AnalyzeOptions = {}, +): AnalysisHarnessResult { + const fileName = "/analysis.ts"; + const programSource = ` +interface OpaqueRefMethods { + map(fn: (...args: unknown[]) => S): OpaqueRef; +} + +type OpaqueRef = { + readonly __opaque: T; +} & OpaqueRefMethods; + +declare const state: { + readonly count: OpaqueRef; + readonly flag: OpaqueRef; + readonly items: OpaqueRef; +}; + +declare function ifElse(predicate: boolean, whenTrue: T, whenFalse: T): T; +declare function recipe(body: () => T): T; + +${options.prelude ?? ""} + +const result = ${source}; +`; + + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ESNext, + noLib: true, + }; + + const sourceFile = ts.createSourceFile( + fileName, + programSource, + compilerOptions.target!, + true, + ts.ScriptKind.TS, + ); + + const host = ts.createCompilerHost(compilerOptions, true); + host.getSourceFile = (name) => name === fileName ? sourceFile : undefined; + host.getCurrentDirectory = () => "/"; + host.getDirectories = () => []; + host.fileExists = (name) => name === fileName; + host.readFile = (name) => name === fileName ? programSource : undefined; + host.writeFile = () => {}; + host.useCaseSensitiveFileNames = () => true; + host.getCanonicalFileName = (name) => name; + host.getNewLine = () => "\n"; + + const program = ts.createProgram([fileName], compilerOptions, host); + const checker = program.getTypeChecker(); + + const declaration = sourceFile.statements + .filter((statement): statement is ts.VariableStatement => + ts.isVariableStatement(statement) + ) + .flatMap((statement) => Array.from(statement.declarationList.declarations)) + .find((decl) => decl.initializer && ts.isExpression(decl.initializer)); + + if (!declaration?.initializer || !ts.isExpression(declaration.initializer)) { + throw new Error("Expected initializer expression"); + } + + const expression = declaration.initializer; + const analyze = createDataFlowAnalyzer(checker); + const analysis = analyze(expression); + + return { sourceFile, checker, expression, analysis }; +} diff --git a/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts b/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts new file mode 100644 index 000000000..123c40bb0 --- /dev/null +++ b/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts @@ -0,0 +1,61 @@ +import { describe, it } from "@std/testing/bdd"; +import { assertStringIncludes } from "@std/assert"; +import { StaticCache } from "@commontools/static"; + +import { transformSource } from "../utils.ts"; + +const staticCache = new StaticCache(); +const commontools = await staticCache.getText("types/commontools.d.ts"); + +const SOURCE = `/// +import { h, recipe, UI, ifElse, NAME } from "commontools"; + +interface Charm { + id: string; + [key: string]: string | undefined; +} + +interface State { + charms: Charm[]; +} + +export default recipe("CharmList", (state) => { + return { + [UI]: ( +
    +
      + {state.charms.map((charm: any, index: number) => ( +
    • + {index + 1} + {charm[NAME] || "Unnamed"} +
    • + ))} +
    + {ifElse(!state.charms.length,

    No charms

    ,

    Loaded charms

    )} +
    + ), + }; +}); +`; + +describe("OpaqueRef map callbacks", () => { + it("derives map callback parameters and unary negations", async () => { + const output = await transformSource(SOURCE, { + types: { "commontools.d.ts": commontools }, + applySchemaTransformer: true, + }); + + assertStringIncludes( + output, + "derive(index, index => index + 1)", + ); + assertStringIncludes( + output, + 'derive(charm, charm => charm[NAME] || "Unnamed")', + ); + assertStringIncludes( + output, + "ifElse(derive(state.charms.length, _v1 => !_v1)", + ); + }); +}); diff --git a/packages/ts-transformers/test/opaque-ref/normalize.test.ts b/packages/ts-transformers/test/opaque-ref/normalize.test.ts new file mode 100644 index 000000000..494810fd5 --- /dev/null +++ b/packages/ts-transformers/test/opaque-ref/normalize.test.ts @@ -0,0 +1,34 @@ +import { describe, it } from "@std/testing/bdd"; +import { assertEquals } from "@std/assert"; +import { assertDefined } from "../../src/core/assert.ts"; + +import { + normalizeDataFlows, + selectDataFlowsWithin, +} from "../../src/opaque-ref/normalize.ts"; +import { analyzeExpression } from "./harness.ts"; + +describe("normalizeDataFlows", () => { + it("filters data flows within a specific node", () => { + const { analysis } = analyzeExpression( + "ifElse(state.count > 3, 'hi', 'bye')", + ); + + const dataFlows = normalizeDataFlows(analysis.graph); + const predicate = + analysis.rewriteHint && analysis.rewriteHint.kind === "call-if-else" + ? analysis.rewriteHint.predicate + : undefined; + if (!predicate) { + throw new Error("Expected predicate hint"); + } + + const filtered = selectDataFlowsWithin(dataFlows, predicate); + assertEquals(filtered.length, 1); + const firstDependency = assertDefined( + filtered[0], + "Expected dataFlow inside predicate", + ); + assertEquals(firstDependency.expression.getText(), "state.count"); + }); +}); diff --git a/packages/ts-transformers/test/utils.ts b/packages/ts-transformers/test/utils.ts new file mode 100644 index 000000000..e76a6dc7e --- /dev/null +++ b/packages/ts-transformers/test/utils.ts @@ -0,0 +1,252 @@ +import ts from "typescript"; +import { join } from "@std/path"; +import { StaticCache } from "@commontools/static"; +import { + createModularOpaqueRefTransformer, + createSchemaTransformer, +} from "../src/mod.ts"; +import { assertDefined } from "../src/core/assert.ts"; +const ENV_TYPE_ENTRIES = ["es2023", "dom", "jsx"] as const; + +type EnvTypeKey = (typeof ENV_TYPE_ENTRIES)[number]; +let envTypesCache: Record | undefined; + +export interface TransformOptions { + mode?: "transform" | "error"; + types?: Record; + logger?: (message: string) => void; + applySchemaTransformer?: boolean; + applyOpaqueRefTransformer?: boolean; + before?: Array<(program: ts.Program) => ts.TransformerFactory>; + after?: Array<(program: ts.Program) => ts.TransformerFactory>; +} + +export async function transformSource( + source: string, + options: TransformOptions = {}, +): Promise { + const { + mode = "transform", + types = {}, + logger, + applySchemaTransformer = false, + applyOpaqueRefTransformer = true, + before = [], + after = [], + } = options; + + if (!envTypesCache) { + envTypesCache = await loadEnvironmentTypes(); + } + + const fileName = "/test.tsx"; + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.React, + jsxFactory: "h", + strict: true, + }; + + const allTypes: Record = { + ...envTypesCache, + ...types, + }; + + const host: ts.CompilerHost = { + getSourceFile: (name) => { + if (name === fileName) { + return ts.createSourceFile(name, source, compilerOptions.target!, true); + } + if (name === "lib.d.ts" || name.endsWith("/lib.d.ts")) { + return ts.createSourceFile( + name, + allTypes.es2023 || "", + compilerOptions.target!, + true, + ); + } + if (allTypes[name]) { + return ts.createSourceFile( + name, + allTypes[name], + compilerOptions.target!, + true, + ); + } + const baseName = baseNameFromPath(name); + if (baseName && allTypes[baseName]) { + return ts.createSourceFile( + name, + allTypes[baseName], + compilerOptions.target!, + true, + ); + } + return undefined; + }, + writeFile: () => {}, + getCurrentDirectory: () => "/", + getDirectories: () => [], + fileExists: (name) => { + if (name === fileName) return true; + if (name === "lib.d.ts" || name.endsWith("/lib.d.ts")) return true; + if (allTypes[name]) return true; + const baseName = baseNameFromPath(name); + if (baseName && allTypes[baseName]) return true; + return false; + }, + readFile: (name) => { + if (name === fileName) return source; + if (name === "lib.d.ts" || name.endsWith("/lib.d.ts")) { + return allTypes.es2023; + } + if (allTypes[name]) return allTypes[name]; + const baseName = baseNameFromPath(name); + if (baseName && allTypes[baseName]) return allTypes[baseName]; + return undefined; + }, + getCanonicalFileName: (name) => name, + useCaseSensitiveFileNames: () => true, + getNewLine: () => "\n", + getDefaultLibFileName: () => "lib.d.ts", + resolveModuleNames: (moduleNames) => { + return moduleNames.map((name) => { + if (name === "commontools" && types["commontools.d.ts"]) { + return { + resolvedFileName: "commontools.d.ts", + extension: ts.Extension.Dts, + isExternalLibraryImport: false, + }; + } + if (name === "@commontools/common" && types["commontools.d.ts"]) { + return { + resolvedFileName: "commontools.d.ts", + extension: ts.Extension.Dts, + isExternalLibraryImport: false, + }; + } + return undefined; + }); + }, + resolveTypeReferenceDirectives: (typeDirectiveNames) => + typeDirectiveNames.map((directive) => { + const name = typeof directive === "string" + ? directive + : directive.fileName; + if (allTypes[name]) { + return { + primary: true, + resolvedFileName: name, + extension: ts.Extension.Dts, + isExternalLibraryImport: false, + }; + } + return undefined; + }), + }; + + const program = ts.createProgram([fileName], compilerOptions, host); + + if (logger) { + const diagnostics = ts.getPreEmitDiagnostics(program); + if (diagnostics.length > 0) { + logger("=== TypeScript Diagnostics ==="); + diagnostics.forEach((diagnostic) => { + const message = ts.flattenDiagnosticMessageText( + diagnostic.messageText, + "\n", + ); + logger(`${diagnostic.file?.fileName || "unknown"}: ${message}`); + }); + logger("=== End Diagnostics ==="); + } + } + + const beforeTransformers = before.map((factory) => factory(program)); + const afterTransformers = after.map((factory) => factory(program)); + + const transformers: ts.TransformerFactory[] = [ + ...beforeTransformers, + ]; + if (applyOpaqueRefTransformer) { + transformers.push(createModularOpaqueRefTransformer(program, { mode })); + } + if (applySchemaTransformer) { + transformers.push(createSchemaTransformer(program)); + } + transformers.push(...afterTransformers); + + const sourceFile = assertDefined( + program.getSourceFile(fileName), + "Expected virtual source file to be present in program", + ); + const result = ts.transform(sourceFile, transformers); + const printer = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + removeComments: false, + }); + const transformedFile = assertDefined( + result.transformed[0], + "Expected transformer pipeline to return a source file", + ); + const output = printer.printFile(transformedFile); + result.dispose?.(); + + if (logger) { + logger(`\n=== TEST TRANSFORMER OUTPUT ===\n${output}\n=== END OUTPUT ===`); + } + + return output; +} + +export async function loadFixture(path: string): Promise { + const fixturesDir = join(import.meta.dirname!, "fixtures"); + const fullPath = join(fixturesDir, path); + const text = await Deno.readTextFile(fullPath); + return text.trim(); +} + +export async function transformFixture( + fixturePath: string, + options?: TransformOptions, +): Promise { + const source = await loadFixture(fixturePath); + const output = await transformSource(source, options); + return output.trim(); +} + +export async function compareFixtureTransformation( + inputPath: string, + expectedPath: string, + options?: TransformOptions, +): Promise<{ actual: string; expected: string; matches: boolean }> { + const [actual, expected] = await Promise.all([ + transformFixture(inputPath, options), + loadFixture(expectedPath), + ]); + + const actualNormalized = actual.trim(); + const expectedNormalized = expected.trim(); + + return { + actual: actualNormalized, + expected: expectedNormalized, + matches: actualNormalized === expectedNormalized, + }; +} + +async function loadEnvironmentTypes(): Promise> { + const cache = new StaticCache(); + const entries = await Promise.all( + ENV_TYPE_ENTRIES.map(async (key) => + [key, await cache.getText(`types/${key}.d.ts`)] as const + ), + ); + return Object.fromEntries(entries) as Record; +} + +function baseNameFromPath(path: string): string | undefined { + const segments = path.split("/"); + return segments.length > 0 ? segments[segments.length - 1] : undefined; +}