From 7ea08c502784e084439bcfcee9c0c9909a9a228e Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Wed, 25 Feb 2026 11:19:58 -0500 Subject: [PATCH 1/8] feat: kotlin dsl --- BUILD.bazel | 7 + MODULE.bazel | 39 + MODULE.bazel.lock | 99 +-- dsl/kotlin/.editorconfig | 32 + dsl/kotlin/BUILD.bazel | 49 ++ dsl/kotlin/pom.tpl | 44 ++ .../playerui/lang/dsl/FluentDslMarker.kt | 11 + .../lang/dsl/core/AssetWrapperBuilder.kt | 10 + .../lang/dsl/core/AuxiliaryStorage.kt | 121 +++ .../playerui/lang/dsl/core/BuildContext.kt | 78 ++ .../playerui/lang/dsl/core/BuildPipeline.kt | 492 ++++++++++++ .../playerui/lang/dsl/core/FluentBuilder.kt | 198 +++++ .../playerui/lang/dsl/core/StoredValue.kt | 167 ++++ .../playerui/lang/dsl/core/SwitchSupport.kt | 83 ++ .../playerui/lang/dsl/core/TemplateConfig.kt | 11 + .../playerui/lang/dsl/core/ValueStorage.kt | 65 ++ .../com/intuit/playerui/lang/dsl/flow/Flow.kt | 134 ++++ .../lang/dsl/flow/NavigationBuilder.kt | 145 ++++ .../playerui/lang/dsl/id/IdGenerator.kt | 113 +++ .../intuit/playerui/lang/dsl/id/IdRegistry.kt | 45 ++ .../lang/dsl/schema/FlowSchemaSupport.kt | 68 ++ .../lang/dsl/schema/SchemaBindings.kt | 169 ++++ .../lang/dsl/tagged/StandardExpressions.kt | 278 +++++++ .../playerui/lang/dsl/tagged/TaggedValue.kt | 163 ++++ .../playerui/lang/dsl/types/PlayerTypes.kt | 169 ++++ .../com/intuit/playerui/lang/dsl/FlowTest.kt | 273 +++++++ .../lang/dsl/FluentBuilderBaseTest.kt | 369 +++++++++ .../playerui/lang/dsl/IdGeneratorTest.kt | 478 ++++++++++++ .../playerui/lang/dsl/IntegrationTest.kt | 429 +++++++++++ .../lang/dsl/NavigationBuilderTest.kt | 192 +++++ .../intuit/playerui/lang/dsl/ProjectConfig.kt | 8 + .../playerui/lang/dsl/SchemaBindingsTest.kt | 380 +++++++++ .../lang/dsl/StandardExpressionsTest.kt | 348 +++++++++ .../lang/dsl/mocks/builders/ActionBuilder.kt | 82 ++ .../dsl/mocks/builders/CollectionBuilder.kt | 117 +++ .../lang/dsl/mocks/builders/InputBuilder.kt | 74 ++ .../lang/dsl/mocks/builders/TextBuilder.kt | 55 ++ generators/kotlin/.editorconfig | 32 + generators/kotlin/BUILD.bazel | 38 + generators/kotlin/pom.tpl | 44 ++ .../playerui/lang/generator/ClassGenerator.kt | 726 ++++++++++++++++++ .../playerui/lang/generator/CodeWriter.kt | 234 ++++++ .../playerui/lang/generator/Generator.kt | 120 +++ .../intuit/playerui/lang/generator/Main.kt | 265 +++++++ .../lang/generator/SchemaBindingGenerator.kt | 258 +++++++ .../playerui/lang/generator/TypeMapper.kt | 306 ++++++++ .../lang/generator/ClassGeneratorTest.kt | 246 ++++++ .../generator/GeneratorIntegrationTest.kt | 120 +++ .../playerui/lang/generator/ProjectConfig.kt | 8 + .../generator/SchemaBindingGeneratorTest.kt | 340 ++++++++ .../playerui/lang/generator/TypeMapperTest.kt | 338 ++++++++ .../lang/generator/XlrDeserializerTest.kt | 233 ++++++ .../lang/generator/fixtures/ActionAsset.json | 126 +++ .../lang/generator/fixtures/ChoiceAsset.json | 191 +++++ .../generator/fixtures/CollectionAsset.json | 40 + .../lang/generator/fixtures/ImageAsset.json | 65 ++ .../lang/generator/fixtures/InfoAsset.json | 58 ++ .../lang/generator/fixtures/InputAsset.json | 109 +++ .../lang/generator/fixtures/TextAsset.json | 125 +++ 59 files changed, 9524 insertions(+), 93 deletions(-) create mode 100644 dsl/kotlin/.editorconfig create mode 100644 dsl/kotlin/BUILD.bazel create mode 100644 dsl/kotlin/pom.tpl create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/FluentDslMarker.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AssetWrapperBuilder.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AuxiliaryStorage.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildContext.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/FluentBuilder.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/StoredValue.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/SwitchSupport.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/TemplateConfig.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/ValueStorage.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/flow/Flow.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/flow/NavigationBuilder.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdGenerator.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdRegistry.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/FlowSchemaSupport.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaBindings.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/StandardExpressions.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/types/PlayerTypes.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FlowTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IdGeneratorTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/NavigationBuilderTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/ProjectConfig.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/SchemaBindingsTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/StandardExpressionsTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/ActionBuilder.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/CollectionBuilder.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/InputBuilder.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/TextBuilder.kt create mode 100644 generators/kotlin/.editorconfig create mode 100644 generators/kotlin/BUILD.bazel create mode 100644 generators/kotlin/pom.tpl create mode 100644 generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt create mode 100644 generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/CodeWriter.kt create mode 100644 generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Generator.kt create mode 100644 generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt create mode 100644 generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt create mode 100644 generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/ClassGeneratorTest.kt create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/GeneratorIntegrationTest.kt create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/ProjectConfig.kt create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGeneratorTest.kt create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/TypeMapperTest.kt create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/XlrDeserializerTest.kt create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ActionAsset.json create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ChoiceAsset.json create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/CollectionAsset.json create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ImageAsset.json create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/InfoAsset.json create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/InputAsset.json create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/TextAsset.json diff --git a/BUILD.bazel b/BUILD.bazel index a6a3dc5..fc9114f 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -2,6 +2,7 @@ load("@aspect_rules_js//js:defs.bzl", "js_library") load("@aspect_rules_ts//ts:defs.bzl", "ts_config") load("@npm//:defs.bzl", "npm_link_all_packages") load("@npm//:tsconfig-to-swcconfig/package_json.bzl", tsconfig_to_swcconfig = "bin") +load("@rules_kotlin//kotlin:core.bzl", "kt_kotlinc_options") package(default_visibility = ["//visibility:public"]) @@ -97,3 +98,9 @@ tsconfig_to_swcconfig.t2s( stdout = ".swcrc", visibility = ["//:__subpackages__"], ) + +kt_kotlinc_options( + name = "kt_opts", + jvm_target = "21", + visibility = ["//visibility:public"], +) diff --git a/MODULE.bazel b/MODULE.bazel index 0527495..a6666e5 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -66,8 +66,47 @@ pip.parse( use_repo(pip, "pypi") +####### Kotlin ######### +bazel_dep(name = "rules_kotlin", version = "2.2.1") +bazel_dep(name = "rules_jvm_external", version = "6.9") + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +maven.install( + known_contributing_modules = ["bazel_worker_java", "player-lang"], + artifacts = [ + "org.jetbrains.kotlin:kotlin-stdlib:2.3.0", + "org.jetbrains.kotlin:kotlin-test:2.3.0", + "org.jetbrains.kotlin:kotlin-test-junit5:2.3.0", + "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0", + "io.kotest:kotest-runner-junit5:6.0.7", + "io.kotest:kotest-assertions-core:6.0.7", + "io.kotest:kotest-assertions-json:6.0.7", + "org.junit.jupiter:junit-jupiter-api:5.7.2", + "org.junit.jupiter:junit-jupiter-engine:5.7.2", + "org.junit.jupiter:junit-jupiter-params:5.7.2", + "org.junit.platform:junit-platform-commons:1.7.2", + "org.junit.platform:junit-platform-console:1.7.2", + "org.junit.platform:junit-platform-engine:1.7.2", + "org.junit.platform:junit-platform-launcher:1.7.2", + "org.junit.platform:junit-platform-suite-api:1.7.2", + "org.apiguardian:apiguardian-api:1.0.0", + "org.opentest4j:opentest4j:1.1.1", + "com.intuit.playerui.xlr:xlr-types:0.2.0--canary.4.100-SNAPSHOT", + "com.squareup:kotlinpoet-jvm:2.1.0", + ], + repositories = [ + "https://repo1.maven.org/maven2", + "https://central.sonatype.com/repository/maven-snapshots", + ], +) +use_repo(maven, "maven") +######################## + build_constants = use_repo_rule("@rules_player//distribution:defs.bzl", "build_constants") build_constants( name = "build_constants", + constants = { + "GROUP": "com.intuit.playerui.lang", + }, version_file = "//:VERSION", ) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 877b894..dd5d699 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -102,7 +102,8 @@ "https://bcr.bazel.build/modules/nlohmann_json/3.12.0.bcr.1/source.json": "93f82a5ae985eb935c539bfee95e04767187818189241ac956f3ccadbdb8fb02", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92", - "https://bcr.bazel.build/modules/package_metadata/0.0.2/source.json": "e53a759a72488d2c0576f57491ef2da0cf4aab05ac0997314012495935531b73", + "https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83", + "https://bcr.bazel.build/modules/package_metadata/0.0.3/source.json": "742075a428ad12a3fa18a69014c2f57f01af910c6d9d18646c990200853e641a", "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", "https://bcr.bazel.build/modules/platforms/0.0.11/source.json": "f7e188b79ebedebfe75e9e1d098b8845226c7992b307e28e1496f23112e8fc29", @@ -191,12 +192,14 @@ "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", "https://bcr.bazel.build/modules/rules_jvm_external/6.6/MODULE.bazel": "153042249c7060536dc95b6bb9f9bb8063b8a0b0cb7acdb381bddbc2374aed55", "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", - "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", + "https://bcr.bazel.build/modules/rules_jvm_external/6.9/MODULE.bazel": "07c5db05527db7744a54fcffd653e1550d40e0540207a7f7e6d0a4de5bef8274", + "https://bcr.bazel.build/modules/rules_jvm_external/6.9/source.json": "b12970214f3cc144b26610caeb101fa622d910f1ab3d98f0bae1058edbd00bd4", "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", "https://bcr.bazel.build/modules/rules_kotlin/1.9.5/MODULE.bazel": "043a16a572f610558ec2030db3ff0c9938574e7dd9f58bded1bb07c0192ef025", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", "https://bcr.bazel.build/modules/rules_kotlin/2.1.8/MODULE.bazel": "e832456b08f4bdf23a466ae4ec424ce700d4ccaa2c9e83912d32791ade43b77d", - "https://bcr.bazel.build/modules/rules_kotlin/2.1.8/source.json": "4ccee7dee20c21546ae4ea3d26d4f9c859fc906fa4c8e317d571b62d8048ae10", + "https://bcr.bazel.build/modules/rules_kotlin/2.2.1/MODULE.bazel": "e6e2dc739345a74a0946056d0bf03a3e15080da8f54e872404c944f777803e10", + "https://bcr.bazel.build/modules/rules_kotlin/2.2.1/source.json": "2a549f7f39f66a07d22081346bc51053b45f413ede77a34b3a21b13e1d20b320", "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", @@ -433,96 +436,6 @@ "recordedRepoMappingEntries": [] } }, - "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { - "general": { - "bzlTransitiveDigest": "vfLCTchDthU74iCKvoskQ+ovk2Wu2tLykbCddrcLy7U=", - "usagesDigest": "QPppUlwb7NSBhcaYae+JZPqTEmJKCkOXKFPXQS7aAJE=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, - "generatedRepoSpecs": { - "com_github_jetbrains_kotlin_git": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", - "attributes": { - "urls": [ - "https://github.com/JetBrains/kotlin/releases/download/v2.1.21/kotlin-compiler-2.1.21.zip" - ], - "sha256": "1ba08a8b45da99339a0601134cc037b54cf85e9bc0edbe76dcbd27c2d684a977" - } - }, - "com_github_jetbrains_kotlin": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", - "attributes": { - "git_repository_name": "com_github_jetbrains_kotlin_git", - "compiler_version": "2.1.21" - } - }, - "com_github_google_ksp": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", - "attributes": { - "urls": [ - "https://github.com/google/ksp/releases/download/2.1.21-2.0.1/artifacts.zip" - ], - "sha256": "44e965bb067b2bb5cd9184dab2c3dea6e3eab747d341c07645bb4c88f09e49c8", - "strip_version": "2.1.21-2.0.1" - } - }, - "com_github_pinterest_ktlint": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", - "attributes": { - "sha256": "5ba1ac917a06b0f02daaa60d10abbedd2220d60216af670c67a45b91c74cf8bb", - "urls": [ - "https://github.com/pinterest/ktlint/releases/download/1.6.0/ktlint" - ], - "executable": true - } - }, - "kotlinx_serialization_core_jvm": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", - "attributes": { - "sha256": "3565b6d4d789bf70683c45566944287fc1d8dc75c23d98bd87d01059cc76f2b3", - "urls": [ - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-core-jvm/1.8.1/kotlinx-serialization-core-jvm-1.8.1.jar" - ] - } - }, - "kotlinx_serialization_json": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", - "attributes": { - "sha256": "58adf3358a0f99dd8d66a550fbe19064d395e0d5f7f1e46515cd3470a56fbbb0", - "urls": [ - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json/1.8.1/kotlinx-serialization-json-1.8.1.jar" - ] - } - }, - "kotlinx_serialization_json_jvm": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", - "attributes": { - "sha256": "8769e5647557e3700919c32d508f5c5dad53c5d8234cd10846354fbcff14aa24", - "urls": [ - "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json-jvm/1.8.1/kotlinx-serialization-json-jvm-1.8.1.jar" - ] - } - }, - "kotlin_build_tools_impl": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", - "attributes": { - "sha256": "6e94896e321603e3bfe89fef02478e44d1d64a3d25d49d0694892ffc01c60acf", - "urls": [ - "https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-build-tools-impl/2.1.20/kotlin-build-tools-impl-2.1.20.jar" - ] - } - } - }, - "recordedRepoMappingEntries": [ - [ - "rules_kotlin+", - "bazel_tools", - "bazel_tools" - ] - ] - } - }, "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "NwcLXHrbh2hoorA/Ybmcpjxsn/6avQmewDglodkDrgo=", diff --git a/dsl/kotlin/.editorconfig b/dsl/kotlin/.editorconfig new file mode 100644 index 0000000..9eab273 --- /dev/null +++ b/dsl/kotlin/.editorconfig @@ -0,0 +1,32 @@ +# EditorConfig for Kotlin code style +# https://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +trim_trailing_whitespace = true + +[*.kt] +# ktlint specific rules +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_package-name = disabled + +# Allow trailing commas +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled + +# Function signature - allow multi-line +ktlint_standard_function-signature = disabled + +# Disable some strict rules for DSL-style code +ktlint_standard_parameter-list-wrapping = disabled +ktlint_standard_argument-list-wrapping = disabled + +[*.kts] +ktlint_standard_no-wildcard-imports = disabled diff --git a/dsl/kotlin/BUILD.bazel b/dsl/kotlin/BUILD.bazel new file mode 100644 index 0000000..04a72bf --- /dev/null +++ b/dsl/kotlin/BUILD.bazel @@ -0,0 +1,49 @@ +load("@rules_player//kotlin:defs.bzl", "kt_jvm") +load("@rules_kotlin//kotlin:core.bzl", "kt_compiler_plugin") +load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library") +load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config") +load("@build_constants//:constants.bzl", "GROUP", "VERSION") + +package(default_visibility = ["//visibility:public"]) + +ktlint_config( + name = "lint_config", + editorconfig = ".editorconfig", +) + +kt_compiler_plugin( + name = "serialization_plugin", + compile_phase = True, + id = "org.jetbrains.kotlin.serialization", + stubs_phase = True, + deps = ["@rules_kotlin//kotlin/compiler:kotlin-serialization-compiler-plugin"], +) + +kt_jvm_library( + name = "kotlin_serialization", + srcs = [], + exported_compiler_plugins = [":serialization_plugin"], + exports = ["@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json"], +) + +kt_jvm( + name = "kotlin-dsl", + group = GROUP, + version = VERSION, + lint_config = ":lint_config", + main_opts = "//:kt_opts", + main_deps = [ + ":kotlin_serialization", + "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + ], + test_package = "com.intuit.playerui.lang.dsl", + test_opts = "//:kt_opts", + test_deps = [ + "@maven//:io_kotest_kotest_assertions_core", + "@maven//:io_kotest_kotest_assertions_json", + "@maven//:io_kotest_kotest_runner_junit5", + "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + "@maven//:org_junit_platform_junit_platform_console", + ], + pom_template = ":pom.tpl", +) diff --git a/dsl/kotlin/pom.tpl b/dsl/kotlin/pom.tpl new file mode 100644 index 0000000..96c57b4 --- /dev/null +++ b/dsl/kotlin/pom.tpl @@ -0,0 +1,44 @@ + + + 4.0.0 + + Player Language - {artifactId} + Kotlin DSL for building Player UI content + https://github.com/player-ui/language + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + rafbcampos + Rafael Campos + + + sugarmanz + Jeremiah Zucker + + + brocollie08 + Tony Lin + + + + https://github.com/player-ui/language.git + https://github.com/player-ui/language.git + v{version} + https://github.com/player-ui/language.git + + + {groupId} + {artifactId} + {version} + {type} + + +{dependencies} + + diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/FluentDslMarker.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/FluentDslMarker.kt new file mode 100644 index 0000000..b39b8a5 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/FluentDslMarker.kt @@ -0,0 +1,11 @@ +package com.intuit.playerui.lang.dsl + +/** + * DSL marker to prevent scope leakage in nested builder blocks. + * This ensures that methods from outer builders are not accessible + * in nested builder scopes, preventing accidental property assignments + * to the wrong builder. + */ +@DslMarker +@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) +annotation class FluentDslMarker diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AssetWrapperBuilder.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AssetWrapperBuilder.kt new file mode 100644 index 0000000..f678d38 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AssetWrapperBuilder.kt @@ -0,0 +1,10 @@ +package com.intuit.playerui.lang.dsl.core + +/** + * Wrapper for builders that should be wrapped in AssetWrapper format. + * Used by generated builders to wrap nested assets. + */ +@JvmInline +value class AssetWrapperBuilder( + val builder: FluentBuilder<*>, +) diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AuxiliaryStorage.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AuxiliaryStorage.kt new file mode 100644 index 0000000..a763a33 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AuxiliaryStorage.kt @@ -0,0 +1,121 @@ +package com.intuit.playerui.lang.dsl.core + +/** + * A type-safe key for AuxiliaryStorage. + * The type parameter T represents the type of value stored at this key. + */ +class TypedKey( + val name: String, +) + +/** + * A type-safe key for list values in AuxiliaryStorage. + * The type parameter T represents the element type of the list. + */ +class TypedListKey( + val name: String, +) + +/** + * Storage for auxiliary metadata like templates and switches. + * Uses typed keys to ensure type safety at call sites. + */ +class AuxiliaryStorage { + private val data = mutableMapOf() + + /** + * Sets a value for a typed key, replacing any existing value. + */ + operator fun set( + key: TypedKey, + value: T, + ) { + data[key.name] = value + } + + /** + * Gets a value by typed key. + */ + @Suppress("UNCHECKED_CAST") + fun get(key: TypedKey): T? = data[key.name] as? T + + /** + * Pushes an item to a list at the given typed list key. + * Creates a new list if one doesn't exist. + */ + @Suppress("UNCHECKED_CAST") + fun push( + key: TypedListKey, + item: T, + ) { + val existing = data[key.name] + if (existing is MutableList<*>) { + (existing as MutableList).add(item) + } else { + data[key.name] = mutableListOf(item) + } + } + + /** + * Gets a list by typed list key, returning empty list if not found. + */ + @Suppress("UNCHECKED_CAST") + fun getList(key: TypedListKey): List = (data[key.name] as? List) ?: emptyList() + + /** + * Checks if a typed key exists. + */ + fun has(key: TypedKey): Boolean = key.name in data + + /** + * Checks if a typed list key exists. + */ + fun has(key: TypedListKey): Boolean = key.name in data + + /** + * Removes a value by typed key. + */ + fun remove(key: TypedKey): Boolean = data.remove(key.name) != null + + /** + * Removes a value by typed list key. + */ + fun remove(key: TypedListKey): Boolean = data.remove(key.name) != null + + /** + * Clears all stored data. + */ + fun clear() = data.clear() + + /** + * Creates a shallow clone of this storage. + */ + fun clone(): AuxiliaryStorage = + AuxiliaryStorage().also { cloned -> + cloned.copyFrom(this) + } + + /** + * Copies all data from another AuxiliaryStorage instance. + * Clears existing data before copying. + */ + fun copyFrom(other: AuxiliaryStorage) { + data.clear() + other.data.forEach { (key, value) -> + data[key] = + if (value is MutableList<*>) { + value.toMutableList() + } else { + value + } + } + } + + companion object { + /** Key for storing template configuration functions */ + internal val TEMPLATES = TypedListKey<(BuildContext) -> TemplateConfig>("__templates__") + + /** Key for storing switch metadata */ + internal val SWITCHES = TypedListKey("__switches__") + } +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildContext.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildContext.kt new file mode 100644 index 0000000..21e48e0 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildContext.kt @@ -0,0 +1,78 @@ +package com.intuit.playerui.lang.dsl.core + +import com.intuit.playerui.lang.dsl.id.IdRegistry + +/** + * Represents the different branch types used for hierarchical ID generation. + * Each branch type determines how the ID segment is constructed. + */ +sealed interface IdBranch { + /** + * A named slot branch (e.g., "parent-label", "parent-values"). + * Used for named properties like label, values, actions. + */ + data class Slot( + val name: String, + ) : IdBranch + + /** + * An array item branch with index (e.g., "parent-0", "parent-1"). + * Used for items within array properties. + */ + data class ArrayItem( + val index: Int, + ) : IdBranch + + /** + * A template branch with optional depth for nested templates. + * depth=0 or null produces "_index_", depth=1 produces "_index1_", etc. + */ + data class Template( + val depth: Int = 0, + ) : IdBranch + + /** + * A switch branch for conditional asset selection. + * Generates IDs like "parent-staticSwitch-0" or "parent-dynamicSwitch-1". + */ + data class Switch( + val index: Int, + val kind: SwitchKind, + ) : IdBranch { + enum class SwitchKind { STATIC, DYNAMIC } + } +} + +/** + * Metadata about an asset being built, used for smart ID naming. + */ +data class AssetMetadata( + val type: String? = null, + val binding: String? = null, + val value: String? = null, +) + +/** + * Context passed during the build process to generate hierarchical IDs + * and manage nested asset relationships. + */ +data class BuildContext( + val parentId: String = "", + val parameterName: String? = null, + val index: Int? = null, + val branch: IdBranch? = null, + val assetMetadata: AssetMetadata? = null, + val idRegistry: IdRegistry = IdRegistry(), +) { + fun withParentId(id: String): BuildContext = copy(parentId = id) + + fun withBranch(branch: IdBranch): BuildContext = copy(branch = branch) + + fun withIndex(index: Int): BuildContext = copy(index = index) + + fun withAssetMetadata(metadata: AssetMetadata): BuildContext = copy(assetMetadata = metadata) + + fun withParameterName(name: String): BuildContext = copy(parameterName = name) + + fun clearBranch(): BuildContext = copy(branch = null) +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt new file mode 100644 index 0000000..ae9d945 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt @@ -0,0 +1,492 @@ +package com.intuit.playerui.lang.dsl.core + +import com.intuit.playerui.lang.dsl.id.determineSlotName +import com.intuit.playerui.lang.dsl.id.genId +import com.intuit.playerui.lang.dsl.tagged.TaggedValue + +/** + * The 8-step build pipeline for resolving builder properties into final JSON. + * Matches the TypeScript implementation's resolution order. + */ +object BuildPipeline { + /** + * Executes the full build pipeline. + * + * Steps: + * 1. Resolve static values (TaggedValue → string) + * 2. Generate asset ID + * 3. Create nested context for child assets + * 4. Resolve AssetWrapper values + * 5. Resolve mixed arrays (static + builder values) + * 6. Resolve builders + * 7. Resolve switches + * 8. Resolve templates + */ + fun execute( + storage: ValueStorage, + auxiliary: AuxiliaryStorage, + defaults: Map, + context: BuildContext?, + arrayProperties: Set, + assetWrapperProperties: Set, + ): Map { + val result = mutableMapOf() + + // Apply defaults first + defaults.forEach { (k, v) -> result[k] = v } + + val allEntries = storage.getAll() + + // Step 1: Resolve static values (Primitive and Tagged entries) + resolveStaticValues(allEntries, result) + + // Step 2: Generate asset ID + generateAssetId(result, context) + + // Step 3: Create nested context + val nestedContext = createNestedContext(result, context) + + // Step 4: Resolve AssetWrapper values + resolveAssetWrappers(allEntries, result, nestedContext, assetWrapperProperties) + + // Step 5 & 6: Resolve arrays with builders and direct builders + resolveBuilderEntries(allEntries, result, nestedContext, assetWrapperProperties) + + // Step 7: Resolve switches + resolveSwitches(auxiliary, result, nestedContext, arrayProperties) + + // Step 8: Resolve templates + resolveTemplates(auxiliary, result, context) + + return result + } + + /** + * Step 1: Resolve Primitive and Tagged StoredValues to their raw representations. + */ + private fun resolveStaticValues( + entries: Map, + result: MutableMap, + ) { + entries.forEach { (key, stored) -> + when (stored) { + is StoredValue.Primitive -> { + result[key] = stored.value + } + + is StoredValue.Tagged -> { + result[key] = stored.value.toString() + } + + // Builder/WrappedBuilder/ObjectValue/ArrayValue handled in later steps + else -> {} + } + } + } + + /** + * Step 2: Generate a unique ID for the asset. + */ + private fun generateAssetId( + result: MutableMap, + context: BuildContext?, + ) { + // If ID is already set explicitly, use it + if (result["id"] != null) return + if (context == null) return + + // Generate ID from context + val generatedId = genId(context) + if (generatedId.isNotEmpty()) { + result["id"] = generatedId + } + } + + /** + * Step 3: Create a nested context for child assets. + */ + private fun createNestedContext( + result: Map, + context: BuildContext?, + ): BuildContext? { + if (context == null) return null + + val parentId = result["id"] as? String ?: context.parentId + return context + .withParentId(parentId) + .clearBranch() + } + + /** + * Step 4: Resolve AssetWrapper values. + * Wraps builders in { asset: ... } structure. + */ + private fun resolveAssetWrappers( + entries: Map, + result: MutableMap, + context: BuildContext?, + assetWrapperProperties: Set, + ) { + entries.forEach { (key, stored) -> + if (key in assetWrapperProperties && stored is StoredValue.WrappedBuilder) { + val slotContext = createSlotContext(context, key, stored.builder) + val builtAsset = stored.builder.build(slotContext) + result[key] = mapOf("asset" to builtAsset) + } + } + } + + /** + * Steps 5 & 6: Resolve builder entries (Builder, ArrayValue with builders, ObjectValue with builders). + */ + private fun resolveBuilderEntries( + entries: Map, + result: MutableMap, + context: BuildContext?, + assetWrapperProperties: Set, + ) { + entries.forEach { (key, stored) -> + // Skip WrappedBuilder in assetWrapperProperties (handled in step 4) + if (key in assetWrapperProperties && stored is StoredValue.WrappedBuilder) return@forEach + + when (stored) { + is StoredValue.Builder -> { + val slotContext = createSlotContext(context, key, stored.builder) + result[key] = stored.builder.build(slotContext) + } + + is StoredValue.WrappedBuilder -> { + // Non-asset-wrapper WrappedBuilder: build it directly + val slotContext = createSlotContext(context, key, stored.builder) + result[key] = stored.builder.build(slotContext) + } + + is StoredValue.ArrayValue -> { + result[key] = resolveArrayValue(stored.items, context, key) + } + + is StoredValue.ObjectValue -> { + result[key] = resolveObjectValue(stored.map, context, key) + } + + // Primitive and Tagged already handled in step 1 + is StoredValue.Primitive, is StoredValue.Tagged -> {} + } + } + } + + /** + * Resolves an array of StoredValues. + */ + private fun resolveArrayValue( + items: List, + context: BuildContext?, + key: String, + ): List = + items.mapIndexedNotNull { index, stored -> + when (stored) { + is StoredValue.Primitive -> { + stored.value + } + + is StoredValue.Tagged -> { + stored.value.toString() + } + + is StoredValue.Builder -> { + val arrayContext = createArrayItemContext(context, key, index, stored.builder) + stored.builder.build(arrayContext) + } + + is StoredValue.WrappedBuilder -> { + val arrayContext = createArrayItemContext(context, key, index, stored.builder) + stored.builder.build(arrayContext) + } + + is StoredValue.ArrayValue -> { + resolveArrayValue(stored.items, context, key) + } + + is StoredValue.ObjectValue -> { + resolveObjectValue(stored.map, context, key) + } + } + } + + /** + * Resolves an object map of StoredValues. + */ + private fun resolveObjectValue( + map: Map, + context: BuildContext?, + key: String, + ): Map = + map.mapValues { (_, stored) -> + when (stored) { + is StoredValue.Primitive -> { + stored.value + } + + is StoredValue.Tagged -> { + stored.value.toString() + } + + is StoredValue.Builder -> { + val slotContext = createSlotContext(context, key, stored.builder) + stored.builder.build(slotContext) + } + + is StoredValue.WrappedBuilder -> { + val slotContext = createSlotContext(context, key, stored.builder) + stored.builder.build(slotContext) + } + + is StoredValue.ArrayValue -> { + resolveArrayValue(stored.items, context, key) + } + + is StoredValue.ObjectValue -> { + resolveObjectValue(stored.map, context, key) + } + } + } + + /** + * Step 7: Resolve switch configurations. + */ + private fun resolveSwitches( + auxiliary: AuxiliaryStorage, + result: MutableMap, + context: BuildContext?, + arrayProperties: Set, + ) { + val switches = auxiliary.getList(AuxiliaryStorage.SWITCHES) + if (switches.isEmpty()) return + + switches.forEach { switchMeta -> + val (path, args) = switchMeta + val switchKey = if (args.isDynamic) "dynamicSwitch" else "staticSwitch" + + val resolvedCases = + args.cases.mapIndexed { index, case -> + val caseContext = + context?.withBranch( + IdBranch.Switch( + index = index, + kind = + if (args.isDynamic) { + IdBranch.Switch.SwitchKind.DYNAMIC + } else { + IdBranch.Switch.SwitchKind.STATIC + }, + ), + ) + + val caseValue: Any = + when (val c = case.condition) { + is SwitchCondition.Static -> c.value + is SwitchCondition.Dynamic -> c.expr.toString() + } + + val assetValue = case.asset.build(caseContext) + + mapOf( + "case" to caseValue, + "asset" to assetValue, + ) + } + + // Inject switch at the specified path + injectAtPath(result, path, mapOf(switchKey to resolvedCases)) + } + } + + /** + * Step 8: Resolve template configurations. + */ + private fun resolveTemplates( + auxiliary: AuxiliaryStorage, + result: MutableMap, + context: BuildContext?, + ) { + val templates = auxiliary.getList(AuxiliaryStorage.TEMPLATES) + if (templates.isEmpty()) return + + templates.forEach { templateFn -> + val templateContext = context ?: BuildContext() + val config = templateFn(templateContext) + + val templateKey = if (config.dynamic) "dynamicTemplate" else "template" + + val templateDepth = extractTemplateDepth(context) + val valueContext = templateContext.withBranch(IdBranch.Template(templateDepth)) + + val resolvedValue = + when (val v = config.value) { + is FluentBuilder<*> -> mapOf("asset" to v.build(valueContext)) + else -> resolveValue(v) + } + + val templateData = + mapOf( + "data" to config.data, + "output" to config.output, + "value" to resolvedValue, + ) + + // Get existing array or create new one + val existingArray = (result[config.output] as? List<*>)?.toMutableList() ?: mutableListOf() + existingArray.add(mapOf(templateKey to templateData)) + result[config.output] = existingArray + } + } + + /** + * Recursively resolves values, converting TaggedValues to strings. + */ + private fun resolveValue(value: Any?): Any? = + when (value) { + null -> null + is TaggedValue<*> -> value.toString() + is Map<*, *> -> value.mapValues { (_, v) -> resolveValue(v) } + is List<*> -> value.map { resolveValue(it) } + else -> value + } + + /** + * Creates a context for a slot (named property). + */ + private fun createSlotContext( + context: BuildContext?, + key: String, + builder: FluentBuilder<*>, + ): BuildContext? { + if (context == null) return null + + val metadata = extractAssetMetadata(builder) + val slotName = determineSlotName(key, metadata) + + return context + .withBranch(IdBranch.Slot(slotName)) + .withParameterName(key) + .withAssetMetadata(metadata) + } + + /** + * Creates a context for an array item. + */ + private fun createArrayItemContext( + context: BuildContext?, + key: String, + index: Int, + builder: FluentBuilder<*>, + ): BuildContext? { + if (context == null) return null + + val metadata = extractAssetMetadata(builder) + + return context + .withBranch(IdBranch.ArrayItem(index)) + .withParameterName(key) + .withIndex(index) + .withAssetMetadata(metadata) + } + + /** + * Extracts asset metadata from a builder for smart ID naming. + */ + private fun extractAssetMetadata(builder: FluentBuilder<*>): AssetMetadata { + val type = builder.peek("type") as? String + val binding = + builder.peek("binding")?.let { + when (it) { + is TaggedValue<*> -> it.toString() + is String -> it + else -> null + } + } + val value = + builder.peek("value")?.let { + when (it) { + is TaggedValue<*> -> it.toString() + is String -> it + else -> null + } + } + return AssetMetadata(type, binding, value) + } + + /** + * Injects a value at a nested path in the result map. + */ + private fun injectAtPath( + result: MutableMap, + path: List, + value: Any?, + ) { + if (path.isEmpty()) return + + var current: Any? = result + val lastIndex = path.size - 1 + + path.forEachIndexed { index, segment -> + if (index == lastIndex) { + when { + current is MutableMap<*, *> && segment is String -> { + @Suppress("UNCHECKED_CAST") + val map = current as MutableMap + val existing = map[segment] + if (existing is Map<*, *> && value is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + map[segment] = (existing as Map) + (value as Map) + } else { + map[segment] = value + } + } + + current is MutableList<*> && segment is Int -> { + @Suppress("UNCHECKED_CAST") + val list = current as MutableList + if (segment < list.size) { + val existing = list[segment] + if (existing is Map<*, *> && value is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + list[segment] = (existing as Map) + (value as Map) + } else { + list[segment] = value + } + } + } + } + } else { + current = + when { + current is Map<*, *> && segment is String -> { + @Suppress("UNCHECKED_CAST") + (current as Map)[segment] + } + + current is List<*> && segment is Int -> { + (current as List<*>).getOrNull(segment) + } + + // Intentional: when path segment type mismatches the container + // (e.g., string key on a list, or int key on a map), we set current to null. + // Subsequent segments then become no-ops. This is by-design defensive behavior + // for switch/template injection where the target path may not yet exist. + else -> { + null + } + } + } + } + } + + /** + * Extracts the current template depth from context. + */ + private fun extractTemplateDepth(context: BuildContext?): Int { + val branch = context?.branch + return if (branch is IdBranch.Template) branch.depth + 1 else 0 + } +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/FluentBuilder.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/FluentBuilder.kt new file mode 100644 index 0000000..d244386 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/FluentBuilder.kt @@ -0,0 +1,198 @@ +package com.intuit.playerui.lang.dsl.core + +import com.intuit.playerui.lang.dsl.FluentDslMarker + +/** + * Base interface for all fluent builders. + * Defines the core contract for building Player-UI assets. + */ +interface FluentBuilder { + /** + * Builds the final asset/object from the configured properties. + * @param context Optional build context for ID generation and nesting + * @return The built object as a Map (JSON-serializable) + */ + fun build(context: BuildContext? = null): Map + + /** + * Peeks at a property value without triggering resolution. + * @param key The property name + * @return The raw value or null if not set + */ + fun peek(key: String): Any? + + /** + * Checks if a property has been set. + * @param key The property name + * @return True if the property has a value + */ + fun has(key: String): Boolean +} + +/** + * Abstract base class for fluent builders. + * Provides common functionality for property storage, conditional building, + * and the build pipeline. + */ +@FluentDslMarker +abstract class FluentBuilderBase : FluentBuilder { + protected val storage = ValueStorage() + protected val auxiliary = AuxiliaryStorage() + + /** + * Default values for properties. Subclasses should override this + * to provide type-specific defaults (e.g., { "type" to "text" }). + */ + protected abstract val defaults: Map + + /** + * Properties that are arrays and should be merged rather than replaced. + * Used by the build pipeline for proper array handling. + */ + protected open val arrayProperties: Set = emptySet() + + /** + * Properties that wrap assets (AssetWrapper). Used to auto-wrap + * builder values in { asset: ... } structure. + */ + protected open val assetWrapperProperties: Set = emptySet() + + /** + * Sets a property value. + * @param key The property name + * @param value The value to set + * @return This builder for chaining + */ + protected fun set( + key: String, + value: Any?, + ): FluentBuilderBase { + storage[key] = value + return this + } + + /** + * Conditionally sets a property if the predicate is true. + */ + fun setIf( + predicate: () -> Boolean, + property: String, + value: Any?, + ): FluentBuilderBase { + if (predicate()) { + val wrapped = maybeWrapAsset(property, value) + set(property, wrapped) + } + return this + } + + /** + * Conditionally sets a property to one of two values based on the predicate. + */ + fun setIfElse( + predicate: () -> Boolean, + property: String, + trueValue: Any?, + falseValue: Any?, + ): FluentBuilderBase { + val valueToUse = if (predicate()) trueValue else falseValue + val wrapped = maybeWrapAsset(property, valueToUse) + set(property, wrapped) + return this + } + + override fun has(key: String): Boolean = storage.has(key) + + override fun peek(key: String): Any? = storage.peek(key) + + /** + * Removes a property value. + */ + fun unset(key: String): FluentBuilderBase { + storage.remove(key) + return this + } + + /** + * Clears all property values, resetting the builder. + */ + fun clear(): FluentBuilderBase { + storage.clear() + auxiliary.clear() + return this + } + + /** + * Creates a copy of this builder with the same property values. + */ + abstract fun clone(): FluentBuilderBase + + /** + * Copies storage state to another builder (used by clone implementations). + */ + protected fun cloneStorageTo(target: FluentBuilderBase) { + val clonedStorage = storage.clone() + target.storage.clear() + clonedStorage.getAll().forEach { (k, stored) -> target.storage[k] = stored.toRawValue() } + target.auxiliary.copyFrom(auxiliary) + } + + /** + * Adds a template configuration for dynamic list generation. + */ + fun template(templateFn: (BuildContext) -> TemplateConfig): FluentBuilderBase { + auxiliary.push(AuxiliaryStorage.TEMPLATES, templateFn) + return this + } + + /** + * Adds a switch configuration for runtime conditional selection. + */ + fun switch( + path: List, + args: SwitchArgs, + ): FluentBuilderBase { + auxiliary.push(AuxiliaryStorage.SWITCHES, SwitchMetadata(path, args)) + return this + } + + /** + * Wraps a builder in AssetWrapper format if the property requires it. + */ + private fun maybeWrapAsset( + property: String, + value: Any?, + ): Any? { + if (value == null) return null + if (property !in assetWrapperProperties) return value + + return when (value) { + is FluentBuilder<*> -> { + AssetWrapperBuilder(value) + } + + is List<*> -> { + value.map { item -> + if (item is FluentBuilder<*>) AssetWrapperBuilder(item) else item + } + } + + else -> { + value + } + } + } + + /** + * Executes the build pipeline with defaults. + */ + protected fun buildWithDefaults(context: BuildContext?): Map = + BuildPipeline.execute( + storage = storage, + auxiliary = auxiliary, + defaults = defaults, + context = context, + arrayProperties = arrayProperties, + assetWrapperProperties = assetWrapperProperties, + ) +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/StoredValue.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/StoredValue.kt new file mode 100644 index 0000000..7a6412e --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/StoredValue.kt @@ -0,0 +1,167 @@ +package com.intuit.playerui.lang.dsl.core + +import com.intuit.playerui.lang.dsl.tagged.TaggedValue + +/** + * Sealed class representing all possible value types that can be stored in a builder. + * This provides compile-time type safety and exhaustive pattern matching. + */ +sealed interface StoredValue { + /** + * A primitive JSON value (string, number, boolean, null). + */ + data class Primitive( + val value: Any?, + ) : StoredValue + + /** + * A tagged value (binding or expression). + */ + data class Tagged( + val value: TaggedValue<*>, + ) : StoredValue + + /** + * A nested builder instance. + */ + data class Builder( + val builder: FluentBuilder<*>, + ) : StoredValue + + /** + * A builder wrapped for AssetWrapper properties. + */ + data class WrappedBuilder( + val builder: FluentBuilder<*>, + ) : StoredValue + + /** + * A map (object) that may contain nested builders. + */ + data class ObjectValue( + val map: Map, + ) : StoredValue + + /** + * An array that may contain mixed values. + */ + data class ArrayValue( + val items: List, + ) : StoredValue +} + +/** + * Creates a deep copy of this StoredValue, ensuring mutable containers are not shared. + */ +fun StoredValue.deepCopy(): StoredValue = + when (this) { + is StoredValue.Primitive -> StoredValue.Primitive(value) + is StoredValue.Tagged -> StoredValue.Tagged(value) + is StoredValue.Builder -> StoredValue.Builder(builder) + is StoredValue.WrappedBuilder -> StoredValue.WrappedBuilder(builder) + is StoredValue.ObjectValue -> StoredValue.ObjectValue(map.mapValues { (_, v) -> v.deepCopy() }) + is StoredValue.ArrayValue -> StoredValue.ArrayValue(items.map { it.deepCopy() }) + } + +/** + * Converts a raw value to a StoredValue with proper type classification. + */ +fun toStoredValue(value: Any?): StoredValue = + when (value) { + null -> { + StoredValue.Primitive(null) + } + + is TaggedValue<*> -> { + StoredValue.Tagged(value) + } + + is FluentBuilder<*> -> { + StoredValue.Builder(value) + } + + is AssetWrapperBuilder -> { + StoredValue.WrappedBuilder(value.builder) + } + + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + val map = value as Map + if (map.values.any { containsBuilder(it) }) { + StoredValue.ObjectValue(map.mapValues { (_, v) -> toStoredValue(v) }) + } else { + StoredValue.Primitive(value) + } + } + + is List<*> -> { + if (value.any { containsBuilder(it) }) { + StoredValue.ArrayValue(value.map { toStoredValue(it) }) + } else { + StoredValue.Primitive(value) + } + } + + else -> { + StoredValue.Primitive(value) + } + } + +/** + * Converts a StoredValue back to a raw value (for JSON serialization). + */ +fun StoredValue.toRawValue(): Any? = + when (this) { + is StoredValue.Primitive -> value + + is StoredValue.Tagged -> value.toString() + + is StoredValue.Builder -> builder + + // Will be resolved during build + is StoredValue.WrappedBuilder -> builder + + // Will be wrapped during build + is StoredValue.ObjectValue -> map.mapValues { (_, v) -> v.toRawValue() } + + is StoredValue.ArrayValue -> items.map { it.toRawValue() } + } + +/** + * Checks if a StoredValue contains any builders that need resolution. + */ +fun StoredValue.hasBuilders(): Boolean = + when (this) { + is StoredValue.Primitive, is StoredValue.Tagged -> false + is StoredValue.Builder, is StoredValue.WrappedBuilder -> true + is StoredValue.ObjectValue -> map.values.any { it.hasBuilders() } + is StoredValue.ArrayValue -> items.any { it.hasBuilders() } + } + +/** + * Checks if a raw value contains any builders. + */ +private fun containsBuilder(value: Any?): Boolean = + when (value) { + null -> false + is FluentBuilder<*> -> true + is AssetWrapperBuilder -> true + is Map<*, *> -> value.values.any { containsBuilder(it) } + is List<*> -> value.any { containsBuilder(it) } + else -> false + } + +/** + * Type alias for JSON-compatible values (the output of build()). + */ +typealias JsonValue = Any? + +/** + * Type alias for JSON objects. + */ +typealias JsonObject = Map + +/** + * Type alias for JSON arrays. + */ +typealias JsonArray = List diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/SwitchSupport.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/SwitchSupport.kt new file mode 100644 index 0000000..43f1d2a --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/SwitchSupport.kt @@ -0,0 +1,83 @@ +package com.intuit.playerui.lang.dsl.core + +import com.intuit.playerui.lang.dsl.FluentDslMarker +import com.intuit.playerui.lang.dsl.tagged.Expression + +/** + * Typed condition for switch cases. + */ +sealed interface SwitchCondition { + data class Static( + val value: Boolean, + ) : SwitchCondition + + data class Dynamic( + val expr: Expression, + ) : SwitchCondition +} + +/** + * Arguments for switch configuration. + */ +data class SwitchArgs( + val cases: List, + val isDynamic: Boolean = false, +) + +/** + * A single case in a switch configuration. + */ +data class SwitchCase( + val condition: SwitchCondition, + val asset: FluentBuilder<*>, +) + +/** + * Internal metadata for switch configurations. + */ +internal data class SwitchMetadata( + val path: List, + val args: SwitchArgs, +) + +/** + * Helper builder for constructing switch cases. + */ +@FluentDslMarker +class SwitchBuilder { + internal val cases = mutableListOf() + + /** + * Adds a case with a boolean condition. + */ + fun case( + condition: Boolean, + asset: FluentBuilder<*>, + ) { + cases.add(SwitchCase(SwitchCondition.Static(condition), asset)) + } + + /** + * Adds a case with an expression condition. + */ + fun case( + condition: Expression, + asset: FluentBuilder<*>, + ) { + cases.add(SwitchCase(SwitchCondition.Dynamic(condition), asset)) + } + + /** + * Adds a default case (always true). + */ + fun default(asset: FluentBuilder<*>) { + cases.add(SwitchCase(SwitchCondition.Static(true), asset)) + } + + /** + * Adds a default case using a DSL block. + */ + fun default(init: () -> FluentBuilder<*>) { + cases.add(SwitchCase(SwitchCondition.Static(true), init())) + } +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/TemplateConfig.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/TemplateConfig.kt new file mode 100644 index 0000000..3946f87 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/TemplateConfig.kt @@ -0,0 +1,11 @@ +package com.intuit.playerui.lang.dsl.core + +/** + * Configuration for a template (dynamic list generation). + */ +data class TemplateConfig( + val data: String, + val output: String, + val value: Any, + val dynamic: Boolean = false, +) diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/ValueStorage.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/ValueStorage.kt new file mode 100644 index 0000000..057b93a --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/ValueStorage.kt @@ -0,0 +1,65 @@ +package com.intuit.playerui.lang.dsl.core + +/** + * Storage for builder property values using type-safe [StoredValue] representation. + * Provides intelligent routing and retrieval based on value category. + */ +class ValueStorage { + private val entries = mutableMapOf() + + /** + * Sets a value with automatic type classification via [toStoredValue]. + */ + operator fun set( + key: String, + value: Any?, + ) { + if (value == null) { + entries[key] = StoredValue.Primitive(null) + } else { + entries[key] = toStoredValue(value) + } + } + + /** + * Gets the raw value for a key (resolves StoredValue back to raw form). + */ + operator fun get(key: String): Any? = peek(key) + + /** + * Checks if a key has any value. + */ + fun has(key: String): Boolean = key in entries + + /** + * Peeks at a value without resolution (returns raw form). + */ + fun peek(key: String): Any? = entries[key]?.toRawValue() + + /** + * Removes a value. + */ + fun remove(key: String) { + entries.remove(key) + } + + /** + * Clears all stored values. + */ + fun clear() { + entries.clear() + } + + /** + * Returns all stored values as a map of StoredValue. + */ + internal fun getAll(): Map = entries.toMap() + + /** + * Creates a deep clone of this storage. + */ + fun clone(): ValueStorage = + ValueStorage().also { cloned -> + entries.forEach { (k, v) -> cloned.entries[k] = v.deepCopy() } + } +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/flow/Flow.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/flow/Flow.kt new file mode 100644 index 0000000..f25846a --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/flow/Flow.kt @@ -0,0 +1,134 @@ +package com.intuit.playerui.lang.dsl.flow + +import com.intuit.playerui.lang.dsl.FluentDslMarker +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.FluentBuilder +import com.intuit.playerui.lang.dsl.core.IdBranch +import com.intuit.playerui.lang.dsl.id.IdRegistry + +/** + * Options for creating a Player-UI Flow. + * This is a builder-style class for constructing flows with views, data, and navigation. + */ +@FluentDslMarker +class FlowBuilder { + var id: String = "root" + var views: List = emptyList() + var data: Map? = null + var schema: Map? = null + var navigation: Map = emptyMap() + + /** + * Additional properties to include in the flow output. + */ + private val additionalProperties = mutableMapOf() + + /** + * Sets an additional property on the flow. + * @throws IllegalArgumentException if the key is a reserved flow property + */ + fun set( + key: String, + value: Any?, + ) { + require(key !in RESERVED_KEYS) { "Cannot override reserved key '$key'. Use the dedicated property instead." } + additionalProperties[key] = value + } + + companion object { + private val RESERVED_KEYS = setOf("id", "views", "navigation", "data", "schema") + } + + /** + * Builds the flow, processing all views with proper context. + * Creates a fresh [IdRegistry] per build for thread-safe ID generation. + */ + fun build(): Map { + val registry = IdRegistry() + + val flowId = id + val viewsNamespace = "$flowId-views" + + val processedViews = + views.mapIndexed { index, viewOrBuilder -> + val ctx = + BuildContext( + parentId = viewsNamespace, + branch = IdBranch.ArrayItem(index), + idRegistry = registry, + ) + + when (viewOrBuilder) { + is FluentBuilder<*> -> viewOrBuilder.build(ctx) + is Map<*, *> -> viewOrBuilder + else -> viewOrBuilder + } + } + + val result = + mutableMapOf( + "id" to flowId, + "views" to processedViews, + "navigation" to navigation, + ) + + data?.let { result["data"] = it } + schema?.let { result["schema"] = it } + result.putAll(additionalProperties) + + return result + } +} + +/** + * DSL function to create a Player-UI Flow. + * + * A flow combines views, data, and navigation into a complete Player-UI content structure. + * + * Example: + * ```kotlin + * val myFlow = flow { + * id = "welcome-flow" + * views = listOf( + * collection { + * label { value = "Welcome" } + * values( + * text { value = "Hello World" }, + * input { binding("user.name") } + * ) + * actions( + * action { value = "next"; label { value = "Continue" } } + * ) + * } + * ) + * data = mapOf( + * "user" to mapOf("name" to "") + * ) + * navigation = mapOf( + * "BEGIN" to "FLOW_1", + * "FLOW_1" to mapOf( + * "startState" to "VIEW_welcome", + * "VIEW_welcome" to mapOf( + * "state_type" to "VIEW", + * "ref" to "welcome-flow-views-0", + * "transitions" to mapOf("next" to "END_Done") + * ), + * "END_Done" to mapOf( + * "state_type" to "END", + * "outcome" to "done" + * ) + * ) + * ) + * } + * ``` + * + * @param init Configuration block for the flow + * @return The built flow as a Map (JSON-serializable) + */ +fun flow(init: FlowBuilder.() -> Unit): Map = FlowBuilder().apply(init).build() + +/** + * Creates a flow builder without immediately building. + * Useful when you need to further configure the flow before building. + */ +fun flowBuilder(init: FlowBuilder.() -> Unit = {}): FlowBuilder = FlowBuilder().apply(init) diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/flow/NavigationBuilder.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/flow/NavigationBuilder.kt new file mode 100644 index 0000000..d07aab6 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/flow/NavigationBuilder.kt @@ -0,0 +1,145 @@ +package com.intuit.playerui.lang.dsl.flow + +import com.intuit.playerui.lang.dsl.FluentDslMarker +import com.intuit.playerui.lang.dsl.tagged.Expression + +/** + * Type-safe builder for Player-UI navigation state machines. + * + * Example: + * ```kotlin + * navigation { + * begin = "FLOW_1" + * flow("FLOW_1") { + * startState = "VIEW_welcome" + * view("VIEW_welcome", ref = "welcome-views-0") { + * on("next", "END_Done") + * } + * end("END_Done", outcome = "done") + * } + * } + * ``` + */ +@FluentDslMarker +class NavigationBuilder { + var begin: String = "FLOW_1" + private val flows = mutableMapOf() + + fun flow( + name: String, + init: NavigationFlowBuilder.() -> Unit, + ) { + flows[name] = NavigationFlowBuilder().apply(init) + } + + fun build(): Map { + val result = mutableMapOf("BEGIN" to begin) + flows.forEach { (name, builder) -> + result[name] = builder.build() + } + return result + } +} + +@FluentDslMarker +class NavigationFlowBuilder { + var startState: String = "" + var onStart: Expression<*>? = null + var onEnd: Expression<*>? = null + private val states = mutableMapOf>() + + fun view( + name: String, + ref: String, + init: ViewStateBuilder.() -> Unit = {}, + ) { + states[name] = ViewStateBuilder(ref).apply(init).build() + } + + fun end( + name: String, + outcome: String, + ) { + states[name] = + mapOf( + "state_type" to "END", + "outcome" to outcome, + ) + } + + fun action( + name: String, + exp: Expression<*>, + init: ActionStateBuilder.() -> Unit = {}, + ) { + states[name] = ActionStateBuilder(exp).apply(init).build() + } + + fun build(): Map { + val result = mutableMapOf() + if (startState.isNotEmpty()) result["startState"] = startState + onStart?.let { result["onStart"] = it.toString() } + onEnd?.let { result["onEnd"] = it.toString() } + result.putAll(states) + return result + } +} + +@FluentDslMarker +class ViewStateBuilder( + private val ref: String, +) { + private val transitions = mutableMapOf() + + fun on( + event: String, + target: String, + ) { + transitions[event] = target + } + + fun build(): Map { + val result = + mutableMapOf( + "state_type" to "VIEW", + "ref" to ref, + ) + if (transitions.isNotEmpty()) { + result["transitions"] = transitions.toMap() + } + return result + } +} + +@FluentDslMarker +class ActionStateBuilder( + private val exp: Expression<*>, +) { + private val transitions = mutableMapOf() + + fun on( + event: String, + target: String, + ) { + transitions[event] = target + } + + fun build(): Map { + val result = + mutableMapOf( + "state_type" to "ACTION", + "exp" to exp.toString(), + ) + if (transitions.isNotEmpty()) { + result["transitions"] = transitions.toMap() + } + return result + } +} + +/** + * Extension function on FlowBuilder to add type-safe navigation. + */ +fun FlowBuilder.navigation(init: NavigationBuilder.() -> Unit) { + navigation = NavigationBuilder().apply(init).build() +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdGenerator.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdGenerator.kt new file mode 100644 index 0000000..db5122f --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdGenerator.kt @@ -0,0 +1,113 @@ +package com.intuit.playerui.lang.dsl.id + +import com.intuit.playerui.lang.dsl.core.AssetMetadata +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.IdBranch + +/** + * Generates a unique ID based on the build context and registers it + * in the context's [IdRegistry]. + * + * @param context The build context containing parent ID, branch info, and metadata + * @return A unique ID string + * @throws IllegalArgumentException if branch validation fails + */ +fun genId(context: BuildContext): String { + val baseId = generateBaseId(context) + return context.idRegistry.ensureUnique(baseId) +} + +/** + * Generates an ID without registering it. Useful for intermediate lookups + * or when you need to preview what an ID would be without consuming it. + * + * @param context The build context + * @return The ID that would be generated (without collision suffix) + */ +fun peekId(context: BuildContext): String = generateBaseId(context) + +/** + * Generates the base ID from context without collision detection. + */ +private fun generateBaseId(context: BuildContext): String { + val parentId = context.parentId + val branch = context.branch + + return when (branch) { + null -> { + parentId + } + + is IdBranch.Slot -> { + require(branch.name.isNotEmpty()) { + "genId: Slot branch requires a 'name' property" + } + if (parentId.isEmpty()) branch.name else "$parentId-${branch.name}" + } + + is IdBranch.ArrayItem -> { + require(branch.index >= 0) { + "genId: Array-item index must be non-negative" + } + "$parentId-${branch.index}" + } + + is IdBranch.Template -> { + require(branch.depth >= 0) { + "genId: Template depth must be non-negative" + } + val suffix = if (branch.depth == 0) "" else branch.depth.toString() + "$parentId-_index${suffix}_" + } + + is IdBranch.Switch -> { + require(branch.index >= 0) { + "genId: Switch index must be non-negative" + } + val kindName = + when (branch.kind) { + IdBranch.Switch.SwitchKind.STATIC -> "static" + IdBranch.Switch.SwitchKind.DYNAMIC -> "dynamic" + } + "$parentId-${kindName}Switch-${branch.index}" + } + } +} + +/** + * Determines a smart slot name based on asset metadata. + * Used for generating meaningful IDs based on the asset's type, binding, or value. + * + * @param parameterName The default parameter name (e.g., "label", "values") + * @param assetMetadata Optional metadata about the asset + * @return A descriptive slot name + */ +fun determineSlotName( + parameterName: String, + assetMetadata: AssetMetadata?, +): String { + if (assetMetadata == null) return parameterName + + val type = assetMetadata.type + val binding = assetMetadata.binding + val value = assetMetadata.value + + // Rule 1: Action with value - append the value's last segment + if (type == "action" && value != null) { + val cleanValue = value.removeSurrounding("{{", "}}") + val lastSegment = cleanValue.split(".").lastOrNull() ?: return type + return "$type-$lastSegment" + } + + // Rule 2: Non-action with binding - append the binding's last segment + if (type != "action" && binding != null) { + val cleanBinding = binding.removeSurrounding("{{", "}}") + val lastSegment = cleanBinding.split(".").lastOrNull() + if (lastSegment != null) { + return "${type ?: parameterName}-$lastSegment" + } + } + + // Rule 3: Use type or fall back to parameter name + return type ?: parameterName +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdRegistry.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdRegistry.kt new file mode 100644 index 0000000..27b949b --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdRegistry.kt @@ -0,0 +1,45 @@ +package com.intuit.playerui.lang.dsl.id + +/** + * Per-build registry for tracking generated asset IDs and ensuring uniqueness. + * When an ID collision is detected, a numeric suffix (-1, -2, etc.) is appended. + * + * Each flow build should create a fresh [IdRegistry] instance, eliminating the need + * for global mutable state and enabling safe concurrent builds. + */ +class IdRegistry { + private val registered = LinkedHashSet() + private val suffixCounters = mutableMapOf() + + /** + * Registers an ID and returns a unique version. + * If the ID already exists, appends a numeric suffix. + * + * @param baseId The desired base ID + * @return A unique ID (either baseId or baseId-N where N is a number) + */ + fun ensureUnique(baseId: String): String { + if (registered.add(baseId)) { + return baseId + } + + suffixCounters.putIfAbsent(baseId, 0) + while (true) { + val next = suffixCounters.merge(baseId, 1, Int::plus)!! + val candidate = "$baseId-$next" + if (registered.add(candidate)) { + return candidate + } + } + } + + /** + * Checks if an ID is already registered without registering it. + */ + fun isRegistered(id: String): Boolean = id in registered + + /** + * Returns the count of registered IDs. + */ + fun size(): Int = registered.size +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/FlowSchemaSupport.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/FlowSchemaSupport.kt new file mode 100644 index 0000000..e7e4c10 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/FlowSchemaSupport.kt @@ -0,0 +1,68 @@ +package com.intuit.playerui.lang.dsl.schema + +import com.intuit.playerui.lang.dsl.flow.FlowBuilder +import com.intuit.playerui.lang.dsl.types.Schema +import com.intuit.playerui.lang.dsl.types.SchemaDataType + +/** + * Sets the schema on the FlowBuilder from a typed [Schema] definition. + * + * Converts the typed schema to the raw map format expected by the flow output. + * + * Example: + * ```kotlin + * val schema: Schema = mapOf( + * "ROOT" to mapOf( + * "name" to SchemaDataType(type = "StringType"), + * "age" to SchemaDataType(type = "NumberType"), + * ), + * ) + * + * flow { + * id = "my-flow" + * schema(schema) + * navigation = mapOf("BEGIN" to "FLOW_1") + * } + * ``` + */ +fun FlowBuilder.schema(typedSchema: Schema) { + schema = + typedSchema.mapValues { (_, node) -> + node.mapValues { (_, dataType) -> dataType.toMap() } + } +} + +/** + * Sets the schema on the FlowBuilder and returns extracted bindings for use in the flow. + * + * Example: + * ```kotlin + * val schema: Schema = mapOf( + * "ROOT" to mapOf( + * "name" to SchemaDataType(type = "StringType"), + * ), + * ) + * + * flow { + * id = "my-flow" + * val bindings = schemaWithBindings(schema) + * val nameBinding = bindings.binding("name")!! + * views = listOf(input { binding(nameBinding) }) + * navigation = mapOf("BEGIN" to "FLOW_1") + * } + * ``` + */ +fun FlowBuilder.schemaWithBindings(typedSchema: Schema): SchemaBindings { + schema(typedSchema) + return extractBindings(typedSchema) +} + +private fun SchemaDataType.toMap(): Map = + buildMap { + put("type", type) + isArray?.let { put("isArray", it) } + isRecord?.let { put("isRecord", it) } + validation?.let { put("validation", it) } + format?.let { put("format", it) } + default?.let { put("default", it) } + } diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaBindings.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaBindings.kt new file mode 100644 index 0000000..487df42 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaBindings.kt @@ -0,0 +1,169 @@ +package com.intuit.playerui.lang.dsl.schema + +import com.intuit.playerui.lang.dsl.tagged.Binding +import com.intuit.playerui.lang.dsl.types.Schema +import com.intuit.playerui.lang.dsl.types.SchemaDataType +import com.intuit.playerui.lang.dsl.types.SchemaNode + +/** + * A container for typed bindings extracted from a Player-UI schema. + * Each key maps to either a [Binding] (for primitive types) or a nested [SchemaBindings] (for complex types). + */ +class SchemaBindings internal constructor( + private val bindings: Map, +) { + /** Access a binding or nested schema bindings by key. */ + operator fun get(key: String): Any? = bindings[key] + + /** + * Get a typed binding for the given key, or null if the key doesn't exist or isn't a binding. + */ + @Suppress("UNCHECKED_CAST") + fun binding(key: String): Binding? = bindings[key] as? Binding + + /** Get nested schema bindings for the given key, or null if the key doesn't exist or isn't nested. */ + fun nested(key: String): SchemaBindings? = bindings[key] as? SchemaBindings + + /** The set of all property keys at this level. */ + val keys: Set get() = bindings.keys + + /** Whether this container has any bindings. */ + val isEmpty: Boolean get() = bindings.isEmpty() +} + +/** + * Extracts typed [Binding] instances from a Player-UI [Schema] definition. + * + * Follows the same algorithm as the TypeScript `extractBindingsFromSchema`: + * - Starts from the ROOT node in the schema + * - Recursively processes each property + * - Creates [Binding], [Binding], or [Binding] for primitive types + * - Creates nested [SchemaBindings] for complex types + * - Handles arrays (via `_current_` path) and records + * - Prevents infinite recursion with a visited type set + * + * Example: + * ```kotlin + * val schema: Schema = mapOf( + * "ROOT" to mapOf( + * "name" to SchemaDataType(type = "StringType"), + * "age" to SchemaDataType(type = "NumberType"), + * "address" to SchemaDataType(type = "AddressType"), + * ), + * "AddressType" to mapOf( + * "street" to SchemaDataType(type = "StringType"), + * "city" to SchemaDataType(type = "StringType"), + * ), + * ) + * + * val bindings = SchemaBindingsExtractor.extract(schema) + * val name: Binding = bindings.binding("name")!! // path: "name" + * val street: Binding = bindings.nested("address")!!.binding("street")!! // path: "address.street" + * ``` + */ +object SchemaBindingsExtractor { + private val PRIMITIVE_TYPES = setOf("StringType", "NumberType", "BooleanType") + + /** + * Extract typed bindings from a schema definition. + * + * @param schema The Player-UI schema with a ROOT node + * @return A [SchemaBindings] object providing typed access to bindings + */ + fun extract(schema: Schema): SchemaBindings { + val root = schema["ROOT"] ?: return SchemaBindings(emptyMap()) + return processNode(root, schema, "", mutableSetOf()) + } + + private fun processNode( + node: SchemaNode, + schema: Schema, + basePath: String, + visited: MutableSet, + ): SchemaBindings { + val result = mutableMapOf() + for ((key, dataType) in node) { + val path = if (basePath.isEmpty()) key else "$basePath.$key" + result[key] = processDataType(dataType, schema, path, HashSet(visited)) + } + return SchemaBindings(result) + } + + private fun processDataType( + dataType: SchemaDataType, + schema: Schema, + path: String, + visited: MutableSet, + ): Any { + val typeName = dataType.type + + // Prevent infinite recursion + if (typeName in visited) { + return createBinding(typeName, path) + } + + // Handle arrays + if (dataType.isArray == true) { + val arrayPath = if (path.isNotEmpty()) "$path._current_" else "_current_" + + if (typeName in PRIMITIVE_TYPES) { + // Primitive arrays: StringType → { name: Binding }, others → { value: Binding } + val propName = if (typeName == "StringType") "name" else "value" + return SchemaBindings(mapOf(propName to createBinding(typeName, arrayPath))) + } + + val typeNode = schema[typeName] + if (typeNode != null) { + val newVisited = HashSet(visited) + newVisited.add(typeName) + return processNode(typeNode, schema, arrayPath, newVisited) + } + return createBinding("StringType", arrayPath) + } + + // Handle records + if (dataType.isRecord == true) { + val typeNode = schema[typeName] + if (typeNode != null) { + val newVisited = HashSet(visited) + newVisited.add(typeName) + return processNode(typeNode, schema, path, newVisited) + } + return createBinding("StringType", path) + } + + // Handle primitives + if (typeName in PRIMITIVE_TYPES) { + return createBinding(typeName, path) + } + + // Handle complex types (look up type definition in schema) + val typeNode = schema[typeName] + if (typeNode != null) { + val newVisited = HashSet(visited) + newVisited.add(typeName) + return processNode(typeNode, schema, path, newVisited) + } + + // Fallback: unknown type, treat as string binding + return createBinding("StringType", path) + } + + private fun createBinding( + typeName: String, + path: String, + ): Binding<*> = + when (typeName) { + "StringType" -> Binding(path) + "NumberType" -> Binding(path) + "BooleanType" -> Binding(path) + else -> Binding(path) + } +} + +/** + * Top-level convenience function to extract bindings from a schema. + * + * @see SchemaBindingsExtractor.extract + */ +fun extractBindings(schema: Schema): SchemaBindings = SchemaBindingsExtractor.extract(schema) diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/StandardExpressions.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/StandardExpressions.kt new file mode 100644 index 0000000..15b7bc8 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/StandardExpressions.kt @@ -0,0 +1,278 @@ +package com.intuit.playerui.lang.dsl.tagged + +/* + * Standard library of expressions for the Player-UI DSL. + * Provides common logical, comparison, and arithmetic operations. + * Matches the TypeScript fluent library's std.ts implementation. + */ + +/** + * Logical AND operation - returns true if all arguments are truthy. + */ +fun and(vararg args: Any): Expression { + val expressions = + args.map { arg -> + val expr = toExpressionString(arg) + // Wrap OR expressions in parentheses to maintain proper precedence + if (expr.contains(" || ") && !expr.startsWith("(")) { + "($expr)" + } else { + expr + } + } + return expression(expressions.joinToString(" && ")) +} + +/** + * Logical OR operation - returns true if any argument is truthy. + */ +fun or(vararg args: Any): Expression { + val expressions = args.map { toExpressionString(it) } + return expression(expressions.joinToString(" || ")) +} + +/** + * Logical NOT operation - returns true if argument is falsy. + */ +fun not(value: Any): Expression { + val expr = toExpressionString(value) + // Wrap complex expressions in parentheses + val wrappedExpr = + if (expr.contains(" ") && !expr.startsWith("(")) { + "($expr)" + } else { + expr + } + return expression("!$wrappedExpr") +} + +/** + * Logical NOR operation - returns true if all arguments are falsy. + */ +fun nor(vararg args: Any): Expression = not(or(*args)) + +/** + * Logical NAND operation - returns false if all arguments are truthy. + */ +fun nand(vararg args: Any): Expression = not(and(*args)) + +/** + * Logical XOR operation - returns true if exactly one argument is truthy. + */ +fun xor(left: Any, right: Any): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toExpressionString(right) + return expression("($leftExpr && !$rightExpr) || (!$leftExpr && $rightExpr)") +} + +/** + * Equality comparison (loose equality ==). + */ +fun equal(left: Any, right: T): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toValueString(right) + return expression("$leftExpr == $rightExpr") +} + +/** + * Strict equality comparison (===). + */ +fun strictEqual(left: Any, right: T): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toValueString(right) + return expression("$leftExpr === $rightExpr") +} + +/** + * Inequality comparison (!=). + */ +fun notEqual(left: Any, right: T): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toValueString(right) + return expression("$leftExpr != $rightExpr") +} + +/** + * Strict inequality comparison (!==). + */ +fun strictNotEqual(left: Any, right: T): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toValueString(right) + return expression("$leftExpr !== $rightExpr") +} + +/** + * Greater than comparison (>). + */ +fun greaterThan(left: Any, right: Any): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toValueString(right) + return expression("$leftExpr > $rightExpr") +} + +/** + * Greater than or equal comparison (>=). + */ +fun greaterThanOrEqual(left: Any, right: Any): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toValueString(right) + return expression("$leftExpr >= $rightExpr") +} + +/** + * Less than comparison (<). + */ +fun lessThan(left: Any, right: Any): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toValueString(right) + return expression("$leftExpr < $rightExpr") +} + +/** + * Less than or equal comparison (<=). + */ +fun lessThanOrEqual(left: Any, right: Any): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toValueString(right) + return expression("$leftExpr <= $rightExpr") +} + +/** + * Addition operation (+). + */ +fun add(vararg args: Any): Expression { + val expressions = args.map { toExpressionString(it) } + return expression(expressions.joinToString(" + ")) +} + +/** + * Subtraction operation (-). + */ +fun subtract(left: Any, right: Any): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toExpressionString(right) + return expression("$leftExpr - $rightExpr") +} + +/** + * Multiplication operation (*). + */ +fun multiply(vararg args: Any): Expression { + val expressions = args.map { toExpressionString(it) } + return expression(expressions.joinToString(" * ")) +} + +/** + * Division operation (/). + */ +fun divide(left: Any, right: Any): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toExpressionString(right) + return expression("$leftExpr / $rightExpr") +} + +/** + * Modulo operation (%). + */ +fun modulo(left: Any, right: Any): Expression { + val leftExpr = toExpressionString(left) + val rightExpr = toExpressionString(right) + return expression("$leftExpr % $rightExpr") +} + +/** + * Conditional (ternary) operation - if-then-else logic. + */ +fun conditional( + condition: Any, + ifTrue: T, + ifFalse: T +): Expression { + val conditionExpr = toExpressionString(condition) + val trueExpr = toValueString(ifTrue) + val falseExpr = toValueString(ifFalse) + return expression("$conditionExpr ? $trueExpr : $falseExpr") +} + +/** + * Function call expression. + */ +fun call(functionName: String, vararg args: Any): Expression { + val argExpressions = args.map { toValueString(it) } + return expression("$functionName(${argExpressions.joinToString(", ")})") +} + +/** + * Creates a literal value expression. + */ +fun literal(value: T): Expression = expression(toValueString(value)) + +/** + * Converts a value to its expression string representation. + * For TaggedValues, extracts the inner expression/binding path. + * For primitives, returns the string representation. + */ +private fun toExpressionString(value: Any): String = + when (value) { + is TaggedValue<*> -> value.toValue() + is Boolean -> value.toString() + is Number -> value.toString() + is String -> value + else -> value.toString() + } + +/** + * Converts a value to its JSON representation for use in expressions. + * For TaggedValues, extracts the inner expression/binding path. + * For primitives, returns JSON-encoded strings. + */ +private fun toValueString(value: Any?): String = + when (value) { + null -> "null" + is TaggedValue<*> -> value.toValue() + is Boolean -> value.toString() + is Number -> value.toString() + is String -> "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" + is List<*> -> "[${value.joinToString(", ") { toValueString(it) }}]" + is Map<*, *> -> "{${value.entries.joinToString(", ") { "\"${it.key}\": ${toValueString(it.value)}" }}}" + else -> "\"$value\"" + } + +/** + * Comparison aliases as functions. + */ +fun eq(left: Any, right: T): Expression = equal(left, right) + +fun strictEq(left: Any, right: T): Expression = strictEqual(left, right) + +fun neq(left: Any, right: T): Expression = notEqual(left, right) + +fun strictNeq(left: Any, right: T): Expression = strictNotEqual(left, right) + +fun gt(left: Any, right: Any): Expression = greaterThan(left, right) + +fun gte(left: Any, right: Any): Expression = greaterThanOrEqual(left, right) + +fun lt(left: Any, right: Any): Expression = lessThan(left, right) + +fun lte(left: Any, right: Any): Expression = lessThanOrEqual(left, right) + +/** + * Arithmetic aliases as functions. + */ +fun plus(vararg args: Any): Expression = add(*args) + +fun minus(left: Any, right: Any): Expression = subtract(left, right) + +fun times(vararg args: Any): Expression = multiply(*args) + +fun div(left: Any, right: Any): Expression = divide(left, right) + +fun mod(left: Any, right: Any): Expression = modulo(left, right) + +/** + * Control flow aliases as functions. + */ +fun ternary(condition: Any, ifTrue: T, ifFalse: T): Expression = conditional(condition, ifTrue, ifFalse) + +fun ifElse(condition: Any, ifTrue: T, ifFalse: T): Expression = conditional(condition, ifTrue, ifFalse) diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt new file mode 100644 index 0000000..2367b6c --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt @@ -0,0 +1,163 @@ +package com.intuit.playerui.lang.dsl.tagged + +/** + * Base interface for tagged values (bindings and expressions). + * These represent dynamic values that are resolved at runtime by Player-UI. + * + * @param T Phantom type parameter for type-safe usage (not used at runtime) + */ +sealed interface TaggedValue { + /** + * Returns the raw value without formatting. + */ + fun toValue(): String + + /** + * Returns the formatted string representation. + * For bindings: "{{path}}" + * For expressions: "@[expr]@" + */ + override fun toString(): String +} + +/** + * Represents a data binding in Player-UI. + * Bindings reference paths in the data model. + * + * Example: binding("user.name") produces "{{user.name}}" + * + * @param T The expected type of the bound value (phantom type) + * @property path The data path to bind to + */ +class Binding( + private val path: String, +) : TaggedValue { + override fun toValue(): String = path + + override fun toString(): String = "{{$path}}" + + /** + * Returns the path with _index_ placeholders replaced. + */ + fun withIndex( + index: Int, + depth: Int = 0, + ): Binding { + val placeholder = if (depth == 0) "_index_" else "_index${depth}_" + return Binding(path.replace(placeholder, index.toString())) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Binding<*>) return false + return path == other.path + } + + override fun hashCode(): Int = path.hashCode() +} + +/** + * Represents an expression in Player-UI. + * Expressions are evaluated at runtime. + * + * Example: expression("user.age >= 18") produces "@[user.age >= 18]@" + * + * @param T The expected return type of the expression (phantom type) + * @property expr The expression string + */ +class Expression( + private val expr: String, +) : TaggedValue { + init { + validateSyntax(expr) + } + + override fun toValue(): String = expr + + override fun toString(): String = "@[$expr]@" + + /** + * Returns the expression with _index_ placeholders replaced. + */ + fun withIndex( + index: Int, + depth: Int = 0, + ): Expression { + val placeholder = if (depth == 0) "_index_" else "_index${depth}_" + return Expression(expr.replace(placeholder, index.toString())) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Expression<*>) return false + return expr == other.expr + } + + override fun hashCode(): Int = expr.hashCode() + + private fun validateSyntax(expression: String) { + var openParens = 0 + expression.forEachIndexed { index, char -> + when (char) { + '(' -> { + openParens++ + } + + ')' -> { + openParens-- + if (openParens < 0) { + throw IllegalArgumentException( + "Unexpected ) at character $index in expression: $expression", + ) + } + } + } + } + if (openParens > 0) { + throw IllegalArgumentException("Expected ) in expression: $expression") + } + } +} + +/** + * Creates a binding to a data path. + */ +fun binding(path: String): Binding = Binding(path) + +/** + * Creates an expression. + */ +fun expression(expr: String): Expression = Expression(expr) + +/** + * Checks if a value is a TaggedValue. + */ +fun isTaggedValue(value: Any?): Boolean = value is TaggedValue<*> + +// Operator overloads for Expression + +operator fun Expression.plus(other: Any): Expression = add(this, other) + +operator fun Expression.minus(other: Any): Expression = subtract(this, other) + +operator fun Expression.times(other: Any): Expression = multiply(this, other) + +operator fun Expression.div(other: Any): Expression = divide(this, other) + +@Suppress("ktlint:standard:chain-method-continuation") +operator fun Expression.not(): Expression = com.intuit.playerui.lang.dsl.tagged.not(this) + +// Infix comparison operators for TaggedValue + +infix fun TaggedValue.eq(other: Any): Expression = equal(this, other) + +infix fun TaggedValue.neq(other: Any): Expression = notEqual(this, other) + +// Binding path composition + +operator fun Binding<*>.div(segment: String): Binding = Binding("${toValue()}.$segment") + +fun Binding>.indexed(depth: Int = 0): Binding { + val placeholder = if (depth == 0) "_index_" else "_index${depth}_" + return Binding("${toValue()}.$placeholder") +} diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/types/PlayerTypes.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/types/PlayerTypes.kt new file mode 100644 index 0000000..8fdba60 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/types/PlayerTypes.kt @@ -0,0 +1,169 @@ +package com.intuit.playerui.lang.dsl.types + +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// Player-UI type definitions for Kotlin. +// These types represent the core structures of Player-UI content. + +/** + * A binding describes a location in the data model. + * Format: "{{path.to.data}}" + */ +typealias BindingRef = String + +/** + * An expression reference for runtime evaluation. + * Format: "@[expression]@" + */ +typealias ExpressionRef = String + +/** + * Expression can be a single string or an array of strings. + * Represented as Any since it can be either String or List. + */ +typealias Expression = Any + +/** + * The data model is the location where all user data is stored. + */ +typealias DataModel = Map + +/** + * Base interface for all Player-UI assets. + * Each asset requires a unique id per view and a type that determines semantics. + */ +interface Asset { + val id: String + val type: String +} + +/** + * A template describes a mapping from a data array to an array of objects. + */ +@Serializable +data class Template( + val data: BindingRef, + val output: String, + val value: kotlinx.serialization.json.JsonElement, + val dynamic: Boolean = false, + val placement: TemplatePlacement? = null, +) + +/** + * Template placement relative to existing elements. + */ +@Serializable +enum class TemplatePlacement { + @SerialName("prepend") + PREPEND, + + @SerialName("append") + APPEND, +} + +/** + * Base for navigation state transitions. + */ +typealias NavigationTransitions = Map + +/** + * The complete navigation section of a flow. + * BEGIN specifies the starting flow, and additional keys are flow definitions. + */ +typealias Navigation = Map + +/** + * Validation severity levels. + */ +@Serializable +enum class ValidationSeverity { + @SerialName("error") + ERROR, + + @SerialName("warning") + WARNING, +} + +/** + * Validation trigger timing. + */ +@Serializable +enum class ValidationTrigger { + @SerialName("navigation") + NAVIGATION, + + @SerialName("change") + CHANGE, + + @SerialName("load") + LOAD, +} + +/** + * Validation display target. + */ +@Serializable +enum class ValidationDisplayTarget { + @SerialName("page") + PAGE, + + @SerialName("section") + SECTION, + + @SerialName("field") + FIELD, +} + +/** + * A validation reference. + * @property blocking Can be Boolean or the string "once" + */ +@Serializable +data class ValidationReference( + val type: String, + val message: String? = null, + val severity: ValidationSeverity? = null, + val trigger: ValidationTrigger? = null, + val displayTarget: ValidationDisplayTarget? = null, + @Contextual val blocking: Any? = null, +) + +/** + * Schema data type definition. + */ +@Serializable +data class SchemaDataType( + val type: String, + val validation: List? = null, + val format: Map? = null, + @Contextual val default: Any? = null, + val isRecord: Boolean? = null, + val isArray: Boolean? = null, +) + +/** + * Schema node definition. + */ +typealias SchemaNode = Map + +/** + * Complete schema definition. + */ +typealias Schema = Map + +/** + * A single case in a switch statement. + */ +@Serializable +data class SwitchCase( + val asset: kotlinx.serialization.json.JsonObject, + /** Expression or true */ + val case: kotlinx.serialization.json.JsonElement, +) + +/** + * A switch replaces an asset with the applicable case on first render. + */ +typealias Switch = List diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FlowTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FlowTest.kt new file mode 100644 index 0000000..d470cde --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FlowTest.kt @@ -0,0 +1,273 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.flow.flow +import com.intuit.playerui.lang.dsl.flow.flowBuilder +import com.intuit.playerui.lang.dsl.mocks.builders.* +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class FlowTest : + DescribeSpec({ + + describe("flow()") { + it("creates a basic flow with id and navigation") { + val result = + flow { + id = "test-flow" + navigation = + mapOf( + "BEGIN" to "FLOW_1", + "FLOW_1" to + mapOf( + "startState" to "VIEW_1" + ) + ) + } + + result["id"] shouldBe "test-flow" + result["navigation"] shouldNotBe null + result["views"] shouldBe emptyList() + } + + it("creates a flow with views") { + val result = + flow { + id = "my-flow" + views = + listOf( + text { value = "Hello" }, + text { value = "World" } + ) + navigation = mapOf("BEGIN" to "FLOW_1") + } + + result["id"] shouldBe "my-flow" + val views = result["views"] + views.shouldBeInstanceOf>() + val viewList = views as List> + viewList.size shouldBe 2 + viewList[0]["type"] shouldBe "text" + viewList[0]["value"] shouldBe "Hello" + viewList[1]["type"] shouldBe "text" + viewList[1]["value"] shouldBe "World" + } + + it("generates hierarchical IDs for views") { + val result = + flow { + id = "registration" + views = + listOf( + collection { + label { value = "Step 1" } + values( + input { binding("user.firstName") }, + input { binding("user.lastName") } + ) + } + ) + navigation = mapOf("BEGIN" to "FLOW_1") + } + + val views = result["views"] as List> + views.size shouldBe 1 + + val firstView = views[0] + firstView["id"] shouldBe "registration-views-0" + firstView["type"] shouldBe "collection" + + val values = firstView["values"] as List> + values.size shouldBe 2 + values[0]["id"] shouldBe "registration-views-0-0" + values[1]["id"] shouldBe "registration-views-0-1" + } + + it("includes data when provided") { + val result = + flow { + id = "data-flow" + data = + mapOf( + "user" to + mapOf( + "name" to "John", + "email" to "john@example.com" + ) + ) + navigation = mapOf("BEGIN" to "FLOW_1") + } + + result["data"] shouldBe + mapOf( + "user" to + mapOf( + "name" to "John", + "email" to "john@example.com" + ) + ) + } + + it("includes schema when provided") { + val result = + flow { + id = "schema-flow" + schema = + mapOf( + "ROOT" to + mapOf( + "user" to + mapOf( + "type" to "UserType" + ) + ) + ) + navigation = mapOf("BEGIN" to "FLOW_1") + } + + result["schema"] shouldNotBe null + (result["schema"] as Map<*, *>)["ROOT"] shouldNotBe null + } + + it("excludes data and schema when not provided") { + val result = + flow { + id = "minimal-flow" + navigation = mapOf("BEGIN" to "FLOW_1") + } + + result.containsKey("data") shouldBe false + result.containsKey("schema") shouldBe false + } + + it("supports additional properties") { + val result = + flow { + id = "extended-flow" + navigation = mapOf("BEGIN" to "FLOW_1") + set("customField", "customValue") + set("version", 1) + } + + result["customField"] shouldBe "customValue" + result["version"] shouldBe 1 + } + + it("processes complex nested views") { + val result = + flow { + id = "complex-flow" + views = + listOf( + collection { + label { value = "Form" } + values( + input { + binding("user.email") + label { value = "Email" } + } + ) + actions( + action { + value = "submit" + label { value = "Submit" } + } + ) + } + ) + navigation = + mapOf( + "BEGIN" to "FLOW_1", + "FLOW_1" to + mapOf( + "startState" to "VIEW_form", + "VIEW_form" to + mapOf( + "state_type" to "VIEW", + "ref" to "complex-flow-views-0", + "transitions" to + mapOf( + "submit" to "END_Done" + ) + ), + "END_Done" to + mapOf( + "state_type" to "END", + "outcome" to "done" + ) + ) + ) + } + + val views = result["views"] as List> + val formView = views[0] + + // Verify the collection structure + formView["type"] shouldBe "collection" + formView["id"] shouldBe "complex-flow-views-0" + + // Verify nested label is wrapped + val label = formView["label"] as Map + val labelAsset = label["asset"] as Map + labelAsset["type"] shouldBe "text" + labelAsset["value"] shouldBe "Form" + + // Verify actions + val actions = formView["actions"] as List> + actions.size shouldBe 1 + actions[0]["value"] shouldBe "submit" + } + } + + describe("flowBuilder()") { + it("creates a builder that can be built later") { + val builder = + flowBuilder { + id = "deferred-flow" + navigation = mapOf("BEGIN" to "FLOW_1") + } + + // Can modify before building + builder.views = listOf(text { value = "Added later" }) + + val result = builder.build() + result["id"] shouldBe "deferred-flow" + (result["views"] as List<*>).size shouldBe 1 + } + + it("each build creates independent ID namespace") { + val builder1 = + flowBuilder { + id = "flow1" + views = listOf(text { value = "View 1" }) + navigation = mapOf("BEGIN" to "FLOW_1") + } + builder1.build() + + // Build a second flow - IDs are independent because each build creates a fresh IdRegistry + val builder2 = + flowBuilder { + id = "flow2" + views = listOf(text { value = "View 2" }) + navigation = mapOf("BEGIN" to "FLOW_1") + } + val result2 = builder2.build() + + result2["id"] shouldBe "flow2" + } + } + + describe("uses default id") { + it("uses 'root' as default flow id") { + val result = + flow { + navigation = mapOf("BEGIN" to "FLOW_1") + } + + result["id"] shouldBe "root" + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt new file mode 100644 index 0000000..2792018 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt @@ -0,0 +1,369 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.IdBranch +import com.intuit.playerui.lang.dsl.id.IdRegistry +import com.intuit.playerui.lang.dsl.mocks.builders.* +import com.intuit.playerui.lang.dsl.tagged.binding +import com.intuit.playerui.lang.dsl.tagged.expression +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class FluentBuilderBaseTest : + DescribeSpec({ + + fun registry() = IdRegistry() + + describe("TextBuilder") { + it("builds a simple text asset with defaults") { + val result = + text { + value = "Hello World" + }.build() + + result["type"] shouldBe "text" + result["value"] shouldBe "Hello World" + } + + it("builds text with explicit ID") { + val result = + text { + id = "my-text" + value = "Test" + }.build() + + result["id"] shouldBe "my-text" + result["type"] shouldBe "text" + result["value"] shouldBe "Test" + } + + it("builds text with binding value") { + val result = + text { + value(binding("user.name")) + }.build() + + result["value"] shouldBe "{{user.name}}" + } + + it("generates ID from context") { + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("label"), + idRegistry = registry(), + ) + + val result = + text { + value = "Label Text" + }.build(ctx) + + result["id"] shouldBe "parent-label" + } + } + + describe("InputBuilder") { + it("builds an input with binding") { + val result = + input { + binding("user.email") + placeholder = "Enter email" + }.build() + + result["type"] shouldBe "input" + result["binding"] shouldBe "{{user.email}}" + result["placeholder"] shouldBe "Enter email" + } + + it("builds an input with nested label") { + val ctx = BuildContext(parentId = "form", idRegistry = registry()) + + val result = + input { + binding("user.name") + label { value = "Name" } + }.build(ctx) + + result["type"] shouldBe "input" + result["binding"] shouldBe "{{user.name}}" + + val label = result["label"] + label.shouldBeInstanceOf>() + (label as Map<*, *>)["asset"].shouldBeInstanceOf>() + val labelAsset = label["asset"] as Map<*, *> + labelAsset["type"] shouldBe "text" + labelAsset["value"] shouldBe "Name" + } + } + + describe("ActionBuilder") { + it("builds an action with value") { + val result = + action { + value = "submit" + label { value = "Submit" } + }.build() + + result["type"] shouldBe "action" + result["value"] shouldBe "submit" + } + + it("builds an action with metadata") { + val result = + action { + value = "next" + metaData = mapOf("role" to "primary", "size" to "large") + }.build() + + result["metaData"] shouldBe mapOf("role" to "primary", "size" to "large") + } + + it("builds an action with metadata DSL") { + val result = + action { + value = "next" + metaData { + put("role", "primary") + put("icon", "arrow-right") + } + }.build() + + val meta = result["metaData"] as Map<*, *> + meta["role"] shouldBe "primary" + meta["icon"] shouldBe "arrow-right" + } + } + + describe("CollectionBuilder") { + it("builds a collection with label") { + val ctx = BuildContext(parentId = "page", idRegistry = registry()) + + val result = + collection { + label { value = "User Form" } + }.build(ctx) + + result["type"] shouldBe "collection" + result.keys shouldContain "label" + } + + it("builds a collection with values array") { + val ctx = BuildContext(parentId = "page", branch = IdBranch.Slot("content"), idRegistry = registry()) + + val result = + collection { + values( + text { value = "Item 1" }, + text { value = "Item 2" }, + ) + }.build(ctx) + + val values = result["values"] + values.shouldBeInstanceOf>() + (values as List<*>).size shouldBe 2 + } + + it("builds a collection with actions") { + val result = + collection { + actions( + action { + value = "submit" + label { value = "Submit" } + }, + action { + value = "cancel" + label { value = "Cancel" } + }, + ) + }.build() + + val actions = result["actions"] + actions.shouldBeInstanceOf>() + (actions as List<*>).size shouldBe 2 + } + } + + describe("Nested ID generation") { + it("generates hierarchical IDs for nested assets") { + val ctx = BuildContext(parentId = "my-flow-views-0", idRegistry = registry()) + + val result = + collection { + id = "form" + label { value = "Registration" } + values( + input { binding("user.firstName") }, + input { binding("user.lastName") }, + ) + actions( + action { + value = "submit" + label { value = "Register" } + }, + ) + }.build(ctx) + + // The collection uses explicit ID + result["id"] shouldBe "form" + + // Nested assets should have generated IDs based on parent + val values = result["values"] as List> + values.size shouldBe 2 + + val actions = result["actions"] as List> + actions.size shouldBe 1 + } + } + + describe("Conditional building") { + it("conditionally sets property with setIf") { + val showPlaceholder = true + + val builder = + input { + binding("user.email") + } + builder.setIf({ showPlaceholder }, "placeholder", "Enter email") + + val result = builder.build() + result["placeholder"] shouldBe "Enter email" + } + + it("skips property when setIf condition is false") { + val showPlaceholder = false + + val builder = + input { + binding("user.email") + } + builder.setIf({ showPlaceholder }, "placeholder", "Enter email") + + val result = builder.build() + result.containsKey("placeholder") shouldBe false + } + + it("uses setIfElse for conditional values") { + val isPrimary = true + + val builder = + action { + value = "submit" + } + builder.setIfElse( + { isPrimary }, + "metaData", + mapOf("role" to "primary"), + mapOf("role" to "secondary"), + ) + + val result = builder.build() + (result["metaData"] as Map<*, *>)["role"] shouldBe "primary" + } + } + + describe("Tagged values resolution") { + it("resolves bindings to string format") { + val result = + text { + value(binding("data.message")) + }.build() + + result["value"] shouldBe "{{data.message}}" + } + + it("resolves expressions to string format") { + val result = + action { + value(expression("navigate('home')")) + }.build() + + result["value"] shouldBe "@[navigate('home')]@" + } + + it("handles bindings with index placeholders") { + val result = + text { + value(binding("items._index_.name")) + }.build() + + result["value"] shouldBe "{{items._index_.name}}" + } + } + + describe("Builder cloning") { + it("creates an independent copy") { + val original = + text { + id = "original" + value = "Original text" + } + + val clone = original.clone() + clone.id = "clone" + clone.value = "Clone text" + + val originalResult = original.build() + val cloneResult = clone.build() + + originalResult["id"] shouldBe "original" + originalResult["value"] shouldBe "Original text" + + cloneResult["id"] shouldBe "clone" + cloneResult["value"] shouldBe "Clone text" + } + } + + describe("Property management") { + it("has() returns true for set properties") { + val builder = + text { + value = "Test" + } + + builder.has("value") shouldBe true + builder.has("id") shouldBe false + } + + it("peek() returns raw value without resolution") { + val b = binding("user.name") + val builder = + text { + value(b) + } + + builder.peek("value") shouldBe b + } + + it("unset() removes a property") { + val builder = + text { + id = "test" + value = "Test" + } + + builder.unset("id") + + builder.has("id") shouldBe false + builder.has("value") shouldBe true + } + + it("clear() removes all properties") { + val builder = + text { + id = "test" + value = "Test" + } + + builder.clear() + + builder.has("id") shouldBe false + builder.has("value") shouldBe false + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IdGeneratorTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IdGeneratorTest.kt new file mode 100644 index 0000000..e78674c --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IdGeneratorTest.kt @@ -0,0 +1,478 @@ +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.core.AssetMetadata +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.IdBranch +import com.intuit.playerui.lang.dsl.id.IdRegistry +import com.intuit.playerui.lang.dsl.id.determineSlotName +import com.intuit.playerui.lang.dsl.id.genId +import com.intuit.playerui.lang.dsl.id.peekId +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe + +class IdGeneratorTest : + DescribeSpec({ + + fun registry() = IdRegistry() + + describe("genId") { + describe("no branch (custom ID case)") { + it("returns parentId when no branch is provided") { + val ctx = BuildContext(parentId = "custom-id", idRegistry = registry()) + genId(ctx) shouldBe "custom-id" + } + + it("returns parentId when branch is null") { + val ctx = BuildContext(parentId = "another-custom-id", branch = null, idRegistry = registry()) + genId(ctx) shouldBe "another-custom-id" + } + + it("handles empty string parentId with no branch") { + val ctx = BuildContext(parentId = "", idRegistry = registry()) + genId(ctx) shouldBe "" + } + + it("handles complex parentId with special characters") { + val ctx = BuildContext(parentId = "parent_with-special.chars@123", idRegistry = registry()) + genId(ctx) shouldBe "parent_with-special.chars@123" + } + } + + describe("slot branch") { + it("generates ID for slot with parentId") { + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("header"), + idRegistry = registry(), + ) + genId(ctx) shouldBe "parent-header" + } + + it("generates ID for slot with empty parentId") { + val ctx = + BuildContext( + parentId = "", + branch = IdBranch.Slot("footer"), + idRegistry = registry(), + ) + genId(ctx) shouldBe "footer" + } + + it("throws error for slot with empty name") { + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot(""), + idRegistry = registry(), + ) + shouldThrow { + genId(ctx) + }.message shouldBe "genId: Slot branch requires a 'name' property" + } + + it("handles slot with special characters in name") { + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("slot_with-special.chars"), + idRegistry = registry(), + ) + genId(ctx) shouldBe "parent-slot_with-special.chars" + } + + it("handles slot with numeric name") { + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("123"), + idRegistry = registry(), + ) + genId(ctx) shouldBe "parent-123" + } + } + + describe("array-item branch") { + it("generates ID for array item with positive index") { + val ctx = + BuildContext( + parentId = "list", + branch = IdBranch.ArrayItem(2), + idRegistry = registry(), + ) + genId(ctx) shouldBe "list-2" + } + + it("generates ID for array item with zero index") { + val ctx = + BuildContext( + parentId = "array", + branch = IdBranch.ArrayItem(0), + idRegistry = registry(), + ) + genId(ctx) shouldBe "array-0" + } + + it("throws error for array item with negative index") { + val ctx = + BuildContext( + parentId = "items", + branch = IdBranch.ArrayItem(-1), + idRegistry = registry(), + ) + shouldThrow { + genId(ctx) + }.message shouldBe "genId: Array-item index must be non-negative" + } + + it("generates ID for array item with large index") { + val ctx = + BuildContext( + parentId = "bigArray", + branch = IdBranch.ArrayItem(999999), + idRegistry = registry(), + ) + genId(ctx) shouldBe "bigArray-999999" + } + + it("handles array item with empty parentId") { + val ctx = + BuildContext( + parentId = "", + branch = IdBranch.ArrayItem(5), + idRegistry = registry(), + ) + genId(ctx) shouldBe "-5" + } + } + + describe("template branch") { + it("generates ID for template with depth") { + val ctx = + BuildContext( + parentId = "template", + branch = IdBranch.Template(depth = 1), + idRegistry = registry(), + ) + genId(ctx) shouldBe "template-_index1_" + } + + it("generates ID for template with zero depth") { + val ctx = + BuildContext( + parentId = "template", + branch = IdBranch.Template(depth = 0), + idRegistry = registry(), + ) + genId(ctx) shouldBe "template-_index_" + } + + it("generates ID for template with default depth") { + val ctx = + BuildContext( + parentId = "template", + branch = IdBranch.Template(), + idRegistry = registry(), + ) + genId(ctx) shouldBe "template-_index_" + } + + it("throws error for template with negative depth") { + val ctx = + BuildContext( + parentId = "template", + branch = IdBranch.Template(depth = -2), + idRegistry = registry(), + ) + shouldThrow { + genId(ctx) + }.message shouldBe "genId: Template depth must be non-negative" + } + + it("generates ID for template with large depth") { + val ctx = + BuildContext( + parentId = "deepTemplate", + branch = IdBranch.Template(depth = 100), + idRegistry = registry(), + ) + genId(ctx) shouldBe "deepTemplate-_index100_" + } + + it("handles template with empty parentId") { + val ctx = + BuildContext( + parentId = "", + branch = IdBranch.Template(depth = 3), + idRegistry = registry(), + ) + genId(ctx) shouldBe "-_index3_" + } + } + + describe("switch branch") { + it("generates ID for static switch") { + val ctx = + BuildContext( + parentId = "condition", + branch = IdBranch.Switch(0, IdBranch.Switch.SwitchKind.STATIC), + idRegistry = registry(), + ) + genId(ctx) shouldBe "condition-staticSwitch-0" + } + + it("generates ID for dynamic switch") { + val ctx = + BuildContext( + parentId = "condition", + branch = IdBranch.Switch(1, IdBranch.Switch.SwitchKind.DYNAMIC), + idRegistry = registry(), + ) + genId(ctx) shouldBe "condition-dynamicSwitch-1" + } + + it("generates ID for switch with zero index") { + val ctx = + BuildContext( + parentId = "switch", + branch = IdBranch.Switch(0, IdBranch.Switch.SwitchKind.DYNAMIC), + idRegistry = registry(), + ) + genId(ctx) shouldBe "switch-dynamicSwitch-0" + } + + it("throws error for switch with negative index") { + val ctx = + BuildContext( + parentId = "negativeSwitch", + branch = IdBranch.Switch(-1, IdBranch.Switch.SwitchKind.STATIC), + idRegistry = registry(), + ) + shouldThrow { + genId(ctx) + }.message shouldBe "genId: Switch index must be non-negative" + } + + it("generates ID for switch with large index") { + val ctx = + BuildContext( + parentId = "bigSwitch", + branch = IdBranch.Switch(9999, IdBranch.Switch.SwitchKind.DYNAMIC), + idRegistry = registry(), + ) + genId(ctx) shouldBe "bigSwitch-dynamicSwitch-9999" + } + + it("handles switch with empty parentId") { + val ctx = + BuildContext( + parentId = "", + branch = IdBranch.Switch(2, IdBranch.Switch.SwitchKind.STATIC), + idRegistry = registry(), + ) + genId(ctx) shouldBe "-staticSwitch-2" + } + } + + describe("collision detection") { + it("enforces uniqueness across multiple calls with same input") { + val reg = registry() + val ctx = + BuildContext( + parentId = "consistent", + branch = IdBranch.Slot("test"), + idRegistry = reg, + ) + + val result1 = genId(ctx) + val result2 = genId(ctx) + val result3 = genId(ctx) + + result1 shouldBe "consistent-test" + result2 shouldBe "consistent-test-1" + result3 shouldBe "consistent-test-2" + + result1 shouldNotBe result2 + result2 shouldNotBe result3 + result1 shouldNotBe result3 + } + + it("generates different IDs for different contexts") { + val reg = registry() + val contexts = + listOf( + BuildContext(parentId = "parent1", branch = IdBranch.Slot("slot1"), idRegistry = reg), + BuildContext(parentId = "parent2", branch = IdBranch.Slot("slot1"), idRegistry = reg), + BuildContext(parentId = "parent1", branch = IdBranch.Slot("slot2"), idRegistry = reg), + BuildContext(parentId = "parent1", branch = IdBranch.ArrayItem(0), idRegistry = reg), + ) + + val results = contexts.map { genId(it) } + val uniqueResults = results.toSet() + + uniqueResults.size shouldBe results.size + results shouldBe + listOf( + "parent1-slot1", + "parent2-slot1", + "parent1-slot2", + "parent1-0", + ) + } + } + + describe("edge cases and integration") { + it("handles complex parentId with all branch types") { + val complexParentId = "complex_parent-with.special@chars123" + + val testCases = + listOf( + IdBranch.Slot("test") to "complex_parent-with.special@chars123-test", + IdBranch.ArrayItem(5) to "complex_parent-with.special@chars123-5", + IdBranch.Template(2) to "complex_parent-with.special@chars123-_index2_", + IdBranch.Switch(3, IdBranch.Switch.SwitchKind.STATIC) to + "complex_parent-with.special@chars123-staticSwitch-3", + ) + + testCases.forEach { (branch, expected) -> + val ctx = BuildContext(parentId = complexParentId, branch = branch, idRegistry = registry()) + genId(ctx) shouldBe expected + } + } + + it("handles all branch types with empty parentId") { + val testCases = + listOf( + IdBranch.Slot("empty") to "empty", + IdBranch.ArrayItem(0) to "-0", + IdBranch.Template(1) to "-_index1_", + IdBranch.Switch(0, IdBranch.Switch.SwitchKind.DYNAMIC) to "-dynamicSwitch-0", + ) + + testCases.forEach { (branch, expected) -> + val ctx = BuildContext(parentId = "", branch = branch, idRegistry = registry()) + genId(ctx) shouldBe expected + } + } + } + } + + describe("peekId") { + it("generates ID without registering") { + val reg = registry() + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("test"), + idRegistry = reg, + ) + + val peeked = peekId(ctx) + peeked shouldBe "parent-test" + + // Should still be able to generate the same ID since peek doesn't register + val generated = genId(ctx) + generated shouldBe "parent-test" + } + + it("does not affect collision detection") { + val reg = registry() + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("test"), + idRegistry = reg, + ) + + // Peek multiple times + peekId(ctx) shouldBe "parent-test" + peekId(ctx) shouldBe "parent-test" + peekId(ctx) shouldBe "parent-test" + + // First genId should still get the base ID + genId(ctx) shouldBe "parent-test" + // Second genId should get collision suffix + genId(ctx) shouldBe "parent-test-1" + } + } + + describe("determineSlotName") { + it("returns parameter name when no metadata") { + determineSlotName("label", null) shouldBe "label" + } + + it("returns type when no binding or value") { + val metadata = AssetMetadata(type = "text") + determineSlotName("label", metadata) shouldBe "text" + } + + it("uses action value for action types") { + val metadata = AssetMetadata(type = "action", value = "submit") + determineSlotName("label", metadata) shouldBe "action-submit" + } + + it("uses action value with binding syntax stripped") { + val metadata = AssetMetadata(type = "action", value = "{{user.action}}") + determineSlotName("label", metadata) shouldBe "action-action" + } + + it("uses binding last segment for non-action types") { + val metadata = AssetMetadata(type = "input", binding = "user.email") + determineSlotName("label", metadata) shouldBe "input-email" + } + + it("strips binding syntax from binding") { + val metadata = AssetMetadata(type = "input", binding = "{{user.firstName}}") + determineSlotName("label", metadata) shouldBe "input-firstName" + } + + it("uses parameter name when type is null and no binding") { + val metadata = AssetMetadata(type = null) + determineSlotName("values", metadata) shouldBe "values" + } + + it("handles complex binding paths") { + val metadata = AssetMetadata(type = "text", binding = "data.users.0.profile.name") + determineSlotName("label", metadata) shouldBe "text-name" + } + } + + describe("IdRegistry") { + it("reset via fresh instance clears all registered IDs") { + val reg = registry() + val ctx = BuildContext(parentId = "test", branch = IdBranch.Slot("item"), idRegistry = reg) + + genId(ctx) shouldBe "test-item" + genId(ctx) shouldBe "test-item-1" + + // Fresh registry = clean namespace + val reg2 = registry() + val ctx2 = BuildContext(parentId = "test", branch = IdBranch.Slot("item"), idRegistry = reg2) + + genId(ctx2) shouldBe "test-item" + } + + it("tracks registration count") { + val reg = registry() + + reg.size() shouldBe 0 + + genId(BuildContext(parentId = "a", idRegistry = reg)) + reg.size() shouldBe 1 + + genId(BuildContext(parentId = "b", idRegistry = reg)) + reg.size() shouldBe 2 + } + + it("isRegistered returns correct status") { + val reg = registry() + val ctx = BuildContext(parentId = "check", branch = IdBranch.Slot("status"), idRegistry = reg) + + reg.isRegistered("check-status") shouldBe false + genId(ctx) + reg.isRegistered("check-status") shouldBe true + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt new file mode 100644 index 0000000..b54f930 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt @@ -0,0 +1,429 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.IdBranch +import com.intuit.playerui.lang.dsl.flow.flow +import com.intuit.playerui.lang.dsl.id.IdRegistry +import com.intuit.playerui.lang.dsl.mocks.builders.* +import com.intuit.playerui.lang.dsl.tagged.binding +import com.intuit.playerui.lang.dsl.tagged.expression +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.serialization.json.* + +class IntegrationTest : + DescribeSpec({ + + /** + * Converts a Map to JsonElement for JSON serialization. + * Handles nested maps, lists, and primitive values. + */ + fun Any?.toJsonElement(): JsonElement = + when (this) { + null -> JsonNull + is String -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + is Boolean -> JsonPrimitive(this) + is Map<*, *> -> + JsonObject( + this.entries.associate { (k, v) -> k.toString() to v.toJsonElement() }, + ) + is List<*> -> JsonArray(this.map { it.toJsonElement() }) + else -> JsonPrimitive(this.toString()) + } + + fun Map.toJson(): String { + val jsonElement = this.toJsonElement() + return Json { prettyPrint = true }.encodeToString(jsonElement) + } + + fun registry() = IdRegistry() + + describe("Complete flow output") { + it("produces valid Player-UI JSON structure") { + val result = + flow { + id = "registration-flow" + views = + listOf( + collection { + id = "form" + label { value = "User Registration" } + values( + input { + binding("user.firstName") + label { value = "First Name" } + placeholder = "Enter your first name" + }, + input { + binding("user.lastName") + label { value = "Last Name" } + placeholder = "Enter your last name" + }, + input { + binding("user.email") + label { value = "Email" } + placeholder = "Enter your email" + }, + ) + actions( + action { + value = "submit" + label { value = "Register" } + metaData = mapOf("role" to "primary") + }, + action { + value = "cancel" + label { value = "Cancel" } + }, + ) + }, + ) + data = + mapOf( + "user" to + mapOf( + "firstName" to "", + "lastName" to "", + "email" to "", + ), + ) + navigation = + mapOf( + "BEGIN" to "FLOW_1", + "FLOW_1" to + mapOf( + "startState" to "VIEW_form", + "VIEW_form" to + mapOf( + "state_type" to "VIEW", + "ref" to "form", + "transitions" to + mapOf( + "submit" to "END_Done", + "cancel" to "END_Cancelled", + ), + ), + "END_Done" to + mapOf( + "state_type" to "END", + "outcome" to "done", + ), + "END_Cancelled" to + mapOf( + "state_type" to "END", + "outcome" to "cancelled", + ), + ), + ) + } + + // Verify top-level structure + result["id"] shouldBe "registration-flow" + result["navigation"] shouldNotBe null + result["data"] shouldNotBe null + + // Verify views + val views = result["views"] as List> + views.size shouldBe 1 + + val form = views[0] + form["id"] shouldBe "form" + form["type"] shouldBe "collection" + + // Verify label is wrapped in AssetWrapper + val formLabel = form["label"] as Map + formLabel["asset"] shouldNotBe null + val formLabelAsset = formLabel["asset"] as Map + formLabelAsset["type"] shouldBe "text" + formLabelAsset["value"] shouldBe "User Registration" + + // Verify inputs with bindings + val values = form["values"] as List> + values.size shouldBe 3 + + values[0]["type"] shouldBe "input" + values[0]["binding"] shouldBe "{{user.firstName}}" + values[0]["placeholder"] shouldBe "Enter your first name" + + val firstNameLabel = values[0]["label"] as Map + val firstNameLabelAsset = firstNameLabel["asset"] as Map + firstNameLabelAsset["value"] shouldBe "First Name" + + // Verify actions + val actions = form["actions"] as List> + actions.size shouldBe 2 + + actions[0]["type"] shouldBe "action" + actions[0]["value"] shouldBe "submit" + actions[0]["metaData"] shouldBe mapOf("role" to "primary") + + // Verify JSON is serializable + val jsonString = result.toJson() + jsonString.shouldBeInstanceOf() + } + } + + describe("Template output") { + it("produces correct template structure") { + val reg = registry() + + val result = + collection { + id = "user-list" + label { value = "Users" } + template( + data = binding>("users"), + output = "values", + dynamic = true, + ) { + text { value(binding("users._index_.name")) } + } + }.build(BuildContext(parentId = "my-flow-views-0", idRegistry = reg)) + + result["id"] shouldBe "user-list" + result["type"] shouldBe "collection" + + // Verify template is added to values array + val values = result["values"] as List + values.size shouldBe 1 + + val templateWrapper = values[0] as Map + templateWrapper.containsKey("dynamicTemplate") shouldBe true + + val templateData = templateWrapper["dynamicTemplate"] as Map + templateData["data"] shouldBe "{{users}}" + templateData["output"] shouldBe "values" + + val templateValue = templateData["value"] as Map + templateValue["asset"] shouldNotBe null + } + } + + describe("Switch output") { + it("produces correct static switch structure") { + val reg = registry() + + val result = + collection { + id = "i18n-content" + switch( + path = listOf("label"), + isDynamic = false, + ) { + case(expression("user.locale === 'es'"), text { value = "Hola" }) + case(expression("user.locale === 'fr'"), text { value = "Bonjour" }) + default(text { value = "Hello" }) + } + }.build(BuildContext(parentId = "flow-views-0", idRegistry = reg)) + + result["id"] shouldBe "i18n-content" + result["type"] shouldBe "collection" + + // Verify switch is in the label property + val label = result["label"] as Map + label.containsKey("staticSwitch") shouldBe true + + val staticSwitch = label["staticSwitch"] as List> + staticSwitch.size shouldBe 3 + + // First case - Spanish + staticSwitch[0]["case"] shouldBe "@[user.locale === 'es']@" + val esAsset = staticSwitch[0]["asset"] as Map + esAsset["type"] shouldBe "text" + esAsset["value"] shouldBe "Hola" + + // Third case - Default (English) + staticSwitch[2]["case"] shouldBe true + val enAsset = staticSwitch[2]["asset"] as Map + enAsset["value"] shouldBe "Hello" + } + + it("produces correct dynamic switch structure") { + val reg = registry() + + val result = + collection { + id = "conditional-content" + switch( + path = listOf("label"), + isDynamic = true, + ) { + case(expression("showWelcome"), text { value = "Welcome!" }) + default(text { value = "Goodbye!" }) + } + }.build(BuildContext(parentId = "flow-views-0", idRegistry = reg)) + + val label = result["label"] as Map + label.containsKey("dynamicSwitch") shouldBe true + + val dynamicSwitch = label["dynamicSwitch"] as List> + dynamicSwitch.size shouldBe 2 + } + } + + describe("ID generation in complex structures") { + it("generates correct hierarchical IDs") { + val reg = registry() + + // When building in a flow context, the view gets an ArrayItem branch + val ctx = + BuildContext( + parentId = "my-flow-views", + branch = IdBranch.ArrayItem(0), + idRegistry = reg, + ) + + val result = + collection { + label { value = "Outer" } + values( + collection { + label { value = "Inner 1" } + values( + text { value = "Deep Text 1" }, + text { value = "Deep Text 2" }, + ) + }, + collection { + label { value = "Inner 2" } + values( + text { value = "Deep Text 3" }, + ) + }, + ) + }.build(ctx) + + // Outer collection gets ID from array index branch + result["id"] shouldBe "my-flow-views-0" + + val outerValues = result["values"] as List> + + // Inner collections get sequential IDs based on their array index + outerValues[0]["id"] shouldBe "my-flow-views-0-0" + outerValues[1]["id"] shouldBe "my-flow-views-0-1" + + // Deep nested texts + val inner1Values = outerValues[0]["values"] as List> + inner1Values[0]["id"] shouldBe "my-flow-views-0-0-0" + inner1Values[1]["id"] shouldBe "my-flow-views-0-0-1" + + val inner2Values = outerValues[1]["values"] as List> + inner2Values[0]["id"] shouldBe "my-flow-views-0-1-0" + } + } + + describe("Binding and Expression formatting") { + it("formats bindings correctly") { + val result = + text { + value(binding("user.profile.name")) + }.build() + + result["value"] shouldBe "{{user.profile.name}}" + } + + it("formats expressions correctly") { + val result = + action { + value(expression("navigate('home')")) + }.build() + + result["value"] shouldBe "@[navigate('home')]@" + } + + it("preserves _index_ placeholder in bindings") { + val result = + text { + value(binding("items._index_.details.name")) + }.build() + + result["value"] shouldBe "{{items._index_.details.name}}" + } + } + + describe("Conditional building") { + it("handles setIf correctly") { + val showPlaceholder = true + val showValidation = false + + val builder = + input { + binding("user.email") + } + builder.setIf({ showPlaceholder }, "placeholder", "Enter email") + builder.setIf({ showValidation }, "validation", mapOf("required" to true)) + + val result = builder.build() + + result["placeholder"] shouldBe "Enter email" + result.containsKey("validation") shouldBe false + } + + it("handles setIfElse correctly") { + val isPrimary = true + + val builder = + action { + value = "submit" + } + builder.setIfElse( + { isPrimary }, + "metaData", + mapOf("role" to "primary", "size" to "large"), + mapOf("role" to "secondary", "size" to "small"), + ) + + val result = builder.build() + val metaData = result["metaData"] as Map<*, *> + + metaData["role"] shouldBe "primary" + metaData["size"] shouldBe "large" + } + } + + describe("JSON serialization") { + it("produces valid JSON that can be parsed back") { + val result = + flow { + id = "serialization-test" + views = + listOf( + collection { + label { value = "Test" } + values( + text { value = "Item 1" }, + input { binding("field1") }, + ) + }, + ) + data = mapOf("field1" to "initial value") + navigation = mapOf("BEGIN" to "FLOW_1") + } + + // Serialize to JSON + val jsonString = result.toJson() + + // Parse back + val parsed = Json.decodeFromString(jsonString) + parsed shouldNotBe null + } + + it("handles special characters in values") { + val result = + text { + value = "Hello \"World\" with special & symbols" + }.build() + + val jsonString = result.toJson() + jsonString.shouldBeInstanceOf() + + // Should contain escaped quotes + jsonString.contains("\\\"World\\\"") shouldBe true + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/NavigationBuilderTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/NavigationBuilderTest.kt new file mode 100644 index 0000000..dcdd705 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/NavigationBuilderTest.kt @@ -0,0 +1,192 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.flow.ActionStateBuilder +import com.intuit.playerui.lang.dsl.flow.NavigationBuilder +import com.intuit.playerui.lang.dsl.flow.NavigationFlowBuilder +import com.intuit.playerui.lang.dsl.flow.ViewStateBuilder +import com.intuit.playerui.lang.dsl.tagged.expression +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class NavigationBuilderTest : + DescribeSpec({ + + describe("NavigationBuilder") { + it("builds a basic navigation with BEGIN") { + val nav = + NavigationBuilder() + .apply { + begin = "FLOW_1" + }.build() + + nav["BEGIN"] shouldBe "FLOW_1" + } + + it("defaults begin to FLOW_1") { + val nav = + NavigationBuilder() + .build() + nav["BEGIN"] shouldBe "FLOW_1" + } + + it("builds navigation with a flow") { + val nav = + NavigationBuilder() + .apply { + begin = "FLOW_1" + flow("FLOW_1") { + startState = "VIEW_start" + view("VIEW_start", ref = "view-0") { + on("next", "END_Done") + } + end("END_Done", outcome = "done") + } + }.build() + + nav["BEGIN"] shouldBe "FLOW_1" + val flow1 = nav["FLOW_1"] as Map + flow1["startState"] shouldBe "VIEW_start" + + val viewStart = flow1["VIEW_start"] as Map + viewStart["state_type"] shouldBe "VIEW" + viewStart["ref"] shouldBe "view-0" + val transitions = viewStart["transitions"] as Map + transitions["next"] shouldBe "END_Done" + + val endDone = flow1["END_Done"] as Map + endDone["state_type"] shouldBe "END" + endDone["outcome"] shouldBe "done" + } + + it("builds navigation with multiple flows") { + val nav = + NavigationBuilder() + .apply { + begin = "FLOW_1" + flow("FLOW_1") { + startState = "VIEW_1" + view("VIEW_1", ref = "ref-1") { + on("next", "END_Done") + } + end("END_Done", outcome = "done") + } + flow("FLOW_2") { + startState = "VIEW_2" + view("VIEW_2", ref = "ref-2") + end("END_Done", outcome = "complete") + } + }.build() + + nav.containsKey("FLOW_1") shouldBe true + nav.containsKey("FLOW_2") shouldBe true + } + } + + describe("NavigationFlowBuilder") { + it("includes onStart and onEnd expressions") { + val flow = + NavigationFlowBuilder() + .apply { + startState = "VIEW_1" + onStart = expression("someAction()") + onEnd = expression("cleanupAction()") + view("VIEW_1", ref = "ref-1") + end("END_Done", outcome = "done") + }.build() + + flow["startState"] shouldBe "VIEW_1" + flow["onStart"] shouldBe "@[someAction()]@" + flow["onEnd"] shouldBe "@[cleanupAction()]@" + } + + it("omits onStart and onEnd when not set") { + val flow = + NavigationFlowBuilder() + .apply { + startState = "VIEW_1" + view("VIEW_1", ref = "ref-1") + end("END_Done", outcome = "done") + }.build() + + flow.containsKey("onStart") shouldBe false + flow.containsKey("onEnd") shouldBe false + } + + it("builds action states") { + val flow = + NavigationFlowBuilder() + .apply { + startState = "ACTION_1" + action("ACTION_1", expression("doSomething()")) { + on("success", "VIEW_1") + on("error", "END_Error") + } + view("VIEW_1", ref = "ref-1") + end("END_Error", outcome = "error") + }.build() + + val actionState = flow["ACTION_1"] as Map + actionState["state_type"] shouldBe "ACTION" + actionState["exp"] shouldBe "@[doSomething()]@" + val transitions = actionState["transitions"] as Map + transitions["success"] shouldBe "VIEW_1" + transitions["error"] shouldBe "END_Error" + } + } + + describe("ViewStateBuilder") { + it("builds a view state with transitions") { + val state = + ViewStateBuilder("my-ref") + .apply { + on("next", "END_Done") + on("back", "VIEW_prev") + }.build() + + state["state_type"] shouldBe "VIEW" + state["ref"] shouldBe "my-ref" + val transitions = state["transitions"] as Map + transitions["next"] shouldBe "END_Done" + transitions["back"] shouldBe "VIEW_prev" + } + + it("omits transitions when empty") { + val state = + ViewStateBuilder("my-ref") + .build() + + state["state_type"] shouldBe "VIEW" + state["ref"] shouldBe "my-ref" + state.containsKey("transitions") shouldBe false + } + } + + describe("ActionStateBuilder") { + it("builds an action state with transitions") { + val state = + ActionStateBuilder(expression("validate()")) + .apply { + on("success", "VIEW_next") + on("failure", "END_Error") + }.build() + + state["state_type"] shouldBe "ACTION" + state["exp"] shouldBe "@[validate()]@" + val transitions = state["transitions"] as Map + transitions["success"] shouldBe "VIEW_next" + transitions["failure"] shouldBe "END_Error" + } + + it("omits transitions when empty") { + val state = + ActionStateBuilder(expression("fire()")) + .build() + + state["state_type"] shouldBe "ACTION" + state["exp"] shouldBe "@[fire()]@" + state.containsKey("transitions") shouldBe false + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/ProjectConfig.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/ProjectConfig.kt new file mode 100644 index 0000000..61ef8bb --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/ProjectConfig.kt @@ -0,0 +1,8 @@ +package com.intuit.playerui.lang.dsl + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.spec.IsolationMode + +object ProjectConfig : AbstractProjectConfig() { + override val isolationMode = IsolationMode.InstancePerLeaf +} diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/SchemaBindingsTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/SchemaBindingsTest.kt new file mode 100644 index 0000000..f186479 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/SchemaBindingsTest.kt @@ -0,0 +1,380 @@ +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.flow.flow +import com.intuit.playerui.lang.dsl.schema.SchemaBindings +import com.intuit.playerui.lang.dsl.schema.SchemaBindingsExtractor +import com.intuit.playerui.lang.dsl.schema.extractBindings +import com.intuit.playerui.lang.dsl.schema.schema +import com.intuit.playerui.lang.dsl.schema.schemaWithBindings +import com.intuit.playerui.lang.dsl.tagged.Binding +import com.intuit.playerui.lang.dsl.types.Schema +import com.intuit.playerui.lang.dsl.types.SchemaDataType +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class SchemaBindingsTest : + DescribeSpec({ + + describe("SchemaBindingsExtractor") { + + describe("primitive types") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "name" to SchemaDataType(type = "StringType"), + "age" to SchemaDataType(type = "NumberType"), + "active" to SchemaDataType(type = "BooleanType"), + ), + ) + + it("extracts string bindings") { + val bindings = SchemaBindingsExtractor.extract(schema) + val name = bindings.binding("name") + name.shouldNotBeNull() + name.toString() shouldBe "{{name}}" + } + + it("extracts number bindings") { + val bindings = SchemaBindingsExtractor.extract(schema) + val age = bindings.binding("age") + age.shouldNotBeNull() + age.toString() shouldBe "{{age}}" + } + + it("extracts boolean bindings") { + val bindings = SchemaBindingsExtractor.extract(schema) + val active = bindings.binding("active") + active.shouldNotBeNull() + active.toString() shouldBe "{{active}}" + } + + it("returns null for non-existent keys") { + val bindings = SchemaBindingsExtractor.extract(schema) + bindings.binding("nonexistent").shouldBeNull() + } + + it("exposes all keys") { + val bindings = SchemaBindingsExtractor.extract(schema) + bindings.keys shouldContainExactly setOf("name", "age", "active") + } + } + + describe("complex types") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "user" to SchemaDataType(type = "UserType"), + ), + "UserType" to + mapOf( + "firstName" to SchemaDataType(type = "StringType"), + "lastName" to SchemaDataType(type = "StringType"), + "age" to SchemaDataType(type = "NumberType"), + ), + ) + + it("creates nested bindings for complex types") { + val bindings = SchemaBindingsExtractor.extract(schema) + val user = bindings.nested("user") + user.shouldNotBeNull() + + val firstName = user.binding("firstName") + firstName.shouldNotBeNull() + firstName.toString() shouldBe "{{user.firstName}}" + + val lastName = user.binding("lastName") + lastName.shouldNotBeNull() + lastName.toString() shouldBe "{{user.lastName}}" + + val age = user.binding("age") + age.shouldNotBeNull() + age.toString() shouldBe "{{user.age}}" + } + + it("returns null when accessing nested as binding") { + val bindings = SchemaBindingsExtractor.extract(schema) + bindings.binding("user").shouldBeNull() + } + } + + describe("deeply nested types") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "company" to SchemaDataType(type = "CompanyType"), + ), + "CompanyType" to + mapOf( + "name" to SchemaDataType(type = "StringType"), + "address" to SchemaDataType(type = "AddressType"), + ), + "AddressType" to + mapOf( + "street" to SchemaDataType(type = "StringType"), + "city" to SchemaDataType(type = "StringType"), + "zip" to SchemaDataType(type = "StringType"), + ), + ) + + it("handles multi-level nesting") { + val bindings = SchemaBindingsExtractor.extract(schema) + val company = bindings.nested("company") + company.shouldNotBeNull() + + company.binding("name")!!.toString() shouldBe "{{company.name}}" + + val address = company.nested("address") + address.shouldNotBeNull() + address.binding("street")!!.toString() shouldBe "{{company.address.street}}" + address.binding("city")!!.toString() shouldBe "{{company.address.city}}" + address.binding("zip")!!.toString() shouldBe "{{company.address.zip}}" + } + } + + describe("array types") { + it("creates array bindings with _current_ for primitive string arrays") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "tags" to SchemaDataType(type = "StringType", isArray = true), + ), + ) + + val bindings = SchemaBindingsExtractor.extract(schema) + val tags = bindings.nested("tags") + tags.shouldNotBeNull() + + // String arrays get a "name" property per TypeScript reference + val name = tags.binding("name") + name.shouldNotBeNull() + name.toString() shouldBe "{{tags._current_}}" + } + + it("creates array bindings with _current_ for primitive number arrays") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "scores" to SchemaDataType(type = "NumberType", isArray = true), + ), + ) + + val bindings = SchemaBindingsExtractor.extract(schema) + val scores = bindings.nested("scores") + scores.shouldNotBeNull() + + // Non-string primitive arrays get a "value" property + val value = scores.binding("value") + value.shouldNotBeNull() + value.toString() shouldBe "{{scores._current_}}" + } + + it("creates array bindings for complex type arrays") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "items" to SchemaDataType(type = "ItemType", isArray = true), + ), + "ItemType" to + mapOf( + "label" to SchemaDataType(type = "StringType"), + "value" to SchemaDataType(type = "NumberType"), + ), + ) + + val bindings = SchemaBindingsExtractor.extract(schema) + val items = bindings.nested("items") + items.shouldNotBeNull() + + items.binding("label")!!.toString() shouldBe "{{items._current_.label}}" + items.binding("value")!!.toString() shouldBe "{{items._current_.value}}" + } + } + + describe("record types") { + it("processes record type definitions") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "settings" to SchemaDataType(type = "SettingsType", isRecord = true), + ), + "SettingsType" to + mapOf( + "theme" to SchemaDataType(type = "StringType"), + "fontSize" to SchemaDataType(type = "NumberType"), + ), + ) + + val bindings = SchemaBindingsExtractor.extract(schema) + val settings = bindings.nested("settings") + settings.shouldNotBeNull() + + settings.binding("theme")!!.toString() shouldBe "{{settings.theme}}" + settings.binding("fontSize")!!.toString() shouldBe "{{settings.fontSize}}" + } + } + + describe("circular reference handling") { + it("prevents infinite recursion on circular type references") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "node" to SchemaDataType(type = "TreeNode"), + ), + "TreeNode" to + mapOf( + "value" to SchemaDataType(type = "StringType"), + "child" to SchemaDataType(type = "TreeNode"), + ), + ) + + val bindings = SchemaBindingsExtractor.extract(schema) + val node = bindings.nested("node") + node.shouldNotBeNull() + node.binding("value")!!.toString() shouldBe "{{node.value}}" + + // The circular reference falls back to a binding + val child = node["child"] + child.shouldNotBeNull() + child.shouldBeInstanceOf>() + child.toString() shouldBe "{{node.child}}" + } + } + + describe("missing ROOT") { + it("returns empty bindings when ROOT is missing") { + val schema: Schema = + mapOf( + "SomeType" to + mapOf( + "name" to SchemaDataType(type = "StringType"), + ), + ) + + val bindings = SchemaBindingsExtractor.extract(schema) + bindings.isEmpty shouldBe true + } + } + + describe("unknown types") { + it("falls back to string binding for unknown types") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "data" to SchemaDataType(type = "UnknownType"), + ), + ) + + val bindings = SchemaBindingsExtractor.extract(schema) + val data = bindings.binding("data") + data.shouldNotBeNull() + data.toString() shouldBe "{{data}}" + } + } + + describe("extractBindings convenience function") { + it("works the same as SchemaBindingsExtractor.extract") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "name" to SchemaDataType(type = "StringType"), + ), + ) + + val bindings = extractBindings(schema) + bindings.binding("name")!!.toString() shouldBe "{{name}}" + } + } + } + + describe("FlowBuilder schema extensions") { + val schema: Schema = + mapOf( + "ROOT" to + mapOf( + "name" to SchemaDataType(type = "StringType"), + "age" to SchemaDataType(type = "NumberType"), + ), + ) + + it("sets typed schema on flow builder") { + val result = + flow { + id = "schema-flow" + schema(schema) + navigation = mapOf("BEGIN" to "FLOW_1") + } + + val outputSchema = result["schema"] as Map<*, *> + outputSchema.containsKey("ROOT") shouldBe true + + val root = outputSchema["ROOT"] as Map<*, *> + val nameField = root["name"] as Map<*, *> + nameField["type"] shouldBe "StringType" + + val ageField = root["age"] as Map<*, *> + ageField["type"] shouldBe "NumberType" + } + + it("sets schema with array and record flags") { + val arraySchema: Schema = + mapOf( + "ROOT" to + mapOf( + "items" to SchemaDataType(type = "ItemType", isArray = true), + "config" to SchemaDataType(type = "ConfigType", isRecord = true), + ), + ) + + val result = + flow { + id = "array-schema-flow" + schema(arraySchema) + navigation = mapOf("BEGIN" to "FLOW_1") + } + + val outputSchema = result["schema"] as Map<*, *> + val root = outputSchema["ROOT"] as Map<*, *> + + val items = root["items"] as Map<*, *> + items["type"] shouldBe "ItemType" + items["isArray"] shouldBe true + + val config = root["config"] as Map<*, *> + config["type"] shouldBe "ConfigType" + config["isRecord"] shouldBe true + } + + it("schemaWithBindings sets schema and returns bindings") { + var capturedBindings: SchemaBindings? = null + + val result = + flow { + id = "bindings-flow" + capturedBindings = schemaWithBindings(schema) + navigation = mapOf("BEGIN" to "FLOW_1") + } + + // Schema is set + result["schema"].shouldNotBeNull() + + // Bindings are extracted + capturedBindings.shouldNotBeNull() + capturedBindings!!.binding("name")!!.toString() shouldBe "{{name}}" + capturedBindings!!.binding("age")!!.toString() shouldBe "{{age}}" + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/StandardExpressionsTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/StandardExpressionsTest.kt new file mode 100644 index 0000000..ef0af8e --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/StandardExpressionsTest.kt @@ -0,0 +1,348 @@ +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.tagged.* +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class StandardExpressionsTest : + DescribeSpec({ + + describe("Logical operations") { + describe("and()") { + it("combines two boolean values") { + and(true, false).toString() shouldBe "@[true && false]@" + } + + it("combines bindings") { + val user = binding("user.isActive") + val admin = binding("user.isAdmin") + and(user, admin).toString() shouldBe "@[user.isActive && user.isAdmin]@" + } + + it("wraps OR expressions in parentheses") { + val a = binding("a") + val b = binding("b") + val c = binding("c") + // (a || b) && c should be wrapped + and(or(a, b), c).toString() shouldBe "@[(a || b) && c]@" + } + + it("combines multiple values") { + val a = binding("a") + val b = binding("b") + val c = binding("c") + and(a, b, c).toString() shouldBe "@[a && b && c]@" + } + } + + describe("or()") { + it("combines two boolean values") { + or(true, false).toString() shouldBe "@[true || false]@" + } + + it("combines bindings") { + val a = binding("hasPermission") + val b = binding("isAdmin") + or(a, b).toString() shouldBe "@[hasPermission || isAdmin]@" + } + + it("combines multiple values") { + val a = binding("a") + val b = binding("b") + val c = binding("c") + or(a, b, c).toString() shouldBe "@[a || b || c]@" + } + } + + describe("not()") { + it("negates a boolean") { + not(true).toString() shouldBe "@[!true]@" + } + + it("negates a binding") { + val active = binding("user.isActive") + not(active).toString() shouldBe "@[!user.isActive]@" + } + } + + describe("nor()") { + it("returns NOT of OR") { + val a = binding("a") + val b = binding("b") + nor(a, b).toString() shouldBe "@[!(a || b)]@" + } + } + + describe("nand()") { + it("returns NOT of AND") { + val a = binding("a") + val b = binding("b") + nand(a, b).toString() shouldBe "@[!(a && b)]@" + } + } + + describe("xor()") { + it("returns exclusive or") { + val a = binding("a") + val b = binding("b") + xor(a, b).toString() shouldBe "@[(a && !b) || (!a && b)]@" + } + } + } + + describe("Comparison operations") { + describe("equal()") { + it("compares binding with literal") { + val status = binding("order.status") + equal(status, "completed").toString() shouldBe "@[order.status == \"completed\"]@" + } + + it("compares binding with number") { + val count = binding("items.count") + equal(count, 0).toString() shouldBe "@[items.count == 0]@" + } + + it("compares two bindings") { + val a = binding("a") + val b = binding("b") + equal(a, b).toString() shouldBe "@[a == b]@" + } + } + + describe("strictEqual()") { + it("uses === operator") { + val status = binding("status") + strictEqual(status, "active").toString() shouldBe "@[status === \"active\"]@" + } + } + + describe("notEqual()") { + it("uses != operator") { + val status = binding("status") + notEqual(status, "inactive").toString() shouldBe "@[status != \"inactive\"]@" + } + } + + describe("strictNotEqual()") { + it("uses !== operator") { + val status = binding("status") + strictNotEqual(status, "disabled").toString() shouldBe "@[status !== \"disabled\"]@" + } + } + + describe("greaterThan()") { + it("compares numbers") { + val age = binding("user.age") + greaterThan(age, 18).toString() shouldBe "@[user.age > 18]@" + } + } + + describe("greaterThanOrEqual()") { + it("compares numbers") { + val score = binding("score") + greaterThanOrEqual(score, 70).toString() shouldBe "@[score >= 70]@" + } + } + + describe("lessThan()") { + it("compares numbers") { + val quantity = binding("cart.quantity") + lessThan(quantity, 10).toString() shouldBe "@[cart.quantity < 10]@" + } + } + + describe("lessThanOrEqual()") { + it("compares numbers") { + val price = binding("item.price") + lessThanOrEqual(price, 99.99).toString() shouldBe "@[item.price <= 99.99]@" + } + } + } + + describe("Arithmetic operations") { + describe("add()") { + it("adds numbers") { + add(5, 3).toString() shouldBe "@[5 + 3]@" + } + + it("adds bindings") { + val subtotal = binding("cart.subtotal") + val tax = binding("cart.tax") + add(subtotal, tax).toString() shouldBe "@[cart.subtotal + cart.tax]@" + } + + it("adds multiple values") { + val a = binding("a") + val b = binding("b") + val c = binding("c") + add(a, b, c).toString() shouldBe "@[a + b + c]@" + } + } + + describe("subtract()") { + it("subtracts numbers") { + val total = binding("total") + subtract(total, 10.0).toString() shouldBe "@[total - 10.0]@" + } + } + + describe("multiply()") { + it("multiplies numbers") { + val price = binding("price") + val quantity = binding("quantity") + multiply(price, quantity).toString() shouldBe "@[price * quantity]@" + } + + it("multiplies multiple values") { + multiply(2, 3, 4).toString() shouldBe "@[2 * 3 * 4]@" + } + } + + describe("divide()") { + it("divides numbers") { + val total = binding("total") + divide(total, 2).toString() shouldBe "@[total / 2]@" + } + } + + describe("modulo()") { + it("computes modulo") { + val index = binding("index") + modulo(index, 2).toString() shouldBe "@[index % 2]@" + } + } + } + + describe("Control flow operations") { + describe("conditional()") { + it("creates ternary expression") { + val isPremium = binding("user.isPremium") + conditional(isPremium, "Premium User", "Free User") + .toString() shouldBe "@[user.isPremium ? \"Premium User\" : \"Free User\"]@" + } + + it("works with nested expressions") { + val count = binding("count") + val isZero = equal(count, 0) + conditional(isZero, "Empty", "Has items") + .toString() shouldBe "@[count == 0 ? \"Empty\" : \"Has items\"]@" + } + + it("works with numeric values") { + val hasDiscount = binding("hasDiscount") + conditional(hasDiscount, 0.10, 0.0) + .toString() shouldBe "@[hasDiscount ? 0.1 : 0.0]@" + } + } + + describe("call()") { + it("creates function call") { + call("navigate", "home") + .toString() shouldBe "@[navigate(\"home\")]@" + } + + it("creates function call with multiple args") { + val userId = binding("user.id") + call("setUser", userId, true) + .toString() shouldBe "@[setUser(user.id, true)]@" + } + + it("creates function call with no args") { + call("refresh") + .toString() shouldBe "@[refresh()]@" + } + } + + describe("literal()") { + it("creates string literal") { + literal("hello").toString() shouldBe "@[\"hello\"]@" + } + + it("creates number literal") { + literal(42).toString() shouldBe "@[42]@" + } + + it("creates boolean literal") { + literal(true).toString() shouldBe "@[true]@" + } + } + } + + describe("Complex expressions") { + it("composes logical and comparison operations") { + val age = binding("user.age") + val hasPermission = binding("user.hasPermission") + + val expr = + and( + greaterThanOrEqual(age, 18), + hasPermission + ) + expr.toString() shouldBe "@[user.age >= 18 && user.hasPermission]@" + } + + it("composes arithmetic and comparison") { + val price = binding("item.price") + val quantity = binding("item.quantity") + val budget = binding("budget") + + val total = multiply(price, quantity) + val withinBudget = lessThanOrEqual(total, budget) + + withinBudget.toString() shouldBe "@[item.price * item.quantity <= budget]@" + } + + it("composes conditional with comparison") { + val score = binding("score") + val isPassing = greaterThanOrEqual(score, 70) + + conditional(isPassing, "Pass", "Fail") + .toString() shouldBe "@[score >= 70 ? \"Pass\" : \"Fail\"]@" + } + } + + describe("Aliases") { + it("eq is alias for equal") { + val x = binding("x") + eq(x, 5).toString() shouldBe "@[x == 5]@" + } + + it("gt is alias for greaterThan") { + val x = binding("x") + gt(x, 5).toString() shouldBe "@[x > 5]@" + } + + it("lt is alias for lessThan") { + val x = binding("x") + lt(x, 5).toString() shouldBe "@[x < 5]@" + } + + it("gte is alias for greaterThanOrEqual") { + val x = binding("x") + gte(x, 5).toString() shouldBe "@[x >= 5]@" + } + + it("lte is alias for lessThanOrEqual") { + val x = binding("x") + lte(x, 5).toString() shouldBe "@[x <= 5]@" + } + + it("plus is alias for add") { + val a = binding("a") + val b = binding("b") + plus(a, b).toString() shouldBe "@[a + b]@" + } + + it("minus is alias for subtract") { + val a = binding("a") + val b = binding("b") + minus(a, b).toString() shouldBe "@[a - b]@" + } + + it("times is alias for multiply") { + val a = binding("a") + val b = binding("b") + times(a, b).toString() shouldBe "@[a * b]@" + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/ActionBuilder.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/ActionBuilder.kt new file mode 100644 index 0000000..8cba905 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/ActionBuilder.kt @@ -0,0 +1,82 @@ +package com.intuit.playerui.lang.dsl.mocks.builders + +import com.intuit.playerui.lang.dsl.core.AssetWrapperBuilder +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.FluentBuilderBase +import com.intuit.playerui.lang.dsl.tagged.TaggedValue + +/** + * Builder for ActionAsset with strongly-typed property setters. + */ +class ActionBuilder : FluentBuilderBase>() { + override val defaults: Map = mapOf("type" to "action") + override val assetWrapperProperties: Set = setOf("label") + + var id: String? + get() = peek("id") as? String + set(value) { + set("id", value) + } + + /** + * Sets the action value (transition target). + */ + var value: String? + get() = peek("value") as? String + set(value) { + set("value", value) + } + + /** + * Sets the action value from a tagged value (expression). + */ + fun value(taggedValue: TaggedValue) { + set("value", taggedValue) + } + + /** + * Sets the label using a TextBuilder. + * Automatically wrapped in AssetWrapper format during build. + */ + var label: TextBuilder? + get() = null // Write-only for DSL + set(value) { + if (value != null) { + set("label", AssetWrapperBuilder(value)) + } + } + + /** + * Sets the label using a DSL block. + * Automatically wrapped in AssetWrapper format during build. + */ + fun label(init: TextBuilder.() -> Unit) { + set("label", AssetWrapperBuilder(text(init))) + } + + /** + * Sets metadata for this action. + */ + var metaData: Map? + @Suppress("UNCHECKED_CAST") + get() = peek("metaData") as? Map + set(value) { + set("metaData", value) + } + + /** + * Sets metadata using a builder DSL. + */ + fun metaData(init: MutableMap.() -> Unit) { + set("metaData", mutableMapOf().apply(init)) + } + + override fun build(context: BuildContext?): Map = buildWithDefaults(context) + + override fun clone(): ActionBuilder = ActionBuilder().also { cloneStorageTo(it) } +} + +/** + * DSL function to create an ActionBuilder. + */ +fun action(init: ActionBuilder.() -> Unit = {}): ActionBuilder = ActionBuilder().apply(init) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/CollectionBuilder.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/CollectionBuilder.kt new file mode 100644 index 0000000..3dac4c8 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/CollectionBuilder.kt @@ -0,0 +1,117 @@ +package com.intuit.playerui.lang.dsl.mocks.builders + +import com.intuit.playerui.lang.dsl.core.AssetWrapperBuilder +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.FluentBuilder +import com.intuit.playerui.lang.dsl.core.FluentBuilderBase +import com.intuit.playerui.lang.dsl.core.SwitchArgs +import com.intuit.playerui.lang.dsl.core.SwitchBuilder +import com.intuit.playerui.lang.dsl.core.TemplateConfig +import com.intuit.playerui.lang.dsl.tagged.Binding + +/** + * Builder for CollectionAsset with strongly-typed property setters. + */ +class CollectionBuilder : FluentBuilderBase>() { + override val defaults: Map = mapOf("type" to "collection") + override val assetWrapperProperties: Set = setOf("label") + override val arrayProperties: Set = setOf("values", "actions") + + var id: String? + get() = peek("id") as? String + set(value) { + set("id", value) + } + + /** + * Sets the label using a TextBuilder. + * Automatically wrapped in AssetWrapper format during build. + */ + var label: TextBuilder? + get() = null // Write-only for DSL + set(value) { + if (value != null) { + set("label", AssetWrapperBuilder(value)) + } + } + + /** + * Sets the label using a DSL block. + * Automatically wrapped in AssetWrapper format during build. + */ + fun label(init: TextBuilder.() -> Unit) { + set("label", AssetWrapperBuilder(text(init))) + } + + /** + * Sets the values array (list of asset builders). + */ + var values: List>? + get() = null // Write-only for DSL + set(value) { + set("values", value) + } + + /** + * Adds values using a builder DSL. + */ + fun values(vararg builders: FluentBuilder<*>) { + set("values", builders.toList()) + } + + /** + * Sets the actions array. + */ + var actions: List? + get() = null // Write-only for DSL + set(value) { + set("actions", value) + } + + /** + * Adds actions using a builder DSL. + */ + fun actions(vararg builders: ActionBuilder) { + set("actions", builders.toList()) + } + + /** + * Adds a template for dynamic list generation. + */ + fun template( + data: Binding>, + output: String = "values", + dynamic: Boolean = false, + builder: () -> FluentBuilder<*>, + ) { + template { _ -> + TemplateConfig( + data = data.toString(), + output = output, + value = builder(), + dynamic = dynamic, + ) + } + } + + /** + * Adds a switch for runtime conditional selection. + */ + fun switch( + path: List, + isDynamic: Boolean = false, + init: SwitchBuilder.() -> Unit, + ) { + val switchBuilder = SwitchBuilder().apply(init) + switch(path, SwitchArgs(switchBuilder.cases, isDynamic)) + } + + override fun build(context: BuildContext?): Map = buildWithDefaults(context) + + override fun clone(): CollectionBuilder = CollectionBuilder().also { cloneStorageTo(it) } +} + +/** + * DSL function to create a CollectionBuilder. + */ +fun collection(init: CollectionBuilder.() -> Unit = {}): CollectionBuilder = CollectionBuilder().apply(init) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/InputBuilder.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/InputBuilder.kt new file mode 100644 index 0000000..06a2691 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/InputBuilder.kt @@ -0,0 +1,74 @@ +package com.intuit.playerui.lang.dsl.mocks.builders + +import com.intuit.playerui.lang.dsl.core.AssetWrapperBuilder +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.FluentBuilderBase +import com.intuit.playerui.lang.dsl.tagged.Binding + +/** + * Builder for InputAsset with strongly-typed property setters. + */ +class InputBuilder : FluentBuilderBase>() { + override val defaults: Map = mapOf("type" to "input") + override val assetWrapperProperties: Set = setOf("label") + + var id: String? + get() = peek("id") as? String + set(value) { + set("id", value) + } + + /** + * Sets the data binding for this input. + */ + var binding: Binding<*>? + get() = peek("binding") as? Binding<*> + set(value) { + set("binding", value) + } + + /** + * Sets the data binding from a string path. + */ + fun binding(path: String) { + set("binding", Binding(path)) + } + + /** + * Sets the label using a TextBuilder. + * Automatically wrapped in AssetWrapper format during build. + */ + var label: TextBuilder? + get() = null // Write-only for DSL + set(value) { + if (value != null) { + set("label", AssetWrapperBuilder(value)) + } + } + + /** + * Sets the label using a DSL block. + * Automatically wrapped in AssetWrapper format during build. + */ + fun label(init: TextBuilder.() -> Unit) { + set("label", AssetWrapperBuilder(text(init))) + } + + /** + * Sets the placeholder text. + */ + var placeholder: String? + get() = peek("placeholder") as? String + set(value) { + set("placeholder", value) + } + + override fun build(context: BuildContext?): Map = buildWithDefaults(context) + + override fun clone(): InputBuilder = InputBuilder().also { cloneStorageTo(it) } +} + +/** + * DSL function to create an InputBuilder. + */ +fun input(init: InputBuilder.() -> Unit = {}): InputBuilder = InputBuilder().apply(init) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/TextBuilder.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/TextBuilder.kt new file mode 100644 index 0000000..c12ae01 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/TextBuilder.kt @@ -0,0 +1,55 @@ +package com.intuit.playerui.lang.dsl.mocks.builders + +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.FluentBuilderBase +import com.intuit.playerui.lang.dsl.tagged.Binding +import com.intuit.playerui.lang.dsl.tagged.TaggedValue + +/** + * Builder for TextAsset with strongly-typed property setters. + * Demonstrates how generated builders provide type safety. + */ +class TextBuilder : FluentBuilderBase>() { + override val defaults: Map = mapOf("type" to "text") + + /** + * Sets the text ID explicitly. + */ + var id: String? + get() = peek("id") as? String + set(value) { + set("id", value) + } + + /** + * Sets the text value (static string). + */ + var value: String? + get() = peek("value") as? String + set(value) { + set("value", value) + } + + /** + * Sets the text value from a binding. + */ + fun value(binding: Binding) { + set("value", binding) + } + + /** + * Sets the text value from any tagged value. + */ + fun value(taggedValue: TaggedValue) { + set("value", taggedValue) + } + + override fun build(context: BuildContext?): Map = buildWithDefaults(context) + + override fun clone(): TextBuilder = TextBuilder().also { cloneStorageTo(it) } +} + +/** + * DSL function to create a TextBuilder. + */ +fun text(init: TextBuilder.() -> Unit = {}): TextBuilder = TextBuilder().apply(init) diff --git a/generators/kotlin/.editorconfig b/generators/kotlin/.editorconfig new file mode 100644 index 0000000..9eab273 --- /dev/null +++ b/generators/kotlin/.editorconfig @@ -0,0 +1,32 @@ +# EditorConfig for Kotlin code style +# https://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +trim_trailing_whitespace = true + +[*.kt] +# ktlint specific rules +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_package-name = disabled + +# Allow trailing commas +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled + +# Function signature - allow multi-line +ktlint_standard_function-signature = disabled + +# Disable some strict rules for DSL-style code +ktlint_standard_parameter-list-wrapping = disabled +ktlint_standard_argument-list-wrapping = disabled + +[*.kts] +ktlint_standard_no-wildcard-imports = disabled diff --git a/generators/kotlin/BUILD.bazel b/generators/kotlin/BUILD.bazel new file mode 100644 index 0000000..9281005 --- /dev/null +++ b/generators/kotlin/BUILD.bazel @@ -0,0 +1,38 @@ +load("@rules_player//kotlin:defs.bzl", "kt_jvm") +load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config") +load("@build_constants//:constants.bzl", "GROUP", "VERSION") + +package(default_visibility = ["//visibility:public"]) + +ktlint_config( + name = "lint_config", + editorconfig = ".editorconfig", +) + +kt_jvm( + name = "kotlin-dsl-generator", + group = GROUP, + version = VERSION, + lint_config = ":lint_config", + main_opts = "//:kt_opts", + main_deps = [ + "//dsl/kotlin:kotlin_serialization", + "@maven//:com_intuit_playerui_xlr_xlr_types", + "@maven//:com_squareup_kotlinpoet_jvm", + "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + ], + test_package = "com.intuit.playerui.lang.generator", + test_opts = "//:kt_opts", + test_deps = [ + "@maven//:com_intuit_playerui_xlr_xlr_types", + "@maven//:io_kotest_kotest_assertions_core", + "@maven//:io_kotest_kotest_assertions_json", + "@maven//:io_kotest_kotest_runner_junit5", + "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + "@maven//:org_junit_platform_junit_platform_console", + ], + test_resources = glob(["src/test/kotlin/**/fixtures/*.json"]), + test_resource_strip_prefix = "generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures", + pom_template = ":pom.tpl", +) diff --git a/generators/kotlin/pom.tpl b/generators/kotlin/pom.tpl new file mode 100644 index 0000000..775aa0b --- /dev/null +++ b/generators/kotlin/pom.tpl @@ -0,0 +1,44 @@ + + + 4.0.0 + + Player Language - {artifactId} + Kotlin code generator for Player UI DSL builders + https://github.com/player-ui/language + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + rafbcampos + Rafael Campos + + + sugarmanz + Jeremiah Zucker + + + brocollie08 + Tony Lin + + + + https://github.com/player-ui/language.git + https://github.com/player-ui/language.git + v{version} + https://github.com/player-ui/language.git + + + {groupId} + {artifactId} + {version} + {type} + + +{dependencies} + + diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt new file mode 100644 index 0000000..a55f4e2 --- /dev/null +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt @@ -0,0 +1,726 @@ +package com.intuit.playerui.lang.generator + +import com.intuit.playerui.xlr.ObjectProperty +import com.intuit.playerui.xlr.ObjectType +import com.intuit.playerui.xlr.ParamTypeNode +import com.intuit.playerui.xlr.XlrDocument +import com.intuit.playerui.xlr.extractAssetTypeConstant +import com.intuit.playerui.xlr.isAssetWrapperRef +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.TypeVariableName +import com.squareup.kotlinpoet.WildcardTypeName + +/** + * Information about a property for code generation. + */ +data class PropertyInfo( + val originalName: String, + val kotlinName: String, + val typeInfo: KotlinTypeInfo, + val required: Boolean, + val hasBindingOverload: Boolean, + val hasExpressionOverload: Boolean, + val isAssetWrapper: Boolean, + val isArray: Boolean, + val isNestedObject: Boolean, + val nestedObjectClassName: String? = null, +) + +/** + * Result of class generation, containing the main class and any nested classes. + */ +data class GeneratedClass( + val className: String, + val code: String, + val nestedClasses: List = emptyList(), +) + +/** + * Generates Kotlin builder classes from XLR schemas using KotlinPoet. + */ +class ClassGenerator( + private val document: XlrDocument, + private val packageName: String, +) { + private val objectType: ObjectType = document.objectType + + private val genericTokens: Map = + objectType.genericTokens + ?.associateBy { it.symbol } + ?: emptyMap() + + private val nestedTypeSpecs = mutableListOf() + + private val mainBuilderName: String = + TypeMapper.toBuilderClassName(document.name.removeSuffix("Asset")) + + private val cachedProperties: List by lazy { + objectType.properties.map { (name, prop) -> + createPropertyInfo(name, prop) + } + } + + /** + * Generate the builder class for the XLR document. + */ + fun generate(): GeneratedClass { + nestedTypeSpecs.clear() + val className = mainBuilderName + val dslFunctionName = TypeMapper.toDslFunctionName(document.name) + val assetType = extractAssetTypeConstant(objectType.extends) + + val classSpec = buildMainClass(className, assetType) + val dslFunction = buildDslFunction(dslFunctionName, className, objectType.description) + + val fileBuilder = + FileSpec + .builder(packageName, className) + .indent(" ") + .addType(classSpec) + .addFunction(dslFunction) + + nestedTypeSpecs.forEach { fileBuilder.addType(it) } + + return GeneratedClass( + className = className, + code = fileBuilder.build().toString(), + ) + } + + private fun buildMainClass( + className: String, + assetType: String?, + ): TypeSpec { + val builder = + TypeSpec + .classBuilder(className) + .addAnnotation(FLUENT_DSL_MARKER) + .superclass(BUILDER_SUPERCLASS) + + objectType.description?.let { builder.addKdoc("%L", it) } + + // defaults property + val defaultsInit = + if (assetType != null) { + CodeBlock.of("mapOf(%S to %S)", "type", assetType) + } else { + CodeBlock.of("emptyMap()") + } + builder.addProperty( + PropertySpec + .builder("defaults", MAP_STRING_ANY) + .addModifiers(KModifier.OVERRIDE) + .initializer(defaultsInit) + .build(), + ) + + // assetWrapperProperties + val awProps = collectProperties().filter { it.isAssetWrapper && !it.isArray } + builder.addProperty( + PropertySpec + .builder("assetWrapperProperties", SET.parameterizedBy(STRING)) + .addModifiers(KModifier.OVERRIDE) + .initializer(buildSetInitializer(awProps.map { it.originalName })) + .build(), + ) + + // arrayProperties + val arrProps = collectProperties().filter { it.isArray } + builder.addProperty( + PropertySpec + .builder("arrayProperties", SET.parameterizedBy(STRING)) + .addModifiers(KModifier.OVERRIDE) + .initializer(buildSetInitializer(arrProps.map { it.originalName })) + .build(), + ) + + // id property + builder.addProperty( + PropertySpec + .builder("id", STRING.copy(nullable = true)) + .mutable(true) + .addKdoc("Each asset requires a unique id per view") + .getter( + FunSpec + .getterBuilder() + .addStatement("return peek(%S) as? String", "id") + .build(), + ).setter( + FunSpec + .setterBuilder() + .addParameter("value", STRING.copy(nullable = true)) + .addStatement("set(%S, value)", "id") + .build(), + ).build(), + ) + + // Schema-driven properties + collectProperties().forEach { prop -> + addPropertyMembers(builder, prop, className, generateWithMethods = true) + } + + // build method + builder.addFunction( + FunSpec + .builder("build") + .addModifiers(KModifier.OVERRIDE) + .addParameter("context", BUILD_CONTEXT.copy(nullable = true)) + .returns(MAP_STRING_ANY) + .addStatement("return buildWithDefaults(context)") + .build(), + ) + + // clone method + val classType = ClassName(packageName, className) + builder.addFunction( + FunSpec + .builder("clone") + .addModifiers(KModifier.OVERRIDE) + .returns(classType) + .addStatement("return %T().also { cloneStorageTo(it) }", classType) + .build(), + ) + + return builder.build() + } + + private fun addPropertyMembers( + classBuilder: TypeSpec.Builder, + prop: PropertyInfo, + ownerClassName: String, + generateWithMethods: Boolean, + ) { + when { + prop.isNestedObject && prop.nestedObjectClassName != null -> + addNestedObjectProperty(classBuilder, prop, ownerClassName, generateWithMethods) + prop.isAssetWrapper && prop.isArray -> + addAssetArrayProperty(classBuilder, prop, ownerClassName, generateWithMethods) + prop.isAssetWrapper -> + addAssetWrapperProperty(classBuilder, prop, ownerClassName, generateWithMethods) + prop.isArray -> + addArrayProperty(classBuilder, prop, ownerClassName, generateWithMethods) + else -> + addSimpleProperty(classBuilder, prop, ownerClassName, generateWithMethods) + } + } + + private fun addSimpleProperty( + classBuilder: TypeSpec.Builder, + prop: PropertyInfo, + ownerClassName: String, + generateWithMethods: Boolean, + ) { + val baseType = toBaseTypeName(prop.typeInfo) + val nullableType = baseType.copy(nullable = true) + val poetName = cleanName(prop.kotlinName) + + val propBuilder = + PropertySpec + .builder(poetName, nullableType) + .mutable(true) + prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } + propBuilder + .getter( + FunSpec + .getterBuilder() + .addStatement("return peek(%S) as? %T", prop.originalName, baseType) + .build(), + ).setter( + FunSpec + .setterBuilder() + .addParameter("value", nullableType) + .addStatement("set(%S, value)", prop.originalName) + .build(), + ) + classBuilder.addProperty(propBuilder.build()) + + // Binding overload + if (prop.hasBindingOverload) { + classBuilder.addFunction( + FunSpec + .builder(poetName) + .addParameter("binding", BINDING.parameterizedBy(baseType)) + .addStatement("set(%S, binding)", prop.originalName) + .build(), + ) + } + + // TaggedValue overload + if (prop.hasExpressionOverload) { + classBuilder.addFunction( + FunSpec + .builder(poetName) + .addParameter("taggedValue", TAGGED_VALUE.parameterizedBy(baseType)) + .addStatement("set(%S, taggedValue)", prop.originalName) + .build(), + ) + } + + // with() fluent chaining + if (generateWithMethods) { + addWithMethod(classBuilder, poetName, nullableType, ownerClassName) + } + } + + private fun addAssetWrapperProperty( + classBuilder: TypeSpec.Builder, + prop: PropertyInfo, + ownerClassName: String, + generateWithMethods: Boolean, + ) { + val poetName = cleanName(prop.kotlinName) + val type = FLUENT_BUILDER_BASE_STAR.copy(nullable = true) + + val propBuilder = + PropertySpec + .builder(poetName, type) + .mutable(true) + prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } + propBuilder + .getter( + FunSpec + .getterBuilder() + .addComment("Write-only") + .addStatement("return null") + .build(), + ).setter( + FunSpec + .setterBuilder() + .addParameter("value", type) + .addStatement( + "if (value != null) set(%S, %T(value))", + prop.originalName, + ASSET_WRAPPER_BUILDER, + ).build(), + ) + classBuilder.addProperty(propBuilder.build()) + + // Typed builder function + val typeVar = TypeVariableName("T", FLUENT_BUILDER_BASE_STAR) + classBuilder.addFunction( + FunSpec + .builder(poetName) + .addTypeVariable(typeVar) + .addParameter("builder", typeVar) + .addStatement("set(%S, %T(builder))", prop.originalName, ASSET_WRAPPER_BUILDER) + .build(), + ) + + if (generateWithMethods) { + addWithMethod(classBuilder, poetName, type, ownerClassName) + } + } + + private fun addAssetArrayProperty( + classBuilder: TypeSpec.Builder, + prop: PropertyInfo, + ownerClassName: String, + generateWithMethods: Boolean, + ) { + val poetName = cleanName(prop.kotlinName) + val listType = LIST.parameterizedBy(FLUENT_BUILDER_BASE_STAR).copy(nullable = true) + + val propBuilder = + PropertySpec + .builder(poetName, listType) + .mutable(true) + prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } + propBuilder + .getter( + FunSpec + .getterBuilder() + .addComment("Write-only") + .addStatement("return null") + .build(), + ).setter( + FunSpec + .setterBuilder() + .addParameter("value", listType) + .addStatement("set(%S, value)", prop.originalName) + .build(), + ) + classBuilder.addProperty(propBuilder.build()) + + // Varargs function + classBuilder.addFunction( + FunSpec + .builder(poetName) + .addParameter("builders", FLUENT_BUILDER_BASE_STAR, KModifier.VARARG) + .addStatement("set(%S, builders.toList())", prop.originalName) + .build(), + ) + + if (generateWithMethods) { + addWithMethod(classBuilder, poetName, listType, ownerClassName) + } + } + + private fun addArrayProperty( + classBuilder: TypeSpec.Builder, + prop: PropertyInfo, + ownerClassName: String, + generateWithMethods: Boolean, + ) { + val poetName = cleanName(prop.kotlinName) + val elementType = + prop.typeInfo.elementType?.let { toBaseTypeName(it) } + ?: ANY.copy(nullable = true) + val listType = LIST.parameterizedBy(elementType) + val nullableListType = listType.copy(nullable = true) + + val propBuilder = + PropertySpec + .builder(poetName, nullableListType) + .mutable(true) + prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } + propBuilder + .getter( + FunSpec + .getterBuilder() + .addStatement("return peek(%S) as? %T", prop.originalName, listType) + .build(), + ).setter( + FunSpec + .setterBuilder() + .addParameter("value", nullableListType) + .addStatement("set(%S, value)", prop.originalName) + .build(), + ) + classBuilder.addProperty(propBuilder.build()) + + // Varargs function + classBuilder.addFunction( + FunSpec + .builder(poetName) + .addParameter("items", elementType, KModifier.VARARG) + .addStatement("set(%S, items.toList())", prop.originalName) + .build(), + ) + + if (generateWithMethods) { + addWithMethod(classBuilder, poetName, nullableListType, ownerClassName) + } + } + + private fun addNestedObjectProperty( + classBuilder: TypeSpec.Builder, + prop: PropertyInfo, + ownerClassName: String, + generateWithMethods: Boolean, + ) { + val poetName = cleanName(prop.kotlinName) + val nestedClassName = ClassName(packageName, prop.nestedObjectClassName!!) + val nullableType = nestedClassName.copy(nullable = true) + + val propBuilder = + PropertySpec + .builder(poetName, nullableType) + .mutable(true) + prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } + propBuilder + .getter( + FunSpec + .getterBuilder() + .addComment("Write-only") + .addStatement("return null") + .build(), + ).setter( + FunSpec + .setterBuilder() + .addParameter("value", nullableType) + .addStatement("if (value != null) set(%S, value)", prop.originalName) + .build(), + ) + classBuilder.addProperty(propBuilder.build()) + + // Lambda DSL function + val lambdaType = LambdaTypeName.get(receiver = nestedClassName, returnType = UNIT) + classBuilder.addFunction( + FunSpec + .builder(poetName) + .addParameter("init", lambdaType) + .addStatement("set(%S, %T().apply(init))", prop.originalName, nestedClassName) + .build(), + ) + + // with() fluent chaining (lambda overload) + if (generateWithMethods) { + val ownerType = ClassName(packageName, ownerClassName) + classBuilder.addFunction( + FunSpec + .builder(withMethodName(poetName)) + .addParameter("init", lambdaType) + .returns(ownerType) + .addStatement("%N(init)", poetName) + .addStatement("return this") + .build(), + ) + } + } + + private fun addWithMethod( + classBuilder: TypeSpec.Builder, + poetName: String, + type: TypeName, + ownerClassName: String, + ) { + val ownerType = ClassName(packageName, ownerClassName) + classBuilder.addFunction( + FunSpec + .builder(withMethodName(poetName)) + .addParameter("value", type) + .returns(ownerType) + .addStatement("this.%N = value", poetName) + .addStatement("return this") + .build(), + ) + } + + private fun buildDslFunction( + functionName: String, + className: String, + description: String?, + ): FunSpec { + val classType = ClassName(packageName, className) + val lambdaType = LambdaTypeName.get(receiver = classType, returnType = UNIT) + val param = + ParameterSpec + .builder("init", lambdaType) + .defaultValue("{}") + .build() + + val builder = + FunSpec + .builder(functionName) + .addParameter(param) + .returns(classType) + .addStatement("return %T().apply(init)", classType) + description?.let { builder.addKdoc("%L", it) } + return builder.build() + } + + private fun generateNestedClass( + propertyName: String, + objectType: ObjectType, + ): String { + val baseName = mainBuilderName.removeSuffix("Builder") + val className = baseName + propertyName.replaceFirstChar { it.uppercase() } + "Config" + val classType = ClassName(packageName, className) + + val builder = + TypeSpec + .classBuilder(className) + .addAnnotation(FLUENT_DSL_MARKER) + .superclass(BUILDER_SUPERCLASS) + + objectType.description?.let { builder.addKdoc("%L", it) } + + builder.addProperty( + PropertySpec + .builder("defaults", MAP_STRING_ANY) + .addModifiers(KModifier.OVERRIDE) + .initializer("emptyMap()") + .build(), + ) + builder.addProperty( + PropertySpec + .builder("assetWrapperProperties", SET.parameterizedBy(STRING)) + .addModifiers(KModifier.OVERRIDE) + .initializer("emptySet()") + .build(), + ) + builder.addProperty( + PropertySpec + .builder("arrayProperties", SET.parameterizedBy(STRING)) + .addModifiers(KModifier.OVERRIDE) + .initializer("emptySet()") + .build(), + ) + + objectType.properties.forEach { (propName, propObj) -> + val propInfo = createPropertyInfo(propName, propObj, allowNestedGeneration = false) + addPropertyMembers(builder, propInfo, className, generateWithMethods = false) + } + + builder.addFunction( + FunSpec + .builder("build") + .addModifiers(KModifier.OVERRIDE) + .addParameter("context", BUILD_CONTEXT.copy(nullable = true)) + .returns(MAP_STRING_ANY) + .addStatement("return buildWithDefaults(context)") + .build(), + ) + builder.addFunction( + FunSpec + .builder("clone") + .addModifiers(KModifier.OVERRIDE) + .returns(classType) + .addStatement("return %T().also { cloneStorageTo(it) }", classType) + .build(), + ) + + nestedTypeSpecs.add(builder.build()) + return className + } + + private fun collectProperties(): List = cachedProperties + + private fun createPropertyInfo( + name: String, + prop: ObjectProperty, + allowNestedGeneration: Boolean = true, + ): PropertyInfo { + val context = TypeMapperContext(genericTokens = genericTokens) + val typeInfo = TypeMapper.mapToKotlinType(prop.node, context) + val kotlinName = TypeMapper.toKotlinIdentifier(name) + + val isNestedObject = allowNestedGeneration && prop.node is ObjectType + val nestedClassName = + if (isNestedObject) { + generateNestedClass(name, prop.node as ObjectType) + } else { + null + } + + return PropertyInfo( + originalName = name, + kotlinName = kotlinName, + typeInfo = typeInfo, + required = prop.required, + hasBindingOverload = shouldHaveOverload(typeInfo.typeName) || typeInfo.isBinding, + hasExpressionOverload = shouldHaveOverload(typeInfo.typeName) || typeInfo.isExpression, + isAssetWrapper = typeInfo.isAssetWrapper || isAssetWrapperRef(prop.node), + isArray = typeInfo.isArray, + isNestedObject = isNestedObject, + nestedObjectClassName = nestedClassName, + ) + } + + private fun toBaseTypeName(info: KotlinTypeInfo): TypeName { + if (info.isArray) { + val elementType = + if (info.elementType != null && + (info.elementType.isAssetWrapper || info.elementType.builderType != null) + ) { + FLUENT_BUILDER_BASE_STAR + } else if (info.elementType != null) { + toBaseTypeName(info.elementType) + } else { + ANY.copy(nullable = true) + } + return LIST.parameterizedBy(elementType) + } + if (info.isAssetWrapper || info.builderType != null) return FLUENT_BUILDER_BASE_STAR + if (info.isBinding) return BINDING.parameterizedBy(STAR) + if (info.isExpression) return TAGGED_VALUE.parameterizedBy(STAR) + if (info.isNestedObject) return MAP_STRING_ANY + return parseBaseTypeName(info.typeName) + } + + private fun parseBaseTypeName(name: String): TypeName { + val base = name.removeSuffix("?") + return when (base) { + "String" -> STRING + "Number" -> NUMBER + "Boolean" -> BOOLEAN + "Any" -> ANY + "Nothing" -> NOTHING + "Unit" -> UNIT + else -> { + if (base.startsWith("Map<") && base.endsWith(">")) { + val inner = base.removePrefix("Map<").removeSuffix(">") + val commaIdx = findTopLevelComma(inner) + if (commaIdx >= 0) { + MAP.parameterizedBy( + parseBaseTypeName(inner.substring(0, commaIdx).trim()), + parseBaseTypeName(inner.substring(commaIdx + 1).trim()), + ) + } else { + MAP_STRING_ANY + } + } else { + ClassName.bestGuess(base) + } + } + } + } + + private fun findTopLevelComma(str: String): Int { + var depth = 0 + for (i in str.indices) { + when (str[i]) { + '<' -> depth++ + '>' -> depth-- + ',' -> if (depth == 0) return i + } + } + return -1 + } + + private fun buildSetInitializer(names: List): CodeBlock { + if (names.isEmpty()) return CodeBlock.of("emptySet()") + val format = names.joinToString(", ") { "%S" } + return CodeBlock.of("setOf($format)", *names.toTypedArray()) + } + + companion object { + // Kotlin stdlib types (replacing KotlinPoet's top-level constants that don't work through KMP metadata) + val STRING = ClassName("kotlin", "String") + val BOOLEAN = ClassName("kotlin", "Boolean") + val NUMBER = ClassName("kotlin", "Number") + val ANY = ClassName("kotlin", "Any") + val NOTHING = ClassName("kotlin", "Nothing") + val UNIT = ClassName("kotlin", "Unit") + val LIST = ClassName("kotlin.collections", "List") + val MAP = ClassName("kotlin.collections", "Map") + val SET = ClassName("kotlin.collections", "Set") + val STAR = WildcardTypeName.producerOf(ANY) + + // DSL framework types + val FLUENT_DSL_MARKER = ClassName("com.intuit.playerui.lang.dsl", "FluentDslMarker") + val FLUENT_BUILDER_BASE = ClassName("com.intuit.playerui.lang.dsl.core", "FluentBuilderBase") + val ASSET_WRAPPER_BUILDER = ClassName("com.intuit.playerui.lang.dsl.core", "AssetWrapperBuilder") + val BUILD_CONTEXT = ClassName("com.intuit.playerui.lang.dsl.core", "BuildContext") + val BINDING = ClassName("com.intuit.playerui.lang.dsl.tagged", "Binding") + val TAGGED_VALUE = ClassName("com.intuit.playerui.lang.dsl.tagged", "TaggedValue") + + // Parameterized types + val MAP_STRING_ANY = MAP.parameterizedBy(STRING, ANY.copy(nullable = true)) + val FLUENT_BUILDER_BASE_STAR = FLUENT_BUILDER_BASE.parameterizedBy(STAR) + val BUILDER_SUPERCLASS = FLUENT_BUILDER_BASE.parameterizedBy(MAP_STRING_ANY) + + private val PRIMITIVE_OVERLOAD_TYPES = setOf("String", "Number", "Boolean") + + /** + * Generate Kotlin builder code from an XLR document. + */ + fun generate( + document: XlrDocument, + packageName: String, + ): GeneratedClass = + ClassGenerator(document, packageName) + .generate() + + /** + * Check if a type should have binding/expression overloads. + */ + internal fun shouldHaveOverload(typeName: String): Boolean = typeName in PRIMITIVE_OVERLOAD_TYPES + + private fun cleanName(kotlinName: String): String = + kotlinName.removePrefix("`").removeSuffix("`") + + private fun withMethodName(poetName: String): String = + "with${poetName.replaceFirstChar { it.uppercase() }}" + } +} diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/CodeWriter.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/CodeWriter.kt new file mode 100644 index 0000000..ad26bee --- /dev/null +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/CodeWriter.kt @@ -0,0 +1,234 @@ +package com.intuit.playerui.lang.generator + +/** + * Utility class for generating formatted Kotlin code. + * Handles indentation, line management, and common code patterns. + */ +class CodeWriter { + private val lines = mutableListOf() + private var indentLevel = 0 + private val indentString = " " // 4 spaces + + /** + * Get the current indentation string. + */ + private fun currentIndent(): String = indentString.repeat(indentLevel) + + /** + * Add a line of code at the current indentation level. + */ + fun line(code: String) { + if (code.isEmpty()) { + lines.add("") + } else { + lines.add("${currentIndent()}$code") + } + } + + /** + * Add an empty line. + */ + fun blankLine() { + lines.add("") + } + + /** + * Increase indentation level. + */ + fun indent() { + indentLevel++ + } + + /** + * Decrease indentation level. + */ + fun dedent() { + if (indentLevel > 0) { + indentLevel-- + } + } + + /** + * Add a block of code with automatic indentation. + * Opens with the given line, increases indent, runs the block, decreases indent, closes with closing line. + */ + fun block( + openLine: String, + closeLine: String = "}", + block: CodeWriter.() -> Unit, + ) { + line(openLine) + indent() + block() + dedent() + line(closeLine) + } + + /** + * Add a class definition block. + */ + fun classBlock( + name: String, + annotations: List = emptyList(), + superClass: String? = null, + typeParams: String? = null, + block: CodeWriter.() -> Unit, + ) { + annotations.forEach { line(it) } + val classDecl = + buildString { + append("class $name") + if (typeParams != null) { + append("<$typeParams>") + } + if (superClass != null) { + append(" : $superClass") + } + append(" {") + } + block(classDecl, "}", block) + } + + /** + * Add an object declaration block. + */ + fun objectBlock( + name: String, + annotations: List = emptyList(), + block: CodeWriter.() -> Unit, + ) { + annotations.forEach { line(it) } + block("object $name {", "}", block) + } + + /** + * Add a function definition block. + */ + fun functionBlock( + name: String, + params: String = "", + returnType: String? = null, + modifiers: List = emptyList(), + block: CodeWriter.() -> Unit, + ) { + val modifierStr = if (modifiers.isNotEmpty()) modifiers.joinToString(" ") + " " else "" + val returnStr = if (returnType != null) ": $returnType" else "" + block("${modifierStr}fun $name($params)$returnStr {", "}", block) + } + + /** + * Add a property with getter and setter. + */ + fun property( + name: String, + type: String, + modifiers: List = emptyList(), + getter: (CodeWriter.() -> Unit)? = null, + setter: (CodeWriter.() -> Unit)? = null, + ) { + val modifierStr = if (modifiers.isNotEmpty()) modifiers.joinToString(" ") + " " else "" + line("${modifierStr}var $name: $type") + if (getter != null) { + indent() + line("get() {") + indent() + getter() + dedent() + line("}") + dedent() + } + if (setter != null) { + indent() + line("set(value) {") + indent() + setter() + dedent() + line("}") + dedent() + } + } + + /** + * Add a simple property with inline getter/setter. + */ + fun simpleProperty( + name: String, + type: String, + getterExpr: String, + setterExpr: String, + modifiers: List = emptyList(), + ) { + val modifierStr = if (modifiers.isNotEmpty()) modifiers.joinToString(" ") + " " else "" + line("${modifierStr}var $name: $type") + indent() + line("get() = $getterExpr") + line("set(value) { $setterExpr }") + dedent() + } + + /** + * Add a val property with inline getter. + */ + fun valProperty( + name: String, + type: String, + value: String, + modifiers: List = emptyList(), + ) { + val modifierStr = if (modifiers.isNotEmpty()) modifiers.joinToString(" ") + " " else "" + line("${modifierStr}val $name: $type = $value") + } + + /** + * Add an override val property. + */ + fun overrideVal( + name: String, + type: String, + value: String, + ) { + line("override val $name: $type = $value") + } + + /** + * Add a KDoc comment block. + */ + fun kdoc(comment: String) { + if (comment.contains("\n")) { + line("/**") + comment.lines().forEach { line(" * $it") } + line(" */") + } else { + line("/** $comment */") + } + } + + /** + * Add raw lines (useful for multi-line strings). + */ + fun raw(code: String) { + code.lines().forEach { lines.add(it) } + } + + /** + * Get the generated code as a string. + */ + fun build(): String = lines.joinToString("\n") + + /** + * Get the generated code with a trailing newline. + */ + fun buildWithNewline(): String = build() + "\n" + + companion object { + /** + * Create a CodeWriter and run the builder block. + */ + fun write(block: CodeWriter.() -> Unit): String = CodeWriter().apply(block).buildWithNewline() + } +} + +/** + * Extension function for easily creating code blocks. + */ +fun codeWriter(block: CodeWriter.() -> Unit): String = CodeWriter.write(block) diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Generator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Generator.kt new file mode 100644 index 0000000..0b169b9 --- /dev/null +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Generator.kt @@ -0,0 +1,120 @@ +package com.intuit.playerui.lang.generator + +import com.intuit.playerui.xlr.XlrDeserializer +import com.intuit.playerui.xlr.XlrDocument +import java.io.File + +/** + * Configuration for the Kotlin DSL generator. + */ +data class GeneratorConfig( + val packageName: String, + val outputDir: File, +) + +/** + * Result of generating a single file. + */ +data class GeneratorResult( + val className: String, + val filePath: File, + val code: String, +) + +/** + * Main orchestrator for generating Kotlin DSL builders from XLR schemas. + * + * Usage: + * ```kotlin + * val generator = Generator(GeneratorConfig( + * packageName = "com.example.builders", + * outputDir = File("generated") + * )) + * val results = generator.generateFromFiles(listOf(File("ActionAsset.json"))) + * ``` + */ +class Generator( + private val config: GeneratorConfig, +) { + /** + * Generate Kotlin builders from a list of XLR JSON files. + */ + fun generateFromFiles(files: List): List = + files.map { file -> + generateFromFile(file) + } + + /** + * Generate a Kotlin builder from a single XLR JSON file. + */ + fun generateFromFile(file: File): GeneratorResult { + val jsonContent = file.readText() + return generateFromJson(jsonContent) + } + + /** + * Generate a Kotlin builder from XLR JSON content. + */ + fun generateFromJson(jsonContent: String): GeneratorResult { + val document = XlrDeserializer.deserialize(jsonContent) + return generateFromDocument(document) + } + + /** + * Generate a Kotlin builder from an XLR document. + */ + fun generateFromDocument(document: XlrDocument): GeneratorResult { + val generatedClass = ClassGenerator.generate(document, config.packageName) + + // Create output directory if it doesn't exist + config.outputDir.mkdirs() + + // Create package directory structure + val packageDir = config.packageName.replace('.', File.separatorChar) + val outputPackageDir = File(config.outputDir, packageDir) + outputPackageDir.mkdirs() + + // Write the generated file + val outputFile = File(outputPackageDir, "${generatedClass.className}.kt") + outputFile.writeText(generatedClass.code) + + return GeneratorResult( + className = generatedClass.className, + filePath = outputFile, + code = generatedClass.code, + ) + } + + /** + * Generate Kotlin builder code without writing to disk. + */ + fun generateCode(document: XlrDocument): String { + val generatedClass = ClassGenerator.generate(document, config.packageName) + return generatedClass.code + } + + companion object { + /** + * Generate Kotlin builder code from XLR JSON without creating a Generator instance. + * Useful for one-off generation or testing. + */ + fun generateCode( + jsonContent: String, + packageName: String, + ): String { + val document = XlrDeserializer.deserialize(jsonContent) + return ClassGenerator.generate(document, packageName).code + } + + /** + * Generate Kotlin builder code from an XLR document without creating a Generator instance. + */ + fun generateCode( + document: XlrDocument, + packageName: String, + ): String = + ClassGenerator + .generate(document, packageName) + .code + } +} diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt new file mode 100644 index 0000000..f7956cc --- /dev/null +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt @@ -0,0 +1,265 @@ +package com.intuit.playerui.lang.generator + +import java.io.File +import kotlin.system.exitProcess + +/** + * CLI entry point for the Kotlin DSL generator. + * + * Usage: + * ``` + * kotlin-dsl-generator --input --output --package + * kotlin-dsl-generator --schema --schema-name --output --package + * ``` + * + * Arguments: + * - --input, -i: Path to XLR JSON file(s) or directory containing XLR files + * - --output, -o: Output directory for generated Kotlin files + * - --package, -p: Package name for generated classes + * - --schema, -s: Path to a Player-UI schema JSON file (generates typed binding accessors) + * - --schema-name: Name for the generated schema bindings object (default: derived from filename) + * - --help, -h: Show help message + */ +fun main(args: Array) { + val parsedArgs = parseArgs(args) + + if (parsedArgs.showHelp) { + printHelp() + return + } + + if (parsedArgs.outputDir == null) { + System.err.println("Error: Output directory is required. Use --output or -o.") + exitProcess(1) + } + + if (parsedArgs.packageName == null) { + System.err.println("Error: Package name is required. Use --package or -p.") + exitProcess(1) + } + + // Schema generation mode + if (parsedArgs.schemaFile != null) { + generateSchemaBindings(parsedArgs) + return + } + + // Asset builder generation mode + if (parsedArgs.inputPaths.isEmpty()) { + printHelp() + return + } + + generateAssetBuilders(parsedArgs) +} + +private fun generateSchemaBindings(parsedArgs: ParsedArgs) { + val schemaFile = parsedArgs.schemaFile!! + val packageName = parsedArgs.packageName!! + val outputDir = parsedArgs.outputDir!! + + if (!schemaFile.isFile) { + System.err.println("Error: Schema file not found: ${schemaFile.absolutePath}") + exitProcess(1) + } + + val objectName = + parsedArgs.schemaName + ?: schemaFile.nameWithoutExtension.replaceFirstChar { it.uppercase() } + "Schema" + + println("Generating schema bindings...") + println(" Package: $packageName") + println(" Output: ${outputDir.absolutePath}") + println(" Schema: ${schemaFile.name}") + println(" Object: $objectName") + + try { + val schemaJson = schemaFile.readText() + val generator = SchemaBindingGenerator(packageName) + val result = generator.generate(schemaJson, objectName) + + outputDir.mkdirs() + val packageDir = packageName.replace('.', File.separatorChar) + val outputPackageDir = File(outputDir, packageDir) + outputPackageDir.mkdirs() + + val outputFile = File(outputPackageDir, "${result.className}.kt") + outputFile.writeText(result.code) + + println(" Generated: ${result.className} -> ${outputFile.absolutePath}") + println() + println("Generation complete: 1 succeeded, 0 failed") + } catch (e: Exception) { + System.err.println(" Error processing ${schemaFile.name}: ${e.message}") + exitProcess(1) + } +} + +private fun generateAssetBuilders(parsedArgs: ParsedArgs) { + val config = + GeneratorConfig( + packageName = parsedArgs.packageName!!, + outputDir = parsedArgs.outputDir!!, + ) + + val generator = Generator(config) + val inputFiles = collectInputFiles(parsedArgs.inputPaths) + + if (inputFiles.isEmpty()) { + System.err.println("Error: No XLR JSON files found in the specified input paths.") + exitProcess(1) + } + + println("Generating Kotlin DSL builders...") + println(" Package: ${config.packageName}") + println(" Output: ${config.outputDir.absolutePath}") + println(" Files: ${inputFiles.size}") + + var successCount = 0 + var errorCount = 0 + + inputFiles.forEach { file -> + try { + val result = generator.generateFromFile(file) + println(" Generated: ${result.className} -> ${result.filePath.absolutePath}") + successCount++ + } catch (e: Exception) { + System.err.println(" Error processing ${file.name}: ${e.message}") + errorCount++ + } + } + + println() + println("Generation complete: $successCount succeeded, $errorCount failed") + + if (errorCount > 0) { + exitProcess(1) + } +} + +private data class ParsedArgs( + val inputPaths: List = emptyList(), + val outputDir: File? = null, + val packageName: String? = null, + val schemaFile: File? = null, + val schemaName: String? = null, + val showHelp: Boolean = false, +) + +private fun parseArgs(args: Array): ParsedArgs { + var inputPaths = mutableListOf() + var outputDir: File? = null + var packageName: String? = null + var schemaFile: File? = null + var schemaName: String? = null + var showHelp = false + + var i = 0 + while (i < args.size) { + when (args[i]) { + "--help", "-h" -> { + showHelp = true + } + + "--input", "-i" -> { + i++ + if (i < args.size) { + inputPaths.add(File(args[i])) + } + } + + "--output", "-o" -> { + i++ + if (i < args.size) { + outputDir = File(args[i]) + } + } + + "--package", "-p" -> { + i++ + if (i < args.size) { + packageName = args[i] + } + } + + "--schema", "-s" -> { + i++ + if (i < args.size) { + schemaFile = File(args[i]) + } + } + + "--schema-name" -> { + i++ + if (i < args.size) { + schemaName = args[i] + } + } + + else -> { + // Treat unknown args as input files + if (!args[i].startsWith("-")) { + inputPaths.add(File(args[i])) + } + } + } + i++ + } + + return ParsedArgs(inputPaths, outputDir, packageName, schemaFile, schemaName, showHelp) +} + +private fun collectInputFiles(paths: List): List { + val result = mutableListOf() + + paths.forEach { path -> + when { + path.isDirectory -> { + path + .walkTopDown() + .filter { it.isFile && it.extension == "json" } + .forEach { result.add(it) } + } + + path.isFile && path.extension == "json" -> { + result.add(path) + } + } + } + + return result +} + +private fun printHelp() { + println( + """ + |Kotlin DSL Generator + | + |Generates Kotlin DSL builder classes from XLR JSON schemas, + |and typed schema binding accessors from Player-UI schema definitions. + | + |Usage: + | kotlin-dsl-generator [options] [input-files...] + | + |Asset Builder Generation: + | -i, --input Path to XLR JSON file or directory (can be specified multiple times) + | -o, --output Output directory for generated Kotlin files (required) + | -p, --package Package name for generated classes (required) + | + |Schema Binding Generation: + | -s, --schema Path to Player-UI schema JSON file + | --schema-name Name for the generated bindings object (default: Schema) + | -o, --output Output directory for generated Kotlin files (required) + | -p, --package Package name for generated classes (required) + | + |General: + | -h, --help Show this help message + | + |Examples: + | kotlin-dsl-generator -i ActionAsset.json -o generated -p com.example.builders + | kotlin-dsl-generator -i xlr/ -o generated -p com.myapp.fluent + | kotlin-dsl-generator --schema my-schema.json -o generated -p com.example.schema + | kotlin-dsl-generator --schema my-schema.json --schema-name MyFlowSchema -o generated -p com.example + """.trimMargin(), + ) +} diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt new file mode 100644 index 0000000..e331590 --- /dev/null +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt @@ -0,0 +1,258 @@ +package com.intuit.playerui.lang.generator + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Schema field definition for deserialization. + */ +data class SchemaField( + val type: String, + val isRecord: Boolean? = null, + val isArray: Boolean? = null, +) + +/** + * Generates Kotlin objects with typed [Binding] properties from Player-UI schema definitions. + * + * Given a schema JSON like: + * ```json + * { + * "ROOT": { + * "name": { "type": "StringType" }, + * "age": { "type": "NumberType" }, + * "address": { "type": "AddressType" } + * }, + * "AddressType": { + * "street": { "type": "StringType" }, + * "city": { "type": "StringType" } + * } + * } + * ``` + * + * Generates: + * ```kotlin + * object MyFlowSchema { + * val name = Binding("name") + * val age = Binding("age") + * + * object address { + * val street = Binding("address.street") + * val city = Binding("address.city") + * } + * } + * ``` + */ +class SchemaBindingGenerator( + private val packageName: String, +) { + /** + * Generate a Kotlin object with typed bindings from a schema JSON string. + * + * @param schemaJson The schema JSON content + * @param objectName The name for the generated object (e.g., "MyFlowSchema") + * @return Generated Kotlin source code + */ + fun generate( + schemaJson: String, + objectName: String, + ): GeneratedClass { + val schema = parseSchema(schemaJson) + val root = schema["ROOT"] ?: error("Schema must contain a ROOT node") + + val objectSpec = buildRootObject(objectName, root, schema) + + val fileSpec = + FileSpec + .builder(packageName, objectName) + .indent(" ") + .addType(objectSpec) + .build() + + return GeneratedClass( + className = objectName, + code = fileSpec.toString(), + ) + } + + private fun buildRootObject( + objectName: String, + rootNode: Map, + schema: Map>, + ): TypeSpec { + val builder = + TypeSpec + .objectBuilder(objectName) + .addKdoc("Generated schema bindings. Provides compile-time type-safe access to data model paths.") + + addNodeProperties(builder, rootNode, schema, "", mutableSetOf()) + + return builder.build() + } + + private fun addNodeProperties( + builder: TypeSpec.Builder, + node: Map, + schema: Map>, + basePath: String, + visited: MutableSet, + ) { + for ((key, field) in node) { + val path = if (basePath.isEmpty()) key else "$basePath.$key" + val kotlinName = TypeMapper.toKotlinIdentifier(key) + + addFieldMember(builder, kotlinName, field, schema, path, visited) + } + } + + private fun addFieldMember( + builder: TypeSpec.Builder, + kotlinName: String, + field: SchemaField, + schema: Map>, + path: String, + visited: MutableSet, + ) { + val typeName = field.type + + // Prevent infinite recursion + if (typeName in visited) { + builder.addProperty(buildBindingProperty(kotlinName, typeName, path)) + return + } + + // Handle arrays + if (field.isArray == true) { + val arrayPath = if (path.isNotEmpty()) "$path._current_" else "_current_" + + if (typeName in PRIMITIVE_TYPES) { + // Primitive arrays: create nested object with name/value property + val propName = if (typeName == "StringType") "name" else "value" + val nestedObject = + TypeSpec + .objectBuilder(kotlinName) + .addProperty(buildBindingProperty(propName, typeName, arrayPath)) + .build() + builder.addType(nestedObject) + return + } + + val typeNode = schema[typeName] + if (typeNode != null) { + val nestedVisited = HashSet(visited) + nestedVisited.add(typeName) + val nestedObject = TypeSpec.objectBuilder(kotlinName) + addNodeProperties(nestedObject, typeNode, schema, arrayPath, nestedVisited) + builder.addType(nestedObject.build()) + return + } + + builder.addProperty(buildBindingProperty(kotlinName, "StringType", arrayPath)) + return + } + + // Handle records + if (field.isRecord == true) { + val typeNode = schema[typeName] + if (typeNode != null) { + val nestedVisited = HashSet(visited) + nestedVisited.add(typeName) + val nestedObject = TypeSpec.objectBuilder(kotlinName) + addNodeProperties(nestedObject, typeNode, schema, path, nestedVisited) + builder.addType(nestedObject.build()) + return + } + + builder.addProperty(buildBindingProperty(kotlinName, "StringType", path)) + return + } + + // Handle primitives + if (typeName in PRIMITIVE_TYPES) { + builder.addProperty(buildBindingProperty(kotlinName, typeName, path)) + return + } + + // Handle complex types + val typeNode = schema[typeName] + if (typeNode != null) { + val nestedVisited = HashSet(visited) + nestedVisited.add(typeName) + val nestedObject = TypeSpec.objectBuilder(kotlinName) + addNodeProperties(nestedObject, typeNode, schema, path, nestedVisited) + builder.addType(nestedObject.build()) + return + } + + // Fallback: unknown type + builder.addProperty(buildBindingProperty(kotlinName, "StringType", path)) + } + + private fun buildBindingProperty( + name: String, + typeName: String, + path: String, + ): PropertySpec { + val typeParam = schemaTypeToKotlinType(typeName) + val bindingType = BINDING.parameterizedBy(typeParam) + + return PropertySpec + .builder(name, bindingType) + .initializer("%T(%S)", BINDING, path) + .build() + } + + private fun schemaTypeToKotlinType(typeName: String): ClassName = + when (typeName) { + "StringType" -> ClassGenerator.STRING + "NumberType" -> ClassGenerator.NUMBER + "BooleanType" -> ClassGenerator.BOOLEAN + else -> ClassGenerator.STRING + } + + private fun parseSchema(json: String): Map> { + val jsonElement = Json.parseToJsonElement(json) + val jsonObject = jsonElement.jsonObject + + return jsonObject.mapValues { (_, nodeElement) -> + val nodeObject = nodeElement.jsonObject + nodeObject.mapValues { (_, fieldElement) -> + parseSchemaField(fieldElement) + } + } + } + + private fun parseSchemaField(element: JsonElement): SchemaField { + val obj = element.jsonObject + val type = obj["type"]?.jsonPrimitive?.content ?: error("Schema field must have a 'type' property") + val isRecord = obj["isRecord"]?.jsonPrimitive?.booleanOrNull + val isArray = obj["isArray"]?.jsonPrimitive?.booleanOrNull + + return SchemaField(type = type, isRecord = isRecord, isArray = isArray) + } + + companion object { + private val PRIMITIVE_TYPES = setOf("StringType", "NumberType", "BooleanType") + private val BINDING = ClassName("com.intuit.playerui.lang.dsl.tagged", "Binding") + + /** + * Generate schema binding code from a JSON string. + */ + fun generateCode( + schemaJson: String, + objectName: String, + packageName: String, + ): String = + SchemaBindingGenerator(packageName) + .generate(schemaJson, objectName) + .code + } +} diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt new file mode 100644 index 0000000..6d782fa --- /dev/null +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt @@ -0,0 +1,306 @@ +package com.intuit.playerui.lang.generator + +import com.intuit.playerui.xlr.AnyType +import com.intuit.playerui.xlr.ArrayType +import com.intuit.playerui.xlr.BooleanType +import com.intuit.playerui.xlr.NeverType +import com.intuit.playerui.xlr.NodeType +import com.intuit.playerui.xlr.NullType +import com.intuit.playerui.xlr.NumberType +import com.intuit.playerui.xlr.ObjectType +import com.intuit.playerui.xlr.OrType +import com.intuit.playerui.xlr.ParamTypeNode +import com.intuit.playerui.xlr.RecordType +import com.intuit.playerui.xlr.RefType +import com.intuit.playerui.xlr.StringType +import com.intuit.playerui.xlr.UndefinedType +import com.intuit.playerui.xlr.UnknownType +import com.intuit.playerui.xlr.VoidType +import com.intuit.playerui.xlr.isAssetRef +import com.intuit.playerui.xlr.isAssetWrapperRef +import com.intuit.playerui.xlr.isBindingRef +import com.intuit.playerui.xlr.isExpressionRef + +/* + * Maps XLR types to Kotlin type information for code generation. + */ + +/** + * Information about a Kotlin type derived from an XLR node. + */ +data class KotlinTypeInfo( + val typeName: String, + val isNullable: Boolean = true, + val isAssetWrapper: Boolean = false, + val isArray: Boolean = false, + val elementType: KotlinTypeInfo? = null, + val isBinding: Boolean = false, + val isExpression: Boolean = false, + val builderType: String? = null, + val isNestedObject: Boolean = false, + val nestedObjectName: String? = null, + val description: String? = null, +) + +/** + * Context for type mapping, including generic token resolution. + */ +data class TypeMapperContext( + val genericTokens: Map = emptyMap(), + val parentPropertyPath: String = "", +) + +/** + * Maps XLR node types to Kotlin type information. + */ +object TypeMapper { + /** + * Convert an XLR NodeType to Kotlin type information. + */ + fun mapToKotlinType( + node: NodeType, + context: TypeMapperContext = TypeMapperContext(), + ): KotlinTypeInfo = + when (node) { + is StringType -> mapPrimitiveType("String", node.description) + is NumberType -> mapPrimitiveType("Number", node.description) + is BooleanType -> mapPrimitiveType("Boolean", node.description) + is NullType -> KotlinTypeInfo("Nothing?", isNullable = true) + is AnyType -> KotlinTypeInfo("Any?", isNullable = true) + is UnknownType -> KotlinTypeInfo("Any?", isNullable = true) + is UndefinedType -> KotlinTypeInfo("Nothing?", isNullable = true) + is VoidType -> KotlinTypeInfo("Unit", isNullable = false) + is NeverType -> KotlinTypeInfo("Nothing", isNullable = false) + is RefType -> mapRefType(node, context) + is ObjectType -> mapObjectType(node, context) + is ArrayType -> mapArrayType(node, context) + is OrType -> mapOrType(node, context) + is RecordType -> mapRecordType(node, context) + else -> KotlinTypeInfo("Any?", isNullable = true) + } + + private fun mapPrimitiveType( + typeName: String, + description: String?, + ): KotlinTypeInfo = + KotlinTypeInfo( + typeName = typeName, + isNullable = true, + description = description, + ) + + private fun mapRefType( + node: RefType, + context: TypeMapperContext, + ): KotlinTypeInfo { + val ref = node.ref + + // Check for generic token + context.genericTokens[ref]?.let { token -> + token.default?.let { return mapToKotlinType(it, context) } + token.constraints?.let { return mapToKotlinType(it, context) } + } + + // Check for AssetWrapper + if (isAssetWrapperRef(node)) { + return KotlinTypeInfo( + typeName = "FluentBuilder<*>", + isNullable = true, + isAssetWrapper = true, + builderType = "FluentBuilder<*>", + description = node.description, + ) + } + + // Check for Asset + if (isAssetRef(node)) { + return KotlinTypeInfo( + typeName = "FluentBuilder<*>", + isNullable = true, + builderType = "FluentBuilder<*>", + description = node.description, + ) + } + + // Check for Binding + if (isBindingRef(node)) { + return KotlinTypeInfo( + typeName = "Binding<*>", + isNullable = true, + isBinding = true, + description = node.description, + ) + } + + // Check for Expression + if (isExpressionRef(node)) { + return KotlinTypeInfo( + typeName = "TaggedValue<*>", + isNullable = true, + isExpression = true, + description = node.description, + ) + } + + // Default: unknown ref, use Any + return KotlinTypeInfo( + typeName = "Any?", + isNullable = true, + description = node.description, + ) + } + + private fun mapObjectType( + node: ObjectType, + context: TypeMapperContext, + ): KotlinTypeInfo { + // Inline objects become nested classes + return KotlinTypeInfo( + typeName = "Map", + isNullable = true, + isNestedObject = true, + description = node.description, + ) + } + + private fun mapArrayType( + node: ArrayType, + context: TypeMapperContext, + ): KotlinTypeInfo { + val elementTypeInfo = mapToKotlinType(node.elementType, context) + + val listTypeName = + if (elementTypeInfo.isAssetWrapper || elementTypeInfo.builderType != null) { + "List>" + } else { + "List<${elementTypeInfo.typeName}>" + } + + return KotlinTypeInfo( + typeName = listTypeName, + isNullable = true, + isArray = true, + elementType = elementTypeInfo, + isAssetWrapper = elementTypeInfo.isAssetWrapper, + description = node.description, + ) + } + + private fun mapOrType( + node: OrType, + context: TypeMapperContext, + ): KotlinTypeInfo { + val types = node.orTypes + + // Separate nullable types (null, undefined) from non-nullable types + val nullableTypes = types.filter { it is NullType || it is UndefinedType } + val nonNullTypes = types.filter { it !is NullType && it !is UndefinedType } + val hasNullBranch = nullableTypes.isNotEmpty() + + // If all non-null types are the same primitive, collapse to nullable primitive + // e.g., String | null → String?, Number | undefined → Number? + if (nonNullTypes.size == 1) { + val inner = mapToKotlinType(nonNullTypes[0], context) + return inner.copy(isNullable = hasNullBranch || inner.isNullable) + } + + // If all non-null types are StringType with const values, it's a literal string union + // e.g., "foo" | "bar" | "baz" → String + if (nonNullTypes.all { it is StringType && (it as StringType).const != null }) { + return KotlinTypeInfo( + typeName = "String", + isNullable = hasNullBranch, + description = node.description, + ) + } + + // If all non-null types map to the same Kotlin type, collapse + if (nonNullTypes.size > 1) { + val mapped = nonNullTypes.map { mapToKotlinType(it, context) } + val distinctTypes = mapped.map { it.typeName }.distinct() + if (distinctTypes.size == 1) { + return mapped[0].copy(isNullable = hasNullBranch || mapped[0].isNullable) + } + } + + // Heterogeneous union, fall back to Any + return KotlinTypeInfo( + typeName = "Any?", + isNullable = true, + description = node.description, + ) + } + + private fun mapRecordType( + node: RecordType, + context: TypeMapperContext, + ): KotlinTypeInfo { + val keyTypeInfo = mapToKotlinType(node.keyType, context) + val valueTypeInfo = mapToKotlinType(node.valueType, context) + + return KotlinTypeInfo( + typeName = "Map<${keyTypeInfo.typeName}, ${valueTypeInfo.typeName}>", + isNullable = true, + description = node.description, + ) + } + + /** + * Get the nullable version of a type name. + */ + fun makeNullable(typeName: String): String = if (typeName.endsWith("?")) typeName else "$typeName?" + + /** + * Get the non-nullable version of a type name. + */ + fun makeNonNullable(typeName: String): String = typeName.removeSuffix("?") + + /** + * Kotlin hard keywords that must be escaped with backticks when used as identifiers. + */ + private val KOTLIN_KEYWORDS = + setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", + "if", "in", "interface", "is", "null", "object", "package", "return", + "super", "this", "throw", "true", "try", "typealias", "typeof", "val", + "var", "when", "while", + ) + + /** + * Convert a property name to a valid Kotlin identifier. + * Escapes Kotlin keywords with backticks and handles invalid characters. + */ + fun toKotlinIdentifier(name: String): String { + // Replace invalid characters + val cleaned = + name + .replace("-", "_") + .replace(".", "_") + .replace(" ", "_") + + return when { + cleaned.isEmpty() -> "_unnamed_" + cleaned.first().isDigit() -> "_$cleaned" + cleaned in KOTLIN_KEYWORDS -> "`$cleaned`" + else -> cleaned + } + } + + /** + * Convert an asset type name to a builder class name. + * E.g., "action" -> "ActionBuilder", "text" -> "TextBuilder" + */ + fun toBuilderClassName(assetType: String): String { + val capitalized = assetType.replaceFirstChar { it.uppercase() } + return "${capitalized}Builder" + } + + /** + * Convert an asset type name to a DSL function name. + * E.g., "ActionAsset" -> "action", "TextAsset" -> "text" + */ + fun toDslFunctionName(assetName: String): String = + assetName + .removeSuffix("Asset") + .replaceFirstChar { it.lowercase() } +} diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/ClassGeneratorTest.kt b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/ClassGeneratorTest.kt new file mode 100644 index 0000000..3394c03 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/ClassGeneratorTest.kt @@ -0,0 +1,246 @@ +package com.intuit.playerui.lang.generator + +import com.intuit.playerui.xlr.ObjectProperty +import com.intuit.playerui.xlr.ObjectType +import com.intuit.playerui.xlr.RefType +import com.intuit.playerui.xlr.StringType +import com.intuit.playerui.xlr.XlrDocument +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +class ClassGeneratorTest : + DescribeSpec({ + + describe("ClassGenerator") { + + describe("generate") { + it("generates a basic builder class") { + val document = + XlrDocument( + name = "TextAsset", + source = "test", + objectType = + ObjectType( + properties = + mapOf( + "value" to + ObjectProperty( + required = false, + node = StringType(), + ), + ), + extends = + RefType( + ref = "Asset", + genericArguments = + listOf( + StringType(const = "text"), + ), + ), + ), + ) + + val result = ClassGenerator.generate(document, "com.test") + + result.className shouldBe "TextBuilder" + result.code shouldContain "package com.test" + result.code shouldContain "@FluentDslMarker" + result.code shouldContain "class TextBuilder" + result.code shouldContain "FluentBuilderBase>" + result.code shouldContain "override val defaults" + result.code shouldContain "\"type\" to \"text\"" + result.code shouldContain "var value: String?" + result.code shouldContain "fun text(init: TextBuilder.() -> Unit = {})" + } + + it("generates binding and expression overloads for string properties") { + val document = + XlrDocument( + name = "LabelAsset", + source = "test", + objectType = + ObjectType( + properties = + mapOf( + "text" to + ObjectProperty( + required = false, + node = StringType(), + ), + ), + ), + ) + + val result = ClassGenerator.generate(document, "com.test") + + result.code shouldContain "var text: String?" + result.code shouldContain "fun text(binding: Binding)" + result.code shouldContain "fun text(taggedValue: TaggedValue)" + } + + it("generates asset wrapper property for AssetWrapper refs") { + val document = + XlrDocument( + name = "ActionAsset", + source = "test", + objectType = + ObjectType( + properties = + mapOf( + "label" to + ObjectProperty( + required = false, + node = RefType(ref = "AssetWrapper"), + ), + ), + ), + ) + + val result = ClassGenerator.generate(document, "com.test") + + result.code shouldContain "var label: FluentBuilderBase<*>?" + result.code shouldContain "AssetWrapperBuilder" + result.code shouldContain "override val assetWrapperProperties" + result.code shouldContain "\"label\"" + } + + it("includes required imports") { + val document = + XlrDocument( + name = "TestAsset", + source = "test", + objectType = + ObjectType(), + ) + + val result = ClassGenerator.generate(document, "com.example") + + result.code shouldContain "import com.intuit.playerui.lang.dsl.FluentDslMarker" + result.code shouldContain "import com.intuit.playerui.lang.dsl.core.BuildContext" + result.code shouldContain "import com.intuit.playerui.lang.dsl.core.FluentBuilderBase" + result.code shouldContain "import com.intuit.playerui.lang.dsl.tagged.Binding" + result.code shouldContain "import com.intuit.playerui.lang.dsl.tagged.TaggedValue" + } + + it("generates build and clone methods") { + val document = + XlrDocument( + name = "SimpleAsset", + source = "test", + objectType = + ObjectType(), + ) + + val result = ClassGenerator.generate(document, "com.test") + + result.code shouldContain "override fun build(context: BuildContext?)" + result.code shouldContain "buildWithDefaults(context)" + result.code shouldContain "override fun clone()" + result.code shouldContain "SimpleBuilder().also { cloneStorageTo(it) }" + } + + it("generates description as KDoc") { + val document = + XlrDocument( + name = "DocAsset", + source = "test", + objectType = + ObjectType( + description = "This is a documented asset", + ), + ) + + val result = ClassGenerator.generate(document, "com.test") + + result.code shouldContain "/** This is a documented asset */" + } + } + } + + describe("CodeWriter") { + + it("writes lines with proper indentation") { + val code = + codeWriter { + line("class Test {") + indent() + line("val x = 1") + dedent() + line("}") + } + + code shouldContain "class Test {" + code shouldContain " val x = 1" + code shouldContain "}" + } + + it("creates blocks with auto-indentation") { + val code = + codeWriter { + block("fun test() {") { + line("println(\"hello\")") + } + } + + code shouldContain "fun test() {" + code shouldContain " println(\"hello\")" + code shouldContain "}" + } + + it("creates class blocks") { + val code = + codeWriter { + classBlock( + name = "MyClass", + annotations = listOf("@Annotation"), + superClass = "BaseClass()", + ) { + line("val x = 1") + } + } + + code shouldContain "@Annotation" + code shouldContain "class MyClass : BaseClass() {" + code shouldContain " val x = 1" + code shouldContain "}" + } + + it("creates val properties") { + val code = + codeWriter { + valProperty("myProp", "String", "\"hello\"") + overrideVal("defaults", "Map", "emptyMap()") + } + + code shouldContain "val myProp: String = \"hello\"" + code shouldContain "override val defaults: Map = emptyMap()" + } + + it("creates simple properties with getters and setters") { + val code = + codeWriter { + simpleProperty( + name = "value", + type = "String?", + getterExpr = "storage[\"value\"] as? String", + setterExpr = "storage[\"value\"] = value", + ) + } + + code shouldContain "var value: String?" + code shouldContain "get() = storage[\"value\"] as? String" + code shouldContain "set(value) { storage[\"value\"] = value }" + } + + it("creates KDoc comments") { + val code = + codeWriter { + kdoc("A simple comment") + line("val x = 1") + } + + code shouldContain "/** A simple comment */" + } + } + }) diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/GeneratorIntegrationTest.kt b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/GeneratorIntegrationTest.kt new file mode 100644 index 0000000..48ac634 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/GeneratorIntegrationTest.kt @@ -0,0 +1,120 @@ +package com.intuit.playerui.lang.generator + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.io.File + +class GeneratorIntegrationTest : + DescribeSpec({ + + fun loadFixture(name: String): String { + val classLoader = GeneratorIntegrationTest::class.java.classLoader + val resource = + classLoader.getResource( + "com/intuit/playerui/lang/generator/fixtures/$name", + ) + return if (resource != null) { + resource.readText() + } else { + val file = + File( + "language/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/$name", + ) + file.readText() + } + } + + describe("Generator integration") { + + it("generates ActionBuilder from ActionAsset.json") { + val json = loadFixture("ActionAsset.json") + val code = Generator.generateCode(json, "com.test.builders") + + code shouldContain "package com.test.builders" + code shouldContain "class ActionBuilder" + code shouldContain "FluentBuilderBase>" + code shouldContain "\"type\" to \"action\"" + // exp is an Expression type, maps to TaggedValue<*> + code shouldContain "var exp: TaggedValue<*>?" + code shouldContain "var label: FluentBuilderBase<*>?" + code shouldContain "AssetWrapperBuilder" + code shouldContain "fun action(init: ActionBuilder.() -> Unit = {})" + } + + it("generates TextBuilder from TextAsset.json") { + val json = loadFixture("TextAsset.json") + val code = Generator.generateCode(json, "com.test.builders") + + code shouldContain "class TextBuilder" + code shouldContain "\"type\" to \"text\"" + code shouldContain "var value: String?" + code shouldContain "fun value(binding: Binding)" + code shouldContain "fun value(taggedValue: TaggedValue)" + code shouldContain "fun text(init: TextBuilder.() -> Unit = {})" + } + + it("generates CollectionBuilder from CollectionAsset.json") { + val json = loadFixture("CollectionAsset.json") + val code = Generator.generateCode(json, "com.test.builders") + + code shouldContain "class CollectionBuilder" + code shouldContain "\"type\" to \"collection\"" + // Collection has array of assets + code shouldContain "var values: List>?" + code shouldContain "fun values(vararg builders: FluentBuilderBase<*>)" + code shouldContain "fun collection(init: CollectionBuilder.() -> Unit = {})" + } + + it("generates InputBuilder from InputAsset.json") { + val json = loadFixture("InputAsset.json") + val code = Generator.generateCode(json, "com.test.builders") + + code shouldContain "class InputBuilder" + code shouldContain "\"type\" to \"input\"" + // Input has binding property + code shouldContain "var binding: Binding<*>?" + code shouldContain "fun input(init: InputBuilder.() -> Unit = {})" + } + + it("includes all required imports") { + val json = loadFixture("ActionAsset.json") + val code = Generator.generateCode(json, "com.example") + + code shouldContain "import com.intuit.playerui.lang.dsl.FluentDslMarker" + code shouldContain "import com.intuit.playerui.lang.dsl.core.AssetWrapperBuilder" + code shouldContain "import com.intuit.playerui.lang.dsl.core.BuildContext" + code shouldContain "import com.intuit.playerui.lang.dsl.core.FluentBuilderBase" + code shouldContain "import com.intuit.playerui.lang.dsl.tagged.Binding" + code shouldContain "import com.intuit.playerui.lang.dsl.tagged.TaggedValue" + } + + it("generates valid Kotlin syntax") { + val json = loadFixture("TextAsset.json") + val code = Generator.generateCode(json, "com.test") + + // Check for balanced braces + val openBraces = code.count { it == '{' } + val closeBraces = code.count { it == '}' } + openBraces shouldBe closeBraces + + // Check for balanced parentheses + val openParens = code.count { it == '(' } + val closeParens = code.count { it == ')' } + openParens shouldBe closeParens + } + } + + describe("Generator class") { + + it("can generate code from XLR document") { + val json = loadFixture("TextAsset.json") + val document = + com.intuit.playerui.xlr.XlrDeserializer + .deserialize(json) + val code = Generator.generateCode(document, "com.test") + + code shouldContain "class TextBuilder" + } + } + }) diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/ProjectConfig.kt b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/ProjectConfig.kt new file mode 100644 index 0000000..09dbc31 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/ProjectConfig.kt @@ -0,0 +1,8 @@ +package com.intuit.playerui.lang.generator + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.spec.IsolationMode + +object ProjectConfig : AbstractProjectConfig() { + override val isolationMode = IsolationMode.InstancePerLeaf +} diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGeneratorTest.kt b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGeneratorTest.kt new file mode 100644 index 0000000..94ed4d3 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGeneratorTest.kt @@ -0,0 +1,340 @@ +package com.intuit.playerui.lang.generator + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +class SchemaBindingGeneratorTest : + DescribeSpec({ + + val packageName = "com.example.schema" + + describe("SchemaBindingGenerator") { + + describe("primitive types") { + it("generates bindings for string, number, and boolean types") { + val schema = + """ + { + "ROOT": { + "name": { "type": "StringType" }, + "age": { "type": "NumberType" }, + "active": { "type": "BooleanType" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "object TestSchema" + result shouldContain "val name: Binding = Binding(\"name\")" + result shouldContain "val age: Binding = Binding(\"age\")" + result shouldContain "val active: Binding = Binding(\"active\")" + } + } + + describe("complex types") { + it("generates nested objects for complex type references") { + val schema = + """ + { + "ROOT": { + "user": { "type": "UserType" } + }, + "UserType": { + "firstName": { "type": "StringType" }, + "lastName": { "type": "StringType" }, + "age": { "type": "NumberType" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "object TestSchema" + result shouldContain "object user" + result shouldContain "val firstName: Binding = Binding(\"user.firstName\")" + result shouldContain "val lastName: Binding = Binding(\"user.lastName\")" + result shouldContain "val age: Binding = Binding(\"user.age\")" + } + } + + describe("deeply nested types") { + it("generates multi-level nested objects") { + val schema = + """ + { + "ROOT": { + "company": { "type": "CompanyType" } + }, + "CompanyType": { + "name": { "type": "StringType" }, + "address": { "type": "AddressType" } + }, + "AddressType": { + "street": { "type": "StringType" }, + "city": { "type": "StringType" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "object company" + result shouldContain "val name: Binding = Binding(\"company.name\")" + result shouldContain "object address" + result shouldContain "val street: Binding = Binding(\"company.address.street\")" + result shouldContain "val city: Binding = Binding(\"company.address.city\")" + } + } + + describe("array types") { + it("generates array bindings with _current_ path for primitive string arrays") { + val schema = + """ + { + "ROOT": { + "tags": { "type": "StringType", "isArray": true } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "object tags" + result shouldContain "val name: Binding = Binding(\"tags._current_\")" + } + + it("generates array bindings with value property for number arrays") { + val schema = + """ + { + "ROOT": { + "scores": { "type": "NumberType", "isArray": true } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "object scores" + result shouldContain "val value: Binding = Binding(\"scores._current_\")" + } + + it("generates nested objects for complex type arrays") { + val schema = + """ + { + "ROOT": { + "items": { "type": "ItemType", "isArray": true } + }, + "ItemType": { + "label": { "type": "StringType" }, + "count": { "type": "NumberType" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "object items" + result shouldContain "val label: Binding = Binding(\"items._current_.label\")" + result shouldContain "val count: Binding = Binding(\"items._current_.count\")" + } + } + + describe("record types") { + it("generates nested objects for record types") { + val schema = + """ + { + "ROOT": { + "settings": { "type": "SettingsType", "isRecord": true } + }, + "SettingsType": { + "theme": { "type": "StringType" }, + "fontSize": { "type": "NumberType" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "object settings" + result shouldContain "val theme: Binding = Binding(\"settings.theme\")" + result shouldContain "val fontSize: Binding = Binding(\"settings.fontSize\")" + } + } + + describe("circular references") { + it("handles circular type references without infinite recursion") { + val schema = + """ + { + "ROOT": { + "node": { "type": "TreeNode" } + }, + "TreeNode": { + "value": { "type": "StringType" }, + "child": { "type": "TreeNode" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "object node" + result shouldContain "val value: Binding = Binding(\"node.value\")" + // Circular reference falls back to a binding + result shouldContain "val child: Binding = Binding(\"node.child\")" + } + } + + describe("unknown types") { + it("falls back to string binding for unknown types") { + val schema = + """ + { + "ROOT": { + "data": { "type": "SomeUnknownType" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "val data: Binding = Binding(\"data\")" + } + } + + describe("package and import generation") { + it("generates correct package declaration and imports") { + val schema = + """ + { + "ROOT": { + "name": { "type": "StringType" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "package com.example.schema" + result shouldContain "import com.intuit.playerui.lang.dsl.tagged.Binding" + } + } + + describe("GeneratedClass result") { + it("returns className matching the object name") { + val schema = + """ + { + "ROOT": { + "name": { "type": "StringType" } + } + } + """.trimIndent() + + val generator = SchemaBindingGenerator(packageName) + val result = generator.generate(schema, "MyFlowSchema") + + result.className shouldBe "MyFlowSchema" + result.code shouldContain "object MyFlowSchema" + } + } + + describe("identifier sanitization") { + it("sanitizes property names with invalid characters") { + val schema = + """ + { + "ROOT": { + "my-property": { "type": "StringType" }, + "123start": { "type": "NumberType" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "TestSchema", packageName) + + result shouldContain "val my_property: Binding = Binding(\"my-property\")" + result shouldContain "val _123start: Binding = Binding(\"123start\")" + } + } + + describe("comprehensive schema") { + it("handles a realistic Player-UI schema") { + val schema = + """ + { + "ROOT": { + "user": { "type": "UserType" }, + "items": { "type": "ItemType", "isArray": true }, + "preferences": { "type": "PrefsType", "isRecord": true } + }, + "UserType": { + "name": { "type": "StringType" }, + "email": { "type": "StringType" }, + "age": { "type": "NumberType" }, + "verified": { "type": "BooleanType" } + }, + "ItemType": { + "id": { "type": "StringType" }, + "quantity": { "type": "NumberType" } + }, + "PrefsType": { + "theme": { "type": "StringType" }, + "notifications": { "type": "BooleanType" } + } + } + """.trimIndent() + + val result = + SchemaBindingGenerator + .generateCode(schema, "RegistrationSchema", packageName) + + result shouldContain "object RegistrationSchema" + + // User nested object + result shouldContain "object user" + result shouldContain "val name: Binding = Binding(\"user.name\")" + result shouldContain "val email: Binding = Binding(\"user.email\")" + result shouldContain "val age: Binding = Binding(\"user.age\")" + result shouldContain "val verified: Binding = Binding(\"user.verified\")" + + // Items array + result shouldContain "object items" + result shouldContain "val id: Binding = Binding(\"items._current_.id\")" + result shouldContain "val quantity: Binding = Binding(\"items._current_.quantity\")" + + // Preferences record + result shouldContain "object preferences" + result shouldContain "val theme: Binding = Binding(\"preferences.theme\")" + result shouldContain "val notifications: Binding = Binding(\"preferences.notifications\")" + } + } + } + }) diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/TypeMapperTest.kt b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/TypeMapperTest.kt new file mode 100644 index 0000000..c3460e1 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/TypeMapperTest.kt @@ -0,0 +1,338 @@ +package com.intuit.playerui.lang.generator + +import com.intuit.playerui.xlr.AnyType +import com.intuit.playerui.xlr.ArrayType +import com.intuit.playerui.xlr.BooleanType +import com.intuit.playerui.xlr.NeverType +import com.intuit.playerui.xlr.NullType +import com.intuit.playerui.xlr.NumberType +import com.intuit.playerui.xlr.ObjectType +import com.intuit.playerui.xlr.OrType +import com.intuit.playerui.xlr.ParamTypeNode +import com.intuit.playerui.xlr.RecordType +import com.intuit.playerui.xlr.RefType +import com.intuit.playerui.xlr.StringType +import com.intuit.playerui.xlr.UndefinedType +import com.intuit.playerui.xlr.UnknownType +import com.intuit.playerui.xlr.VoidType +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe + +class TypeMapperTest : + DescribeSpec({ + + describe("TypeMapper") { + + describe("primitive types") { + it("maps StringType to String") { + val result = TypeMapper.mapToKotlinType(StringType()) + result.typeName shouldBe "String" + result.isNullable shouldBe true + } + + it("maps StringType with description") { + val result = TypeMapper.mapToKotlinType(StringType(description = "A test string")) + result.typeName shouldBe "String" + result.description shouldBe "A test string" + } + + it("maps NumberType to Number") { + val result = TypeMapper.mapToKotlinType(NumberType()) + result.typeName shouldBe "Number" + result.isNullable shouldBe true + } + + it("maps BooleanType to Boolean") { + val result = TypeMapper.mapToKotlinType(BooleanType()) + result.typeName shouldBe "Boolean" + result.isNullable shouldBe true + } + + it("maps NullType to Nothing?") { + val result = TypeMapper.mapToKotlinType(NullType()) + result.typeName shouldBe "Nothing?" + result.isNullable shouldBe true + } + + it("maps AnyType to Any?") { + val result = TypeMapper.mapToKotlinType(AnyType()) + result.typeName shouldBe "Any?" + result.isNullable shouldBe true + } + + it("maps UnknownType to Any?") { + val result = TypeMapper.mapToKotlinType(UnknownType()) + result.typeName shouldBe "Any?" + result.isNullable shouldBe true + } + + it("maps UndefinedType to Nothing?") { + val result = TypeMapper.mapToKotlinType(UndefinedType()) + result.typeName shouldBe "Nothing?" + result.isNullable shouldBe true + } + + it("maps VoidType to Unit") { + val result = TypeMapper.mapToKotlinType(VoidType()) + result.typeName shouldBe "Unit" + result.isNullable shouldBe false + } + + it("maps NeverType to Nothing") { + val result = TypeMapper.mapToKotlinType(NeverType()) + result.typeName shouldBe "Nothing" + result.isNullable shouldBe false + } + } + + describe("RefType mapping") { + it("maps AssetWrapper ref to FluentBuilder with isAssetWrapper flag") { + val result = TypeMapper.mapToKotlinType(RefType(ref = "AssetWrapper")) + result.typeName shouldBe "FluentBuilder<*>" + result.isAssetWrapper shouldBe true + result.builderType shouldBe "FluentBuilder<*>" + } + + it("maps Asset ref to FluentBuilder") { + val result = TypeMapper.mapToKotlinType(RefType(ref = "Asset")) + result.typeName shouldBe "FluentBuilder<*>" + result.builderType shouldBe "FluentBuilder<*>" + } + + it("maps plain Asset ref to FluentBuilder") { + val result = TypeMapper.mapToKotlinType(RefType(ref = "Asset")) + result.typeName shouldBe "FluentBuilder<*>" + result.builderType shouldBe "FluentBuilder<*>" + } + + it("maps Binding ref to Binding with isBinding flag") { + val result = TypeMapper.mapToKotlinType(RefType(ref = "Binding")) + result.typeName shouldBe "Binding<*>" + result.isBinding shouldBe true + } + + it("maps Binding ref to Binding with isBinding flag") { + val result = TypeMapper.mapToKotlinType(RefType(ref = "Binding")) + result.typeName shouldBe "Binding<*>" + result.isBinding shouldBe true + } + + it("maps Expression ref to TaggedValue with isExpression flag") { + val result = TypeMapper.mapToKotlinType(RefType(ref = "Expression")) + result.typeName shouldBe "TaggedValue<*>" + result.isExpression shouldBe true + } + + it("maps Expression ref to TaggedValue with isExpression flag") { + val result = TypeMapper.mapToKotlinType(RefType(ref = "Expression")) + result.typeName shouldBe "TaggedValue<*>" + result.isExpression shouldBe true + } + + it("maps unknown ref to Any?") { + val result = TypeMapper.mapToKotlinType(RefType(ref = "SomeUnknownType")) + result.typeName shouldBe "Any?" + result.isNullable shouldBe true + } + } + + describe("ArrayType mapping") { + it("maps array of strings to List") { + val result = TypeMapper.mapToKotlinType(ArrayType(elementType = StringType())) + result.typeName shouldBe "List" + result.isArray shouldBe true + result.elementType?.typeName shouldBe "String" + } + + it("maps array of numbers to List") { + val result = TypeMapper.mapToKotlinType(ArrayType(elementType = NumberType())) + result.typeName shouldBe "List" + result.isArray shouldBe true + } + + it("maps array of AssetWrapper to List>") { + val result = + TypeMapper.mapToKotlinType( + ArrayType(elementType = RefType(ref = "AssetWrapper")), + ) + result.typeName shouldBe "List>" + result.isArray shouldBe true + result.isAssetWrapper shouldBe true + result.elementType?.isAssetWrapper shouldBe true + } + + it("maps array of Asset to List>") { + val result = + TypeMapper.mapToKotlinType( + ArrayType(elementType = RefType(ref = "Asset")), + ) + result.typeName shouldBe "List>" + result.isArray shouldBe true + } + } + + describe("ObjectType mapping") { + it("maps object type to Map with isNestedObject flag") { + val result = TypeMapper.mapToKotlinType(ObjectType()) + result.typeName shouldBe "Map" + result.isNestedObject shouldBe true + } + } + + describe("OrType mapping") { + it("maps heterogeneous union to Any?") { + val result = + TypeMapper.mapToKotlinType( + OrType(orTypes = listOf(StringType(), NumberType())), + ) + result.typeName shouldBe "Any?" + result.isNullable shouldBe true + } + + it("collapses String | null to nullable String") { + val result = + TypeMapper.mapToKotlinType( + OrType(orTypes = listOf(StringType(), NullType())), + ) + result.typeName shouldBe "String" + result.isNullable.shouldBeTrue() + } + + it("collapses Number | undefined to nullable Number") { + val result = + TypeMapper.mapToKotlinType( + OrType(orTypes = listOf(NumberType(), UndefinedType())), + ) + result.typeName shouldBe "Number" + result.isNullable.shouldBeTrue() + } + + it("collapses Boolean | null | undefined to nullable Boolean") { + val result = + TypeMapper.mapToKotlinType( + OrType(orTypes = listOf(BooleanType(), NullType(), UndefinedType())), + ) + result.typeName shouldBe "Boolean" + result.isNullable.shouldBeTrue() + } + + it("maps literal string union to String") { + val result = + TypeMapper.mapToKotlinType( + OrType( + orTypes = + listOf( + StringType(const = "foo"), + StringType(const = "bar"), + StringType(const = "baz"), + ), + ), + ) + result.typeName shouldBe "String" + result.isNullable.shouldBeFalse() + } + + it("maps nullable literal string union to nullable String") { + val result = + TypeMapper.mapToKotlinType( + OrType( + orTypes = + listOf( + StringType(const = "foo"), + StringType(const = "bar"), + NullType(), + ), + ), + ) + result.typeName shouldBe "String" + result.isNullable.shouldBeTrue() + } + } + + describe("RecordType mapping") { + it("maps record type to Map") { + val result = + TypeMapper.mapToKotlinType( + RecordType(keyType = StringType(), valueType = NumberType()), + ) + result.typeName shouldBe "Map" + result.isNullable shouldBe true + } + + it("maps record with complex value type") { + val result = + TypeMapper.mapToKotlinType( + RecordType(keyType = StringType(), valueType = BooleanType()), + ) + result.typeName shouldBe "Map" + } + } + + describe("generic token resolution") { + it("resolves generic token with default type") { + val context = + TypeMapperContext( + genericTokens = + mapOf( + "T" to ParamTypeNode(symbol = "T", default = StringType()), + ), + ) + val result = TypeMapper.mapToKotlinType(RefType(ref = "T"), context) + result.typeName shouldBe "String" + } + + it("resolves generic token with constraint type when no default") { + val context = + TypeMapperContext( + genericTokens = + mapOf( + "T" to ParamTypeNode(symbol = "T", constraints = NumberType()), + ), + ) + val result = TypeMapper.mapToKotlinType(RefType(ref = "T"), context) + result.typeName shouldBe "Number" + } + } + + describe("utility functions") { + it("makeNullable adds ? to non-nullable type") { + TypeMapper.makeNullable("String") shouldBe "String?" + } + + it("makeNullable preserves already nullable type") { + TypeMapper.makeNullable("String?") shouldBe "String?" + } + + it("makeNonNullable removes ? from nullable type") { + TypeMapper.makeNonNullable("String?") shouldBe "String" + } + + it("makeNonNullable preserves non-nullable type") { + TypeMapper.makeNonNullable("String") shouldBe "String" + } + + it("toKotlinIdentifier replaces invalid characters") { + TypeMapper.toKotlinIdentifier("my-property") shouldBe "my_property" + TypeMapper.toKotlinIdentifier("my.property") shouldBe "my_property" + TypeMapper.toKotlinIdentifier("my property") shouldBe "my_property" + } + + it("toKotlinIdentifier prefixes digit-starting names") { + TypeMapper.toKotlinIdentifier("123name") shouldBe "_123name" + } + + it("toBuilderClassName converts asset type to builder class name") { + TypeMapper.toBuilderClassName("action") shouldBe "ActionBuilder" + TypeMapper.toBuilderClassName("text") shouldBe "TextBuilder" + } + + it("toDslFunctionName converts asset name to DSL function name") { + TypeMapper.toDslFunctionName("ActionAsset") shouldBe "action" + TypeMapper.toDslFunctionName("TextAsset") shouldBe "text" + TypeMapper.toDslFunctionName("SomeAsset") shouldBe "some" + } + } + } + }) diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/XlrDeserializerTest.kt b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/XlrDeserializerTest.kt new file mode 100644 index 0000000..bbeee29 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/XlrDeserializerTest.kt @@ -0,0 +1,233 @@ +package com.intuit.playerui.lang.generator + +import com.intuit.playerui.xlr.ArrayType +import com.intuit.playerui.xlr.BooleanType +import com.intuit.playerui.xlr.ObjectType +import com.intuit.playerui.xlr.OrType +import com.intuit.playerui.xlr.RecordType +import com.intuit.playerui.xlr.RefType +import com.intuit.playerui.xlr.StringType +import com.intuit.playerui.xlr.XlrDeserializer +import com.intuit.playerui.xlr.extractAssetTypeConstant +import com.intuit.playerui.xlr.isAssetWrapperRef +import com.intuit.playerui.xlr.isBindingRef +import com.intuit.playerui.xlr.isExpressionRef +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import java.io.File + +class XlrDeserializerTest : + DescribeSpec({ + describe("XlrDeserializer") { + describe("ActionAsset parsing") { + val json = loadFixture("ActionAsset.json") + val doc = XlrDeserializer.deserialize(json) + + it("should parse document name and source") { + doc.name shouldBe "ActionAsset" + doc.source.shouldNotBeNull() + } + + it("should parse extends clause") { + doc.objectType.extends.shouldNotBeNull() + doc.objectType.extends!!.ref shouldBe "Asset<\"action\">" + + val assetType = extractAssetTypeConstant(doc.objectType.extends) + assetType shouldBe "action" + } + + it("should parse generic tokens") { + doc.genericTokens.shouldNotBeNull() + doc.genericTokens!! shouldHaveSize 1 + doc.genericTokens!![0].symbol shouldBe "AnyTextAsset" + } + + it("should parse properties") { + doc.objectType.properties.keys shouldBe setOf("value", "label", "exp", "accessibility", "metaData") + } + + it("should parse string property") { + val valueProp = doc.objectType.properties["value"] + valueProp.shouldNotBeNull() + valueProp.required shouldBe false + valueProp.node.shouldBeInstanceOf() + + val stringNode = valueProp.node as StringType + stringNode.description shouldBe "The transition value of the action in the state machine" + } + + it("should parse AssetWrapper ref property") { + val labelProp = doc.objectType.properties["label"] + labelProp.shouldNotBeNull() + labelProp.node.shouldBeInstanceOf() + + val refNode = labelProp.node as RefType + refNode.ref shouldBe "AssetWrapper" + isAssetWrapperRef(refNode) shouldBe true + } + + it("should parse Expression ref property") { + val expProp = doc.objectType.properties["exp"] + expProp.shouldNotBeNull() + expProp.node.shouldBeInstanceOf() + + val refNode = expProp.node as RefType + refNode.ref shouldBe "Expression" + isExpressionRef(refNode) shouldBe true + } + + it("should parse nested object property") { + val metaDataProp = doc.objectType.properties["metaData"] + metaDataProp.shouldNotBeNull() + metaDataProp.node.shouldBeInstanceOf() + + val objectNode = metaDataProp.node as ObjectType + objectNode.properties.keys shouldBe setOf("beacon", "skipValidation", "role") + } + + it("should parse nested union type") { + val metaDataProp = doc.objectType.properties["metaData"] + val objectNode = metaDataProp!!.node as ObjectType + val beaconProp = objectNode.properties["beacon"] + + beaconProp.shouldNotBeNull() + beaconProp.node.shouldBeInstanceOf() + + val orNode = beaconProp.node as OrType + orNode.orTypes shouldHaveSize 2 + orNode.orTypes[0].shouldBeInstanceOf() + orNode.orTypes[1].shouldBeInstanceOf() + } + + it("should parse boolean property in nested object") { + val metaDataProp = doc.objectType.properties["metaData"] + val objectNode = metaDataProp!!.node as ObjectType + val skipValidationProp = objectNode.properties["skipValidation"] + + skipValidationProp.shouldNotBeNull() + skipValidationProp.node.shouldBeInstanceOf() + } + } + + describe("InputAsset parsing") { + val json = loadFixture("InputAsset.json") + val doc = XlrDeserializer.deserialize(json) + + it("should parse document name") { + doc.name shouldBe "InputAsset" + } + + it("should parse Binding ref property") { + val bindingProp = doc.objectType.properties["binding"] + bindingProp.shouldNotBeNull() + bindingProp.required shouldBe true + bindingProp.node.shouldBeInstanceOf() + + val refNode = bindingProp.node as RefType + isBindingRef(refNode) shouldBe true + } + } + + describe("CollectionAsset parsing") { + val json = loadFixture("CollectionAsset.json") + val doc = XlrDeserializer.deserialize(json) + + it("should parse document name") { + doc.name shouldBe "CollectionAsset" + } + + it("should parse array property") { + val valuesProp = doc.objectType.properties["values"] + valuesProp.shouldNotBeNull() + valuesProp.node.shouldBeInstanceOf() + + val arrayNode = valuesProp.node as ArrayType + arrayNode.elementType.shouldBeInstanceOf() + + val elementRef = arrayNode.elementType as RefType + isAssetWrapperRef(elementRef) shouldBe true + } + } + + describe("TextAsset parsing") { + val json = loadFixture("TextAsset.json") + val doc = XlrDeserializer.deserialize(json) + + it("should parse document name") { + doc.name shouldBe "TextAsset" + } + + it("should parse extends clause") { + doc.objectType.extends.shouldNotBeNull() + val assetType = extractAssetTypeConstant(doc.objectType.extends) + assetType shouldBe "text" + } + + it("should parse required value property") { + val valueProp = doc.objectType.properties["value"] + valueProp.shouldNotBeNull() + valueProp.required shouldBe true + } + } + + describe("type guards") { + it("should detect AssetWrapper refs") { + val ref = RefType(ref = "AssetWrapper") + isAssetWrapperRef(ref) shouldBe true + + val nonWrapper = RefType(ref = "Asset") + isAssetWrapperRef(nonWrapper) shouldBe false + } + + it("should detect Binding refs") { + val binding = RefType(ref = "Binding") + isBindingRef(binding) shouldBe true + + val bindingWithGeneric = RefType(ref = "Binding") + isBindingRef(bindingWithGeneric) shouldBe true + + val nonBinding = RefType(ref = "Expression") + isBindingRef(nonBinding) shouldBe false + } + + it("should detect Expression refs") { + val expr = RefType(ref = "Expression") + isExpressionRef(expr) shouldBe true + + val exprWithGeneric = RefType(ref = "Expression") + isExpressionRef(exprWithGeneric) shouldBe true + + val nonExpr = RefType(ref = "Binding") + isExpressionRef(nonExpr) shouldBe false + } + + it("should extract asset type constant") { + val extendsRef = + RefType( + ref = "Asset<\"action\">", + genericArguments = listOf(StringType(const = "action")), + ) + extractAssetTypeConstant(extendsRef) shouldBe "action" + + val noConst = RefType(ref = "Asset") + extractAssetTypeConstant(noConst).shouldBeNull() + } + } + } + }) + +private fun loadFixture(name: String): String { + val classLoader = XlrDeserializerTest::class.java.classLoader + val resource = classLoader.getResource("com/intuit/playerui/lang/generator/fixtures/$name") + return if (resource != null) { + resource.readText() + } else { + val file = + File("language/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/$name") + file.readText() + } +} diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ActionAsset.json b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ActionAsset.json new file mode 100644 index 0000000..635c978 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ActionAsset.json @@ -0,0 +1,126 @@ +{ + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/action/types.ts", + "name": "ActionAsset", + "type": "object", + "properties": { + "value": { + "required": false, + "node": { + "type": "string", + "title": "ActionAsset.value", + "description": "The transition value of the action in the state machine" + } + }, + "label": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "genericArguments": [ + { + "type": "ref", + "ref": "AnyTextAsset" + } + ], + "title": "ActionAsset.label", + "description": "A text-like asset for the action's label" + } + }, + "exp": { + "required": false, + "node": { + "type": "ref", + "ref": "Expression", + "title": "ActionAsset.exp", + "description": "An optional expression to execute before transitioning" + } + }, + "accessibility": { + "required": false, + "node": { + "type": "string", + "title": "ActionAsset.accessibility", + "description": "An optional string that describes the action for screen-readers" + } + }, + "metaData": { + "required": false, + "node": { + "type": "object", + "properties": { + "beacon": { + "required": false, + "node": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/node_modules/.aspect_rules_js/@player-ui+beacon-plugin@0.0.0/node_modules/@player-ui/beacon-plugin/types/beacon.d.ts", + "name": "BeaconDataType", + "type": "or", + "or": [ + { + "type": "string", + "title": "BeaconDataType" + }, + { + "type": "record", + "keyType": { + "type": "string" + }, + "valueType": { + "type": "any" + }, + "title": "BeaconDataType" + } + ], + "title": "ActionAsset.metaData.beacon", + "description": "Additional data to beacon" + } + }, + "skipValidation": { + "required": false, + "node": { + "type": "boolean", + "title": "ActionAsset.metaData.skipValidation", + "description": "Force transition to the next view without checking for validation" + } + }, + "role": { + "required": false, + "node": { + "type": "string", + "title": "ActionAsset.metaData.role", + "description": "string value to decide for the left anchor sign" + } + } + }, + "additionalProperties": false, + "title": "ActionAsset.metaData", + "description": "Additional optional data to assist with the action interactions on the page" + } + } + }, + "additionalProperties": false, + "title": "ActionAsset", + "description": "User actions can be represented in several places.\nEach view typically has one or more actions that allow the user to navigate away from that view.\nIn addition, several asset types can have actions that apply to that asset only.", + "genericTokens": [ + { + "symbol": "AnyTextAsset", + "constraints": { + "type": "ref", + "ref": "Asset" + }, + "default": { + "type": "ref", + "ref": "Asset" + } + } + ], + "extends": { + "type": "ref", + "ref": "Asset<\"action\">", + "genericArguments": [ + { + "type": "string", + "const": "action" + } + ] + } +} \ No newline at end of file diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ChoiceAsset.json b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ChoiceAsset.json new file mode 100644 index 0000000..ff8e2bc --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ChoiceAsset.json @@ -0,0 +1,191 @@ +{ + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/choice/types.ts", + "name": "ChoiceAsset", + "type": "object", + "properties": { + "title": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "genericArguments": [ + { + "type": "ref", + "ref": "AnyTextAsset" + } + ], + "title": "ChoiceAsset.title", + "description": "A text-like asset for the choice's label" + } + }, + "note": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "genericArguments": [ + { + "type": "ref", + "ref": "AnyTextAsset" + } + ], + "title": "ChoiceAsset.note", + "description": "Asset container for a note." + } + }, + "binding": { + "required": false, + "node": { + "type": "ref", + "ref": "Binding", + "title": "ChoiceAsset.binding", + "description": "The location in the data-model to store the data" + } + }, + "items": { + "required": false, + "node": { + "type": "array", + "elementType": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/choice/types.ts", + "name": "ChoiceItem", + "type": "object", + "properties": { + "id": { + "required": true, + "node": { + "type": "string", + "title": "ChoiceItem.id", + "description": "The id associated with the choice item" + } + }, + "label": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "genericArguments": [ + { + "type": "ref", + "ref": "AnyTextAsset" + } + ], + "title": "ChoiceItem.label", + "description": "A text-like asset for the choice's label" + } + }, + "value": { + "required": false, + "node": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/choice/types.ts", + "name": "ValueType", + "type": "or", + "or": [ + { + "type": "string", + "title": "ValueType" + }, + { + "type": "number", + "title": "ValueType" + }, + { + "type": "boolean", + "title": "ValueType" + }, + { + "type": "null" + } + ], + "title": "ChoiceItem.value", + "description": "The value of the input from the data-model" + } + } + }, + "additionalProperties": false, + "title": "ChoiceItem", + "genericTokens": [ + { + "symbol": "AnyTextAsset", + "constraints": { + "type": "ref", + "ref": "Asset" + }, + "default": { + "type": "ref", + "ref": "Asset" + } + } + ] + }, + "title": "ChoiceAsset.items", + "description": "The options to select from" + } + }, + "metaData": { + "required": false, + "node": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/node_modules/.aspect_rules_js/@player-ui+beacon-plugin@0.0.0/node_modules/@player-ui/beacon-plugin/types/beacon.d.ts", + "name": "BeaconMetaData", + "type": "object", + "properties": { + "beacon": { + "required": false, + "node": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/node_modules/.aspect_rules_js/@player-ui+beacon-plugin@0.0.0/node_modules/@player-ui/beacon-plugin/types/beacon.d.ts", + "name": "BeaconDataType", + "type": "or", + "or": [ + { + "type": "string", + "title": "BeaconDataType" + }, + { + "type": "record", + "keyType": { + "type": "string" + }, + "valueType": { + "type": "any" + }, + "title": "BeaconDataType" + } + ], + "title": "BeaconMetaData.beacon", + "description": "Additional data to send along with beacons" + } + } + }, + "additionalProperties": false, + "title": "ChoiceAsset.metaData", + "description": "Optional additional data" + } + } + }, + "additionalProperties": false, + "title": "ChoiceAsset", + "description": "A choice asset represents a single selection choice, often displayed as radio buttons in a web context.\nThis will allow users to test out more complex flows than just inputs + buttons.", + "genericTokens": [ + { + "symbol": "AnyTextAsset", + "constraints": { + "type": "ref", + "ref": "Asset" + }, + "default": { + "type": "ref", + "ref": "Asset" + } + } + ], + "extends": { + "type": "ref", + "ref": "Asset<\"choice\">", + "genericArguments": [ + { + "type": "string", + "const": "choice" + } + ] + } +} \ No newline at end of file diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/CollectionAsset.json b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/CollectionAsset.json new file mode 100644 index 0000000..2a9d491 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/CollectionAsset.json @@ -0,0 +1,40 @@ +{ + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/collection/types.ts", + "name": "CollectionAsset", + "type": "object", + "properties": { + "label": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "title": "CollectionAsset.label", + "description": "An optional label to title the collection" + } + }, + "values": { + "required": false, + "node": { + "type": "array", + "elementType": { + "type": "ref", + "ref": "AssetWrapper" + }, + "title": "CollectionAsset.values", + "description": "The string value to show" + } + } + }, + "additionalProperties": false, + "title": "CollectionAsset", + "extends": { + "type": "ref", + "ref": "Asset<\"collection\">", + "genericArguments": [ + { + "type": "string", + "const": "collection" + } + ] + } +} \ No newline at end of file diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ImageAsset.json b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ImageAsset.json new file mode 100644 index 0000000..2a6f364 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/ImageAsset.json @@ -0,0 +1,65 @@ +{ + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/image/types.ts", + "name": "ImageAsset", + "type": "object", + "properties": { + "metaData": { + "required": true, + "node": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/image/types.ts", + "name": "ImageMetaData", + "type": "object", + "properties": { + "ref": { + "required": true, + "node": { + "type": "string", + "title": "ImageMetaData.ref", + "description": "The location of the image to load" + } + }, + "accessibility": { + "required": false, + "node": { + "type": "string", + "title": "ImageMetaData.accessibility", + "description": "Used for accessibility support" + } + } + }, + "additionalProperties": false, + "title": "ImageAsset.metaData", + "description": "Reference to the image" + } + }, + "placeholder": { + "required": false, + "node": { + "type": "string", + "title": "ImageAsset.placeholder", + "description": "Optional placeholder text" + } + }, + "caption": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "title": "ImageAsset.caption", + "description": "Optional caption" + } + } + }, + "additionalProperties": false, + "title": "ImageAsset", + "extends": { + "type": "ref", + "ref": "Asset<\"image\">", + "genericArguments": [ + { + "type": "string", + "const": "image" + } + ] + } +} \ No newline at end of file diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/InfoAsset.json b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/InfoAsset.json new file mode 100644 index 0000000..4294969 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/InfoAsset.json @@ -0,0 +1,58 @@ +{ + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/info/types.ts", + "name": "InfoAsset", + "type": "object", + "properties": { + "title": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "title": "InfoAsset.title", + "description": "The string value to show" + } + }, + "subTitle": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "title": "InfoAsset.subTitle", + "description": "subtitle" + } + }, + "primaryInfo": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "title": "InfoAsset.primaryInfo", + "description": "Primary place for info" + } + }, + "actions": { + "required": false, + "node": { + "type": "array", + "elementType": { + "type": "ref", + "ref": "AssetWrapper" + }, + "title": "InfoAsset.actions", + "description": "List of actions to show at the bottom of the page" + } + } + }, + "additionalProperties": false, + "title": "InfoAsset", + "extends": { + "type": "ref", + "ref": "Asset<\"info\">", + "genericArguments": [ + { + "type": "string", + "const": "info" + } + ] + } +} \ No newline at end of file diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/InputAsset.json b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/InputAsset.json new file mode 100644 index 0000000..ae5a451 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/InputAsset.json @@ -0,0 +1,109 @@ +{ + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/input/types.ts", + "name": "InputAsset", + "type": "object", + "properties": { + "label": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "genericArguments": [ + { + "type": "ref", + "ref": "AnyTextAsset" + } + ], + "title": "InputAsset.label", + "description": "Asset container for a field label." + } + }, + "note": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper", + "genericArguments": [ + { + "type": "ref", + "ref": "AnyTextAsset" + } + ], + "title": "InputAsset.note", + "description": "Asset container for a note." + } + }, + "binding": { + "required": true, + "node": { + "type": "ref", + "ref": "Binding", + "title": "InputAsset.binding", + "description": "The location in the data-model to store the data" + } + }, + "metaData": { + "required": false, + "node": { + "type": "object", + "properties": { + "beacon": { + "required": false, + "node": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/node_modules/.aspect_rules_js/@player-ui+beacon-plugin@0.0.0/node_modules/@player-ui/beacon-plugin/types/beacon.d.ts", + "name": "BeaconDataType", + "type": "or", + "or": [ + { + "type": "string", + "title": "BeaconDataType" + }, + { + "type": "record", + "keyType": { + "type": "string" + }, + "valueType": { + "type": "any" + }, + "title": "BeaconDataType" + } + ], + "title": "InputAsset.metaData.beacon", + "description": "Additional data to beacon when this input changes" + } + } + }, + "additionalProperties": false, + "title": "InputAsset.metaData", + "description": "Optional additional data" + } + } + }, + "additionalProperties": false, + "title": "InputAsset", + "description": "This is the most generic way of gathering data. The input is bound to a data model using the 'binding' property.\nPlayers can get field type information from the 'schema' definition, thus to decide the input controls for visual rendering.", + "genericTokens": [ + { + "symbol": "AnyTextAsset", + "constraints": { + "type": "ref", + "ref": "Asset" + }, + "default": { + "type": "ref", + "ref": "Asset" + } + } + ], + "extends": { + "type": "ref", + "ref": "Asset<\"input\">", + "genericArguments": [ + { + "type": "string", + "const": "input" + } + ] + } +} \ No newline at end of file diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/TextAsset.json b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/TextAsset.json new file mode 100644 index 0000000..9dbf634 --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/fixtures/TextAsset.json @@ -0,0 +1,125 @@ +{ + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/text/types.ts", + "name": "TextAsset", + "type": "object", + "properties": { + "value": { + "required": true, + "node": { + "type": "string", + "title": "TextAsset.value", + "description": "The text to display" + } + }, + "modifiers": { + "required": false, + "node": { + "type": "array", + "elementType": { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/text/types.ts", + "name": "TextModifier", + "type": "or", + "or": [ + { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/text/types.ts", + "name": "BasicTextModifier", + "type": "object", + "properties": { + "type": { + "required": true, + "node": { + "type": "string", + "title": "BasicTextModifier.type", + "description": "The modifier type" + } + }, + "name": { + "required": false, + "node": { + "type": "string", + "title": "BasicTextModifier.name", + "description": "Modifiers can be named when used in strings" + } + } + }, + "additionalProperties": { + "type": "unknown" + }, + "title": "BasicTextModifier" + }, + { + "source": "/home/circleci/.cache/bazel/_bazel_circleci/e8362d362e14c7d23506d1dfa3aea8b8/sandbox/processwrapper-sandbox/3907/execroot/_main/bazel-out/k8-fastbuild/bin/plugins/reference-assets/core/src/assets/text/types.ts", + "name": "LinkModifier", + "type": "object", + "properties": { + "type": { + "required": true, + "node": { + "type": "string", + "const": "link", + "title": "LinkModifier.type", + "description": "The link type denotes this as a link" + } + }, + "exp": { + "required": false, + "node": { + "type": "ref", + "ref": "Expression", + "title": "LinkModifier.exp", + "description": "An optional expression to run before the link is opened" + } + }, + "metaData": { + "required": true, + "node": { + "type": "object", + "properties": { + "ref": { + "required": true, + "node": { + "type": "string", + "title": "LinkModifier.metaData.ref", + "description": "The location of the link to load" + } + }, + "\"mime-type\"": { + "required": false, + "node": { + "type": "string", + "title": "LinkModifier.metaData.\"mime-type\"", + "description": "Used to indicate an application specific resolver to use" + } + } + }, + "additionalProperties": false, + "title": "LinkModifier.metaData", + "description": "metaData about the link's target" + } + } + }, + "additionalProperties": false, + "title": "LinkModifier", + "description": "A modifier to turn the text into a link" + } + ], + "title": "TextModifier" + }, + "title": "TextAsset.modifiers", + "description": "Any modifiers on the text" + } + } + }, + "additionalProperties": false, + "title": "TextAsset", + "extends": { + "type": "ref", + "ref": "Asset<\"text\">", + "genericArguments": [ + { + "type": "string", + "const": "text" + } + ] + } +} \ No newline at end of file From a0a6971dd9be552dbc09495efbe30d262234d2b3 Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Fri, 27 Feb 2026 11:22:03 -0500 Subject: [PATCH 2/8] refactor: detekt --- BUILD.bazel | 1 + MODULE.bazel | 1 + MODULE.bazel.lock | 76 +++++++-- detekt.yml | 95 +++++++++++ dsl/kotlin/BUILD.bazel | 10 ++ .../lang/dsl/core/AuxiliaryStorage.kt | 2 +- .../playerui/lang/dsl/core/BuildPipeline.kt | 33 ++-- .../playerui/lang/dsl/id/IdGenerator.kt | 3 +- .../lang/dsl/schema/SchemaBindings.kt | 27 ++-- .../playerui/lang/dsl/tagged/TaggedValue.kt | 10 +- .../lang/dsl/BuildPipelineSwitchTest.kt | 147 ++++++++++++++++++ generators/kotlin/BUILD.bazel | 10 ++ .../playerui/lang/generator/ClassGenerator.kt | 12 +- .../intuit/playerui/lang/generator/Main.kt | 10 +- .../playerui/lang/generator/TypeMapper.kt | 7 +- justfile | 6 +- 16 files changed, 387 insertions(+), 63 deletions(-) create mode 100644 detekt.yml create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/BuildPipelineSwitchTest.kt diff --git a/BUILD.bazel b/BUILD.bazel index fc9114f..315d7b3 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -22,6 +22,7 @@ exports_files([ "README.md", "requirements.txt", ".pylintrc", + "detekt.yml", ]) js_library( diff --git a/MODULE.bazel b/MODULE.bazel index a6666e5..7caeb3c 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -68,6 +68,7 @@ use_repo(pip, "pypi") ####### Kotlin ######### bazel_dep(name = "rules_kotlin", version = "2.2.1") +bazel_dep(name = "rules_detekt", version = "0.8.1.13") bazel_dep(name = "rules_jvm_external", version = "6.9") maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index dd5d699..d2a004e 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -9,7 +9,10 @@ "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", - "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", + "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", + "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", + "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/source.json": "d725d73707d01bb46ab3ca59ba408b8e9bd336642ca77a2269d4bfb8bbfd413d", "https://bcr.bazel.build/modules/abseil-py/2.1.0/MODULE.bazel": "5ebe5bf853769c65707e5c28f216798f7a4b1042015e6a36e6d03094d94bec8a", "https://bcr.bazel.build/modules/abseil-py/2.1.0/source.json": "0e8fc4f088ce07099c1cd6594c20c7ddbb48b4b3c0849b7d94ba94be88ff042b", "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", @@ -49,7 +52,8 @@ "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", - "https://bcr.bazel.build/modules/bazel_features/1.30.0/source.json": "b07e17f067fe4f69f90b03b36ef1e08fe0d1f3cac254c1241a1818773e3423bc", + "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", + "https://bcr.bazel.build/modules/bazel_features/1.33.0/source.json": "13617db3930328c2cd2807a0f13d52ca870ac05f96db9668655113265147b2a6", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", @@ -69,12 +73,14 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a", "https://bcr.bazel.build/modules/bazel_worker_api/0.0.1/MODULE.bazel": "02a13b77321773b2042e70ee5e4c5e099c8ddee4cf2da9cd420442c36938d4bd", + "https://bcr.bazel.build/modules/bazel_worker_api/0.0.10/MODULE.bazel": "a426df551b40c3997c351d05f00f0d1f86b618ddb646012f1e9c72efce8ed939", + "https://bcr.bazel.build/modules/bazel_worker_api/0.0.10/source.json": "7f220d3edfeba5d1f61535fd8400338df74f8bdeb241ebe660534c6926a5a645", "https://bcr.bazel.build/modules/bazel_worker_api/0.0.4/MODULE.bazel": "460aa12d01231a80cce03c548287b433b321d205b0028ae596728c35e5ee442e", "https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6", - "https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273", + "https://bcr.bazel.build/modules/bazel_worker_java/0.0.10/MODULE.bazel": "538f21f715cab81c4a43f73e1a91a0c1f8accd9b263dd2a831952805fcfc1a62", + "https://bcr.bazel.build/modules/bazel_worker_java/0.0.10/source.json": "1646d3aaf5a4bc0d587ecb33d81e93ea9284ff96f5ef579006a9c054945bbe7a", "https://bcr.bazel.build/modules/bazel_worker_java/0.0.4/MODULE.bazel": "82494a01018bb7ef06d4a17ec4cd7a758721f10eb8b6c820a818e70d669500db", "https://bcr.bazel.build/modules/bazel_worker_java/0.0.6/MODULE.bazel": "bff16868f96a646b37e7f78fc09fb0f6d6d4ae11c0ffd082825bd24701746f73", - "https://bcr.bazel.build/modules/bazel_worker_java/0.0.6/source.json": "c4719e1f11a6f0209005a1a54aae358d47b6f62ad36ca3a31b10ef313731dd31", "https://bcr.bazel.build/modules/buildifier_prebuilt/6.0.0.1/MODULE.bazel": "5d23708e6a5527ab4f151da7accabc22808cb5fb579c8cc4cd4a292da57a5c97", "https://bcr.bazel.build/modules/buildifier_prebuilt/6.0.0.1/source.json": "57bc8b05bd4bb2736efe1b41f9f4bf551cdced8314e8d20420b8a0e5a0751b30", "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", @@ -91,12 +97,15 @@ "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", - "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4", "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", + "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", + "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", - "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", "https://bcr.bazel.build/modules/nlohmann_json/3.12.0.bcr.1/MODULE.bazel": "a1c8bb07b5b91d971727c635f449d05623ac9608f6fe4f5f04254ea12f08e349", "https://bcr.bazel.build/modules/nlohmann_json/3.12.0.bcr.1/source.json": "93f82a5ae985eb935c539bfee95e04767187818189241ac956f3ccadbdb8fb02", @@ -122,19 +131,25 @@ "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", + "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", "https://bcr.bazel.build/modules/protobuf/29.3/MODULE.bazel": "77480eea5fb5541903e49683f24dc3e09f4a79e0eea247414887bb9fc0066e94", - "https://bcr.bazel.build/modules/protobuf/29.3/source.json": "c460e6550ddd24996232c7542ebf201f73c4e01d2183a31a041035fb50f19681", "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", "https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573", "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858", + "https://bcr.bazel.build/modules/protobuf/33.1/MODULE.bazel": "982c8a0cceab4d790076f72b7677faf836b0dfadc2b66a34aab7232116c4ae39", + "https://bcr.bazel.build/modules/protobuf/33.1/source.json": "992c237a40899425648213bf79b05f08c6e8dcd619f96cd944b4511b0276fbd8", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", - "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", - "https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4", + "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", "https://bcr.bazel.build/modules/rules_android/0.6.4/MODULE.bazel": "b4cde12d506dd65d82b2be39761f49f5797303343a3d5b4ee191c0cdf9ef387c", "https://bcr.bazel.build/modules/rules_android/0.6.5/MODULE.bazel": "6a44151a40914578679754a4f4b1fdc80d3e1cc674626524f0e6037a9d6c775f", "https://bcr.bazel.build/modules/rules_android/0.6.5/source.json": "c45d1b4a54d165c16089b1fd90aecadd21a2400f0eca149359a41919da6830ed", + "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", "https://bcr.bazel.build/modules/rules_apple/4.3.3/MODULE.bazel": "c5c2c4adeeac5f3f2f9b7f16abfa8be7ffefa596171d0d92bed4cae9ade0a498", "https://bcr.bazel.build/modules/rules_apple/4.3.3/source.json": "3cb1d69c8243ffcc42ecbf84ae8b9cccd7b1e2f091b0aee5a3e9c9a45267f312", "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", @@ -153,9 +168,10 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", + "https://bcr.bazel.build/modules/rules_detekt/0.8.1.13/MODULE.bazel": "d1807e63bf94d3c3f8d0ab475437e4dede1e3d0c9e166e4556a94cf2a1be7f17", + "https://bcr.bazel.build/modules/rules_detekt/0.8.1.13/source.json": "48340024890d74f72ed0d8084d6fa5a23de259423ca4ab509dafa48dfb37191e", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", - "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8", "https://bcr.bazel.build/modules/rules_go/0.42.0/MODULE.bazel": "8cfa875b9aa8c6fce2b2e5925e73c1388173ea3c32a0db4d2b4804b453c14270", "https://bcr.bazel.build/modules/rules_go/0.46.0/MODULE.bazel": "3477df8bdcc49e698b9d25f734c4f3a9f5931ff34ee48a2c662be168f5f2d3fd", @@ -164,6 +180,7 @@ "https://bcr.bazel.build/modules/rules_go/0.51.0-rc2/source.json": "6b5cd0b3da2bd0e6949580851db990a04af0a285f072b9a0f059424457cd8cc9", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", + "https://bcr.bazel.build/modules/rules_java/5.5.0/MODULE.bazel": "486ad1aa15cdc881af632b4b1448b0136c76025a1fe1ad1b65c5899376b83a50", "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963", "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", @@ -177,10 +194,12 @@ "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", "https://bcr.bazel.build/modules/rules_java/8.13.0/MODULE.bazel": "0444ebf737d144cf2bb2ccb368e7f1cce735264285f2a3711785827c1686625e", "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", - "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", + "https://bcr.bazel.build/modules/rules_java/8.15.2/MODULE.bazel": "5cc6698c822b2f9ef90ca5558599851bed8c3b13f1f8eb140d9bfec638d2acb4", + "https://bcr.bazel.build/modules/rules_java/8.15.2/source.json": "352984cbe6d32fac3bf76449e581ed5bcd54a2da2137fca1559aaf04756b7bfa", "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", "https://bcr.bazel.build/modules/rules_java/8.6.0/MODULE.bazel": "9c064c434606d75a086f15ade5edb514308cccd1544c2b2a89bbac4310e41c71", + "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", "https://bcr.bazel.build/modules/rules_java/8.6.3/MODULE.bazel": "e90505b7a931d194245ffcfb6ff4ca8ef9d46b4e830d12e64817752e0198e2ed", "https://bcr.bazel.build/modules/rules_java/8.9.0/MODULE.bazel": "e17c876cb53dcd817b7b7f0d2985b710610169729e8c371b2221cacdcd3dce4a", "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", @@ -212,24 +231,29 @@ "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", "https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453", "https://bcr.bazel.build/modules/rules_pkg/1.1.0/source.json": "fef768df13a92ce6067e1cd0cdc47560dace01354f1d921cfb1d632511f7d608", + "https://bcr.bazel.build/modules/rules_pmd/0.4.0/MODULE.bazel": "2ce687fc15a244e4404f497be89a230a0de1742c41012530977aaeb693003d10", + "https://bcr.bazel.build/modules/rules_pmd/0.4.0/source.json": "ca481c0e8862f6e5e6217ab58ec427a620ab47035bab6b1a4cc7fd0ab6cdef62", "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", "https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f", "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", - "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", "https://bcr.bazel.build/modules/rules_python/0.37.1/MODULE.bazel": "3faeb2d9fa0a81f8980643ee33f212308f4d93eea4b9ce6f36d0b742e71e9500", "https://bcr.bazel.build/modules/rules_python/0.37.2/MODULE.bazel": "b5ffde91410745750b6c13be1c5dc4555ef5bc50562af4a89fd77807fdde626a", "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", "https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43", "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", + "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", "https://bcr.bazel.build/modules/rules_python/1.6.1/MODULE.bazel": "0dd0dd858e4480a7dc0cecb21d2131a476cdd520bdb42d9fae64a50965a50082", "https://bcr.bazel.build/modules/rules_python/1.6.1/source.json": "ef9a16eb730d643123689686b00bc5fd65d33f17061e7e9ac313a946acb33dea", "https://bcr.bazel.build/modules/rules_robolectric/4.14.1.2/MODULE.bazel": "d44fec647d0aeb67b9f3b980cf68ba634976f3ae7ccd6c07d790b59b87a4f251", @@ -240,6 +264,7 @@ "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", "https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a", "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", + "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", "https://bcr.bazel.build/modules/rules_swift/3.4.1/MODULE.bazel": "c53b33c3f9db4e9cfe1b41ab12e909d62af1eeb9d15e4c0bfe0f39168c80ba44", "https://bcr.bazel.build/modules/rules_swift/3.4.1/source.json": "f9dd7a5f18662c0762452c5a3267f570339a9661fcc325e9b50e6c7bd49ff4c1", @@ -436,6 +461,33 @@ "recordedRepoMappingEntries": [] } }, + "@@rules_detekt+//detekt:extensions.bzl%detekt": { + "general": { + "bzlTransitiveDigest": "+OuFn2ZT61Z6ezaVBJShaz969zy9EqR9H4yrU5h4NF0=", + "usagesDigest": "ijL3kJiGAQ3pUg2VpUJFTr5/8GI6sV/q4KZSOufqDbw=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "detekt_cli_all": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", + "attributes": { + "sha256": "2ce2ff952e150baf28a29cda70a363b0340b3e81a55f43e51ec5edffc3d066c1", + "urls": [ + "https://github.com/detekt/detekt/releases/download/v1.23.8/detekt-cli-1.23.8-all.jar" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_detekt+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "NwcLXHrbh2hoorA/Ybmcpjxsn/6avQmewDglodkDrgo=", diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 0000000..f7ed2ee --- /dev/null +++ b/detekt.yml @@ -0,0 +1,95 @@ +# Detekt configuration for Player Language Kotlin modules. +# Used with build_upon_default_config = True, so only overrides are needed. +# ktlint handles naming, comments, and formatting — those rulesets are disabled here. + +complexity: + active: true + CyclomaticComplexMethod: + active: true + threshold: 25 + LongMethod: + active: true + threshold: 80 + LongParameterList: + active: true + functionThreshold: 8 + constructorThreshold: 10 + TooManyFunctions: + active: true + thresholdInFiles: 40 + thresholdInClasses: 30 + thresholdInInterfaces: 15 + thresholdInObjects: 20 + NestedBlockDepth: + active: true + threshold: 6 + NestedScopeFunctions: + active: true + threshold: 3 + +coroutines: + active: true + +empty-blocks: + active: true + +exceptions: + active: true + +naming: + active: false + +comments: + active: false + +potential-bugs: + active: true + +style: + active: true + ForbiddenComment: + active: false + MagicNumber: + active: false + MaxLineLength: + active: false + NewLineAtEndOfFile: + active: false + NoTabs: + active: false + OptionalAbstractKeyword: + active: false + BracesOnWhenStatements: + active: false + ReturnCount: + active: false + ThrowsCount: + active: false + TrailingWhitespace: + active: false + WildcardImport: + active: false + UnusedImports: + active: true + UnusedParameter: + active: true + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + UnusedPrivateProperty: + active: true + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: true + UnnecessaryApply: + active: true + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: true diff --git a/dsl/kotlin/BUILD.bazel b/dsl/kotlin/BUILD.bazel index 04a72bf..c8f534f 100644 --- a/dsl/kotlin/BUILD.bazel +++ b/dsl/kotlin/BUILD.bazel @@ -2,6 +2,7 @@ load("@rules_player//kotlin:defs.bzl", "kt_jvm") load("@rules_kotlin//kotlin:core.bzl", "kt_compiler_plugin") load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library") load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config") +load("@rules_detekt//detekt:defs.bzl", "detekt_test") load("@build_constants//:constants.bzl", "GROUP", "VERSION") package(default_visibility = ["//visibility:public"]) @@ -26,6 +27,15 @@ kt_jvm_library( exports = ["@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json"], ) +detekt_test( + name = "detekt", + srcs = glob(["src/main/kotlin/**/*.kt", "src/test/kotlin/**/*.kt"]), + cfgs = ["//:detekt.yml"], + build_upon_default_config = True, + html_report = True, + tags = ["manual"], +) + kt_jvm( name = "kotlin-dsl", group = GROUP, diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AuxiliaryStorage.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AuxiliaryStorage.kt index a763a33..b244b85 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AuxiliaryStorage.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/AuxiliaryStorage.kt @@ -60,7 +60,7 @@ class AuxiliaryStorage { * Gets a list by typed list key, returning empty list if not found. */ @Suppress("UNCHECKED_CAST") - fun getList(key: TypedListKey): List = (data[key.name] as? List) ?: emptyList() + fun getList(key: TypedListKey): List = data[key.name] as? List ?: emptyList() /** * Checks if a typed key exists. diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt index ae9d945..cba8388 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt @@ -296,8 +296,19 @@ object BuildPipeline { ) } + // If this property is an array type (e.g., actions: Array>), + // wrap the switch result in an array to match the expected schema type. + // Only wrap if we're replacing the entire property (path.size == 1), + // not a specific element in the array (path.size > 1) + val propertyName = path.firstOrNull()?.toString() ?: "" + var switchResult: Any = mapOf(switchKey to resolvedCases) + + if (propertyName in arrayProperties && path.size == 1) { + switchResult = listOf(switchResult) + } + // Inject switch at the specified path - injectAtPath(result, path, mapOf(switchKey to resolvedCases)) + injectAtPath(result, path, switchResult) } } @@ -438,25 +449,15 @@ object BuildPipeline { val existing = map[segment] if (existing is Map<*, *> && value is Map<*, *>) { @Suppress("UNCHECKED_CAST") - map[segment] = (existing as Map) + (value as Map) + val existingMap = existing as Map + + @Suppress("UNCHECKED_CAST") + val valueMap = value as Map + map[segment] = existingMap + valueMap } else { map[segment] = value } } - - current is MutableList<*> && segment is Int -> { - @Suppress("UNCHECKED_CAST") - val list = current as MutableList - if (segment < list.size) { - val existing = list[segment] - if (existing is Map<*, *> && value is Map<*, *>) { - @Suppress("UNCHECKED_CAST") - list[segment] = (existing as Map) + (value as Map) - } else { - list[segment] = value - } - } - } } } else { current = diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdGenerator.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdGenerator.kt index db5122f..b90dd78 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdGenerator.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdGenerator.kt @@ -31,9 +31,8 @@ fun peekId(context: BuildContext): String = generateBaseId(context) */ private fun generateBaseId(context: BuildContext): String { val parentId = context.parentId - val branch = context.branch - return when (branch) { + return when (val branch = context.branch) { null -> { parentId } diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaBindings.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaBindings.kt index 487df42..6d10ef2 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaBindings.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaBindings.kt @@ -112,24 +112,12 @@ object SchemaBindingsExtractor { return SchemaBindings(mapOf(propName to createBinding(typeName, arrayPath))) } - val typeNode = schema[typeName] - if (typeNode != null) { - val newVisited = HashSet(visited) - newVisited.add(typeName) - return processNode(typeNode, schema, arrayPath, newVisited) - } - return createBinding("StringType", arrayPath) + return resolveComplexType(typeName, schema, arrayPath, visited) } // Handle records if (dataType.isRecord == true) { - val typeNode = schema[typeName] - if (typeNode != null) { - val newVisited = HashSet(visited) - newVisited.add(typeName) - return processNode(typeNode, schema, path, newVisited) - } - return createBinding("StringType", path) + return resolveComplexType(typeName, schema, path, visited) } // Handle primitives @@ -138,14 +126,21 @@ object SchemaBindingsExtractor { } // Handle complex types (look up type definition in schema) + return resolveComplexType(typeName, schema, path, visited) + } + + private fun resolveComplexType( + typeName: String, + schema: Schema, + path: String, + visited: MutableSet, + ): Any { val typeNode = schema[typeName] if (typeNode != null) { val newVisited = HashSet(visited) newVisited.add(typeName) return processNode(typeNode, schema, path, newVisited) } - - // Fallback: unknown type, treat as string binding return createBinding("StringType", path) } diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt index 2367b6c..19a6b42 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt @@ -105,16 +105,14 @@ class Expression( ')' -> { openParens-- - if (openParens < 0) { - throw IllegalArgumentException( - "Unexpected ) at character $index in expression: $expression", - ) + require(openParens >= 0) { + "Unexpected ) at character $index in expression: $expression" } } } } - if (openParens > 0) { - throw IllegalArgumentException("Expected ) in expression: $expression") + require(openParens <= 0) { + "Expected ) in expression: $expression" } } } diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/BuildPipelineSwitchTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/BuildPipelineSwitchTest.kt new file mode 100644 index 0000000..0f469a1 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/BuildPipelineSwitchTest.kt @@ -0,0 +1,147 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.core.AuxiliaryStorage +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.BuildPipeline +import com.intuit.playerui.lang.dsl.core.SwitchArgs +import com.intuit.playerui.lang.dsl.core.SwitchCase +import com.intuit.playerui.lang.dsl.core.SwitchCondition +import com.intuit.playerui.lang.dsl.core.SwitchMetadata +import com.intuit.playerui.lang.dsl.core.ValueStorage +import com.intuit.playerui.lang.dsl.id.IdRegistry +import com.intuit.playerui.lang.dsl.mocks.builders.* +import com.intuit.playerui.lang.dsl.tagged.expression +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class BuildPipelineSwitchTest : + DescribeSpec({ + + fun registry() = IdRegistry() + + describe("Switch array property wrapping") { + it("wraps switch result in array for array properties at path depth 1") { + val reg = registry() + + val result = + collection { + id = "test-collection" + switch( + path = listOf("values"), + isDynamic = false, + ) { + case(expression("showItems"), text { value = "Items" }) + default(text { value = "No Items" }) + } + }.build(BuildContext(parentId = "flow-views-0", idRegistry = reg)) + + // "values" is in arrayProperties for CollectionBuilder, + // so the switch result should be wrapped in an array + val values = result["values"] + values.shouldBeInstanceOf>() + (values as List<*>).size shouldBe 1 + + val switchWrapper = values[0] as Map + switchWrapper.containsKey("staticSwitch") shouldBe true + + val staticSwitch = switchWrapper["staticSwitch"] as List> + staticSwitch.size shouldBe 2 + } + + it("does not wrap switch result for non-array properties") { + val reg = registry() + + val result = + collection { + id = "test-collection" + switch( + path = listOf("label"), + isDynamic = false, + ) { + case(expression("showWelcome"), text { value = "Welcome" }) + default(text { value = "Goodbye" }) + } + }.build(BuildContext(parentId = "flow-views-0", idRegistry = reg)) + + // "label" is NOT in arrayProperties, so the switch result should not be wrapped + val label = result["label"] + label.shouldBeInstanceOf>() + (label as Map).containsKey("staticSwitch") shouldBe true + } + + it("wraps switch in array for actions array property") { + val reg = registry() + + val result = + collection { + id = "test-collection" + switch( + path = listOf("actions"), + isDynamic = true, + ) { + case(expression("canSubmit"), action { value = "submit" }) + default(action { value = "cancel" }) + } + }.build(BuildContext(parentId = "flow-views-0", idRegistry = reg)) + + // "actions" is in arrayProperties for CollectionBuilder + val actions = result["actions"] + actions.shouldBeInstanceOf>() + (actions as List<*>).size shouldBe 1 + + val switchWrapper = actions[0] as Map + switchWrapper.containsKey("dynamicSwitch") shouldBe true + + val dynamicSwitch = switchWrapper["dynamicSwitch"] as List> + dynamicSwitch.size shouldBe 2 + } + + it("does not wrap switch result when path depth is greater than 1") { + val reg = registry() + val storage = ValueStorage() + val auxiliary = AuxiliaryStorage() + + // Set up a map value at "values" so injectAtPath can navigate into it + storage["values"] = mapOf("nested" to "placeholder") + + // Add a switch targeting a deep path into an array property + auxiliary.push( + AuxiliaryStorage.SWITCHES, + SwitchMetadata( + path = listOf("values", "nested"), + args = + SwitchArgs( + cases = + listOf( + SwitchCase( + condition = SwitchCondition.Static(true), + asset = text { value = "Deep Value" }, + ), + ), + isDynamic = false, + ), + ), + ) + + val result = + BuildPipeline.execute( + storage = storage, + auxiliary = auxiliary, + defaults = emptyMap(), + context = BuildContext(parentId = "test", idRegistry = reg), + arrayProperties = setOf("values"), + assetWrapperProperties = emptySet(), + ) + + // "values" is an array property, but path.size > 1, so the switch + // should NOT be wrapped in an array — it targets a specific nested key + val values = result["values"] as Map + val nested = values["nested"] + nested.shouldBeInstanceOf>() + (nested as Map).containsKey("staticSwitch") shouldBe true + } + } + }) diff --git a/generators/kotlin/BUILD.bazel b/generators/kotlin/BUILD.bazel index 9281005..82166a5 100644 --- a/generators/kotlin/BUILD.bazel +++ b/generators/kotlin/BUILD.bazel @@ -1,5 +1,6 @@ load("@rules_player//kotlin:defs.bzl", "kt_jvm") load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config") +load("@rules_detekt//detekt:defs.bzl", "detekt_test") load("@build_constants//:constants.bzl", "GROUP", "VERSION") package(default_visibility = ["//visibility:public"]) @@ -9,6 +10,15 @@ ktlint_config( editorconfig = ".editorconfig", ) +detekt_test( + name = "detekt", + srcs = glob(["src/main/kotlin/**/*.kt", "src/test/kotlin/**/*.kt"]), + cfgs = ["//:detekt.yml"], + build_upon_default_config = True, + html_report = True, + tags = ["manual"], +) + kt_jvm( name = "kotlin-dsl-generator", group = GROUP, diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt index a55f4e2..56172aa 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt @@ -670,8 +670,16 @@ class ClassGenerator( private fun buildSetInitializer(names: List): CodeBlock { if (names.isEmpty()) return CodeBlock.of("emptySet()") - val format = names.joinToString(", ") { "%S" } - return CodeBlock.of("setOf($format)", *names.toTypedArray()) + return CodeBlock + .builder() + .apply { + add("setOf(") + names.forEachIndexed { index, name -> + if (index > 0) add(", ") + add("%S", name) + } + add(")") + }.build() } companion object { diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt index f7956cc..cdaff34 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt @@ -89,7 +89,10 @@ private fun generateSchemaBindings(parsedArgs: ParsedArgs) { println(" Generated: ${result.className} -> ${outputFile.absolutePath}") println() println("Generation complete: 1 succeeded, 0 failed") - } catch (e: Exception) { + } catch (e: java.io.IOException) { + System.err.println(" Error processing ${schemaFile.name}: ${e.message}") + exitProcess(1) + } catch (e: IllegalArgumentException) { System.err.println(" Error processing ${schemaFile.name}: ${e.message}") exitProcess(1) } @@ -123,7 +126,10 @@ private fun generateAssetBuilders(parsedArgs: ParsedArgs) { val result = generator.generateFromFile(file) println(" Generated: ${result.className} -> ${result.filePath.absolutePath}") successCount++ - } catch (e: Exception) { + } catch (e: java.io.IOException) { + System.err.println(" Error processing ${file.name}: ${e.message}") + errorCount++ + } catch (e: IllegalArgumentException) { System.err.println(" Error processing ${file.name}: ${e.message}") errorCount++ } diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt index 6d782fa..5270969 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt @@ -72,7 +72,7 @@ object TypeMapper { is VoidType -> KotlinTypeInfo("Unit", isNullable = false) is NeverType -> KotlinTypeInfo("Nothing", isNullable = false) is RefType -> mapRefType(node, context) - is ObjectType -> mapObjectType(node, context) + is ObjectType -> mapObjectType(node) is ArrayType -> mapArrayType(node, context) is OrType -> mapOrType(node, context) is RecordType -> mapRecordType(node, context) @@ -150,10 +150,7 @@ object TypeMapper { ) } - private fun mapObjectType( - node: ObjectType, - context: TypeMapperContext, - ): KotlinTypeInfo { + private fun mapObjectType(node: ObjectType): KotlinTypeInfo { // Inline objects become nested classes return KotlinTypeInfo( typeName = "Map", diff --git a/justfile b/justfile index d3926b0..a0acf65 100644 --- a/justfile +++ b/justfile @@ -12,4 +12,8 @@ test-py: [doc('Lint all PY Files')] lint-py: - bazel test -- $(bazel query "kind(py_test, //...) intersect attr(name, '_lint$', //...)" --output label 2>/dev/null | tr '\n' ' ') \ No newline at end of file + bazel test -- $(bazel query "kind(py_test, //...) intersect attr(name, '_lint$', //...)" --output label 2>/dev/null | tr '\n' ' ') + +[doc('Run detekt static analysis on Kotlin modules')] +detekt: + bazel test -- $(bazel query "attr(name, 'detekt', //...)" --output label 2>/dev/null | tr '\n' ' ') \ No newline at end of file From b9d2dbf24d3c4cbc015cc7c576688cfd293c341a Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Wed, 4 Mar 2026 09:19:57 -0500 Subject: [PATCH 3/8] feat: id gen --- .../playerui/lang/dsl/core/BuildPipeline.kt | 37 ++++++++++++++----- .../com/intuit/playerui/lang/dsl/FlowTest.kt | 8 ++-- .../lang/dsl/FluentBuilderBaseTest.kt | 2 +- .../playerui/lang/dsl/IntegrationTest.kt | 12 +++--- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt index cba8388..e44ad7f 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt @@ -2,6 +2,7 @@ package com.intuit.playerui.lang.dsl.core import com.intuit.playerui.lang.dsl.id.determineSlotName import com.intuit.playerui.lang.dsl.id.genId +import com.intuit.playerui.lang.dsl.id.peekId import com.intuit.playerui.lang.dsl.tagged.TaggedValue /** @@ -95,8 +96,29 @@ object BuildPipeline { if (result["id"] != null) return if (context == null) return - // Generate ID from context - val generatedId = genId(context) + val type = result["type"] as? String + val binding = result["binding"] as? String + val value = result["value"] as? String + val assetMetadata = if (type != null) AssetMetadata(type, binding, value) else null + val parameterName = type ?: "asset" + val slotName = determineSlotName(parameterName, assetMetadata) + + val generatedId = + when { + // Has branch (e.g., ArrayItem or Slot from parent context): + // First resolve the branch, then append the asset type + context.branch != null -> { + val baseId = genId(context) + genId(context.copy(parentId = baseId, branch = IdBranch.Slot(slotName))) + } + // Has parentId but no branch: append type as a Slot + context.parentId.isNotEmpty() -> { + genId(context.copy(branch = IdBranch.Slot(slotName))) + } + // Fallback + else -> slotName + } + if (generatedId.isNotEmpty()) { result["id"] = generatedId } @@ -374,13 +396,9 @@ object BuildPipeline { ): BuildContext? { if (context == null) return null - val metadata = extractAssetMetadata(builder) - val slotName = determineSlotName(key, metadata) - return context - .withBranch(IdBranch.Slot(slotName)) + .withBranch(IdBranch.Slot(key)) .withParameterName(key) - .withAssetMetadata(metadata) } /** @@ -394,13 +412,14 @@ object BuildPipeline { ): BuildContext? { if (context == null) return null - val metadata = extractAssetMetadata(builder) + // Use peekId (not genId) to avoid registering intermediate ID for each array item + val intermediateId = peekId(context.withBranch(IdBranch.Slot(key))) return context + .withParentId(intermediateId) .withBranch(IdBranch.ArrayItem(index)) .withParameterName(key) .withIndex(index) - .withAssetMetadata(metadata) } /** diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FlowTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FlowTest.kt index d470cde..a09af67 100644 --- a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FlowTest.kt +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FlowTest.kt @@ -77,13 +77,13 @@ class FlowTest : views.size shouldBe 1 val firstView = views[0] - firstView["id"] shouldBe "registration-views-0" + firstView["id"] shouldBe "registration-views-0-collection" firstView["type"] shouldBe "collection" val values = firstView["values"] as List> values.size shouldBe 2 - values[0]["id"] shouldBe "registration-views-0-0" - values[1]["id"] shouldBe "registration-views-0-1" + values[0]["id"] shouldBe "registration-views-0-collection-values-0-input-firstName" + values[1]["id"] shouldBe "registration-views-0-collection-values-1-input-lastName" } it("includes data when provided") { @@ -207,7 +207,7 @@ class FlowTest : // Verify the collection structure formView["type"] shouldBe "collection" - formView["id"] shouldBe "complex-flow-views-0" + formView["id"] shouldBe "complex-flow-views-0-collection" // Verify nested label is wrapped val label = formView["label"] as Map diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt index 2792018..c1b1ae4 100644 --- a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt @@ -63,7 +63,7 @@ class FluentBuilderBaseTest : value = "Label Text" }.build(ctx) - result["id"] shouldBe "parent-label" + result["id"] shouldBe "parent-label-text" } } diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt index b54f930..7b4c19c 100644 --- a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt @@ -299,21 +299,21 @@ class IntegrationTest : }.build(ctx) // Outer collection gets ID from array index branch - result["id"] shouldBe "my-flow-views-0" + result["id"] shouldBe "my-flow-views-0-collection" val outerValues = result["values"] as List> // Inner collections get sequential IDs based on their array index - outerValues[0]["id"] shouldBe "my-flow-views-0-0" - outerValues[1]["id"] shouldBe "my-flow-views-0-1" + outerValues[0]["id"] shouldBe "my-flow-views-0-collection-values-0-collection" + outerValues[1]["id"] shouldBe "my-flow-views-0-collection-values-1-collection" // Deep nested texts val inner1Values = outerValues[0]["values"] as List> - inner1Values[0]["id"] shouldBe "my-flow-views-0-0-0" - inner1Values[1]["id"] shouldBe "my-flow-views-0-0-1" + inner1Values[0]["id"] shouldBe "my-flow-views-0-collection-values-0-collection-values-0-text" + inner1Values[1]["id"] shouldBe "my-flow-views-0-collection-values-0-collection-values-1-text" val inner2Values = outerValues[1]["values"] as List> - inner2Values[0]["id"] shouldBe "my-flow-views-0-1-0" + inner2Values[0]["id"] shouldBe "my-flow-views-0-collection-values-1-collection-values-0-text" } } From cdc72c27a082f6db8ac6261eac390fb8b6429acf Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Wed, 4 Mar 2026 11:29:04 -0500 Subject: [PATCH 4/8] feat: align to js implementation --- .../playerui/lang/dsl/core/BuildPipeline.kt | 180 +++++++++++---- .../playerui/lang/dsl/core/FluentBuilder.kt | 34 +++ .../lang/dsl/schema/SchemaGenerator.kt | 141 ++++++++++++ .../playerui/lang/dsl/ApplicabilityTest.kt | 59 +++++ .../lang/dsl/NestedAssetWrapperTest.kt | 66 ++++++ .../playerui/lang/dsl/SchemaGeneratorTest.kt | 117 ++++++++++ .../dsl/mocks/builders/ContentCardBuilder.kt | 68 ++++++ .../playerui/lang/generator/ClassGenerator.kt | 205 ++++++++++++++---- .../lang/generator/DefaultValueGenerator.kt | 165 ++++++++++++++ .../lang/generator/SchemaBindingGenerator.kt | 8 +- .../playerui/lang/generator/TypeMapper.kt | 133 +++++++++++- .../generator/DefaultValueGeneratorTest.kt | 147 +++++++++++++ .../playerui/lang/generator/TypeMapperTest.kt | 124 +++++++++++ 13 files changed, 1348 insertions(+), 99 deletions(-) create mode 100644 dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaGenerator.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/ApplicabilityTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/NestedAssetWrapperTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/SchemaGeneratorTest.kt create mode 100644 dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/ContentCardBuilder.kt create mode 100644 generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/DefaultValueGenerator.kt create mode 100644 generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/DefaultValueGeneratorTest.kt diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt index e44ad7f..7263df7 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt @@ -6,7 +6,7 @@ import com.intuit.playerui.lang.dsl.id.peekId import com.intuit.playerui.lang.dsl.tagged.TaggedValue /** - * The 8-step build pipeline for resolving builder properties into final JSON. + * The 9-step build pipeline for resolving builder properties into final JSON. * Matches the TypeScript implementation's resolution order. */ object BuildPipeline { @@ -20,8 +20,9 @@ object BuildPipeline { * 4. Resolve AssetWrapper values * 5. Resolve mixed arrays (static + builder values) * 6. Resolve builders - * 7. Resolve switches - * 8. Resolve templates + * 7. Resolve nested AssetWrapper paths + * 8. Resolve switches + * 9. Resolve templates */ fun execute( storage: ValueStorage, @@ -30,6 +31,7 @@ object BuildPipeline { context: BuildContext?, arrayProperties: Set, assetWrapperProperties: Set, + assetWrapperPaths: List> = emptyList(), ): Map { val result = mutableMapOf() @@ -53,10 +55,13 @@ object BuildPipeline { // Step 5 & 6: Resolve arrays with builders and direct builders resolveBuilderEntries(allEntries, result, nestedContext, assetWrapperProperties) - // Step 7: Resolve switches + // Step 7: Resolve nested AssetWrapper paths + resolveNestedAssetWrappers(result, nestedContext, assetWrapperPaths) + + // Step 8: Resolve switches resolveSwitches(auxiliary, result, nestedContext, arrayProperties) - // Step 8: Resolve templates + // Step 9: Resolve templates resolveTemplates(auxiliary, result, context) return result @@ -92,8 +97,8 @@ object BuildPipeline { result: MutableMap, context: BuildContext?, ) { - // If ID is already set explicitly, use it - if (result["id"] != null) return + // If ID is already set explicitly (non-empty), use it + if ((result["id"] as? String)?.isNotEmpty() == true) return if (context == null) return val type = result["type"] as? String @@ -151,7 +156,7 @@ object BuildPipeline { ) { entries.forEach { (key, stored) -> if (key in assetWrapperProperties && stored is StoredValue.WrappedBuilder) { - val slotContext = createSlotContext(context, key, stored.builder) + val slotContext = createSlotContext(context, key) val builtAsset = stored.builder.build(slotContext) result[key] = mapOf("asset" to builtAsset) } @@ -173,18 +178,19 @@ object BuildPipeline { when (stored) { is StoredValue.Builder -> { - val slotContext = createSlotContext(context, key, stored.builder) + val slotContext = createSlotContext(context, key) result[key] = stored.builder.build(slotContext) } is StoredValue.WrappedBuilder -> { // Non-asset-wrapper WrappedBuilder: build it directly - val slotContext = createSlotContext(context, key, stored.builder) + val slotContext = createSlotContext(context, key) result[key] = stored.builder.build(slotContext) } is StoredValue.ArrayValue -> { - result[key] = resolveArrayValue(stored.items, context, key) + val isAssetWrapperArray = key in assetWrapperProperties + result[key] = resolveArrayValue(stored.items, context, key, isAssetWrapperArray) } is StoredValue.ObjectValue -> { @@ -199,11 +205,13 @@ object BuildPipeline { /** * Resolves an array of StoredValues. + * When [wrapInAssetWrapper] is true, each builder element is wrapped in { asset: ... } format. */ private fun resolveArrayValue( items: List, context: BuildContext?, key: String, + wrapInAssetWrapper: Boolean = false, ): List = items.mapIndexedNotNull { index, stored -> when (stored) { @@ -216,17 +224,19 @@ object BuildPipeline { } is StoredValue.Builder -> { - val arrayContext = createArrayItemContext(context, key, index, stored.builder) - stored.builder.build(arrayContext) + val arrayContext = createArrayItemContext(context, key, index) + val built = stored.builder.build(arrayContext) + if (wrapInAssetWrapper) mapOf("asset" to built) else built } is StoredValue.WrappedBuilder -> { - val arrayContext = createArrayItemContext(context, key, index, stored.builder) - stored.builder.build(arrayContext) + val arrayContext = createArrayItemContext(context, key, index) + val built = stored.builder.build(arrayContext) + mapOf("asset" to built) } is StoredValue.ArrayValue -> { - resolveArrayValue(stored.items, context, key) + resolveArrayValue(stored.items, context, key, wrapInAssetWrapper) } is StoredValue.ObjectValue -> { @@ -254,12 +264,12 @@ object BuildPipeline { } is StoredValue.Builder -> { - val slotContext = createSlotContext(context, key, stored.builder) + val slotContext = createSlotContext(context, key) stored.builder.build(slotContext) } is StoredValue.WrappedBuilder -> { - val slotContext = createSlotContext(context, key, stored.builder) + val slotContext = createSlotContext(context, key) stored.builder.build(slotContext) } @@ -274,8 +284,112 @@ object BuildPipeline { } /** - * Step 7: Resolve switch configurations. + * Step 7: Resolve nested AssetWrapper paths. + * Handles AssetWrapper properties nested within intermediate objects. + */ + private fun resolveNestedAssetWrappers( + result: MutableMap, + context: BuildContext?, + assetWrapperPaths: List>, + ) { + if (assetWrapperPaths.isEmpty()) return + + for (path in assetWrapperPaths) { + if (path.size < 2) continue + resolveNestedPath(result, path, context) + } + } + + /** + * Resolves a specific nested path, wrapping the target value in AssetWrapper format. + */ + private fun resolveNestedPath( + result: MutableMap, + path: List, + context: BuildContext?, + ) { + // Navigate to the parent object containing the AssetWrapper property + var current: Any? = result + + for (i in 0 until path.size - 1) { + val key = path[i] + if (current !is Map<*, *>) return + + @Suppress("UNCHECKED_CAST") + var next: Any? = (current as Map)[key] ?: return + + // If intermediate value is a builder, resolve it first + if (next is FluentBuilder<*>) { + val slotContext = createSlotContext(context, key) + next = next.build(slotContext) + @Suppress("UNCHECKED_CAST") + (current as MutableMap)[key] = next + } + + current = next + } + + // Now `current` is the parent object, wrap the final property + val finalKey = path.last() + if (current !is MutableMap<*, *>) return + + @Suppress("UNCHECKED_CAST") + val parent = current as MutableMap + val value = parent[finalKey] ?: return + + // If it's already wrapped in { asset: ... }, skip + if (value is Map<*, *> && value.containsKey("asset")) return + + val slotName = path.joinToString("-") + + // Handle arrays of values that need wrapping + if (value is List<*>) { + parent[finalKey] = + value + .filterNotNull() + .mapIndexed { index, item -> + if (item is Map<*, *> && item.containsKey("asset")) { + item + } else { + wrapAssetValue(item, context, "$slotName-$index") + } + } + return + } + + // Handle single value that needs wrapping + if (value is FluentBuilder<*> || value is Map<*, *>) { + parent[finalKey] = wrapAssetValue(value, context, slotName) + } + } + + /** + * Wraps a value in AssetWrapper format: { asset: { id: ..., ...value } } */ + private fun wrapAssetValue( + value: Any?, + context: BuildContext?, + slotName: String, + ): Map { + val resolved = + when (value) { + is FluentBuilder<*> -> { + val slotContext = context?.withBranch(IdBranch.Slot(slotName)) + value.build(slotContext) + } + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + value as Map + } + else -> return mapOf("asset" to value) + } + return mapOf("asset" to resolved) + } + + /** + * Step 8: Resolve switch configurations. + */ + @Suppress("NestedBlockDepth") private fun resolveSwitches( auxiliary: AuxiliaryStorage, result: MutableMap, @@ -335,7 +449,7 @@ object BuildPipeline { } /** - * Step 8: Resolve template configurations. + * Step 9: Resolve template configurations. */ private fun resolveTemplates( auxiliary: AuxiliaryStorage, @@ -392,7 +506,6 @@ object BuildPipeline { private fun createSlotContext( context: BuildContext?, key: String, - builder: FluentBuilder<*>, ): BuildContext? { if (context == null) return null @@ -408,7 +521,6 @@ object BuildPipeline { context: BuildContext?, key: String, index: Int, - builder: FluentBuilder<*>, ): BuildContext? { if (context == null) return null @@ -422,30 +534,6 @@ object BuildPipeline { .withIndex(index) } - /** - * Extracts asset metadata from a builder for smart ID naming. - */ - private fun extractAssetMetadata(builder: FluentBuilder<*>): AssetMetadata { - val type = builder.peek("type") as? String - val binding = - builder.peek("binding")?.let { - when (it) { - is TaggedValue<*> -> it.toString() - is String -> it - else -> null - } - } - val value = - builder.peek("value")?.let { - when (it) { - is TaggedValue<*> -> it.toString() - is String -> it - else -> null - } - } - return AssetMetadata(type, binding, value) - } - /** * Injects a value at a nested path in the result map. */ diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/FluentBuilder.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/FluentBuilder.kt index d244386..567e274 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/FluentBuilder.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/FluentBuilder.kt @@ -1,6 +1,8 @@ package com.intuit.playerui.lang.dsl.core import com.intuit.playerui.lang.dsl.FluentDslMarker +import com.intuit.playerui.lang.dsl.tagged.Binding +import com.intuit.playerui.lang.dsl.tagged.Expression /** * Base interface for all fluent builders. @@ -57,6 +59,37 @@ abstract class FluentBuilderBase : FluentBuilder { */ protected open val assetWrapperProperties: Set = emptySet() + /** + * Paths to AssetWrapper properties nested within intermediate objects. + * Each path is a list of property names (e.g., ["header", "left"]). + * Paths with length >= 2 are resolved by the nested AssetWrapper step. + */ + protected open val assetWrapperPaths: List> = emptyList() + + /** + * Conditionally show/hide this asset based on an expression or binding. + * When set, the asset will only be rendered when the expression evaluates to true. + */ + var applicability: Any? + get() = storage.peek("applicability") + set(value) { + storage["applicability"] = value + } + + /** + * Sets applicability using a boolean expression. + */ + fun applicability(expr: Expression) { + storage["applicability"] = expr + } + + /** + * Sets applicability using a boolean binding. + */ + fun applicability(binding: Binding) { + storage["applicability"] = binding + } + /** * Sets a property value. * @param key The property name @@ -194,5 +227,6 @@ abstract class FluentBuilderBase : FluentBuilder { context = context, arrayProperties = arrayProperties, assetWrapperProperties = assetWrapperProperties, + assetWrapperPaths = assetWrapperPaths, ) } diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaGenerator.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaGenerator.kt new file mode 100644 index 0000000..fa93224 --- /dev/null +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/schema/SchemaGenerator.kt @@ -0,0 +1,141 @@ +package com.intuit.playerui.lang.dsl.schema + +import com.intuit.playerui.lang.dsl.types.SchemaDataType + +/** + * Generates [Schema][com.intuit.playerui.lang.dsl.types.Schema] representations from plain objects. + * + * Converts an input map to a schema structure with ROOT type and intermediate types + * for nested objects. Handles arrays, duplicate type name detection, and name collision + * resolution (appending numeric suffixes). + * + * Matches the JavaScript `SchemaGenerator` implementation semantics. + * + * Example: + * ```kotlin + * val generator = SchemaGenerator() + * val schema = generator.toSchema(mapOf( + * "name" to SchemaDataType(type = "StringType"), + * "address" to mapOf( + * "street" to SchemaDataType(type = "StringType"), + * "city" to SchemaDataType(type = "StringType"), + * ), + * )) + * // schema["ROOT"]["address"] = SchemaDataType(type = "AddressType") + * // schema["AddressType"]["street"] = SchemaDataType(type = "StringType") + * ``` + */ +class SchemaGenerator { + private data class SchemaChild( + val name: String, + val child: Map, + ) + + private data class GeneratedDataType( + val shape: Any?, + var count: Int, + ) + + private val children = mutableListOf() + private val generatedDataTypes = mutableMapOf() + private val typeNameCache = mutableMapOf() + + /** + * Converts an object to a Schema representation. + * + * @param schema Input map where values are either [SchemaDataType] (leaf types) + * or [Map] (nested objects that become intermediate types) + * or [List] (arrays of objects that become array types) + * @return A schema map with ROOT and intermediate type definitions + */ + fun toSchema(schema: Map): Map> { + children.clear() + generatedDataTypes.clear() + typeNameCache.clear() + + val result = mutableMapOf>() + val rootEntries = mutableMapOf() + + for ((property, subType) in schema) { + rootEntries[property] = processChild(property, subType) + } + + result["ROOT"] = rootEntries + + while (children.isNotEmpty()) { + val (name, child) = children.removeLast() + val typeDef = mutableMapOf() + for ((property, subType) in child) { + typeDef[property] = processChild(property, subType) + } + result[name] = typeDef + } + + return result + } + + private fun processChild(property: String, subType: Any?): SchemaDataType { + if (subType is SchemaDataType) { + return subType + } + + val intermediateType: SchemaDataType + val child: Map + + if (subType is List<*> && subType.isNotEmpty()) { + @Suppress("UNCHECKED_CAST") + val firstElement = subType[0] as? Map ?: return SchemaDataType(type = "AnyType") + intermediateType = makePlaceholderArrayType(property) + child = firstElement + } else if (subType is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + child = subType as Map + intermediateType = makePlaceholderType(property) + } else { + return SchemaDataType(type = "AnyType") + } + + val typeName = intermediateType.type + + val existing = generatedDataTypes[typeName] + if (existing != null) { + if (!deepEquals(child, existing.shape)) { + existing.count += 1 + val newTypeName = "$typeName${existing.count}" + val newType = intermediateType.copy(type = newTypeName) + generatedDataTypes[newTypeName] = GeneratedDataType(shape = child, count = 1) + children.add(SchemaChild(newTypeName, child)) + return newType + } + } else { + generatedDataTypes[typeName] = GeneratedDataType(shape = child, count = 1) + } + + children.add(SchemaChild(intermediateType.type, child)) + return intermediateType + } + + private fun makePlaceholderType(typeName: String): SchemaDataType { + val cachedName = typeNameCache.getOrPut(typeName) { "${typeName}Type" } + return SchemaDataType(type = cachedName) + } + + private fun makePlaceholderArrayType(typeName: String): SchemaDataType { + val cachedName = typeNameCache.getOrPut(typeName) { "${typeName}Type" } + return SchemaDataType(type = cachedName, isArray = true) + } + + private fun deepEquals(a: Any?, b: Any?): Boolean { + if (a === b) return true + if (a == null || b == null) return a == b + if (a is Map<*, *> && b is Map<*, *>) { + if (a.size != b.size) return false + return a.all { (k, v) -> b.containsKey(k) && deepEquals(v, b[k]) } + } + if (a is List<*> && b is List<*>) { + if (a.size != b.size) return false + return a.zip(b).all { (x, y) -> deepEquals(x, y) } + } + return a == b + } +} diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/ApplicabilityTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/ApplicabilityTest.kt new file mode 100644 index 0000000..7e57a54 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/ApplicabilityTest.kt @@ -0,0 +1,59 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.mocks.builders.text +import com.intuit.playerui.lang.dsl.tagged.Binding +import com.intuit.playerui.lang.dsl.tagged.Expression +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class ApplicabilityTest : + DescribeSpec({ + + describe("Applicability") { + it("sets applicability with an expression") { + val builder = + text { + value = "Conditional" + } + builder.applicability(Expression("user.isActive")) + + val result = builder.build() + result["applicability"] shouldBe "@[user.isActive]@" + } + + it("sets applicability with a binding") { + val builder = + text { + value = "Conditional" + } + builder.applicability(Binding("user.showField")) + + val result = builder.build() + result["applicability"] shouldBe "{{user.showField}}" + } + + it("sets applicability via property setter") { + val builder = + text { + value = "Conditional" + } + builder.applicability = Expression("isVisible") + + val result = builder.build() + result["applicability"] shouldBe "@[isVisible]@" + } + + it("reads applicability via property getter") { + val expr = Expression("user.isActive") + val builder = + text { + value = "Test" + } + builder.applicability(expr) + + builder.applicability shouldBe expr + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/NestedAssetWrapperTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/NestedAssetWrapperTest.kt new file mode 100644 index 0000000..2a60c85 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/NestedAssetWrapperTest.kt @@ -0,0 +1,66 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.id.IdRegistry +import com.intuit.playerui.lang.dsl.mocks.builders.contentCard +import com.intuit.playerui.lang.dsl.mocks.builders.text +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe + +class NestedAssetWrapperTest : + DescribeSpec({ + + fun registry() = IdRegistry() + + describe("Nested AssetWrapper resolution") { + it("wraps assets nested in intermediate objects") { + val ctx = BuildContext(parentId = "flow-views-0", idRegistry = registry()) + + val result = + contentCard { + header { + left(text { value = "Left Content" }) + right(text { value = "Right Content" }) + } + }.build(ctx) + + result["type"] shouldBe "content-card" + + val header = result["header"] as Map + header shouldNotBe null + + val left = header["left"] as Map + left.containsKey("asset") shouldBe true + val leftAsset = left["asset"] as Map + leftAsset["type"] shouldBe "text" + leftAsset["value"] shouldBe "Left Content" + + val right = header["right"] as Map + right.containsKey("asset") shouldBe true + val rightAsset = right["asset"] as Map + rightAsset["type"] shouldBe "text" + rightAsset["value"] shouldBe "Right Content" + } + + it("skips already-wrapped assets") { + val ctx = BuildContext(parentId = "flow-views-0", idRegistry = registry()) + + val result = + contentCard { + header { + left(text { value = "Already Wrapped" }) + } + }.build(ctx) + + val header = result["header"] as Map + val left = header["left"] as Map + // Should be wrapped exactly once + left.containsKey("asset") shouldBe true + val leftAsset = left["asset"] as Map + leftAsset["type"] shouldBe "text" + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/SchemaGeneratorTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/SchemaGeneratorTest.kt new file mode 100644 index 0000000..7ccdc71 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/SchemaGeneratorTest.kt @@ -0,0 +1,117 @@ +package com.intuit.playerui.lang.dsl + +import com.intuit.playerui.lang.dsl.schema.SchemaGenerator +import com.intuit.playerui.lang.dsl.types.SchemaDataType +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe + +class SchemaGeneratorTest : + DescribeSpec({ + + describe("SchemaGenerator") { + it("generates schema from flat object with leaf types") { + val generator = SchemaGenerator() + val schema = + generator.toSchema( + mapOf( + "name" to SchemaDataType(type = "StringType"), + "age" to SchemaDataType(type = "IntegerType"), + ), + ) + + schema["ROOT"] shouldNotBe null + schema["ROOT"]!!["name"]!!.type shouldBe "StringType" + schema["ROOT"]!!["age"]!!.type shouldBe "IntegerType" + } + + it("generates intermediate type for nested objects") { + val generator = SchemaGenerator() + val schema = + generator.toSchema( + mapOf( + "user" to + mapOf( + "name" to SchemaDataType(type = "StringType"), + "email" to SchemaDataType(type = "StringType"), + ), + ), + ) + + schema["ROOT"] shouldNotBe null + schema["ROOT"]!!["user"]!!.type shouldBe "userType" + + schema["userType"] shouldNotBe null + schema["userType"]!!["name"]!!.type shouldBe "StringType" + schema["userType"]!!["email"]!!.type shouldBe "StringType" + } + + it("generates array type for list properties") { + val generator = SchemaGenerator() + val schema = + generator.toSchema( + mapOf( + "items" to + listOf( + mapOf( + "label" to SchemaDataType(type = "StringType"), + "value" to SchemaDataType(type = "IntegerType"), + ), + ), + ), + ) + + schema["ROOT"] shouldNotBe null + val itemsType = schema["ROOT"]!!["items"]!! + itemsType.type shouldBe "itemsType" + itemsType.isArray shouldBe true + + schema["itemsType"] shouldNotBe null + schema["itemsType"]!!["label"]!!.type shouldBe "StringType" + } + + it("handles duplicate type names with different shapes") { + val generator = SchemaGenerator() + val schema = + generator.toSchema( + mapOf( + "header" to + mapOf( + "title" to SchemaDataType(type = "StringType"), + ), + "footer" to + mapOf( + "header" to + mapOf( + "subtitle" to SchemaDataType(type = "StringType"), + ), + ), + ), + ) + + schema["ROOT"] shouldNotBe null + schema["ROOT"]!!["header"]!!.type shouldBe "headerType" + schema["headerType"] shouldNotBe null + } + + it("handles deeply nested objects") { + val generator = SchemaGenerator() + val schema = + generator.toSchema( + mapOf( + "level1" to + mapOf( + "level2" to + mapOf( + "value" to SchemaDataType(type = "StringType"), + ), + ), + ), + ) + + schema["ROOT"]!!["level1"]!!.type shouldBe "level1Type" + schema["level1Type"]!!["level2"]!!.type shouldBe "level2Type" + schema["level2Type"]!!["value"]!!.type shouldBe "StringType" + } + } + }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/ContentCardBuilder.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/ContentCardBuilder.kt new file mode 100644 index 0000000..356b309 --- /dev/null +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/mocks/builders/ContentCardBuilder.kt @@ -0,0 +1,68 @@ +package com.intuit.playerui.lang.dsl.mocks.builders + +import com.intuit.playerui.lang.dsl.core.AssetWrapperBuilder +import com.intuit.playerui.lang.dsl.core.BuildContext +import com.intuit.playerui.lang.dsl.core.FluentBuilder +import com.intuit.playerui.lang.dsl.core.FluentBuilderBase + +/** + * Mock builder for testing nested AssetWrapper resolution. + * Represents a content card with a header that contains nested asset slots. + * + * Structure: ContentCard { header: { left: AssetWrapper, right: AssetWrapper } } + */ +class ContentCardBuilder : FluentBuilderBase>() { + override val defaults: Map = mapOf("type" to "content-card") + override val assetWrapperProperties: Set = emptySet() + override val arrayProperties: Set = emptySet() + override val assetWrapperPaths: List> = + listOf( + listOf("header", "left"), + listOf("header", "right"), + ) + + var id: String? + get() = peek("id") as? String + set(value) { + set("id", value) + } + + fun header(init: ContentCardHeaderBuilder.() -> Unit) { + set("header", ContentCardHeaderBuilder().apply(init)) + } + + override fun build(context: BuildContext?): Map = buildWithDefaults(context) + + override fun clone(): ContentCardBuilder = ContentCardBuilder().also { cloneStorageTo(it) } +} + +class ContentCardHeaderBuilder : FluentBuilderBase>() { + override val defaults: Map = emptyMap() + + var left: FluentBuilder<*>? + get() = null + set(value) { + if (value != null) set("left", AssetWrapperBuilder(value)) + } + + fun left(builder: FluentBuilder<*>) { + set("left", AssetWrapperBuilder(builder)) + } + + var right: FluentBuilder<*>? + get() = null + set(value) { + if (value != null) set("right", AssetWrapperBuilder(value)) + } + + fun right(builder: FluentBuilder<*>) { + set("right", AssetWrapperBuilder(builder)) + } + + override fun build(context: BuildContext?): Map = buildWithDefaults(context) + + override fun clone(): ContentCardHeaderBuilder = ContentCardHeaderBuilder().also { cloneStorageTo(it) } +} + +fun contentCard(init: ContentCardBuilder.() -> Unit = {}): ContentCardBuilder = + ContentCardBuilder().apply(init) diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt index 56172aa..2064dff 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt @@ -1,5 +1,24 @@ package com.intuit.playerui.lang.generator +import com.intuit.playerui.lang.generator.PoetTypes.ANY +import com.intuit.playerui.lang.generator.PoetTypes.ASSET_WRAPPER_BUILDER +import com.intuit.playerui.lang.generator.PoetTypes.BINDING +import com.intuit.playerui.lang.generator.PoetTypes.BOOLEAN +import com.intuit.playerui.lang.generator.PoetTypes.BUILDER_SUPERCLASS +import com.intuit.playerui.lang.generator.PoetTypes.BUILD_CONTEXT +import com.intuit.playerui.lang.generator.PoetTypes.FLUENT_BUILDER_BASE_STAR +import com.intuit.playerui.lang.generator.PoetTypes.FLUENT_DSL_MARKER +import com.intuit.playerui.lang.generator.PoetTypes.LIST +import com.intuit.playerui.lang.generator.PoetTypes.MAP +import com.intuit.playerui.lang.generator.PoetTypes.MAP_STRING_ANY +import com.intuit.playerui.lang.generator.PoetTypes.NOTHING +import com.intuit.playerui.lang.generator.PoetTypes.NUMBER +import com.intuit.playerui.lang.generator.PoetTypes.SET +import com.intuit.playerui.lang.generator.PoetTypes.STAR +import com.intuit.playerui.lang.generator.PoetTypes.STRING +import com.intuit.playerui.lang.generator.PoetTypes.TAGGED_VALUE +import com.intuit.playerui.lang.generator.PoetTypes.UNIT +import com.intuit.playerui.xlr.ArrayType import com.intuit.playerui.xlr.ObjectProperty import com.intuit.playerui.xlr.ObjectType import com.intuit.playerui.xlr.ParamTypeNode @@ -109,13 +128,25 @@ class ClassGenerator( objectType.description?.let { builder.addKdoc("%L", it) } + addOverrideProperties(builder, assetType) + addIdProperty(builder) + + // Schema-driven properties + collectProperties().forEach { prop -> + addPropertyMembers(builder, prop, className, generateWithMethods = true) + } + + addBuildAndCloneMethods(builder, className) + + return builder.build() + } + + private fun addOverrideProperties( + builder: TypeSpec.Builder, + assetType: String?, + ) { // defaults property - val defaultsInit = - if (assetType != null) { - CodeBlock.of("mapOf(%S to %S)", "type", assetType) - } else { - CodeBlock.of("emptyMap()") - } + val defaultsInit = buildDefaultsInitializer(assetType) builder.addProperty( PropertySpec .builder("defaults", MAP_STRING_ANY) @@ -125,7 +156,7 @@ class ClassGenerator( ) // assetWrapperProperties - val awProps = collectProperties().filter { it.isAssetWrapper && !it.isArray } + val awProps = collectProperties().filter { it.isAssetWrapper } builder.addProperty( PropertySpec .builder("assetWrapperProperties", SET.parameterizedBy(STRING)) @@ -144,7 +175,22 @@ class ClassGenerator( .build(), ) - // id property + // assetWrapperPaths — nested paths to AssetWrapper properties within intermediate objects + val awPaths = findAssetWrapperPaths(objectType) + if (awPaths.isNotEmpty()) { + builder.addProperty( + PropertySpec + .builder( + "assetWrapperPaths", + LIST.parameterizedBy(LIST.parameterizedBy(STRING)), + ).addModifiers(KModifier.OVERRIDE) + .initializer(buildNestedListInitializer(awPaths)) + .build(), + ) + } + } + + private fun addIdProperty(builder: TypeSpec.Builder) { builder.addProperty( PropertySpec .builder("id", STRING.copy(nullable = true)) @@ -163,13 +209,12 @@ class ClassGenerator( .build(), ).build(), ) + } - // Schema-driven properties - collectProperties().forEach { prop -> - addPropertyMembers(builder, prop, className, generateWithMethods = true) - } - - // build method + private fun addBuildAndCloneMethods( + builder: TypeSpec.Builder, + className: String, + ) { builder.addFunction( FunSpec .builder("build") @@ -180,7 +225,6 @@ class ClassGenerator( .build(), ) - // clone method val classType = ClassName(packageName, className) builder.addFunction( FunSpec @@ -190,8 +234,6 @@ class ClassGenerator( .addStatement("return %T().also { cloneStorageTo(it) }", classType) .build(), ) - - return builder.build() } private fun addPropertyMembers( @@ -574,6 +616,30 @@ class ClassGenerator( return className } + private fun buildDefaultsInitializer(assetType: String?): CodeBlock { + val defaults = DefaultValueGenerator.generateDefaults(objectType, assetType) + if (defaults.isEmpty()) return CodeBlock.of("emptyMap()") + + val builder = CodeBlock.builder().add("mapOf(") + defaults.entries.forEachIndexed { index, (key, value) -> + if (index > 0) builder.add(", ") + when (value) { + is String -> builder.add("%S to %S", key, value) + is Number -> { + val numValue = if (value is Double && value % 1.0 == 0.0) value.toInt() else value + builder.add("%S to %L", key, numValue) + } + is Boolean -> builder.add("%S to %L", key, value) + is List<*> -> builder.add("%S to emptyList()", key) + is Map<*, *> -> builder.add("%S to emptyMap()", key) + null -> builder.add("%S to null", key) + else -> builder.add("%S to %S", key, value.toString()) + } + } + builder.add(")") + return builder.build() + } + private fun collectProperties(): List = cachedProperties private fun createPropertyInfo( @@ -668,6 +734,59 @@ class ClassGenerator( return -1 } + /** + * Recursively finds all paths to AssetWrapper properties within an ObjectType. + * Returns paths with length >= 2 (single-level paths are handled by assetWrapperProperties). + */ + private fun findAssetWrapperPaths( + rootType: ObjectType, + currentPath: List = emptyList(), + visited: MutableSet = mutableSetOf(), + ): List> { + val paths = mutableListOf>() + + for ((propName, prop) in rootType.properties) { + val node = prop.node + val fullPath = currentPath + propName + + when { + // Direct AssetWrapper property + isAssetWrapperRef(node) -> { + if (fullPath.size >= 2) paths.add(fullPath) + } + // Array of AssetWrappers + node is ArrayType && isAssetWrapperRef(node.elementType) -> { + if (fullPath.size >= 2) paths.add(fullPath) + } + // Nested ObjectType — recurse + node is ObjectType -> { + val typeName = node.name + if (typeName != null && typeName in visited) continue + if (typeName != null) visited.add(typeName) + paths.addAll(findAssetWrapperPaths(node, fullPath, visited)) + if (typeName != null) visited.remove(typeName) + } + } + } + + return paths + } + + private fun buildNestedListInitializer(paths: List>): CodeBlock { + val builder = CodeBlock.builder().add("listOf(") + paths.forEachIndexed { index, path -> + if (index > 0) builder.add(", ") + builder.add("listOf(") + path.forEachIndexed { pathIndex, segment -> + if (pathIndex > 0) builder.add(", ") + builder.add("%S", segment) + } + builder.add(")") + } + builder.add(")") + return builder.build() + } + private fun buildSetInitializer(names: List): CodeBlock { if (names.isEmpty()) return CodeBlock.of("emptySet()") return CodeBlock @@ -683,31 +802,6 @@ class ClassGenerator( } companion object { - // Kotlin stdlib types (replacing KotlinPoet's top-level constants that don't work through KMP metadata) - val STRING = ClassName("kotlin", "String") - val BOOLEAN = ClassName("kotlin", "Boolean") - val NUMBER = ClassName("kotlin", "Number") - val ANY = ClassName("kotlin", "Any") - val NOTHING = ClassName("kotlin", "Nothing") - val UNIT = ClassName("kotlin", "Unit") - val LIST = ClassName("kotlin.collections", "List") - val MAP = ClassName("kotlin.collections", "Map") - val SET = ClassName("kotlin.collections", "Set") - val STAR = WildcardTypeName.producerOf(ANY) - - // DSL framework types - val FLUENT_DSL_MARKER = ClassName("com.intuit.playerui.lang.dsl", "FluentDslMarker") - val FLUENT_BUILDER_BASE = ClassName("com.intuit.playerui.lang.dsl.core", "FluentBuilderBase") - val ASSET_WRAPPER_BUILDER = ClassName("com.intuit.playerui.lang.dsl.core", "AssetWrapperBuilder") - val BUILD_CONTEXT = ClassName("com.intuit.playerui.lang.dsl.core", "BuildContext") - val BINDING = ClassName("com.intuit.playerui.lang.dsl.tagged", "Binding") - val TAGGED_VALUE = ClassName("com.intuit.playerui.lang.dsl.tagged", "TaggedValue") - - // Parameterized types - val MAP_STRING_ANY = MAP.parameterizedBy(STRING, ANY.copy(nullable = true)) - val FLUENT_BUILDER_BASE_STAR = FLUENT_BUILDER_BASE.parameterizedBy(STAR) - val BUILDER_SUPERCLASS = FLUENT_BUILDER_BASE.parameterizedBy(MAP_STRING_ANY) - private val PRIMITIVE_OVERLOAD_TYPES = setOf("String", "Number", "Boolean") /** @@ -732,3 +826,30 @@ class ClassGenerator( "with${poetName.replaceFirstChar { it.uppercase() }}" } } + +/** + * KotlinPoet type name constants used during code generation. + */ +internal object PoetTypes { + val STRING = ClassName("kotlin", "String") + val BOOLEAN = ClassName("kotlin", "Boolean") + val NUMBER = ClassName("kotlin", "Number") + val ANY = ClassName("kotlin", "Any") + val NOTHING = ClassName("kotlin", "Nothing") + val UNIT = ClassName("kotlin", "Unit") + val LIST = ClassName("kotlin.collections", "List") + val MAP = ClassName("kotlin.collections", "Map") + val SET = ClassName("kotlin.collections", "Set") + val STAR = WildcardTypeName.producerOf(ANY) + + val FLUENT_DSL_MARKER = ClassName("com.intuit.playerui.lang.dsl", "FluentDslMarker") + val FLUENT_BUILDER_BASE = ClassName("com.intuit.playerui.lang.dsl.core", "FluentBuilderBase") + val ASSET_WRAPPER_BUILDER = ClassName("com.intuit.playerui.lang.dsl.core", "AssetWrapperBuilder") + val BUILD_CONTEXT = ClassName("com.intuit.playerui.lang.dsl.core", "BuildContext") + val BINDING = ClassName("com.intuit.playerui.lang.dsl.tagged", "Binding") + val TAGGED_VALUE = ClassName("com.intuit.playerui.lang.dsl.tagged", "TaggedValue") + + val MAP_STRING_ANY = MAP.parameterizedBy(STRING, ANY.copy(nullable = true)) + val FLUENT_BUILDER_BASE_STAR = FLUENT_BUILDER_BASE.parameterizedBy(STAR) + val BUILDER_SUPERCLASS = FLUENT_BUILDER_BASE.parameterizedBy(MAP_STRING_ANY) +} diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/DefaultValueGenerator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/DefaultValueGenerator.kt new file mode 100644 index 0000000..ba11099 --- /dev/null +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/DefaultValueGenerator.kt @@ -0,0 +1,165 @@ +package com.intuit.playerui.lang.generator + +import com.intuit.playerui.xlr.AndType +import com.intuit.playerui.xlr.ArrayType +import com.intuit.playerui.xlr.BooleanType +import com.intuit.playerui.xlr.NodeType +import com.intuit.playerui.xlr.NullType +import com.intuit.playerui.xlr.NumberType +import com.intuit.playerui.xlr.ObjectType +import com.intuit.playerui.xlr.OrType +import com.intuit.playerui.xlr.RefType +import com.intuit.playerui.xlr.StringType +import com.intuit.playerui.xlr.UndefinedType +import com.intuit.playerui.xlr.hasAnyConstValue +import com.intuit.playerui.xlr.isAssetWrapperRef +import com.intuit.playerui.xlr.isBindingRef +import com.intuit.playerui.xlr.isExpressionRef + +/** + * Generates smart default values for builder classes. + * + * Rules: + * - String → "" + * - Number → 0 + * - Boolean → false + * - Array → emptyList() + * - Object → recurse if depth < maxDepth, else emptyMap() + * - Expression/Binding → "" + * - Union types → first non-null variant + * - AssetWrapper/Asset → skipped (null) + * - Const values → use the const value + */ +object DefaultValueGenerator { + private val SKIP_TYPES = setOf("Asset", "AssetWrapper") + private const val MAX_DEPTH = 3 + + /** + * Generate default values for an ObjectType. + * + * @param objectType The XLR ObjectType to generate defaults for + * @param assetType Optional asset type string (e.g., "text", "action") + * @return Map of property names to their default values + */ + fun generateDefaults( + objectType: ObjectType, + assetType: String?, + ): Map { + val defaults = mutableMapOf() + + if (assetType != null) { + defaults["type"] = assetType + } + + // Add default ID for asset types + if (objectType.extends?.ref?.startsWith("Asset") == true) { + defaults["id"] = "" + } else if ("id" in objectType.properties) { + defaults["id"] = "" + } + + for ((propName, prop) in objectType.properties) { + val defaultValue = resolvePropertyDefault(propName, prop, defaults) + if (defaultValue != null) { + defaults[propName] = defaultValue + } + } + + return defaults + } + + private fun resolvePropertyDefault( + propName: String, + prop: com.intuit.playerui.xlr.ObjectProperty, + existingDefaults: Map, + ): Any? { + // Const values take precedence + if (hasAnyConstValue(prop.node)) return extractConstValue(prop.node) + + // Only generate defaults for required properties not already set + if (!prop.required || propName in existingDefaults) return null + + return getDefaultForType(prop.node, 0) + } + + private fun getDefaultForType(node: NodeType, depth: Int): Any? { + // Skip AssetWrapper - user must provide + if (isAssetWrapperRef(node)) return null + + // Check for other skip types + if (node is RefType) { + val baseName = node.ref.substringBefore("<") + if (baseName in SKIP_TYPES) return null + } + + return when { + node is StringType -> "" + node is NumberType -> 0 + node is BooleanType -> false + isExpressionRef(node) || isBindingRef(node) -> "" + node is ArrayType -> emptyList() + node is OrType -> getDefaultForUnion(node, depth) + node is AndType -> getDefaultForIntersection(node, depth) + node is ObjectType -> { + if (depth >= MAX_DEPTH) { + emptyMap() + } else { + getDefaultForObject(node, depth + 1) + } + } + node is NullType -> null + node is UndefinedType -> null + node is RefType -> emptyMap() + else -> null + } + } + + private fun getDefaultForUnion(node: OrType, depth: Int): Any? { + for (variant in node.orTypes) { + if (variant is NullType || variant is UndefinedType) continue + val defaultValue = getDefaultForType(variant, depth) + if (defaultValue != null) return defaultValue + } + return null + } + + private fun getDefaultForIntersection(node: AndType, depth: Int): Any? { + val merged = mutableMapOf() + for (part in node.andTypes) { + val partDefault = getDefaultForType(part, depth) + if (partDefault is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + merged.putAll(partDefault as Map) + } + } + return if (merged.isNotEmpty()) merged else emptyMap() + } + + private fun getDefaultForObject(node: ObjectType, depth: Int): Map { + val result = mutableMapOf() + for ((propName, prop) in node.properties) { + val defaultValue = resolveNestedPropertyDefault(prop, depth) + if (defaultValue != null) { + result[propName] = defaultValue + } + } + return result + } + + private fun resolveNestedPropertyDefault( + prop: com.intuit.playerui.xlr.ObjectProperty, + depth: Int, + ): Any? { + if (hasAnyConstValue(prop.node)) return extractConstValue(prop.node) + if (!prop.required) return null + return getDefaultForType(prop.node, depth) + } + + private fun extractConstValue(node: NodeType): Any? = + when (node) { + is StringType -> node.const + is NumberType -> node.const + is BooleanType -> node.const + else -> null + } +} diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt index e331590..3935a0b 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt @@ -212,10 +212,10 @@ class SchemaBindingGenerator( private fun schemaTypeToKotlinType(typeName: String): ClassName = when (typeName) { - "StringType" -> ClassGenerator.STRING - "NumberType" -> ClassGenerator.NUMBER - "BooleanType" -> ClassGenerator.BOOLEAN - else -> ClassGenerator.STRING + "StringType" -> PoetTypes.STRING + "NumberType" -> PoetTypes.NUMBER + "BooleanType" -> PoetTypes.BOOLEAN + else -> PoetTypes.STRING } private fun parseSchema(json: String): Map> { diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt index 5270969..f4af282 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt @@ -1,5 +1,6 @@ package com.intuit.playerui.lang.generator +import com.intuit.playerui.xlr.AndType import com.intuit.playerui.xlr.AnyType import com.intuit.playerui.xlr.ArrayType import com.intuit.playerui.xlr.BooleanType @@ -75,6 +76,7 @@ object TypeMapper { is ObjectType -> mapObjectType(node) is ArrayType -> mapArrayType(node, context) is OrType -> mapOrType(node, context) + is AndType -> mapAndType(node, context) is RecordType -> mapRecordType(node, context) else -> KotlinTypeInfo("Any?", isNullable = true) } @@ -99,15 +101,22 @@ object TypeMapper { context.genericTokens[ref]?.let { token -> token.default?.let { return mapToKotlinType(it, context) } token.constraints?.let { return mapToKotlinType(it, context) } + // Unresolvable generic token: treat as Any? + return KotlinTypeInfo( + typeName = "Any?", + isNullable = true, + description = node.description, + ) } - // Check for AssetWrapper + // Check for AssetWrapper — extract inner type from generic arguments if (isAssetWrapperRef(node)) { + val innerType = extractAssetWrapperInnerType(node, context) return KotlinTypeInfo( - typeName = "FluentBuilder<*>", + typeName = innerType ?: "FluentBuilder<*>", isNullable = true, isAssetWrapper = true, - builderType = "FluentBuilder<*>", + builderType = innerType ?: "FluentBuilder<*>", description = node.description, ) } @@ -150,6 +159,38 @@ object TypeMapper { ) } + /** + * Extracts the inner type name from an AssetWrapper generic argument. + * E.g., AssetWrapper → "TextBuilder" + * Returns null if no generic arguments or unable to extract. + */ + private fun extractAssetWrapperInnerType(node: RefType, context: TypeMapperContext): String? { + val genericArgs = node.genericArguments ?: return null + if (genericArgs.isEmpty()) return null + + val firstArg = genericArgs[0] + + // Handle intersection generic: AssetWrapper → use first concrete type + if (firstArg is AndType) { + val concreteTypes = firstArg.andTypes.filterIsInstance() + if (concreteTypes.isNotEmpty()) { + val innerRef = concreteTypes[0].ref.substringBefore("<") + return toBuilderClassName(innerRef.removeSuffix("Asset")) + } + return null + } + + // Handle RefType generic argument + if (firstArg is RefType) { + val innerRef = firstArg.ref.substringBefore("<") + // Skip if it's a generic token that can't be resolved + if (innerRef in context.genericTokens) return null + return toBuilderClassName(innerRef.removeSuffix("Asset")) + } + + return null + } + private fun mapObjectType(node: ObjectType): KotlinTypeInfo { // Inline objects become nested classes return KotlinTypeInfo( @@ -202,12 +243,18 @@ object TypeMapper { } // If all non-null types are StringType with const values, it's a literal string union - // e.g., "foo" | "bar" | "baz" → String + // e.g., "foo" | "bar" | "baz" → String with KDoc listing valid values if (nonNullTypes.all { it is StringType && (it as StringType).const != null }) { + val validValues = nonNullTypes.map { (it as StringType).const!! } + val desc = + buildString { + node.description?.let { append(it).append(". ") } + append("Valid values: ${validValues.joinToString(", ") { "\"$it\"" }}") + } return KotlinTypeInfo( typeName = "String", isNullable = hasNullBranch, - description = node.description, + description = desc, ) } @@ -218,13 +265,85 @@ object TypeMapper { if (distinctTypes.size == 1) { return mapped[0].copy(isNullable = hasNullBranch || mapped[0].isNullable) } + + // Check if there's a common supertype among distinct concrete types + val hasAssetWrapper = mapped.any { it.isAssetWrapper } + val hasBuilder = mapped.any { it.builderType != null } + if (hasAssetWrapper || hasBuilder) { + return KotlinTypeInfo( + typeName = "FluentBuilder<*>", + isNullable = hasNullBranch, + isAssetWrapper = hasAssetWrapper, + builderType = "FluentBuilder<*>", + description = node.description, + ) + } } - // Heterogeneous union, fall back to Any + // Heterogeneous union, fall back to Any with documenting description + val unionDesc = + if (nonNullTypes.size > 1) { + val typeNames = nonNullTypes.map { mapToKotlinType(it, context).typeName } + buildString { + node.description?.let { append(it).append(". ") } + append("Union of: ${typeNames.joinToString(" | ")}") + } + } else { + node.description + } + return KotlinTypeInfo( typeName = "Any?", isNullable = true, - description = node.description, + description = unionDesc, + ) + } + + /** + * Maps an AndType (intersection) to Kotlin type info. + * For object type intersections, merges properties. + * For other cases, falls back to Any? with documenting comment. + */ + private fun mapAndType( + node: AndType, + context: TypeMapperContext, + ): KotlinTypeInfo { + val types = node.andTypes + + // If all parts are object types, merge them into a single nested object type + if (types.all { it is ObjectType }) { + val mergedProperties = mutableMapOf() + for (part in types) { + (part as ObjectType).properties.forEach { (k, v) -> + mergedProperties[k] = v + } + } + return KotlinTypeInfo( + typeName = "Map", + isNullable = true, + isNestedObject = true, + description = node.description, + ) + } + + // If exactly one non-null concrete type exists, use that + val nonNullTypes = types.filter { it !is NullType && it !is UndefinedType } + if (nonNullTypes.size == 1) { + return mapToKotlinType(nonNullTypes[0], context) + } + + // Fall back to Any? with documenting description + val typeNames = types.map { mapToKotlinType(it, context).typeName } + val desc = + buildString { + node.description?.let { append(it).append(". ") } + append("Intersection of: ${typeNames.joinToString(" & ")}") + } + + return KotlinTypeInfo( + typeName = "Any?", + isNullable = true, + description = desc, ) } diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/DefaultValueGeneratorTest.kt b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/DefaultValueGeneratorTest.kt new file mode 100644 index 0000000..0769d7d --- /dev/null +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/DefaultValueGeneratorTest.kt @@ -0,0 +1,147 @@ +package com.intuit.playerui.lang.generator + +import com.intuit.playerui.xlr.ArrayType +import com.intuit.playerui.xlr.BooleanType +import com.intuit.playerui.xlr.NumberType +import com.intuit.playerui.xlr.ObjectProperty +import com.intuit.playerui.xlr.ObjectType +import com.intuit.playerui.xlr.RefType +import com.intuit.playerui.xlr.StringType +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class DefaultValueGeneratorTest : + DescribeSpec({ + + fun objectType( + properties: Map, + extendsRef: RefType? = null, + ): ObjectType = ObjectType(properties = properties, extends = extendsRef) + + fun requiredProp(node: com.intuit.playerui.xlr.NodeType): ObjectProperty = + ObjectProperty(required = true, node = node) + + fun optionalProp(node: com.intuit.playerui.xlr.NodeType): ObjectProperty = + ObjectProperty(required = false, node = node) + + describe("DefaultValueGenerator") { + it("generates type default for asset types") { + val obj = + objectType( + properties = + mapOf( + "value" to requiredProp(StringType()), + ), + extendsRef = + RefType( + ref = "Asset<\"text\">", + genericArguments = listOf(StringType(const = "text")), + ), + ) + + val defaults = DefaultValueGenerator.generateDefaults(obj, "text") + defaults["type"] shouldBe "text" + defaults["id"] shouldBe "" + defaults["value"] shouldBe "" + } + + it("generates string default as empty string") { + val obj = + objectType( + properties = mapOf("name" to requiredProp(StringType())), + ) + + val defaults = DefaultValueGenerator.generateDefaults(obj, null) + defaults["name"] shouldBe "" + } + + it("generates number default as 0") { + val obj = + objectType( + properties = mapOf("count" to requiredProp(NumberType())), + ) + + val defaults = DefaultValueGenerator.generateDefaults(obj, null) + defaults["count"] shouldBe 0 + } + + it("generates boolean default as false") { + val obj = + objectType( + properties = mapOf("active" to requiredProp(BooleanType())), + ) + + val defaults = DefaultValueGenerator.generateDefaults(obj, null) + defaults["active"] shouldBe false + } + + it("generates array default as empty list") { + val obj = + objectType( + properties = + mapOf( + "items" to requiredProp(ArrayType(elementType = StringType())), + ), + ) + + val defaults = DefaultValueGenerator.generateDefaults(obj, null) + defaults["items"] shouldBe emptyList() + } + + it("skips optional properties") { + val obj = + objectType( + properties = + mapOf( + "required" to requiredProp(StringType()), + "optional" to optionalProp(StringType()), + ), + ) + + val defaults = DefaultValueGenerator.generateDefaults(obj, null) + defaults.containsKey("required") shouldBe true + defaults.containsKey("optional") shouldBe false + } + + it("skips AssetWrapper properties") { + val obj = + objectType( + properties = + mapOf( + "label" to requiredProp(RefType(ref = "AssetWrapper")), + ), + ) + + val defaults = DefaultValueGenerator.generateDefaults(obj, null) + defaults.containsKey("label") shouldBe false + } + + it("uses const values over defaults") { + val obj = + objectType( + properties = + mapOf( + "type" to optionalProp(StringType(const = "fixed")), + ), + ) + + val defaults = DefaultValueGenerator.generateDefaults(obj, null) + defaults["type"] shouldBe "fixed" + } + + it("generates expression/binding defaults as empty string") { + val obj = + objectType( + properties = + mapOf( + "expr" to requiredProp(RefType(ref = "Expression")), + "bind" to requiredProp(RefType(ref = "Binding")), + ), + ) + + val defaults = DefaultValueGenerator.generateDefaults(obj, null) + defaults["expr"] shouldBe "" + defaults["bind"] shouldBe "" + } + } + }) diff --git a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/TypeMapperTest.kt b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/TypeMapperTest.kt index c3460e1..f9631ff 100644 --- a/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/TypeMapperTest.kt +++ b/generators/kotlin/src/test/kotlin/com/intuit/playerui/lang/generator/TypeMapperTest.kt @@ -1,11 +1,13 @@ package com.intuit.playerui.lang.generator +import com.intuit.playerui.xlr.AndType import com.intuit.playerui.xlr.AnyType import com.intuit.playerui.xlr.ArrayType import com.intuit.playerui.xlr.BooleanType import com.intuit.playerui.xlr.NeverType import com.intuit.playerui.xlr.NullType import com.intuit.playerui.xlr.NumberType +import com.intuit.playerui.xlr.ObjectProperty import com.intuit.playerui.xlr.ObjectType import com.intuit.playerui.xlr.OrType import com.intuit.playerui.xlr.ParamTypeNode @@ -251,6 +253,128 @@ class TypeMapperTest : } } + describe("AndType mapping") { + it("maps intersection of object types to Map with isNestedObject") { + val result = + TypeMapper.mapToKotlinType( + AndType( + andTypes = + listOf( + ObjectType( + properties = + mapOf( + "name" to ObjectProperty(required = true, node = StringType()), + ), + ), + ObjectType( + properties = + mapOf( + "age" to ObjectProperty(required = true, node = NumberType()), + ), + ), + ), + ), + ) + result.typeName shouldBe "Map" + result.isNestedObject shouldBe true + } + + it("maps single non-null intersection to its type") { + val result = + TypeMapper.mapToKotlinType( + AndType(andTypes = listOf(StringType(), NullType())), + ) + result.typeName shouldBe "String" + } + + it("maps mixed intersection to Any? with description") { + val result = + TypeMapper.mapToKotlinType( + AndType(andTypes = listOf(StringType(), NumberType())), + ) + result.typeName shouldBe "Any?" + result.description?.contains("Intersection") shouldBe true + } + } + + describe("literal string union KDoc") { + it("includes valid values in description") { + val result = + TypeMapper.mapToKotlinType( + OrType( + orTypes = + listOf( + StringType(const = "primary"), + StringType(const = "secondary"), + ), + ), + ) + result.typeName shouldBe "String" + result.description?.contains("\"primary\"") shouldBe true + result.description?.contains("\"secondary\"") shouldBe true + } + } + + describe("AssetWrapper generic extraction") { + it("extracts inner type from AssetWrapper") { + val result = + TypeMapper.mapToKotlinType( + RefType( + ref = "AssetWrapper", + genericArguments = listOf(RefType(ref = "TextAsset")), + ), + ) + result.isAssetWrapper shouldBe true + result.builderType shouldBe "TextBuilder" + } + + it("extracts inner type from AssetWrapper") { + val result = + TypeMapper.mapToKotlinType( + RefType( + ref = "AssetWrapper", + genericArguments = listOf(RefType(ref = "ActionAsset")), + ), + ) + result.isAssetWrapper shouldBe true + result.builderType shouldBe "ActionBuilder" + } + + it("falls back to FluentBuilder<*> for AssetWrapper without generics") { + val result = + TypeMapper.mapToKotlinType( + RefType(ref = "AssetWrapper"), + ) + result.isAssetWrapper shouldBe true + result.builderType shouldBe "FluentBuilder<*>" + } + + it("handles unresolvable generic token as Any?") { + val context = + TypeMapperContext( + genericTokens = mapOf("T" to ParamTypeNode(symbol = "T")), + ) + val result = TypeMapper.mapToKotlinType(RefType(ref = "T"), context) + result.typeName shouldBe "Any?" + } + } + + describe("union with builder types") { + it("collapses union of AssetWrapper types to FluentBuilder") { + val result = + TypeMapper.mapToKotlinType( + OrType( + orTypes = + listOf( + RefType(ref = "AssetWrapper"), + RefType(ref = "Asset"), + ), + ), + ) + result.builderType shouldBe "FluentBuilder<*>" + } + } + describe("RecordType mapping") { it("maps record type to Map") { val result = From 875f8dd19bbde8c750349437d02fa7fb17d51329 Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Wed, 4 Mar 2026 12:57:17 -0500 Subject: [PATCH 5/8] fix: ci syntax issue --- vitest.config.mts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vitest.config.mts b/vitest.config.mts index 07c932a..9af1e9a 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -4,6 +4,11 @@ import { UserConfig } from "vitest"; export default defineConfig({ test: { + server: { + deps: { + inline: [/@player-lang\//], + }, + }, environment: "happy-dom", exclude: [ ...configDefaults.exclude, From 3af8d878eb9a2515067ca5bbd173d4de3f97a18c Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Wed, 4 Mar 2026 13:21:53 -0500 Subject: [PATCH 6/8] fix: ci syntax issue --- tsup.config.ts | 9 --------- vitest.config.mts | 5 ----- 2 files changed, 14 deletions(-) diff --git a/tsup.config.ts b/tsup.config.ts index 1d95863..77fbe61 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -60,15 +60,6 @@ export function createConfig() { fs.copyFileSync("dist/index.mjs", "dist/index.legacy-esm.js"); }, }, - // Browser-ready ESM, production + minified - { - ...defaultOptions, - define: { - "process.env.NODE_ENV": JSON.stringify("production"), - }, - format: ["esm"], - outExtension: () => ({ js: ".mjs" }), - }, { ...defaultOptions, format: "cjs", diff --git a/vitest.config.mts b/vitest.config.mts index 9af1e9a..07c932a 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -4,11 +4,6 @@ import { UserConfig } from "vitest"; export default defineConfig({ test: { - server: { - deps: { - inline: [/@player-lang\//], - }, - }, environment: "happy-dom", exclude: [ ...configDefaults.exclude, From d07fac59a3bd5085d0554ec4f0aaa8ec5ec48813 Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Thu, 5 Mar 2026 10:06:15 -0500 Subject: [PATCH 7/8] refactor: checks and defensive code --- .../playerui/lang/dsl/core/BuildPipeline.kt | 57 ++- .../playerui/lang/dsl/core/StoredValue.kt | 60 ++- .../intuit/playerui/lang/dsl/id/IdRegistry.kt | 13 +- .../lang/dsl/tagged/StandardExpressions.kt | 82 ++-- .../playerui/lang/dsl/tagged/TaggedValue.kt | 45 ++- .../playerui/lang/dsl/IdGeneratorTest.kt | 48 +++ .../lang/dsl/StandardExpressionsTest.kt | 152 ++++++++ .../playerui/lang/generator/ClassGenerator.kt | 367 ++++++++++-------- .../playerui/lang/generator/CodeWriter.kt | 6 +- .../playerui/lang/generator/Generator.kt | 21 +- .../intuit/playerui/lang/generator/Main.kt | 123 +++--- .../lang/generator/SchemaBindingGenerator.kt | 60 ++- .../playerui/lang/generator/TypeMapper.kt | 11 +- 13 files changed, 733 insertions(+), 312 deletions(-) diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt index 7263df7..58ae864 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt @@ -10,6 +10,24 @@ import com.intuit.playerui.lang.dsl.tagged.TaggedValue * Matches the TypeScript implementation's resolution order. */ object BuildPipeline { + /** + * Type-safe helper to cast Map to mutable string-keyed map. + * + * @receiver Any value that is known to be a Map + * @return The value cast to MutableMap + */ + @Suppress("UNCHECKED_CAST") + private fun Any.asMutableStringMap(): MutableMap = this as MutableMap + + /** + * Type-safe helper to cast Map to immutable string-keyed map. + * + * @receiver Any value that is known to be a Map + * @return The value cast to Map + */ + @Suppress("UNCHECKED_CAST") + private fun Any.asStringMap(): Map = this as Map + /** * Executes the full build pipeline. * @@ -116,12 +134,16 @@ object BuildPipeline { val baseId = genId(context) genId(context.copy(parentId = baseId, branch = IdBranch.Slot(slotName))) } + // Has parentId but no branch: append type as a Slot context.parentId.isNotEmpty() -> { genId(context.copy(branch = IdBranch.Slot(slotName))) } + // Fallback - else -> slotName + else -> { + slotName + } } if (generatedId.isNotEmpty()) { @@ -315,15 +337,13 @@ object BuildPipeline { val key = path[i] if (current !is Map<*, *>) return - @Suppress("UNCHECKED_CAST") - var next: Any? = (current as Map)[key] ?: return + var next: Any? = current.asStringMap()[key] ?: return // If intermediate value is a builder, resolve it first if (next is FluentBuilder<*>) { val slotContext = createSlotContext(context, key) next = next.build(slotContext) - @Suppress("UNCHECKED_CAST") - (current as MutableMap)[key] = next + current.asMutableStringMap()[key] = next } current = next @@ -333,8 +353,7 @@ object BuildPipeline { val finalKey = path.last() if (current !is MutableMap<*, *>) return - @Suppress("UNCHECKED_CAST") - val parent = current as MutableMap + val parent = current.asMutableStringMap() val value = parent[finalKey] ?: return // If it's already wrapped in { asset: ... }, skip @@ -377,11 +396,14 @@ object BuildPipeline { val slotContext = context?.withBranch(IdBranch.Slot(slotName)) value.build(slotContext) } + is Map<*, *> -> { - @Suppress("UNCHECKED_CAST") - value as Map + value.asStringMap() + } + + else -> { + return mapOf("asset" to value) } - else -> return mapOf("asset" to value) } return mapOf("asset" to resolved) } @@ -551,15 +573,11 @@ object BuildPipeline { if (index == lastIndex) { when { current is MutableMap<*, *> && segment is String -> { - @Suppress("UNCHECKED_CAST") - val map = current as MutableMap + val map = current.asMutableStringMap() val existing = map[segment] if (existing is Map<*, *> && value is Map<*, *>) { - @Suppress("UNCHECKED_CAST") - val existingMap = existing as Map - - @Suppress("UNCHECKED_CAST") - val valueMap = value as Map + val existingMap = existing.asStringMap() + val valueMap = value.asStringMap() map[segment] = existingMap + valueMap } else { map[segment] = value @@ -570,12 +588,11 @@ object BuildPipeline { current = when { current is Map<*, *> && segment is String -> { - @Suppress("UNCHECKED_CAST") - (current as Map)[segment] + current.asStringMap()[segment] } current is List<*> && segment is Int -> { - (current as List<*>).getOrNull(segment) + current.getOrNull(segment) } // Intentional: when path segment type mismatches the container diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/StoredValue.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/StoredValue.kt index 7a6412e..32a494d 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/StoredValue.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/StoredValue.kt @@ -50,18 +50,37 @@ sealed interface StoredValue { ) : StoredValue } +/** + * Maximum recursion depth for deep copy to prevent stack overflow. + */ +private const val MAX_COPY_DEPTH = 100 + /** * Creates a deep copy of this StoredValue, ensuring mutable containers are not shared. + * @throws IllegalStateException if maximum copy depth is exceeded */ -fun StoredValue.deepCopy(): StoredValue = - when (this) { +fun StoredValue.deepCopy(): StoredValue = deepCopyImpl(0) + +/** + * Internal implementation of deep copy with depth tracking. + */ +private fun StoredValue.deepCopyImpl(depth: Int): StoredValue { + if (depth > MAX_COPY_DEPTH) { + error( + "Deep copy exceeded maximum depth of $MAX_COPY_DEPTH - " + + "possible circular reference or excessively deep structure", + ) + } + + return when (this) { is StoredValue.Primitive -> StoredValue.Primitive(value) is StoredValue.Tagged -> StoredValue.Tagged(value) is StoredValue.Builder -> StoredValue.Builder(builder) is StoredValue.WrappedBuilder -> StoredValue.WrappedBuilder(builder) - is StoredValue.ObjectValue -> StoredValue.ObjectValue(map.mapValues { (_, v) -> v.deepCopy() }) - is StoredValue.ArrayValue -> StoredValue.ArrayValue(items.map { it.deepCopy() }) + is StoredValue.ObjectValue -> StoredValue.ObjectValue(map.mapValues { (_, v) -> v.deepCopyImpl(depth + 1) }) + is StoredValue.ArrayValue -> StoredValue.ArrayValue(items.map { it.deepCopyImpl(depth + 1) }) } +} /** * Converts a raw value to a StoredValue with proper type classification. @@ -85,19 +104,36 @@ fun toStoredValue(value: Any?): StoredValue = } is Map<*, *> -> { - @Suppress("UNCHECKED_CAST") - val map = value as Map - if (map.values.any { containsBuilder(it) }) { - StoredValue.ObjectValue(map.mapValues { (_, v) -> toStoredValue(v) }) - } else { + try { + // Validate all keys are strings before casting + if (value.keys.all { it is String }) { + // Safe: validated all keys are strings + @Suppress("UNCHECKED_CAST") + val map = value as Map + if (map.values.any { containsBuilder(it) }) { + StoredValue.ObjectValue(map.mapValues { (_, v) -> toStoredValue(v) }) + } else { + StoredValue.Primitive(value) + } + } else { + // Fallback: treat as primitive if keys aren't all strings + StoredValue.Primitive(value) + } + } catch (e: Exception) { + // If any error occurs during map processing, treat as primitive StoredValue.Primitive(value) } } is List<*> -> { - if (value.any { containsBuilder(it) }) { - StoredValue.ArrayValue(value.map { toStoredValue(it) }) - } else { + try { + if (value.any { containsBuilder(it) }) { + StoredValue.ArrayValue(value.map { toStoredValue(it) }) + } else { + StoredValue.Primitive(value) + } + } catch (e: Exception) { + // If any error occurs during list processing, treat as primitive StoredValue.Primitive(value) } } diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdRegistry.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdRegistry.kt index 27b949b..11dd544 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdRegistry.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/id/IdRegistry.kt @@ -17,20 +17,27 @@ class IdRegistry { * * @param baseId The desired base ID * @return A unique ID (either baseId or baseId-N where N is a number) + * @throws IllegalStateException if unable to generate unique ID after max attempts */ fun ensureUnique(baseId: String): String { if (registered.add(baseId)) { return baseId } - suffixCounters.putIfAbsent(baseId, 0) - while (true) { - val next = suffixCounters.merge(baseId, 1, Int::plus)!! + // Safeguard against infinite loops (should never be reached in practice) + val maxAttempts = 10000 + repeat(maxAttempts) { + val next = + suffixCounters.compute(baseId) { _, current -> + (current ?: 0) + 1 + } ?: error("Unexpected null from compute operation") val candidate = "$baseId-$next" if (registered.add(candidate)) { return candidate } } + + error("Failed to generate unique ID for '$baseId' after $maxAttempts attempts") } /** diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/StandardExpressions.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/StandardExpressions.kt index 15b7bc8..aae6ed6 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/StandardExpressions.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/StandardExpressions.kt @@ -66,76 +66,82 @@ fun xor(left: Any, right: Any): Expression { } /** - * Equality comparison (loose equality ==). + * Helper function to create binary comparison expressions. + * Reduces code duplication across all comparison operators. */ -fun equal(left: Any, right: T): Expression { +private fun createComparisonExpression( + left: Any, + right: Any?, + operator: String, +): Expression { val leftExpr = toExpressionString(left) val rightExpr = toValueString(right) - return expression("$leftExpr == $rightExpr") + return expression("$leftExpr $operator $rightExpr") } +/** + * Equality comparison (loose equality ==). + */ +fun equal( + left: Any, + right: T, +): Expression = createComparisonExpression(left, right, "==") + /** * Strict equality comparison (===). */ -fun strictEqual(left: Any, right: T): Expression { - val leftExpr = toExpressionString(left) - val rightExpr = toValueString(right) - return expression("$leftExpr === $rightExpr") -} +fun strictEqual( + left: Any, + right: T, +): Expression = createComparisonExpression(left, right, "===") /** * Inequality comparison (!=). */ -fun notEqual(left: Any, right: T): Expression { - val leftExpr = toExpressionString(left) - val rightExpr = toValueString(right) - return expression("$leftExpr != $rightExpr") -} +fun notEqual( + left: Any, + right: T, +): Expression = createComparisonExpression(left, right, "!=") /** * Strict inequality comparison (!==). */ -fun strictNotEqual(left: Any, right: T): Expression { - val leftExpr = toExpressionString(left) - val rightExpr = toValueString(right) - return expression("$leftExpr !== $rightExpr") -} +fun strictNotEqual( + left: Any, + right: T, +): Expression = createComparisonExpression(left, right, "!==") /** * Greater than comparison (>). */ -fun greaterThan(left: Any, right: Any): Expression { - val leftExpr = toExpressionString(left) - val rightExpr = toValueString(right) - return expression("$leftExpr > $rightExpr") -} +fun greaterThan( + left: Any, + right: Any, +): Expression = createComparisonExpression(left, right, ">") /** * Greater than or equal comparison (>=). */ -fun greaterThanOrEqual(left: Any, right: Any): Expression { - val leftExpr = toExpressionString(left) - val rightExpr = toValueString(right) - return expression("$leftExpr >= $rightExpr") -} +fun greaterThanOrEqual( + left: Any, + right: Any, +): Expression = createComparisonExpression(left, right, ">=") /** * Less than comparison (<). */ -fun lessThan(left: Any, right: Any): Expression { - val leftExpr = toExpressionString(left) - val rightExpr = toValueString(right) - return expression("$leftExpr < $rightExpr") -} +fun lessThan( + left: Any, + right: Any, +): Expression = createComparisonExpression(left, right, "<") /** * Less than or equal comparison (<=). */ -fun lessThanOrEqual(left: Any, right: Any): Expression { - val leftExpr = toExpressionString(left) - val rightExpr = toValueString(right) - return expression("$leftExpr <= $rightExpr") -} +fun lessThanOrEqual( + left: Any, + right: Any, +): Expression = createComparisonExpression(left, right, "<=") /** * Addition operation (+). diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt index 19a6b42..94f8852 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/tagged/TaggedValue.kt @@ -32,6 +32,10 @@ sealed interface TaggedValue { class Binding( private val path: String, ) : TaggedValue { + init { + require(path.isNotBlank()) { "Binding path cannot be empty or blank" } + } + override fun toValue(): String = path override fun toString(): String = "{{$path}}" @@ -96,25 +100,48 @@ class Expression( override fun hashCode(): Int = expr.hashCode() private fun validateSyntax(expression: String) { - var openParens = 0 + val stack = mutableListOf>() // (opening bracket, position) + expression.forEachIndexed { index, char -> when (char) { - '(' -> { - openParens++ + '(', '[', '{' -> { + stack.add(char to index) } - ')' -> { - openParens-- - require(openParens >= 0) { - "Unexpected ) at character $index in expression: $expression" + ')', ']', '}' -> { + if (stack.isEmpty()) { + throw IllegalArgumentException( + "Unexpected $char at character $index in expression: $expression", + ) + } + val (opening, openPos) = stack.removeLast() + val expected = + when (char) { + ')' -> '(' + ']' -> '[' + '}' -> '{' + else -> throw IllegalStateException() + } + require(opening == expected) { + "Mismatched brackets: found $char at character $index but expected ${getClosing(opening)} to match $opening at character $openPos in expression: $expression" } } } } - require(openParens <= 0) { - "Expected ) in expression: $expression" + + require(stack.isEmpty()) { + val (bracket, pos) = stack.last() + "Expected ${getClosing(bracket)} to match $bracket at character $pos in expression: $expression" } } + + private fun getClosing(opening: Char): Char = + when (opening) { + '(' -> ')' + '[' -> ']' + '{' -> '}' + else -> throw IllegalArgumentException("Not an opening bracket: $opening") + } } /** diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IdGeneratorTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IdGeneratorTest.kt index e78674c..5c04f46 100644 --- a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IdGeneratorTest.kt +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IdGeneratorTest.kt @@ -474,5 +474,53 @@ class IdGeneratorTest : genId(ctx) reg.isRegistered("check-status") shouldBe true } + + it("handles high-contention ID generation correctly") { + val reg = IdRegistry() + val results = (1..1000).map { reg.ensureUnique("base") } + + // All IDs should be unique + results.toSet().size shouldBe 1000 + + // First should be "base", rest should be "base-N" + results.first() shouldBe "base" + results.drop(1).all { it.matches(Regex("base-\\d+")) } shouldBe true + } + + it("generates correct suffix sequence") { + val reg = IdRegistry() + reg.ensureUnique("test") shouldBe "test" + reg.ensureUnique("test") shouldBe "test-1" + reg.ensureUnique("test") shouldBe "test-2" + reg.ensureUnique("test") shouldBe "test-3" + reg.ensureUnique("test") shouldBe "test-4" + reg.ensureUnique("test") shouldBe "test-5" + } + + it("handles multiple different base IDs concurrently") { + val reg = IdRegistry() + + // Generate IDs for different bases + reg.ensureUnique("alpha") shouldBe "alpha" + reg.ensureUnique("beta") shouldBe "beta" + reg.ensureUnique("alpha") shouldBe "alpha-1" + reg.ensureUnique("gamma") shouldBe "gamma" + reg.ensureUnique("beta") shouldBe "beta-1" + reg.ensureUnique("alpha") shouldBe "alpha-2" + + // Verify size + reg.size() shouldBe 6 + } + + it("handles collisions when suffix already exists") { + val reg = IdRegistry() + + // Manually register both base and first suffix + reg.ensureUnique("item") + reg.ensureUnique("item-1") + + // Next collision should be "item-2" + reg.ensureUnique("item") shouldBe "item-2" + } } }) diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/StandardExpressionsTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/StandardExpressionsTest.kt index ef0af8e..da4b5bb 100644 --- a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/StandardExpressionsTest.kt +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/StandardExpressionsTest.kt @@ -1,8 +1,10 @@ package com.intuit.playerui.lang.dsl import com.intuit.playerui.lang.dsl.tagged.* +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain class StandardExpressionsTest : DescribeSpec({ @@ -301,6 +303,156 @@ class StandardExpressionsTest : } } + describe("Expression validation") { + it("accepts balanced parentheses") { + expression("(a && b)").toString() shouldBe "@[(a && b)]@" + expression("((a || b) && c)").toString() shouldBe "@[((a || b) && c)]@" + expression("(a && (b || c))").toString() shouldBe "@[(a && (b || c))]@" + } + + it("accepts expressions without parentheses") { + expression("a && b").toString() shouldBe "@[a && b]@" + expression("user.name").toString() shouldBe "@[user.name]@" + } + + it("rejects unbalanced opening parentheses") { + val exception = + shouldThrow { + expression("(a && b") + } + exception.message shouldContain "Expected )" + } + + it("rejects multiple unbalanced opening parentheses") { + val exception = + shouldThrow { + expression("((a && b") + } + exception.message shouldContain "Expected )" + } + + it("rejects unbalanced closing parentheses") { + val exception = + shouldThrow { + expression("a && b)") + } + exception.message shouldContain "Unexpected )" + } + + it("rejects expression with only closing parenthesis") { + val exception = + shouldThrow { + expression(")") + } + exception.message shouldContain "Unexpected )" + } + + it("rejects mixed unbalanced parentheses") { + val exception = + shouldThrow { + expression("(a && b))") + } + exception.message shouldContain "Unexpected )" + } + + it("accepts complex nested balanced parentheses") { + expression("((a && b) || (c && d))").toString() shouldBe "@[((a && b) || (c && d))]@" + expression("(((a)))").toString() shouldBe "@[(((a)))]@" + } + } + + describe("Expression validation - extended brackets") { + // Square brackets + it("accepts balanced square brackets") { + expression("array[0]").toString() shouldBe "@[array[0]]@" + expression("data[index[0]]").toString() shouldBe "@[data[index[0]]]@" + } + + it("rejects unbalanced opening square brackets") { + val exception = + shouldThrow { + expression("array[0") + } + exception.message shouldContain "Expected ]" + exception.message shouldContain "at character 5" + } + + it("rejects unbalanced closing square brackets") { + val exception = + shouldThrow { + expression("array0]") + } + exception.message shouldContain "Unexpected ]" + } + + // Curly braces + it("accepts balanced curly braces") { + expression("{a: b}").toString() shouldBe "@[{a: b}]@" + expression("{a: {b: c}}").toString() shouldBe "@[{a: {b: c}}]@" + } + + it("rejects unbalanced opening curly braces") { + val exception = + shouldThrow { + expression("{a: b") + } + exception.message shouldContain "Expected }" + } + + it("rejects unbalanced closing curly braces") { + val exception = + shouldThrow { + expression("a: b}") + } + exception.message shouldContain "Unexpected }" + } + + // Mismatched brackets + it("rejects mismatched opening parenthesis with closing square bracket") { + val exception = + shouldThrow { + expression("(a && b]") + } + exception.message shouldContain "Mismatched brackets" + exception.message shouldContain "found ]" + exception.message shouldContain "expected )" + } + + it("rejects mismatched opening square bracket with closing curly brace") { + val exception = + shouldThrow { + expression("[a, b}") + } + exception.message shouldContain "Mismatched brackets" + exception.message shouldContain "found }" + exception.message shouldContain "expected ]" + } + + it("rejects mismatched opening curly brace with closing parenthesis") { + val exception = + shouldThrow { + expression("{key: value)") + } + exception.message shouldContain "Mismatched brackets" + exception.message shouldContain "found )" + exception.message shouldContain "expected }" + } + + // Mixed bracket types + it("accepts complex nested mixed brackets") { + expression("func(array[index], {key: val})").toString() shouldBe + "@[func(array[index], {key: val})]@" + } + + it("rejects complex mismatched brackets") { + val exception = + shouldThrow { + expression("func(array[index), {key: val})") + } + exception.message shouldContain "Mismatched brackets" + } + } + describe("Aliases") { it("eq is alias for equal") { val x = binding("x") diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt index 2064dff..de4d974 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/ClassGenerator.kt @@ -78,27 +78,19 @@ class ClassGenerator( ?.associateBy { it.symbol } ?: emptyMap() - private val nestedTypeSpecs = mutableListOf() - private val mainBuilderName: String = TypeMapper.toBuilderClassName(document.name.removeSuffix("Asset")) - private val cachedProperties: List by lazy { - objectType.properties.map { (name, prop) -> - createPropertyInfo(name, prop) - } - } - /** * Generate the builder class for the XLR document. */ fun generate(): GeneratedClass { - nestedTypeSpecs.clear() + val nestedTypeSpecs = mutableListOf() val className = mainBuilderName val dslFunctionName = TypeMapper.toDslFunctionName(document.name) val assetType = extractAssetTypeConstant(objectType.extends) - val classSpec = buildMainClass(className, assetType) + val classSpec = buildMainClass(className, assetType, nestedTypeSpecs) val dslFunction = buildDslFunction(dslFunctionName, className, objectType.description) val fileBuilder = @@ -119,6 +111,7 @@ class ClassGenerator( private fun buildMainClass( className: String, assetType: String?, + nestedTypeSpecs: MutableList, ): TypeSpec { val builder = TypeSpec @@ -132,7 +125,7 @@ class ClassGenerator( addIdProperty(builder) // Schema-driven properties - collectProperties().forEach { prop -> + collectProperties(nestedTypeSpecs).forEach { prop -> addPropertyMembers(builder, prop, className, generateWithMethods = true) } @@ -156,22 +149,31 @@ class ClassGenerator( ) // assetWrapperProperties - val awProps = collectProperties().filter { it.isAssetWrapper } + val allProps = + objectType.properties.map { (name, prop) -> + val context = TypeMapperContext(genericTokens = genericTokens) + val typeInfo = TypeMapper.mapToKotlinType(prop.node, context) + val isAssetWrapper = typeInfo.isAssetWrapper || isAssetWrapperRef(prop.node) + val isArray = typeInfo.isArray + Triple(name, isAssetWrapper, isArray) + } + + val awProps = allProps.filter { it.second }.map { it.first } builder.addProperty( PropertySpec .builder("assetWrapperProperties", SET.parameterizedBy(STRING)) .addModifiers(KModifier.OVERRIDE) - .initializer(buildSetInitializer(awProps.map { it.originalName })) + .initializer(buildSetInitializer(awProps)) .build(), ) // arrayProperties - val arrProps = collectProperties().filter { it.isArray } + val arrProps = allProps.filter { it.third }.map { it.first } builder.addProperty( PropertySpec .builder("arrayProperties", SET.parameterizedBy(STRING)) .addModifiers(KModifier.OVERRIDE) - .initializer(buildSetInitializer(arrProps.map { it.originalName })) + .initializer(buildSetInitializer(arrProps)) .build(), ) @@ -190,6 +192,34 @@ class ClassGenerator( } } + /** + * Adds empty override properties for nested classes. + * Nested classes don't have asset wrapper properties or defaults. + */ + private fun addEmptyOverrideProperties(builder: TypeSpec.Builder) { + builder.addProperty( + PropertySpec + .builder("defaults", MAP_STRING_ANY) + .addModifiers(KModifier.OVERRIDE) + .initializer("emptyMap()") + .build(), + ) + builder.addProperty( + PropertySpec + .builder("assetWrapperProperties", SET.parameterizedBy(STRING)) + .addModifiers(KModifier.OVERRIDE) + .initializer("emptySet()") + .build(), + ) + builder.addProperty( + PropertySpec + .builder("arrayProperties", SET.parameterizedBy(STRING)) + .addModifiers(KModifier.OVERRIDE) + .initializer("emptySet()") + .build(), + ) + } + private fun addIdProperty(builder: TypeSpec.Builder) { builder.addProperty( PropertySpec @@ -266,25 +296,22 @@ class ClassGenerator( val nullableType = baseType.copy(nullable = true) val poetName = cleanName(prop.kotlinName) - val propBuilder = - PropertySpec - .builder(poetName, nullableType) - .mutable(true) - prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } - propBuilder - .getter( - FunSpec - .getterBuilder() - .addStatement("return peek(%S) as? %T", prop.originalName, baseType) - .build(), - ).setter( - FunSpec - .setterBuilder() - .addParameter("value", nullableType) - .addStatement("set(%S, value)", prop.originalName) - .build(), - ) - classBuilder.addProperty(propBuilder.build()) + val getter = + FunSpec + .getterBuilder() + .addStatement("return peek(%S) as? %T", prop.originalName, baseType) + .build() + + val setter = + FunSpec + .setterBuilder() + .addParameter("value", nullableType) + .addStatement("set(%S, value)", prop.originalName) + .build() + + classBuilder.addProperty( + createProperty(poetName, nullableType, prop.typeInfo.description, getter, setter), + ) // Binding overload if (prop.hasBindingOverload) { @@ -323,29 +350,26 @@ class ClassGenerator( val poetName = cleanName(prop.kotlinName) val type = FLUENT_BUILDER_BASE_STAR.copy(nullable = true) - val propBuilder = - PropertySpec - .builder(poetName, type) - .mutable(true) - prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } - propBuilder - .getter( - FunSpec - .getterBuilder() - .addComment("Write-only") - .addStatement("return null") - .build(), - ).setter( - FunSpec - .setterBuilder() - .addParameter("value", type) - .addStatement( - "if (value != null) set(%S, %T(value))", - prop.originalName, - ASSET_WRAPPER_BUILDER, - ).build(), - ) - classBuilder.addProperty(propBuilder.build()) + val getter = + FunSpec + .getterBuilder() + .addComment("Write-only") + .addStatement("return null") + .build() + + val setter = + FunSpec + .setterBuilder() + .addParameter("value", type) + .addStatement( + "if (value != null) set(%S, %T(value))", + prop.originalName, + ASSET_WRAPPER_BUILDER, + ).build() + + classBuilder.addProperty( + createProperty(poetName, type, prop.typeInfo.description, getter, setter), + ) // Typed builder function val typeVar = TypeVariableName("T", FLUENT_BUILDER_BASE_STAR) @@ -372,26 +396,23 @@ class ClassGenerator( val poetName = cleanName(prop.kotlinName) val listType = LIST.parameterizedBy(FLUENT_BUILDER_BASE_STAR).copy(nullable = true) - val propBuilder = - PropertySpec - .builder(poetName, listType) - .mutable(true) - prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } - propBuilder - .getter( - FunSpec - .getterBuilder() - .addComment("Write-only") - .addStatement("return null") - .build(), - ).setter( - FunSpec - .setterBuilder() - .addParameter("value", listType) - .addStatement("set(%S, value)", prop.originalName) - .build(), - ) - classBuilder.addProperty(propBuilder.build()) + val getter = + FunSpec + .getterBuilder() + .addComment("Write-only") + .addStatement("return null") + .build() + + val setter = + FunSpec + .setterBuilder() + .addParameter("value", listType) + .addStatement("set(%S, value)", prop.originalName) + .build() + + classBuilder.addProperty( + createProperty(poetName, listType, prop.typeInfo.description, getter, setter), + ) // Varargs function classBuilder.addFunction( @@ -420,25 +441,22 @@ class ClassGenerator( val listType = LIST.parameterizedBy(elementType) val nullableListType = listType.copy(nullable = true) - val propBuilder = - PropertySpec - .builder(poetName, nullableListType) - .mutable(true) - prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } - propBuilder - .getter( - FunSpec - .getterBuilder() - .addStatement("return peek(%S) as? %T", prop.originalName, listType) - .build(), - ).setter( - FunSpec - .setterBuilder() - .addParameter("value", nullableListType) - .addStatement("set(%S, value)", prop.originalName) - .build(), - ) - classBuilder.addProperty(propBuilder.build()) + val getter = + FunSpec + .getterBuilder() + .addStatement("return peek(%S) as? %T", prop.originalName, listType) + .build() + + val setter = + FunSpec + .setterBuilder() + .addParameter("value", nullableListType) + .addStatement("set(%S, value)", prop.originalName) + .build() + + classBuilder.addProperty( + createProperty(poetName, nullableListType, prop.typeInfo.description, getter, setter), + ) // Varargs function classBuilder.addFunction( @@ -461,29 +479,31 @@ class ClassGenerator( generateWithMethods: Boolean, ) { val poetName = cleanName(prop.kotlinName) - val nestedClassName = ClassName(packageName, prop.nestedObjectClassName!!) + val nestedClassName = + ClassName( + packageName, + prop.nestedObjectClassName + ?: error("nestedObjectClassName is required for nested object property"), + ) val nullableType = nestedClassName.copy(nullable = true) - val propBuilder = - PropertySpec - .builder(poetName, nullableType) - .mutable(true) - prop.typeInfo.description?.let { propBuilder.addKdoc("%L", it) } - propBuilder - .getter( - FunSpec - .getterBuilder() - .addComment("Write-only") - .addStatement("return null") - .build(), - ).setter( - FunSpec - .setterBuilder() - .addParameter("value", nullableType) - .addStatement("if (value != null) set(%S, value)", prop.originalName) - .build(), - ) - classBuilder.addProperty(propBuilder.build()) + val getter = + FunSpec + .getterBuilder() + .addComment("Write-only") + .addStatement("return null") + .build() + + val setter = + FunSpec + .setterBuilder() + .addParameter("value", nullableType) + .addStatement("if (value != null) set(%S, value)", prop.originalName) + .build() + + classBuilder.addProperty( + createProperty(poetName, nullableType, prop.typeInfo.description, getter, setter), + ) // Lambda DSL function val lambdaType = LambdaTypeName.get(receiver = nestedClassName, returnType = UNIT) @@ -510,6 +530,28 @@ class ClassGenerator( } } + /** + * Creates a property with getter and setter, optionally adding KDoc. + */ + private fun createProperty( + name: String, + type: TypeName, + description: String?, + getter: FunSpec, + setter: FunSpec, + ): PropertySpec { + val builder = + PropertySpec + .builder(name, type) + .mutable(true) + .getter(getter) + .setter(setter) + + description?.let { builder.addKdoc("%L", it) } + + return builder.build() + } + private fun addWithMethod( classBuilder: TypeSpec.Builder, poetName: String, @@ -554,6 +596,7 @@ class ClassGenerator( private fun generateNestedClass( propertyName: String, objectType: ObjectType, + nestedTypeSpecs: MutableList, ): String { val baseName = mainBuilderName.removeSuffix("Builder") val className = baseName + propertyName.replaceFirstChar { it.uppercase() } + "Config" @@ -567,50 +610,14 @@ class ClassGenerator( objectType.description?.let { builder.addKdoc("%L", it) } - builder.addProperty( - PropertySpec - .builder("defaults", MAP_STRING_ANY) - .addModifiers(KModifier.OVERRIDE) - .initializer("emptyMap()") - .build(), - ) - builder.addProperty( - PropertySpec - .builder("assetWrapperProperties", SET.parameterizedBy(STRING)) - .addModifiers(KModifier.OVERRIDE) - .initializer("emptySet()") - .build(), - ) - builder.addProperty( - PropertySpec - .builder("arrayProperties", SET.parameterizedBy(STRING)) - .addModifiers(KModifier.OVERRIDE) - .initializer("emptySet()") - .build(), - ) + addEmptyOverrideProperties(builder) objectType.properties.forEach { (propName, propObj) -> - val propInfo = createPropertyInfo(propName, propObj, allowNestedGeneration = false) + val propInfo = createPropertyInfo(propName, propObj, nestedTypeSpecs, allowNestedGeneration = false) addPropertyMembers(builder, propInfo, className, generateWithMethods = false) } - builder.addFunction( - FunSpec - .builder("build") - .addModifiers(KModifier.OVERRIDE) - .addParameter("context", BUILD_CONTEXT.copy(nullable = true)) - .returns(MAP_STRING_ANY) - .addStatement("return buildWithDefaults(context)") - .build(), - ) - builder.addFunction( - FunSpec - .builder("clone") - .addModifiers(KModifier.OVERRIDE) - .returns(classType) - .addStatement("return %T().also { cloneStorageTo(it) }", classType) - .build(), - ) + addBuildAndCloneMethods(builder, className) nestedTypeSpecs.add(builder.build()) return className @@ -640,11 +647,17 @@ class ClassGenerator( return builder.build() } - private fun collectProperties(): List = cachedProperties + private fun collectProperties(nestedTypeSpecs: MutableList): List { + // Recompute properties with the provided nestedTypeSpecs + return objectType.properties.map { (name, prop) -> + createPropertyInfo(name, prop, nestedTypeSpecs) + } + } private fun createPropertyInfo( name: String, prop: ObjectProperty, + nestedTypeSpecs: MutableList, allowNestedGeneration: Boolean = true, ): PropertyInfo { val context = TypeMapperContext(genericTokens = genericTokens) @@ -654,7 +667,7 @@ class ClassGenerator( val isNestedObject = allowNestedGeneration && prop.node is ObjectType val nestedClassName = if (isNestedObject) { - generateNestedClass(name, prop.node as ObjectType) + generateNestedClass(name, prop.node as ObjectType, nestedTypeSpecs) } else { null } @@ -727,23 +740,34 @@ class ClassGenerator( for (i in str.indices) { when (str[i]) { '<' -> depth++ - '>' -> depth-- + '>' -> { + depth-- + // Malformed input: unmatched closing bracket + if (depth < 0) return -1 + } ',' -> if (depth == 0) return i } } + // No comma found at depth 0, or unclosed brackets return -1 } /** * Recursively finds all paths to AssetWrapper properties within an ObjectType. * Returns paths with length >= 2 (single-level paths are handled by assetWrapperProperties). + * + * Uses object instance tracking to prevent infinite recursion on circular references. */ private fun findAssetWrapperPaths( rootType: ObjectType, currentPath: List = emptyList(), - visited: MutableSet = mutableSetOf(), + visited: MutableSet = mutableSetOf(), ): List> { + // Prevent cycles by checking if we've already visited this object instance + if (rootType in visited) return emptyList() + val paths = mutableListOf>() + visited.add(rootType) for ((propName, prop) in rootType.properties) { val node = prop.node @@ -760,11 +784,7 @@ class ClassGenerator( } // Nested ObjectType — recurse node is ObjectType -> { - val typeName = node.name - if (typeName != null && typeName in visited) continue - if (typeName != null) visited.add(typeName) paths.addAll(findAssetWrapperPaths(node, fullPath, visited)) - if (typeName != null) visited.remove(typeName) } } } @@ -819,11 +839,22 @@ class ClassGenerator( */ internal fun shouldHaveOverload(typeName: String): Boolean = typeName in PRIMITIVE_OVERLOAD_TYPES - private fun cleanName(kotlinName: String): String = - kotlinName.removePrefix("`").removeSuffix("`") + /** + * Extension function to remove Kotlin backtick escaping from a name. + */ + private fun String.cleanKotlinName(): String = + removePrefix("`").removeSuffix("`") + + /** + * Extension function to convert a property name to a "with" method name. + */ + private fun String.toWithMethodName(): String = + "with${replaceFirstChar { it.uppercase() }}" + + // Keep the old functions for backward compatibility within the class + private fun cleanName(kotlinName: String): String = kotlinName.cleanKotlinName() - private fun withMethodName(poetName: String): String = - "with${poetName.replaceFirstChar { it.uppercase() }}" + private fun withMethodName(poetName: String): String = poetName.toWithMethodName() } } diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/CodeWriter.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/CodeWriter.kt index ad26bee..da04b61 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/CodeWriter.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/CodeWriter.kt @@ -52,7 +52,7 @@ class CodeWriter { * Add a block of code with automatic indentation. * Opens with the given line, increases indent, runs the block, decreases indent, closes with closing line. */ - fun block( + inline fun block( openLine: String, closeLine: String = "}", block: CodeWriter.() -> Unit, @@ -224,11 +224,11 @@ class CodeWriter { /** * Create a CodeWriter and run the builder block. */ - fun write(block: CodeWriter.() -> Unit): String = CodeWriter().apply(block).buildWithNewline() + inline fun write(block: CodeWriter.() -> Unit): String = CodeWriter().apply(block).buildWithNewline() } } /** * Extension function for easily creating code blocks. */ -fun codeWriter(block: CodeWriter.() -> Unit): String = CodeWriter.write(block) +inline fun codeWriter(block: CodeWriter.() -> Unit): String = CodeWriter.write(block) diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Generator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Generator.kt index 0b169b9..7e2075c 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Generator.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Generator.kt @@ -3,6 +3,7 @@ package com.intuit.playerui.lang.generator import com.intuit.playerui.xlr.XlrDeserializer import com.intuit.playerui.xlr.XlrDocument import java.io.File +import java.io.IOException /** * Configuration for the Kotlin DSL generator. @@ -10,7 +11,13 @@ import java.io.File data class GeneratorConfig( val packageName: String, val outputDir: File, -) +) { + init { + require(packageName.matches(Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$"))) { + "Invalid package name: $packageName. Package names must start with a lowercase letter and contain only lowercase letters, digits, underscores, and dots." + } + } +} /** * Result of generating a single file. @@ -48,6 +55,10 @@ class Generator( * Generate a Kotlin builder from a single XLR JSON file. */ fun generateFromFile(file: File): GeneratorResult { + require(file.exists()) { "File not found: ${file.absolutePath}" } + require(file.isFile) { "Not a file: ${file.absolutePath}" } + require(file.canRead()) { "File not readable: ${file.absolutePath}" } + val jsonContent = file.readText() return generateFromJson(jsonContent) } @@ -76,7 +87,13 @@ class Generator( // Write the generated file val outputFile = File(outputPackageDir, "${generatedClass.className}.kt") - outputFile.writeText(generatedClass.code) + try { + outputFile.writeText(generatedClass.code) + } catch (e: IOException) { + throw IllegalStateException("I/O error writing to ${outputFile.absolutePath}: ${e.message}", e) + } catch (e: SecurityException) { + throw IllegalStateException("Permission denied writing to ${outputFile.absolutePath}", e) + } return GeneratorResult( className = generatedClass.className, diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt index cdaff34..4c52fbe 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/Main.kt @@ -54,9 +54,21 @@ fun main(args: Array) { } private fun generateSchemaBindings(parsedArgs: ParsedArgs) { - val schemaFile = parsedArgs.schemaFile!! - val packageName = parsedArgs.packageName!! - val outputDir = parsedArgs.outputDir!! + val schemaFile = + parsedArgs.schemaFile ?: run { + System.err.println("Error: Schema file is required") + exitProcess(1) + } + val packageName = + parsedArgs.packageName ?: run { + System.err.println("Error: Package name is required") + exitProcess(1) + } + val outputDir = + parsedArgs.outputDir ?: run { + System.err.println("Error: Output directory is required") + exitProcess(1) + } if (!schemaFile.isFile) { System.err.println("Error: Schema file not found: ${schemaFile.absolutePath}") @@ -73,7 +85,7 @@ private fun generateSchemaBindings(parsedArgs: ParsedArgs) { println(" Schema: ${schemaFile.name}") println(" Object: $objectName") - try { + handleGenerationErrors(schemaFile.name) { val schemaJson = schemaFile.readText() val generator = SchemaBindingGenerator(packageName) val result = generator.generate(schemaJson, objectName) @@ -89,20 +101,22 @@ private fun generateSchemaBindings(parsedArgs: ParsedArgs) { println(" Generated: ${result.className} -> ${outputFile.absolutePath}") println() println("Generation complete: 1 succeeded, 0 failed") - } catch (e: java.io.IOException) { - System.err.println(" Error processing ${schemaFile.name}: ${e.message}") - exitProcess(1) - } catch (e: IllegalArgumentException) { - System.err.println(" Error processing ${schemaFile.name}: ${e.message}") - exitProcess(1) } } private fun generateAssetBuilders(parsedArgs: ParsedArgs) { val config = GeneratorConfig( - packageName = parsedArgs.packageName!!, - outputDir = parsedArgs.outputDir!!, + packageName = + parsedArgs.packageName ?: run { + System.err.println("Error: Package name is required") + exitProcess(1) + }, + outputDir = + parsedArgs.outputDir ?: run { + System.err.println("Error: Output directory is required") + exitProcess(1) + }, ) val generator = Generator(config) @@ -122,15 +136,15 @@ private fun generateAssetBuilders(parsedArgs: ParsedArgs) { var errorCount = 0 inputFiles.forEach { file -> - try { - val result = generator.generateFromFile(file) - println(" Generated: ${result.className} -> ${result.filePath.absolutePath}") + val success = + handleGenerationErrors(file.name) { + val result = generator.generateFromFile(file) + println(" Generated: ${result.className} -> ${result.filePath.absolutePath}") + } + + if (success) { successCount++ - } catch (e: java.io.IOException) { - System.err.println(" Error processing ${file.name}: ${e.message}") - errorCount++ - } catch (e: IllegalArgumentException) { - System.err.println(" Error processing ${file.name}: ${e.message}") + } else { errorCount++ } } @@ -143,6 +157,25 @@ private fun generateAssetBuilders(parsedArgs: ParsedArgs) { } } +/** + * Handles generation errors consistently across all generation modes. + * Returns true if the operation succeeded, false if an error occurred. + */ +private inline fun handleGenerationErrors( + fileName: String, + block: () -> Unit, +): Boolean = + try { + block() + true + } catch (e: java.io.IOException) { + System.err.println(" Error processing $fileName: ${e.message}") + false + } catch (e: IllegalArgumentException) { + System.err.println(" Error processing $fileName: ${e.message}") + false + } + private data class ParsedArgs( val inputPaths: List = emptyList(), val outputDir: File? = null, @@ -169,37 +202,47 @@ private fun parseArgs(args: Array): ParsedArgs { "--input", "-i" -> { i++ - if (i < args.size) { - inputPaths.add(File(args[i])) + if (i >= args.size) { + System.err.println("Error: --input requires a value") + exitProcess(1) } + inputPaths.add(File(args[i])) } "--output", "-o" -> { i++ - if (i < args.size) { - outputDir = File(args[i]) + if (i >= args.size) { + System.err.println("Error: --output requires a value") + exitProcess(1) } + outputDir = File(args[i]) } "--package", "-p" -> { i++ - if (i < args.size) { - packageName = args[i] + if (i >= args.size) { + System.err.println("Error: --package requires a value") + exitProcess(1) } + packageName = args[i] } "--schema", "-s" -> { i++ - if (i < args.size) { - schemaFile = File(args[i]) + if (i >= args.size) { + System.err.println("Error: --schema requires a value") + exitProcess(1) } + schemaFile = File(args[i]) } "--schema-name" -> { i++ - if (i < args.size) { - schemaName = args[i] + if (i >= args.size) { + System.err.println("Error: --schema-name requires a value") + exitProcess(1) } + schemaName = args[i] } else -> { @@ -215,27 +258,19 @@ private fun parseArgs(args: Array): ParsedArgs { return ParsedArgs(inputPaths, outputDir, packageName, schemaFile, schemaName, showHelp) } -private fun collectInputFiles(paths: List): List { - val result = mutableListOf() - - paths.forEach { path -> +private fun collectInputFiles(paths: List): List = + paths.flatMap { path -> when { - path.isDirectory -> { + path.isDirectory -> path .walkTopDown() .filter { it.isFile && it.extension == "json" } - .forEach { result.add(it) } - } - - path.isFile && path.extension == "json" -> { - result.add(path) - } + .toList() + path.isFile && path.extension == "json" -> listOf(path) + else -> emptyList() } } - return result -} - private fun printHelp() { println( """ diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt index 3935a0b..883fb8b 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/SchemaBindingGenerator.kt @@ -131,11 +131,11 @@ class SchemaBindingGenerator( // Handle arrays if (field.isArray == true) { - val arrayPath = if (path.isNotEmpty()) "$path._current_" else "_current_" + val arrayPath = if (path.isNotEmpty()) "$path.$ARRAY_CURRENT_SEGMENT" else ARRAY_CURRENT_SEGMENT if (typeName in PRIMITIVE_TYPES) { // Primitive arrays: create nested object with name/value property - val propName = if (typeName == "StringType") "name" else "value" + val propName = if (typeName == "StringType") STRING_ARRAY_PROPERTY else PRIMITIVE_ARRAY_PROPERTY val nestedObject = TypeSpec .objectBuilder(kotlinName) @@ -155,6 +155,7 @@ class SchemaBindingGenerator( return } + // Fallback for unknown array types builder.addProperty(buildBindingProperty(kotlinName, "StringType", arrayPath)) return } @@ -219,20 +220,55 @@ class SchemaBindingGenerator( } private fun parseSchema(json: String): Map> { - val jsonElement = Json.parseToJsonElement(json) - val jsonObject = jsonElement.jsonObject + val jsonElement = + try { + Json.parseToJsonElement(json) + } catch (e: Exception) { + throw IllegalArgumentException("Invalid JSON schema: ${e.message}", e) + } + + val jsonObject = + jsonElement.jsonObject + ?: throw IllegalArgumentException( + "Schema root must be a JSON object, got: ${jsonElement::class.simpleName}", + ) - return jsonObject.mapValues { (_, nodeElement) -> - val nodeObject = nodeElement.jsonObject - nodeObject.mapValues { (_, fieldElement) -> - parseSchemaField(fieldElement) + return jsonObject.mapValues { (nodeName, nodeElement) -> + val nodeObject = + nodeElement.jsonObject + ?: throw IllegalArgumentException( + "Schema node '$nodeName' must be a JSON object, " + + "got: ${nodeElement::class.simpleName}", + ) + nodeObject.mapValues { (fieldName, fieldElement) -> + try { + parseSchemaField(fieldElement) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException( + "Error parsing field '$nodeName.$fieldName': ${e.message}", + e, + ) + } catch (e: Exception) { + throw IllegalArgumentException( + "Unexpected error parsing field '$nodeName.$fieldName': ${e.message}", + e, + ) + } } } } private fun parseSchemaField(element: JsonElement): SchemaField { - val obj = element.jsonObject - val type = obj["type"]?.jsonPrimitive?.content ?: error("Schema field must have a 'type' property") + val obj = + element.jsonObject + ?: throw IllegalArgumentException( + "Schema field must be a JSON object, got: ${element::class.simpleName}", + ) + + val type = + obj["type"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Schema field must have a 'type' property") + val isRecord = obj["isRecord"]?.jsonPrimitive?.booleanOrNull val isArray = obj["isArray"]?.jsonPrimitive?.booleanOrNull @@ -243,6 +279,10 @@ class SchemaBindingGenerator( private val PRIMITIVE_TYPES = setOf("StringType", "NumberType", "BooleanType") private val BINDING = ClassName("com.intuit.playerui.lang.dsl.tagged", "Binding") + private const val ARRAY_CURRENT_SEGMENT = "_current_" + private const val STRING_ARRAY_PROPERTY = "name" + private const val PRIMITIVE_ARRAY_PROPERTY = "value" + /** * Generate schema binding code from a JSON string. */ diff --git a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt index f4af282..001de76 100644 --- a/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt +++ b/generators/kotlin/src/main/kotlin/com/intuit/playerui/lang/generator/TypeMapper.kt @@ -224,6 +224,11 @@ object TypeMapper { ) } + /** + * Extension function to filter out null and undefined types from a list of NodeTypes. + */ + private fun List.filterNullTypes() = filter { it !is NullType && it !is UndefinedType } + private fun mapOrType( node: OrType, context: TypeMapperContext, @@ -232,7 +237,7 @@ object TypeMapper { // Separate nullable types (null, undefined) from non-nullable types val nullableTypes = types.filter { it is NullType || it is UndefinedType } - val nonNullTypes = types.filter { it !is NullType && it !is UndefinedType } + val nonNullTypes = types.filterNullTypes() val hasNullBranch = nullableTypes.isNotEmpty() // If all non-null types are the same primitive, collapse to nullable primitive @@ -245,7 +250,7 @@ object TypeMapper { // If all non-null types are StringType with const values, it's a literal string union // e.g., "foo" | "bar" | "baz" → String with KDoc listing valid values if (nonNullTypes.all { it is StringType && (it as StringType).const != null }) { - val validValues = nonNullTypes.map { (it as StringType).const!! } + val validValues = nonNullTypes.mapNotNull { (it as? StringType)?.const } val desc = buildString { node.description?.let { append(it).append(". ") } @@ -327,7 +332,7 @@ object TypeMapper { } // If exactly one non-null concrete type exists, use that - val nonNullTypes = types.filter { it !is NullType && it !is UndefinedType } + val nonNullTypes = types.filterNullTypes() if (nonNullTypes.size == 1) { return mapToKotlinType(nonNullTypes[0], context) } From bc459ae41f88d3199c5e2c5583ecf101439762d0 Mon Sep 17 00:00:00 2001 From: Rafael Campos Date: Thu, 5 Mar 2026 15:20:10 -0500 Subject: [PATCH 8/8] fix: remove binding brackets --- .../playerui/lang/dsl/core/BuildPipeline.kt | 63 ++++++++++--------- .../lang/dsl/FluentBuilderBaseTest.kt | 2 +- .../playerui/lang/dsl/IntegrationTest.kt | 14 ++--- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt index 58ae864..26ce288 100644 --- a/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt +++ b/dsl/kotlin/src/main/kotlin/com/intuit/playerui/lang/dsl/core/BuildPipeline.kt @@ -99,7 +99,11 @@ object BuildPipeline { } is StoredValue.Tagged -> { - result[key] = stored.value.toString() + result[key] = if (key == "binding" || key == "data") { + stored.value.toValue() + } else { + stored.value.toString() + } } // Builder/WrappedBuilder/ObjectValue/ArrayValue handled in later steps @@ -120,11 +124,12 @@ object BuildPipeline { if (context == null) return val type = result["type"] as? String + // Not an asset (no type field) — skip ID generation + if (type == null) return val binding = result["binding"] as? String val value = result["value"] as? String - val assetMetadata = if (type != null) AssetMetadata(type, binding, value) else null - val parameterName = type ?: "asset" - val slotName = determineSlotName(parameterName, assetMetadata) + val assetMetadata = AssetMetadata(type, binding, value) + val slotName = determineSlotName(type, assetMetadata) val generatedId = when { @@ -421,16 +426,25 @@ object BuildPipeline { val switches = auxiliary.getList(AuxiliaryStorage.SWITCHES) if (switches.isEmpty()) return + var globalCaseIndex = 0 switches.forEach { switchMeta -> val (path, args) = switchMeta val switchKey = if (args.isDynamic) "dynamicSwitch" else "staticSwitch" + val propertyName = path.firstOrNull()?.toString() ?: "" + val switchParentId = if (context?.parentId?.isNotEmpty() == true) { + "${context.parentId}-$propertyName" + } else { + propertyName + } + val switchContext = context?.withParentId(switchParentId)?.clearBranch() + val resolvedCases = args.cases.mapIndexed { index, case -> val caseContext = - context?.withBranch( + switchContext?.withBranch( IdBranch.Switch( - index = index, + index = globalCaseIndex + index, kind = if (args.isDynamic) { IdBranch.Switch.SwitchKind.DYNAMIC @@ -458,7 +472,6 @@ object BuildPipeline { // wrap the switch result in an array to match the expected schema type. // Only wrap if we're replacing the entire property (path.size == 1), // not a specific element in the array (path.size > 1) - val propertyName = path.firstOrNull()?.toString() ?: "" var switchResult: Any = mapOf(switchKey to resolvedCases) if (propertyName in arrayProperties && path.size == 1) { @@ -467,6 +480,8 @@ object BuildPipeline { // Inject switch at the specified path injectAtPath(result, path, switchResult) + + globalCaseIndex += args.cases.size } } @@ -481,14 +496,13 @@ object BuildPipeline { val templates = auxiliary.getList(AuxiliaryStorage.TEMPLATES) if (templates.isEmpty()) return - templates.forEach { templateFn -> + val resolvedTemplates = templates.map { templateFn -> val templateContext = context ?: BuildContext() val config = templateFn(templateContext) - val templateKey = if (config.dynamic) "dynamicTemplate" else "template" - val templateDepth = extractTemplateDepth(context) - val valueContext = templateContext.withBranch(IdBranch.Template(templateDepth)) + val newParentId = genId(templateContext) + val valueContext = templateContext.copy(parentId = newParentId, branch = IdBranch.Template(templateDepth)) val resolvedValue = when (val v = config.value) { @@ -496,18 +510,14 @@ object BuildPipeline { else -> resolveValue(v) } - val templateData = - mapOf( - "data" to config.data, - "output" to config.output, - "value" to resolvedValue, - ) - - // Get existing array or create new one - val existingArray = (result[config.output] as? List<*>)?.toMutableList() ?: mutableListOf() - existingArray.add(mapOf(templateKey to templateData)) - result[config.output] = existingArray + buildMap { + put("data", config.data) + put("output", config.output) + put("value", resolvedValue) + if (config.dynamic) put("dynamic", true) + } } + result["template"] = resolvedTemplates } /** @@ -574,14 +584,7 @@ object BuildPipeline { when { current is MutableMap<*, *> && segment is String -> { val map = current.asMutableStringMap() - val existing = map[segment] - if (existing is Map<*, *> && value is Map<*, *>) { - val existingMap = existing.asStringMap() - val valueMap = value.asStringMap() - map[segment] = existingMap + valueMap - } else { - map[segment] = value - } + map[segment] = value } } } else { diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt index c1b1ae4..8ccfde8 100644 --- a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/FluentBuilderBaseTest.kt @@ -76,7 +76,7 @@ class FluentBuilderBaseTest : }.build() result["type"] shouldBe "input" - result["binding"] shouldBe "{{user.email}}" + result["binding"] shouldBe "user.email" result["placeholder"] shouldBe "Enter email" } diff --git a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt index 7b4c19c..45d019e 100644 --- a/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt +++ b/dsl/kotlin/src/test/kotlin/com/intuit/playerui/lang/dsl/IntegrationTest.kt @@ -147,7 +147,7 @@ class IntegrationTest : values.size shouldBe 3 values[0]["type"] shouldBe "input" - values[0]["binding"] shouldBe "{{user.firstName}}" + values[0]["binding"] shouldBe "user.firstName" values[0]["placeholder"] shouldBe "Enter your first name" val firstNameLabel = values[0]["label"] as Map @@ -188,16 +188,14 @@ class IntegrationTest : result["id"] shouldBe "user-list" result["type"] shouldBe "collection" - // Verify template is added to values array - val values = result["values"] as List - values.size shouldBe 1 + // Verify templates are in top-level template[] array + val templates = result["template"] as List> + templates.size shouldBe 1 - val templateWrapper = values[0] as Map - templateWrapper.containsKey("dynamicTemplate") shouldBe true - - val templateData = templateWrapper["dynamicTemplate"] as Map + val templateData = templates[0] templateData["data"] shouldBe "{{users}}" templateData["output"] shouldBe "values" + templateData["dynamic"] shouldBe true val templateValue = templateData["value"] as Map templateValue["asset"] shouldNotBe null