diff --git a/BUILD.bazel b/BUILD.bazel index 9d05c86c..fdd7de38 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -101,3 +101,12 @@ tsconfig_to_swcconfig.t2s( stdout = ".swcrc", visibility = ["//:__subpackages__"], ) + +# Kotlin compiler options for kotlin-dsl +load("@rules_kotlin//kotlin:core.bzl", "kt_kotlinc_options") + +kt_kotlinc_options( + name = "kt_opts", + jvm_target = "21", + visibility = ["//visibility:public"], +) diff --git a/MODULE.bazel b/MODULE.bazel index 0a535ab9..26beeff0 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -78,3 +78,24 @@ build_constants( name = "build_constants", version_file = "//:VERSION", ) + +####### Kotlin (for kotlin-dsl) ######### +bazel_dep(name = "rules_kotlin", version = "2.2.1") +bazel_dep(name = "rules_jvm_external", version = "6.6") + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +maven.install( + artifacts = [ + "org.jetbrains.kotlin:kotlin-stdlib: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.platform:junit-platform-console:1.11.4", + ], + repositories = [ + "https://repo1.maven.org/maven2", + ], +) +use_repo(maven, "maven") +########################################### diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 133abf85..7dad1231 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -189,7 +189,8 @@ "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", @@ -425,96 +426,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/language/dsl/kotlin/.editorconfig b/language/dsl/kotlin/.editorconfig new file mode 100644 index 00000000..9eab273a --- /dev/null +++ b/language/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/language/dsl/kotlin/BUILD.bazel b/language/dsl/kotlin/BUILD.bazel new file mode 100644 index 00000000..4e40ba4b --- /dev/null +++ b/language/dsl/kotlin/BUILD.bazel @@ -0,0 +1,59 @@ +load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") +load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config", "ktlint_fix", "ktlint_test") + +package(default_visibility = ["//visibility:public"]) + +ktlint_config( + name = "ktlint_config", + editorconfig = ".editorconfig", +) + +kt_jvm_library( + name = "kotlin-dsl", + srcs = glob(["src/main/kotlin/**/*.kt"]), + kotlinc_opts = "//:kt_opts", + deps = [ + "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + ], +) + +kt_jvm_test( + name = "kotlin-dsl-test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + kotlinc_opts = "//:kt_opts", + args = [ + "--select-class=com.intuit.playertools.fluent.IdGeneratorTest", + "--select-class=com.intuit.playertools.fluent.FluentBuilderBaseTest", + "--select-class=com.intuit.playertools.fluent.FlowTest", + "--select-class=com.intuit.playertools.fluent.IntegrationTest", + "--select-class=com.intuit.playertools.fluent.StandardExpressionsTest", + ], + main_class = "org.junit.platform.console.ConsoleLauncher", + deps = [ + ":kotlin-dsl", + "@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", + ], +) + +ktlint_test( + name = "ktlint", + srcs = glob([ + "src/main/kotlin/**/*.kt", + "src/test/kotlin/**/*.kt", + ]), + config = ":ktlint_config", +) + +ktlint_fix( + name = "ktlint_fix", + srcs = glob([ + "src/main/kotlin/**/*.kt", + "src/test/kotlin/**/*.kt", + ]), + config = ":ktlint_config", +) diff --git a/language/dsl/kotlin/README.md b/language/dsl/kotlin/README.md new file mode 100644 index 00000000..ceae4228 --- /dev/null +++ b/language/dsl/kotlin/README.md @@ -0,0 +1,330 @@ +# Kotlin DSL for Player-UI + +A Kotlin DSL library for building Player-UI content programmatically. This library provides type-safe builders that produce JSON-serializable output compatible with Player-UI. + +## Installation + +### Bazel + +```starlark +deps = [ + "//language/dsl/kotlin:kotlin-dsl", +] +``` + +## Usage + +### Building a Flow + +```kotlin +import com.intuit.playertools.fluent.flow.flow +import com.intuit.playertools.fluent.mocks.builders.* +import com.intuit.playertools.fluent.tagged.binding + +val content = flow { + id = "registration-flow" + views = listOf( + collection { + id = "form" + label { value = "User Registration" } + values( + input { + binding("user.firstName") + label { value = "First Name" } + }, + input { + binding("user.lastName") + label { value = "Last Name" } + } + ) + actions( + action { + value = "submit" + label { value = "Register" } + } + ) + } + ) + data = mapOf( + "user" to mapOf( + "firstName" to "", + "lastName" 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") + ), + "END_Done" to mapOf( + "state_type" to "END", + "outcome" to "done" + ) + ) + ) +} +``` + +### Building Individual Assets + +```kotlin +import com.intuit.playertools.fluent.mocks.builders.* +import com.intuit.playertools.fluent.tagged.binding +import com.intuit.playertools.fluent.tagged.expression + +// Text asset +val textAsset = text { + value = "Hello World" +}.build() + +// Text with binding +val boundText = text { + value(binding("user.displayName")) +}.build() + +// Input asset +val inputAsset = input { + binding("user.email") + label { value = "Email Address" } + placeholder = "Enter your email" +}.build() + +// Action asset +val actionAsset = action { + value = "submit" + label { value = "Submit" } + exp = expression("submitForm()") +}.build() + +// Collection asset +val collectionAsset = collection { + label { value = "Form Section" } + values( + text { value = "Item 1" }, + text { value = "Item 2" } + ) +}.build() +``` + +### Bindings and Expressions + +Bindings reference paths in the Player-UI data model: + +```kotlin +import com.intuit.playertools.fluent.tagged.binding + +// Simple binding - produces "{{user.name}}" +val nameBinding = binding("user.name") + +// Nested binding - produces "{{user.address.city}}" +val cityBinding = binding("user.address.city") + +// Template binding with index - produces "{{items._index_.value}}" +val templateBinding = binding("items._index_.value") +``` + +Expressions are evaluated at runtime: + +```kotlin +import com.intuit.playertools.fluent.tagged.expression + +// Boolean expression - produces "@[user.age >= 18]@" +val isAdult = expression("user.age >= 18") + +// Function call - produces "@[navigate('home')]@" +val navExpr = expression("navigate('home')") +``` + +### Conditional Building + +```kotlin +val showValidation = true +val isPrimary = false + +val builder = input { + binding("user.email") +} + +// Set property conditionally +builder.setIf({ showValidation }, "validation", mapOf("required" to true)) + +// Set one of two values based on condition +builder.setIfElse( + { isPrimary }, + "metaData", + mapOf("role" to "primary"), + mapOf("role" to "secondary") +) +``` + +### Templates + +Templates generate dynamic lists from data: + +```kotlin +collection { + id = "user-list" + label { value = "Users" } + template( + data = binding>("users"), + output = "values", + dynamic = true + ) { + text { value(binding("users._index_.name")) } + } +} +``` + +### Switches + +Switches select assets based on conditions: + +```kotlin +collection { + id = "i18n-content" + switch( + path = listOf("label"), + isDynamic = false + ) { + case(expression("locale === 'es'"), text { value = "Hola" }) + case(expression("locale === 'fr'"), text { value = "Bonjour" }) + default(text { value = "Hello" }) + } +} +``` + +## API Reference + +### Core Classes + +| Class | Description | +|-------|-------------| +| `FluentBuilder` | Base interface for all builders | +| `FluentBuilderBase` | Abstract base class with property storage and build pipeline | +| `FlowBuilder` | Builder for Player-UI Flow content | +| `BuildContext` | Context for ID generation and nesting | + +### Tagged Values + +| Class | Format | Description | +|-------|--------|-------------| +| `Binding` | `{{path}}` | Reference to data model path | +| `Expression` | `@[expr]@` | Runtime expression | + +### Builder Properties + +Builders support these property types: + +| Property Type | Description | +|---------------|-------------| +| `defaults` | Default values (e.g., `{"type": "text"}`) | +| `assetWrapperProperties` | Properties wrapped in `{asset: ...}` format | +| `arrayProperties` | Array properties for proper merging | + +### Build Pipeline + +The build process executes these steps: + +1. Resolve static values (TaggedValue → string) +2. Generate asset ID from context +3. Create nested context for children +4. Resolve asset wrapper properties +5. Resolve mixed arrays +6. Resolve nested builders +7. Resolve switches +8. Resolve templates + +## Output Format + +The DSL produces JSON-serializable `Map` objects: + +```json +{ + "id": "registration-flow", + "views": [ + { + "id": "form", + "type": "collection", + "label": { + "asset": { + "id": "form-label", + "type": "text", + "value": "User Registration" + } + }, + "values": [ + { + "id": "form-0", + "type": "input", + "binding": "{{user.firstName}}", + "label": { + "asset": { + "id": "form-0-label", + "type": "text", + "value": "First Name" + } + } + } + ] + } + ], + "navigation": { ... }, + "data": { ... } +} +``` + +## Development + +### Build + +```bash +bazel build //language/dsl/kotlin:kotlin-dsl +``` + +### Test + +```bash +bazel test //language/dsl/kotlin:kotlin-dsl-test +``` + +### Lint + +```bash +# Check +bazel test //language/dsl/kotlin:ktlint + +# Auto-fix +bazel run //language/dsl/kotlin:ktlint_fix +``` + +## Project Structure + +``` +language/dsl/kotlin/ +├── src/main/kotlin/com/intuit/playertools/fluent/ +│ ├── core/ +│ │ ├── FluentBuilder.kt # Base builder interface and class +│ │ ├── BuildContext.kt # Build context for ID generation +│ │ ├── BuildPipeline.kt # Build pipeline execution +│ │ ├── ValueStorage.kt # Property value storage +│ │ └── AuxiliaryStorage.kt # Template/switch storage +│ ├── tagged/ +│ │ ├── TaggedValue.kt # Binding and Expression types +│ │ └── StandardExpressions.kt +│ ├── flow/ +│ │ └── Flow.kt # Flow builder +│ ├── id/ +│ │ ├── IdGenerator.kt # ID generation logic +│ │ └── IdRegistry.kt # Global ID registry +│ └── mocks/builders/ # Sample asset builders +└── src/test/kotlin/ # Tests +``` + +## Dependencies + +- `org.jetbrains.kotlin:kotlin-stdlib` +- `org.jetbrains.kotlinx:kotlinx-serialization-json` diff --git a/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/FluentDslMarker.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/FluentDslMarker.kt new file mode 100644 index 00000000..32377722 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/FluentDslMarker.kt @@ -0,0 +1,11 @@ +package com.intuit.playertools.fluent + +/** + * 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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/AuxiliaryStorage.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/AuxiliaryStorage.kt new file mode 100644 index 00000000..9e132be1 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/AuxiliaryStorage.kt @@ -0,0 +1,121 @@ +package com.intuit.playertools.fluent.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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/BuildContext.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/BuildContext.kt new file mode 100644 index 00000000..6db26696 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/BuildContext.kt @@ -0,0 +1,75 @@ +package com.intuit.playertools.fluent.core + +/** + * 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? = null, + val parameterName: String? = null, + val index: Int? = null, + val branch: IdBranch? = null, + val assetMetadata: AssetMetadata? = null, +) { + 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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/BuildPipeline.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/BuildPipeline.kt new file mode 100644 index 00000000..90a697c9 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/BuildPipeline.kt @@ -0,0 +1,493 @@ +package com.intuit.playertools.fluent.core + +import com.intuit.playertools.fluent.id.determineSlotName +import com.intuit.playertools.fluent.id.genId +import com.intuit.playertools.fluent.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 } + + // Step 1: Resolve static values + resolveStaticValues(storage, result) + + // Step 2: Generate asset ID + generateAssetId(storage, result, context) + + // Step 3: Create nested context + val nestedContext = createNestedContext(result, context) + + // Step 4: Resolve AssetWrapper values + resolveAssetWrappers(storage, result, nestedContext, assetWrapperProperties) + + // Step 5: Resolve mixed arrays + resolveMixedArrays(storage, result, nestedContext) + + // Step 6: Resolve builders + resolveBuilders(storage, result, nestedContext) + + // Step 7: Resolve switches + resolveSwitches(auxiliary, result, nestedContext, arrayProperties) + + // Step 8: Resolve templates + resolveTemplates(auxiliary, result, context) + + return result + } + + /** + * Step 1: Resolve TaggedValues to their string representations. + */ + private fun resolveStaticValues( + storage: ValueStorage, + result: MutableMap, + ) { + storage.getValues().forEach { (key, value) -> + result[key] = resolveValue(value) + } + } + + /** + * 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 + } + + /** + * Step 2: Generate a unique ID for the asset. + */ + private fun generateAssetId( + storage: ValueStorage, + 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( + storage: ValueStorage, + result: MutableMap, + context: BuildContext?, + assetWrapperProperties: Set, + ) { + storage.getBuilders().forEach { (key, value) -> + if (key in assetWrapperProperties && value is AssetWrapperBuilder) { + val slotContext = createSlotContext(context, key, value.builder) + val builtAsset = value.builder.build(slotContext) + result[key] = mapOf("asset" to builtAsset) + } + } + } + + /** + * Step 5: Resolve mixed arrays (arrays with both static and builder values). + */ + private fun resolveMixedArrays( + storage: ValueStorage, + result: MutableMap, + context: BuildContext?, + ) { + storage.getMixedArrays().forEach { (key, metadata) -> + val resolvedArray = + metadata.array.mapIndexedNotNull { index, item -> + when { + item == null -> { + null + } + + index in metadata.builderIndices && item is FluentBuilder<*> -> { + val arrayContext = createArrayItemContext(context, key, index, item) + item.build(arrayContext) + } + + index in metadata.objectIndices -> { + resolveNestedBuilders(item, context, key, index) + } + + else -> { + resolveValue(item) + } + } + } + result[key] = resolvedArray + } + } + + /** + * Step 6: Resolve direct builder values. + */ + private fun resolveBuilders( + storage: ValueStorage, + result: MutableMap, + context: BuildContext?, + ) { + storage.getBuilders().forEach { (key, value) -> + // Skip AssetWrapperBuilders (handled in step 4) + if (value is AssetWrapperBuilder) return@forEach + + when (value) { + is FluentBuilder<*> -> { + val slotContext = createSlotContext(context, key, value) + result[key] = value.build(slotContext) + } + + is Map<*, *> -> { + result[key] = resolveNestedBuilders(value, context, key, null) + } + + is List<*> -> { + result[key] = + value.mapIndexedNotNull { index, item -> + when (item) { + null -> { + null + } + + is FluentBuilder<*> -> { + val arrayContext = createArrayItemContext(context, key, index, item) + item.build(arrayContext) + } + + else -> { + resolveNestedBuilders(item, context, key, index) + } + } + } + } + } + } + } + + /** + * 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 = + when (val c = case.case) { + is Boolean -> c + is TaggedValue<*> -> c.toString() + else -> c.toString() + } + + val assetValue = + when (val a = case.asset) { + is FluentBuilder<*> -> a.build(caseContext) + else -> resolveValue(a) + } + + 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 + } + } + + /** + * 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) + } + + /** + * Resolves nested builders within objects/arrays. + */ + private fun resolveNestedBuilders( + value: Any?, + context: BuildContext?, + key: String, + index: Int?, + ): Any? = + when (value) { + null -> { + null + } + + is FluentBuilder<*> -> { + val nestedContext = + if (index != null) { + createArrayItemContext(context, key, index, value) + } else { + createSlotContext(context, key, value) + } + value.build(nestedContext) + } + + is Map<*, *> -> { + value.mapValues { (_, v) -> + resolveNestedBuilders(v, context, key, index) + } + } + + is List<*> -> { + value.mapIndexed { i, item -> + resolveNestedBuilders(item, context, key, i) + } + } + + is TaggedValue<*> -> { + value.toString() + } + + else -> { + 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) + } + + 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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/FluentBuilder.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/FluentBuilder.kt new file mode 100644 index 00000000..d6b0b323 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/FluentBuilder.kt @@ -0,0 +1,260 @@ +package com.intuit.playertools.fluent.core + +import com.intuit.playertools.fluent.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. + * @param predicate A function that returns true if the property should be set + * @param property The property name + * @param value The value to set (can be a builder or static value) + * @return This builder for chaining + */ + 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. + * @param predicate A function that returns true for trueValue, false for falseValue + * @param property The property name + * @param trueValue The value if predicate is true + * @param falseValue The value if predicate is false + * @return This builder for chaining + */ + 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) + + /** + * Gets the type of value stored for a property. + */ + fun getValueType(key: String): ValueType = storage.getValueType(key) + + /** + * Removes a property value. + * @param key The property name + * @return This builder for chaining + */ + fun unset(key: String): FluentBuilderBase { + storage.remove(key) + return this + } + + /** + * Clears all property values, resetting the builder. + * @return This builder for chaining + */ + 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() + // Copy to target's storage + target.storage.clear() + clonedStorage.getValues().forEach { (k, v) -> target.storage[k] = v } + clonedStorage.getBuilders().forEach { (k, v) -> target.storage[k] = v } + clonedStorage.getMixedArrays().forEach { (k, v) -> target.storage[k] = v.array } + 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, + ) +} + +/** + * Wrapper for builders that should be wrapped in AssetWrapper format. + * Used by generated builders to wrap nested assets. + */ +class AssetWrapperBuilder( + val builder: FluentBuilder<*>, +) + +/** + * Configuration for a template (dynamic list generation). + */ +data class TemplateConfig( + val data: String, + val output: String, + val value: Any, + val dynamic: Boolean = false, +) + +/** + * 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 case: Any, + val asset: Any, +) + +/** + * Internal metadata for switch configurations. + */ +internal data class SwitchMetadata( + val path: List, + val args: SwitchArgs, +) diff --git a/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/StoredValue.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/StoredValue.kt new file mode 100644 index 00000000..1e0ea614 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/StoredValue.kt @@ -0,0 +1,154 @@ +package com.intuit.playertools.fluent.core + +import com.intuit.playertools.fluent.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 +} + +/** + * 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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/ValueStorage.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/ValueStorage.kt new file mode 100644 index 00000000..38f2cb5a --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/core/ValueStorage.kt @@ -0,0 +1,199 @@ +package com.intuit.playertools.fluent.core + +/** + * Metadata for arrays that contain a mix of static values and builders. + * Tracks which indices contain builders for selective resolution. + */ +data class MixedArrayMetadata( + val array: List, + val builderIndices: Set, + val objectIndices: Set, +) + +/** + * Determines the type of value stored for a given key. + */ +enum class ValueType { + STATIC, + BUILDER, + MIXED_ARRAY, + UNSET, +} + +/** + * Storage for builder property values with intelligent routing. + * Separates static values, builder instances, and mixed arrays + * for efficient resolution during the build phase. + */ +class ValueStorage { + private val values = mutableMapOf() + private val builders = mutableMapOf() + private val mixedArrays = mutableMapOf() + + /** + * Sets a value with automatic routing based on type. + * - FluentBuilder instances → builders map + * - AssetWrapperBuilder instances → builders map + * - Arrays containing builders → mixedArrays map + * - Objects containing builders → builders map + * - Everything else → values map + */ + operator fun set( + key: String, + value: Any?, + ) { + when { + value == null -> { + values[key] = null + builders.remove(key) + mixedArrays.remove(key) + } + + value is FluentBuilder<*> -> { + builders[key] = value + values.remove(key) + mixedArrays.remove(key) + } + + value is AssetWrapperBuilder -> { + builders[key] = value + values.remove(key) + mixedArrays.remove(key) + } + + value is List<*> -> { + handleArrayValue(key, value) + } + + value.containsBuilder() -> { + builders[key] = value + values.remove(key) + mixedArrays.remove(key) + } + + else -> { + values[key] = value + builders.remove(key) + mixedArrays.remove(key) + } + } + } + + /** + * Gets a static value by key. + */ + operator fun get(key: String): Any? = values[key] + + /** + * Gets a builder by key. + */ + fun getBuilder(key: String): Any? = builders[key] + + /** + * Gets mixed array metadata by key. + */ + fun getMixedArray(key: String): MixedArrayMetadata? = mixedArrays[key] + + /** + * Checks if a key has any value (static, builder, or mixed array). + */ + fun has(key: String): Boolean = key in values || key in builders || key in mixedArrays + + /** + * Peeks at a value without resolution (checks all storage maps). + */ + fun peek(key: String): Any? = values[key] ?: builders[key] ?: mixedArrays[key]?.array + + /** + * Removes a value from all storage maps. + */ + fun remove(key: String) { + values.remove(key) + builders.remove(key) + mixedArrays.remove(key) + } + + /** + * Clears all stored values. + */ + fun clear() { + values.clear() + builders.clear() + mixedArrays.clear() + } + + /** + * Returns the type of value stored for a key. + */ + fun getValueType(key: String): ValueType = + when { + key in mixedArrays -> ValueType.MIXED_ARRAY + key in builders -> ValueType.BUILDER + key in values -> ValueType.STATIC + else -> ValueType.UNSET + } + + /** + * Creates a shallow clone of this storage. + */ + fun clone(): ValueStorage = + ValueStorage().also { cloned -> + cloned.values.putAll(values) + cloned.builders.putAll(builders) + cloned.mixedArrays.putAll(mixedArrays) + } + + /** + * Returns all static values (for internal use in build pipeline). + */ + internal fun getValues(): Map = values.toMap() + + /** + * Returns all builders (for internal use in build pipeline). + */ + internal fun getBuilders(): Map = builders.toMap() + + /** + * Returns all mixed arrays (for internal use in build pipeline). + */ + internal fun getMixedArrays(): Map = mixedArrays.toMap() + + private fun handleArrayValue( + key: String, + value: List<*>, + ) { + val builderIndices = mutableSetOf() + val objectIndices = mutableSetOf() + + value.forEachIndexed { index, item -> + when { + item is FluentBuilder<*> -> builderIndices.add(index) + item is AssetWrapperBuilder -> builderIndices.add(index) + item.containsBuilder() -> objectIndices.add(index) + } + } + + if (builderIndices.isNotEmpty() || objectIndices.isNotEmpty()) { + mixedArrays[key] = + MixedArrayMetadata( + array = value.toList(), + builderIndices = builderIndices, + objectIndices = objectIndices, + ) + values.remove(key) + } else { + values[key] = value + mixedArrays.remove(key) + } + builders.remove(key) + } + + private fun Any?.containsBuilder(): Boolean { + if (this == null) return false + if (this is FluentBuilder<*>) return true + if (this is AssetWrapperBuilder) return true + if (this is Map<*, *>) return values.any { it.containsBuilder() } + if (this is List<*>) return any { it.containsBuilder() } + return false + } +} diff --git a/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/flow/Flow.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/flow/Flow.kt new file mode 100644 index 00000000..6df7587e --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/flow/Flow.kt @@ -0,0 +1,130 @@ +package com.intuit.playertools.fluent.flow + +import com.intuit.playertools.fluent.FluentDslMarker +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.FluentBuilder +import com.intuit.playertools.fluent.core.IdBranch +import com.intuit.playertools.fluent.id.GlobalIdRegistry + +/** + * 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. + */ + fun set( + key: String, + value: Any?, + ) { + additionalProperties[key] = value + } + + /** + * Builds the flow, processing all views with proper context. + * @param resetIdRegistry Whether to reset the global ID registry before building. + * Defaults to true for top-level flow building. + */ + fun build(resetIdRegistry: Boolean = true): Map { + if (resetIdRegistry) { + GlobalIdRegistry.reset() + } + + val flowId = id + val viewsNamespace = "$flowId-views" + + val processedViews = + views.mapIndexed { index, viewOrBuilder -> + val ctx = + BuildContext( + parentId = viewsNamespace, + branch = IdBranch.ArrayItem(index), + ) + + 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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/id/IdGenerator.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/id/IdGenerator.kt new file mode 100644 index 00000000..c399df20 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/id/IdGenerator.kt @@ -0,0 +1,113 @@ +package com.intuit.playertools.fluent.id + +import com.intuit.playertools.fluent.core.AssetMetadata +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.IdBranch + +/** + * Generates a unique ID based on the build context and registers it. + * Uses the global registry to ensure uniqueness with collision detection. + * + * @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 GlobalIdRegistry.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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/id/IdRegistry.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/id/IdRegistry.kt new file mode 100644 index 00000000..8a940999 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/id/IdRegistry.kt @@ -0,0 +1,66 @@ +package com.intuit.playertools.fluent.id + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * Thread-safe global registry for tracking generated asset IDs and ensuring uniqueness. + * When an ID collision is detected, a numeric suffix (-1, -2, etc.) is appended. + * Call [reset] between flow builds to clear the ID namespace. + */ +object GlobalIdRegistry { + private val registered: MutableSet = ConcurrentHashMap.newKeySet() + private val suffixCounters = ConcurrentHashMap() + + /** + * 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 { + // Try to add the base ID directly first + if (registered.add(baseId)) { + return baseId + } + + // ID exists, use atomic counter for suffix generation + val counter = suffixCounters.computeIfAbsent(baseId) { AtomicInteger(0) } + while (true) { + val candidate = "$baseId-${counter.incrementAndGet()}" + if (registered.add(candidate)) { + return candidate + } + } + } + + /** + * Checks if an ID is already registered without registering it. + */ + fun isRegistered(id: String): Boolean = id in registered + + /** + * Clears all registered IDs. Should be called between flow builds + * to reset the ID namespace. + * + * Note: This method is not atomic with respect to ongoing registrations. + * Ensure no concurrent registrations are happening when calling reset. + */ + fun reset() { + registered.clear() + suffixCounters.clear() + } + + /** + * Returns the count of registered IDs (useful for testing). + */ + fun size(): Int = registered.size +} + +/** + * Resets the global ID registry. Convenience function for use between flow builds. + */ +fun resetGlobalIdRegistry() { + GlobalIdRegistry.reset() +} diff --git a/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/ActionBuilder.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/ActionBuilder.kt new file mode 100644 index 00000000..ca23c752 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/ActionBuilder.kt @@ -0,0 +1,84 @@ +package com.intuit.playertools.fluent.mocks.builders + +import com.intuit.playertools.fluent.FluentDslMarker +import com.intuit.playertools.fluent.core.AssetWrapperBuilder +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.FluentBuilderBase +import com.intuit.playertools.fluent.tagged.TaggedValue + +/** + * Builder for ActionAsset with strongly-typed property setters. + */ +@FluentDslMarker +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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/CollectionBuilder.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/CollectionBuilder.kt new file mode 100644 index 00000000..c21d4f73 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/CollectionBuilder.kt @@ -0,0 +1,185 @@ +package com.intuit.playertools.fluent.mocks.builders + +import com.intuit.playertools.fluent.FluentDslMarker +import com.intuit.playertools.fluent.core.AssetWrapperBuilder +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.FluentBuilder +import com.intuit.playertools.fluent.core.FluentBuilderBase +import com.intuit.playertools.fluent.core.SwitchArgs +import com.intuit.playertools.fluent.core.TemplateConfig +import com.intuit.playertools.fluent.tagged.Binding + +/** + * Builder for CollectionAsset with strongly-typed property setters. + */ +@FluentDslMarker +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 { ctx -> + 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) + +/** + * 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( + com.intuit.playertools.fluent.core + .SwitchCase(condition, asset), + ) + } + + /** + * Adds a case with an expression condition. + */ + fun case( + condition: com.intuit.playertools.fluent.tagged.Expression, + asset: FluentBuilder<*>, + ) { + cases.add( + com.intuit.playertools.fluent.core + .SwitchCase(condition, asset), + ) + } + + /** + * Adds a case using a DSL block. + */ + fun case( + condition: Any, + init: () -> FluentBuilder<*>, + ) { + cases.add( + com.intuit.playertools.fluent.core + .SwitchCase(condition, init()), + ) + } + + /** + * Adds a default case (always true). + */ + fun default(asset: FluentBuilder<*>) { + cases.add( + com.intuit.playertools.fluent.core + .SwitchCase(true, asset), + ) + } + + /** + * Adds a default case using a DSL block. + */ + fun default(init: () -> FluentBuilder<*>) { + cases.add( + com.intuit.playertools.fluent.core + .SwitchCase(true, init()), + ) + } +} diff --git a/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/InputBuilder.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/InputBuilder.kt new file mode 100644 index 00000000..ef1678a4 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/InputBuilder.kt @@ -0,0 +1,76 @@ +package com.intuit.playertools.fluent.mocks.builders + +import com.intuit.playertools.fluent.FluentDslMarker +import com.intuit.playertools.fluent.core.AssetWrapperBuilder +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.FluentBuilderBase +import com.intuit.playertools.fluent.tagged.Binding + +/** + * Builder for InputAsset with strongly-typed property setters. + */ +@FluentDslMarker +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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/TextBuilder.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/TextBuilder.kt new file mode 100644 index 00000000..c3bea631 --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/builders/TextBuilder.kt @@ -0,0 +1,57 @@ +package com.intuit.playertools.fluent.mocks.builders + +import com.intuit.playertools.fluent.FluentDslMarker +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.FluentBuilderBase +import com.intuit.playertools.fluent.tagged.Binding +import com.intuit.playertools.fluent.tagged.TaggedValue + +/** + * Builder for TextAsset with strongly-typed property setters. + * Demonstrates how generated builders provide type safety. + */ +@FluentDslMarker +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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/types/Asset.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/types/Asset.kt new file mode 100644 index 00000000..7501b3bb --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/mocks/types/Asset.kt @@ -0,0 +1,60 @@ +package com.intuit.playertools.fluent.mocks.types + +/** + * Base interface for all Player-UI assets. + * All assets have an ID and a type discriminator. + */ +interface Asset { + val id: String? + val type: String +} + +/** + * Asset wrapper - wraps an asset for nested properties. + * Used for properties like `label`, where the asset is wrapped in { asset: ... }. + */ +data class AssetWrapper( + val asset: T, +) + +/** + * Text asset - displays static or dynamic text. + */ +data class TextAsset( + override val id: String? = null, + override val type: String = "text", + val value: String? = null, +) : Asset + +/** + * Input asset - captures user input bound to the data model. + */ +data class InputAsset( + override val id: String? = null, + override val type: String = "input", + val binding: String? = null, + val label: AssetWrapper? = null, + val placeholder: String? = null, +) : Asset + +/** + * Action asset - triggers transitions or side effects. + */ +data class ActionAsset( + override val id: String? = null, + override val type: String = "action", + val value: String? = null, + val label: AssetWrapper? = null, + val metaData: Map? = null, +) : Asset + +/** + * Collection asset - contains other assets in a structured layout. + */ +data class CollectionAsset( + override val id: String? = null, + override val type: String = "collection", + val label: AssetWrapper? = null, + val values: List>? = null, + val actions: List>? = null, +) : Asset diff --git a/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/tagged/StandardExpressions.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/tagged/StandardExpressions.kt new file mode 100644 index 00000000..3ce56e4a --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/tagged/StandardExpressions.kt @@ -0,0 +1,281 @@ +package com.intuit.playertools.fluent.tagged + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/* + * 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\"" + is List<*> -> Json.encodeToString(value.map { toValueString(it) }) + is Map<*, *> -> Json.encodeToString(value.mapValues { 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/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/tagged/TaggedValue.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/tagged/TaggedValue.kt new file mode 100644 index 00000000..2df3190f --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/tagged/TaggedValue.kt @@ -0,0 +1,163 @@ +package com.intuit.playertools.fluent.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. + * + * @param T The expected type of the bound value + * @param path The data path (e.g., "user.name", "items._index_.value") + * @return A Binding instance + */ +fun binding(path: String): Binding = Binding(processIndexPlaceholders(path)) + +/** + * Creates an expression. + * + * @param T The expected return type of the expression + * @param expr The expression string (e.g., "user.age >= 18", "navigate('home')") + * @return An Expression instance + */ +fun expression(expr: String): Expression = Expression(processIndexPlaceholders(expr)) + +/** + * Processes _index_ placeholders in a path/expression. + * Supports nested indices: _index_, _index1_, _index2_, etc. + */ +private fun processIndexPlaceholders(input: String): String { + // This function currently returns the input as-is. + // The placeholder substitution happens during template resolution. + return input +} + +/** + * Checks if a value is a TaggedValue. + */ +fun isTaggedValue(value: Any?): Boolean = value is TaggedValue<*> + +/** + * Resolves a TaggedValue to its string representation. + * Non-tagged values are returned as-is. + */ +fun resolveTaggedValue(value: Any?): Any? = + when (value) { + is TaggedValue<*> -> value.toString() + else -> value + } diff --git a/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/types/PlayerTypes.kt b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/types/PlayerTypes.kt new file mode 100644 index 00000000..697796cc --- /dev/null +++ b/language/dsl/kotlin/src/main/kotlin/com/intuit/playertools/fluent/types/PlayerTypes.kt @@ -0,0 +1,292 @@ +package com.intuit.playertools.fluent.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +// 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 +} + +/** + * An asset that contains a data binding. + */ +interface AssetBinding : Asset { + val binding: BindingRef +} + +/** + * Wraps an asset in the AssetWrapper format. + */ +@Serializable +data class AssetWrapper( + val asset: T, +) + +/** + * A single case in a switch statement. + */ +@Serializable +data class SwitchCase( + val asset: JsonObject, + /** Expression or true */ + val case: JsonElement, +) + +/** + * A switch replaces an asset with the applicable case on first render. + */ +typealias Switch = List + +/** + * Static switch - evaluates only on first render. + */ +@Serializable +data class StaticSwitch( + val staticSwitch: Switch, +) + +/** + * Dynamic switch - re-evaluates when data changes. + */ +@Serializable +data class DynamicSwitch( + val dynamicSwitch: Switch, +) + +/** + * 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: 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, +} + +/** + * Navigation state types. + */ +enum class NavigationStateType( + val value: String, +) { + VIEW("VIEW"), + END("END"), + ACTION("ACTION"), + ASYNC_ACTION("ASYNC_ACTION"), + EXTERNAL("EXTERNAL"), + FLOW("FLOW"), +} + +/** + * Base for navigation state transitions. + */ +typealias NavigationTransitions = Map + +/** + * View state in navigation. + */ +@Serializable +data class NavigationViewState( + @SerialName("state_type") val stateType: String = "VIEW", + val ref: String, + val transitions: NavigationTransitions, + val onStart: Expression? = null, + val onEnd: Expression? = null, + val attributes: Map? = null, +) + +/** + * End state in navigation. + */ +@Serializable +data class NavigationEndState( + @SerialName("state_type") val stateType: String = "END", + val outcome: String, + val onStart: Expression? = null, + val onEnd: Expression? = null, +) + +/** + * Action state in navigation. + */ +@Serializable +data class NavigationActionState( + @SerialName("state_type") val stateType: String = "ACTION", + val exp: Expression, + val transitions: NavigationTransitions, +) + +/** + * Flow state in navigation. + */ +@Serializable +data class NavigationFlowState( + @SerialName("state_type") val stateType: String = "FLOW", + val ref: String, + val transitions: NavigationTransitions, +) + +/** + * A navigation flow describes a state machine. + * Additional states beyond startState, onStart, onEnd are dynamic keys. + */ +@Serializable +data class NavigationFlow( + val startState: String, + val onStart: Expression? = null, + val onEnd: Expression? = null, +) + +/** + * 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, + val blocking: Any? = null, +) + +/** + * Schema data type definition. + */ +@Serializable +data class SchemaDataType( + val type: String, + val validation: List? = null, + val format: Map? = null, + val default: Any? = null, + val isRecord: Boolean? = null, + val isArray: Boolean? = null, +) + +/** + * Schema node definition. + */ +typealias SchemaNode = Map + +/** + * Complete schema definition. + */ +typealias Schema = Map + +/** + * The complete Flow structure for Player-UI. + * This is the top-level JSON structure that Player-UI consumes. + */ +@Serializable +data class Flow( + val id: String, + val views: List? = null, + val data: DataModel? = null, + val schema: Schema? = null, + val navigation: Navigation, +) + +/** + * Result of a completed flow. + */ +@Serializable +data class FlowResult( + val endState: NavigationEndState, + val data: DataModel? = null, +) diff --git a/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/FlowTest.kt b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/FlowTest.kt new file mode 100644 index 00000000..d7eea931 --- /dev/null +++ b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/FlowTest.kt @@ -0,0 +1,276 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playertools.fluent + +import com.intuit.playertools.fluent.flow.flow +import com.intuit.playertools.fluent.flow.flowBuilder +import com.intuit.playertools.fluent.id.GlobalIdRegistry +import com.intuit.playertools.fluent.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({ + beforeEach { + GlobalIdRegistry.reset() + } + + 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>() + (views as List<*>).size shouldBe 2 + } + + 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("allows building with resetIdRegistry=false") { + // First, generate some IDs + GlobalIdRegistry.reset() + + val builder1 = + flowBuilder { + id = "flow1" + views = listOf(text { value = "View 1" }) + navigation = mapOf("BEGIN" to "FLOW_1") + } + builder1.build(resetIdRegistry = true) + + // Build a second flow without resetting + val builder2 = + flowBuilder { + id = "flow2" + views = listOf(text { value = "View 2" }) + navigation = mapOf("BEGIN" to "FLOW_1") + } + val result2 = builder2.build(resetIdRegistry = false) + + // IDs should be unique across flows since registry wasn't reset + 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/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/FluentBuilderBaseTest.kt b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/FluentBuilderBaseTest.kt new file mode 100644 index 00000000..21a80271 --- /dev/null +++ b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/FluentBuilderBaseTest.kt @@ -0,0 +1,369 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playertools.fluent + +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.IdBranch +import com.intuit.playertools.fluent.id.GlobalIdRegistry +import com.intuit.playertools.fluent.mocks.builders.* +import com.intuit.playertools.fluent.tagged.binding +import com.intuit.playertools.fluent.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({ + beforeEach { + GlobalIdRegistry.reset() + } + + 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") + ) + + 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") + + 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") + + 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")) + + 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") + + 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/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/IdGeneratorTest.kt b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/IdGeneratorTest.kt new file mode 100644 index 00000000..8d9b07d5 --- /dev/null +++ b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/IdGeneratorTest.kt @@ -0,0 +1,446 @@ +package com.intuit.playertools.fluent + +import com.intuit.playertools.fluent.core.AssetMetadata +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.IdBranch +import com.intuit.playertools.fluent.id.GlobalIdRegistry +import com.intuit.playertools.fluent.id.determineSlotName +import com.intuit.playertools.fluent.id.genId +import com.intuit.playertools.fluent.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({ + beforeEach { + GlobalIdRegistry.reset() + } + + describe("genId") { + describe("no branch (custom ID case)") { + it("returns parentId when no branch is provided") { + val ctx = BuildContext(parentId = "custom-id") + genId(ctx) shouldBe "custom-id" + } + + it("returns parentId when branch is null") { + val ctx = BuildContext(parentId = "another-custom-id", branch = null) + genId(ctx) shouldBe "another-custom-id" + } + + it("handles empty string parentId with no branch") { + val ctx = BuildContext(parentId = "") + genId(ctx) shouldBe "" + } + + it("handles complex parentId with special characters") { + val ctx = BuildContext(parentId = "parent_with-special.chars@123") + 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") + ) + genId(ctx) shouldBe "parent-header" + } + + it("generates ID for slot with empty parentId") { + val ctx = + BuildContext( + parentId = "", + branch = IdBranch.Slot("footer") + ) + genId(ctx) shouldBe "footer" + } + + it("throws error for slot with empty name") { + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("") + ) + 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") + ) + genId(ctx) shouldBe "parent-slot_with-special.chars" + } + + it("handles slot with numeric name") { + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("123") + ) + 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) + ) + genId(ctx) shouldBe "list-2" + } + + it("generates ID for array item with zero index") { + val ctx = + BuildContext( + parentId = "array", + branch = IdBranch.ArrayItem(0) + ) + genId(ctx) shouldBe "array-0" + } + + it("throws error for array item with negative index") { + val ctx = + BuildContext( + parentId = "items", + branch = IdBranch.ArrayItem(-1) + ) + 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) + ) + genId(ctx) shouldBe "bigArray-999999" + } + + it("handles array item with empty parentId") { + val ctx = + BuildContext( + parentId = "", + branch = IdBranch.ArrayItem(5) + ) + genId(ctx) shouldBe "-5" + } + } + + describe("template branch") { + it("generates ID for template with depth") { + val ctx = + BuildContext( + parentId = "template", + branch = IdBranch.Template(depth = 1) + ) + genId(ctx) shouldBe "template-_index1_" + } + + it("generates ID for template with zero depth") { + val ctx = + BuildContext( + parentId = "template", + branch = IdBranch.Template(depth = 0) + ) + genId(ctx) shouldBe "template-_index_" + } + + it("generates ID for template with default depth") { + val ctx = + BuildContext( + parentId = "template", + branch = IdBranch.Template() + ) + genId(ctx) shouldBe "template-_index_" + } + + it("throws error for template with negative depth") { + val ctx = + BuildContext( + parentId = "template", + branch = IdBranch.Template(depth = -2) + ) + 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) + ) + genId(ctx) shouldBe "deepTemplate-_index100_" + } + + it("handles template with empty parentId") { + val ctx = + BuildContext( + parentId = "", + branch = IdBranch.Template(depth = 3) + ) + 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) + ) + 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) + ) + 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) + ) + 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) + ) + 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) + ) + genId(ctx) shouldBe "bigSwitch-dynamicSwitch-9999" + } + + it("handles switch with empty parentId") { + val ctx = + BuildContext( + parentId = "", + branch = IdBranch.Switch(2, IdBranch.Switch.SwitchKind.STATIC) + ) + genId(ctx) shouldBe "-staticSwitch-2" + } + } + + describe("collision detection") { + it("enforces uniqueness across multiple calls with same input") { + val ctx = + BuildContext( + parentId = "consistent", + branch = IdBranch.Slot("test") + ) + + 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 contexts = + listOf( + BuildContext(parentId = "parent1", branch = IdBranch.Slot("slot1")), + BuildContext(parentId = "parent2", branch = IdBranch.Slot("slot1")), + BuildContext(parentId = "parent1", branch = IdBranch.Slot("slot2")), + BuildContext(parentId = "parent1", branch = IdBranch.ArrayItem(0)) + ) + + 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) -> + GlobalIdRegistry.reset() + val ctx = BuildContext(parentId = complexParentId, branch = branch) + 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) -> + GlobalIdRegistry.reset() + val ctx = BuildContext(parentId = "", branch = branch) + genId(ctx) shouldBe expected + } + } + } + } + + describe("peekId") { + it("generates ID without registering") { + val ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("test") + ) + + 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 ctx = + BuildContext( + parentId = "parent", + branch = IdBranch.Slot("test") + ) + + // 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("GlobalIdRegistry") { + it("reset clears all registered IDs") { + val ctx = BuildContext(parentId = "test", branch = IdBranch.Slot("item")) + + genId(ctx) shouldBe "test-item" + genId(ctx) shouldBe "test-item-1" + + GlobalIdRegistry.reset() + + genId(ctx) shouldBe "test-item" + } + + it("tracks registration count") { + GlobalIdRegistry.size() shouldBe 0 + + genId(BuildContext(parentId = "a")) + GlobalIdRegistry.size() shouldBe 1 + + genId(BuildContext(parentId = "b")) + GlobalIdRegistry.size() shouldBe 2 + } + + it("isRegistered returns correct status") { + val ctx = BuildContext(parentId = "check", branch = IdBranch.Slot("status")) + + GlobalIdRegistry.isRegistered("check-status") shouldBe false + genId(ctx) + GlobalIdRegistry.isRegistered("check-status") shouldBe true + } + } + }) diff --git a/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/IntegrationTest.kt b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/IntegrationTest.kt new file mode 100644 index 00000000..8099b13f --- /dev/null +++ b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/IntegrationTest.kt @@ -0,0 +1,430 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.intuit.playertools.fluent + +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.IdBranch +import com.intuit.playertools.fluent.flow.flow +import com.intuit.playertools.fluent.id.GlobalIdRegistry +import com.intuit.playertools.fluent.mocks.builders.* +import com.intuit.playertools.fluent.tagged.binding +import com.intuit.playertools.fluent.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) + } + + beforeEach { + GlobalIdRegistry.reset() + } + + 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") { + GlobalIdRegistry.reset() + + 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")) + + 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") { + GlobalIdRegistry.reset() + + 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")) + + 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") { + GlobalIdRegistry.reset() + + 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")) + + 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") { + GlobalIdRegistry.reset() + + // When building in a flow context, the view gets an ArrayItem branch + val ctx = + BuildContext( + parentId = "my-flow-views", + branch = IdBranch.ArrayItem(0) + ) + + 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/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/ProjectConfig.kt b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/ProjectConfig.kt new file mode 100644 index 00000000..ceeee54e --- /dev/null +++ b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/ProjectConfig.kt @@ -0,0 +1,8 @@ +package com.intuit.playertools.fluent + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.spec.IsolationMode + +object ProjectConfig : AbstractProjectConfig() { + override val isolationMode = IsolationMode.InstancePerLeaf +} diff --git a/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/StandardExpressionsTest.kt b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/StandardExpressionsTest.kt new file mode 100644 index 00000000..f2c50b7f --- /dev/null +++ b/language/dsl/kotlin/src/test/kotlin/com/intuit/playertools/fluent/StandardExpressionsTest.kt @@ -0,0 +1,348 @@ +package com.intuit.playertools.fluent + +import com.intuit.playertools.fluent.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/language/generators/kotlin/.editorconfig b/language/generators/kotlin/.editorconfig new file mode 100644 index 00000000..9eab273a --- /dev/null +++ b/language/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/language/generators/kotlin/BUILD.bazel b/language/generators/kotlin/BUILD.bazel new file mode 100644 index 00000000..25d35902 --- /dev/null +++ b/language/generators/kotlin/BUILD.bazel @@ -0,0 +1,70 @@ +load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") +load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config", "ktlint_fix", "ktlint_test") + +package(default_visibility = ["//visibility:public"]) + +ktlint_config( + name = "ktlint_config", + editorconfig = ".editorconfig", +) + +kt_jvm_library( + name = "kotlin-dsl-generator", + srcs = glob( + ["src/main/kotlin/**/*.kt"], + exclude = ["src/main/kotlin/**/xlr/*.kt"], + ), + kotlinc_opts = "//:kt_opts", + deps = [ + "//xlr/types/kotlin:xlr-types", + "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + ], +) + +kt_jvm_test( + name = "kotlin-dsl-generator-test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + data = glob(["src/test/kotlin/**/fixtures/*.json"]), + kotlinc_opts = "//:kt_opts", + args = [ + "--select-class=com.intuit.playertools.fluent.generator.XlrDeserializerTest", + "--select-class=com.intuit.playertools.fluent.generator.TypeMapperTest", + "--select-class=com.intuit.playertools.fluent.generator.ClassGeneratorTest", + "--select-class=com.intuit.playertools.fluent.generator.GeneratorIntegrationTest", + ], + main_class = "org.junit.platform.console.ConsoleLauncher", + deps = [ + ":kotlin-dsl-generator", + "//xlr/types/kotlin: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", + ], +) + +ktlint_test( + name = "ktlint", + srcs = glob( + [ + "src/main/kotlin/**/*.kt", + "src/test/kotlin/**/*.kt", + ], + exclude = ["src/main/kotlin/**/xlr/*.kt"], + ), + config = ":ktlint_config", +) + +ktlint_fix( + name = "ktlint_fix", + srcs = glob( + [ + "src/main/kotlin/**/*.kt", + "src/test/kotlin/**/*.kt", + ], + exclude = ["src/main/kotlin/**/xlr/*.kt"], + ), + config = ":ktlint_config", +) diff --git a/language/generators/kotlin/README.md b/language/generators/kotlin/README.md new file mode 100644 index 00000000..d1b95cc9 --- /dev/null +++ b/language/generators/kotlin/README.md @@ -0,0 +1,346 @@ +# Kotlin DSL Generator + +Generates Kotlin DSL builder classes from XLR (Cross-Language Representation) JSON schemas. The generated builders are compatible with the `//language/dsl/kotlin:kotlin-dsl` library. + +## Installation + +### Bazel + +```starlark +deps = [ + "//language/generators/kotlin:kotlin-dsl-generator", +] +``` + +## CLI Usage + +```bash +# Build the generator +bazel build //language/generators/kotlin:kotlin-dsl-generator + +# Run the generator +java -cp "$(bazel-bin/language/generators/kotlin/kotlin-dsl-generator.jar)" \ + com.intuit.playertools.fluent.generator.MainKt \ + --input \ + --output \ + --package +``` + +### Arguments + +| Argument | Short | Required | Description | +| ----------- | ----- | -------- | ------------------------------------ | +| `--input` | `-i` | Yes | Path to XLR JSON file or directory | +| `--output` | `-o` | Yes | Output directory for generated files | +| `--package` | `-p` | Yes | Package name for generated classes | +| `--help` | `-h` | No | Show help message | + +### Examples + +```bash +# Single file +java -cp "$CLASSPATH" com.intuit.playertools.fluent.generator.MainKt \ + -i ActionAsset.json \ + -o generated \ + -p com.example.builders + +# Directory of XLR files +java -cp "$CLASSPATH" com.intuit.playertools.fluent.generator.MainKt \ + -i xlr/ \ + -o generated \ + -p com.myapp.fluent + +# Multiple files +java -cp "$CLASSPATH" com.intuit.playertools.fluent.generator.MainKt \ + ActionAsset.json TextAsset.json InputAsset.json \ + -o out \ + -p com.test +``` + +## Programmatic Usage + +```kotlin +import com.intuit.playertools.fluent.generator.Generator +import com.intuit.playertools.fluent.generator.GeneratorConfig +import java.io.File + +// Create generator with configuration +val generator = Generator(GeneratorConfig( + packageName = "com.example.builders", + outputDir = File("generated") +)) + +// Generate from file +val result = generator.generateFromFile(File("ActionAsset.json")) +println("Generated: ${result.className} -> ${result.filePath}") + +// Generate from JSON string +val jsonContent = File("ActionAsset.json").readText() +val result2 = generator.generateFromJson(jsonContent, "ActionAsset") + +// Generate code without writing to disk +val code = Generator.generateCode(jsonContent, "com.example") +``` + +## Input Format (XLR) + +The generator accepts XLR JSON schemas. XLR is Player-UI's cross-language type representation. + +### XLR Structure + +```json +{ + "name": "ActionAsset", + "type": "object", + "title": "ActionAsset", + "description": "User actions can be represented in several places.", + "properties": { + "value": { + "required": true, + "node": { + "type": "string", + "title": "ActionAsset.value", + "description": "The transition value of the action" + } + }, + "label": { + "required": false, + "node": { + "type": "ref", + "ref": "AssetWrapper" + } + } + }, + "extends": { + "type": "ref", + "ref": "Asset" + } +} +``` + +### Supported XLR Types + +| XLR Type | Kotlin Type | +| ----------------------- | ------------------------------------- | +| `string` | `String` | +| `number` | `Number` | +| `boolean` | `Boolean` | +| `object` | Nested config class | +| `array` | `List` | +| `ref` to `Asset` | `FluentBuilderBase<*>` | +| `ref` to `AssetWrapper` | `FluentBuilderBase<*>` (auto-wrapped) | +| `ref` to `Binding` | `Binding<*>` | +| `ref` to `Expression` | `TaggedValue<*>` | +| `or` (union) | `Any?` | + +## Generated Output + +### Example Input + +```json +{ + "name": "TextAsset", + "type": "object", + "properties": { + "value": { + "required": true, + "node": { + "type": "string", + "description": "The text to display" + } + } + } +} +``` + +### Generated Output + +```kotlin +package com.example.builders + +import com.intuit.playertools.fluent.FluentDslMarker +import com.intuit.playertools.fluent.core.AssetWrapperBuilder +import com.intuit.playertools.fluent.core.BuildContext +import com.intuit.playertools.fluent.core.FluentBuilderBase +import com.intuit.playertools.fluent.tagged.Binding +import com.intuit.playertools.fluent.tagged.TaggedValue + +@FluentDslMarker +class TextBuilder : FluentBuilderBase>() { + override val defaults: Map = mapOf("type" to "text") + override val assetWrapperProperties: Set = emptySet() + override val arrayProperties: Set = emptySet() + + /** Each asset requires a unique id per view */ + var id: String? + get() = peek("id") as? String + set(value) { set("id", value) } + + /** The text to display */ + var value: String? + get() = peek("value") as? String + set(value) { set("value", value) } + + fun value(binding: Binding) { set("value", binding) } + fun value(taggedValue: TaggedValue) { set("value", taggedValue) } + + override fun build(context: BuildContext?) = buildWithDefaults(context) + override fun clone() = TextBuilder().also { cloneStorageTo(it) } +} + +fun text(init: TextBuilder.() -> Unit = {}) = TextBuilder().apply(init) +``` + +## Generated Features + +### Property Types + +| XLR Property | Generated Code | +| ---------------- | --------------------------------------------------------------- | +| String | `var prop: String?` with binding/expression overloads | +| Number | `var prop: Number?` with binding/expression overloads | +| Boolean | `var prop: Boolean?` with binding/expression overloads | +| AssetWrapper ref | `var prop: FluentBuilderBase<*>?` with `fun prop(builder)` | +| Asset array | `var prop: List>?` with `fun prop(vararg)` | +| Nested object | Nested config class with `fun prop(init: Config.() -> Unit)` | +| Binding ref | `var prop: Binding<*>?` | +| Expression ref | `var prop: TaggedValue<*>?` | + +### Nested Objects + +Nested objects generate separate config classes: + +```kotlin +// Generated from metaData property +@FluentDslMarker +class ActionMetaDataConfig : FluentBuilderBase>() { + override val defaults: Map = emptyMap() + + var skipValidation: Boolean? + get() = peek("skipValidation") as? Boolean + set(value) { set("skipValidation", value) } + + var role: String? + get() = peek("role") as? String + set(value) { set("role", value) } + + override fun build(context: BuildContext?) = buildWithDefaults(context) + override fun clone() = ActionMetaDataConfig().also { cloneStorageTo(it) } +} + +// Usage +action { + value = "submit" + metaData { + skipValidation = true + role = "primary" + } +} +``` + +### DSL Functions + +Each builder generates a DSL function: + +```kotlin +// Generated +fun action(init: ActionBuilder.() -> Unit = {}) = ActionBuilder().apply(init) + +// Usage +val myAction = action { + value = "submit" + label { value = "Submit" } +} +``` + +## API Reference + +### Generator + +```kotlin +class Generator(config: GeneratorConfig) { + fun generateFromFiles(files: List): List + fun generateFromFile(file: File): GeneratorResult + fun generateFromJson(jsonContent: String, sourceName: String): GeneratorResult + fun generateFromDocument(document: XlrDocument): GeneratorResult + fun generateCode(document: XlrDocument): String + + companion object { + fun generateCode(jsonContent: String, packageName: String): String + fun generateCode(document: XlrDocument, packageName: String): String + } +} +``` + +### GeneratorConfig + +```kotlin +data class GeneratorConfig( + val packageName: String, + val outputDir: File +) +``` + +### GeneratorResult + +```kotlin +data class GeneratorResult( + val className: String, + val filePath: File, + val code: String +) +``` + +## Development + +### Build + +```bash +bazel build //language/generators/kotlin:kotlin-dsl-generator +``` + +### Test + +```bash +bazel test //language/generators/kotlin:kotlin-dsl-generator-test +``` + +### Lint + +```bash +# Check +bazel test //language/generators/kotlin:ktlint + +# Auto-fix +bazel run //language/generators/kotlin:ktlint_fix +``` + +## Project Structure + +``` +language/generators/kotlin/ +├── src/main/kotlin/com/intuit/playertools/fluent/generator/ +│ ├── Main.kt # CLI entry point +│ ├── Generator.kt # Main generator class +│ ├── ClassGenerator.kt # Kotlin class generation +│ ├── CodeWriter.kt # Code formatting utilities +│ ├── TypeMapper.kt # XLR to Kotlin type mapping +│ └── xlr/ +│ ├── XlrTypes.kt # XLR type definitions +│ ├── XlrDeserializer.kt # JSON to XLR parsing +│ └── XlrGuards.kt # Type guard utilities +├── src/test/kotlin/ # Tests +│ └── fixtures/ # Sample XLR JSON files +└── BUILD.bazel +``` + +## Dependencies + +- `org.jetbrains.kotlin:kotlin-stdlib` +- `org.jetbrains.kotlinx:kotlinx-serialization-json` + +## Runtime Dependencies + +Generated code requires: + +- `//language/dsl/kotlin:kotlin-dsl` diff --git a/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/ClassGenerator.kt b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/ClassGenerator.kt new file mode 100644 index 00000000..95ec79fb --- /dev/null +++ b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/ClassGenerator.kt @@ -0,0 +1,436 @@ +package com.intuit.playertools.fluent.generator + +import com.intuit.playertools.xlr.ObjectProperty +import com.intuit.playertools.xlr.ObjectType +import com.intuit.playertools.xlr.ParamTypeNode +import com.intuit.playertools.xlr.XlrDocument +import com.intuit.playertools.xlr.extractAssetTypeConstant +import com.intuit.playertools.xlr.isAssetWrapperRef +import com.intuit.playertools.xlr.isObjectType + +/** + * 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. + */ +class ClassGenerator( + private val document: XlrDocument, + private val packageName: String, +) { + private val genericTokens: Map = + document.genericTokens + ?.associateBy { it.symbol } + ?: emptyMap() + + private val nestedClasses = mutableListOf() + + // Main builder class name (e.g., "ActionBuilder"), used as prefix for nested classes + private val mainBuilderName: String = + TypeMapper.toBuilderClassName(document.name.removeSuffix("Asset")) + + // Cache for collectProperties() to avoid generating duplicate nested classes + private val cachedProperties: List by lazy { + document.properties.map { (name, prop) -> + createPropertyInfo(name, prop) + } + } + + /** + * Generate the builder class for the XLR document. + */ + fun generate(): GeneratedClass { + val className = + TypeMapper.toBuilderClassName( + document.name.removeSuffix("Asset"), + ) + val dslFunctionName = TypeMapper.toDslFunctionName(document.name) + val assetType = extractAssetTypeConstant(document.extends) + + val code = + codeWriter { + // Package declaration + line("package $packageName") + blankLine() + + // Imports + generateImports() + blankLine() + + // Class documentation + document.description?.let { kdoc(it) } + + // Class definition + classBlock( + name = className, + annotations = listOf("@FluentDslMarker"), + superClass = "FluentBuilderBase>()", + ) { + // Properties section + generateDefaultsProperty(assetType) + blankLine() + generateAssetWrapperPropertiesSet() + generateArrayPropertiesSet() + blankLine() + + // Standard id property (all Player-UI assets need this) + generateIdProperty() + blankLine() + + // Generate property accessors + val properties = collectProperties() + properties.forEach { prop -> + generateProperty(prop) + blankLine() + } + + // Build method + generateBuildMethod() + blankLine() + + // Clone method + generateCloneMethod(className) + } + + blankLine() + + // Top-level DSL function + generateDslFunction(dslFunctionName, className, document.description) + + // Generate nested classes at end of file + nestedClasses.forEach { nested -> + blankLine() + raw(nested.code) + } + } + + return GeneratedClass( + className = className, + code = code, + nestedClasses = nestedClasses, + ) + } + + private fun CodeWriter.generateImports() { + line("import com.intuit.playertools.fluent.FluentDslMarker") + line("import com.intuit.playertools.fluent.core.AssetWrapperBuilder") + line("import com.intuit.playertools.fluent.core.BuildContext") + line("import com.intuit.playertools.fluent.core.FluentBuilderBase") + line("import com.intuit.playertools.fluent.tagged.Binding") + line("import com.intuit.playertools.fluent.tagged.TaggedValue") + } + + private fun CodeWriter.generateDefaultsProperty(assetType: String?) { + val defaultsValue = + if (assetType != null) { + "mapOf(\"type\" to \"$assetType\")" + } else { + "emptyMap()" + } + overrideVal("defaults", "Map", defaultsValue) + } + + private fun CodeWriter.generateAssetWrapperPropertiesSet() { + // Only include non-array asset wrapper properties + // Array properties are handled differently by the build pipeline + val assetWrapperProps = + collectProperties() + .filter { it.isAssetWrapper && !it.isArray } + .map { "\"${it.originalName}\"" } + + if (assetWrapperProps.isNotEmpty()) { + val setContent = assetWrapperProps.joinToString(", ") + overrideVal("assetWrapperProperties", "Set", "setOf($setContent)") + } else { + overrideVal("assetWrapperProperties", "Set", "emptySet()") + } + } + + private fun CodeWriter.generateArrayPropertiesSet() { + val arrayProps = + collectProperties() + .filter { it.isArray } + .map { "\"${it.originalName}\"" } + + if (arrayProps.isNotEmpty()) { + val setContent = arrayProps.joinToString(", ") + overrideVal("arrayProperties", "Set", "setOf($setContent)") + } else { + overrideVal("arrayProperties", "Set", "emptySet()") + } + } + + private fun CodeWriter.generateIdProperty() { + kdoc("Each asset requires a unique id per view") + simpleProperty( + name = "id", + type = "String?", + getterExpr = "peek(\"id\") as? String", + setterExpr = "set(\"id\", value)", + ) + } + + 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) + + // Check if property node is a nested object that needs its own class + val isNestedObject = allowNestedGeneration && isObjectType(prop.node) + 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 generateNestedClass( + propertyName: String, + objectType: ObjectType, + ): String { + // Use main builder name as prefix to avoid class name conflicts across files + // e.g., "ActionMetaDataConfig" instead of just "MetaDataConfig" + val baseName = mainBuilderName.removeSuffix("Builder") + val className = baseName + propertyName.replaceFirstChar { it.uppercase() } + "Config" + + val code = + codeWriter { + objectType.description?.let { kdoc(it) } + + classBlock( + name = className, + annotations = listOf("@FluentDslMarker"), + superClass = "FluentBuilderBase>()", + ) { + overrideVal("defaults", "Map", "emptyMap()") + overrideVal("assetWrapperProperties", "Set", "emptySet()") + overrideVal("arrayProperties", "Set", "emptySet()") + blankLine() + + // Generate properties for nested object + objectType.properties.forEach { (propName, propObj) -> + val propInfo = createPropertyInfo(propName, propObj, allowNestedGeneration = false) + generateProperty(propInfo) + blankLine() + } + + generateBuildMethod() + blankLine() + generateCloneMethod(className) + } + } + + nestedClasses.add(GeneratedClass(className, code.trim())) + return className + } + + private fun CodeWriter.generateProperty(prop: PropertyInfo) { + // Add property documentation + prop.typeInfo.description?.let { kdoc(it) } + + when { + prop.isNestedObject && prop.nestedObjectClassName != null -> { + generateNestedObjectProperty(prop) + } + + prop.isAssetWrapper && prop.isArray -> { + generateAssetArrayProperty(prop) + } + + prop.isAssetWrapper -> { + generateAssetWrapperProperty(prop) + } + + prop.isArray -> { + generateArrayProperty(prop) + } + + else -> { + generateSimpleProperty(prop) + } + } + } + + private fun CodeWriter.generateSimpleProperty(prop: PropertyInfo) { + val typeName = prop.typeInfo.typeName + val nullableType = TypeMapper.makeNullable(typeName) + + simpleProperty( + name = prop.kotlinName, + type = nullableType, + getterExpr = "peek(\"${prop.originalName}\") as? $typeName", + setterExpr = "set(\"${prop.originalName}\", value)", + ) + + // Generate binding overload + if (prop.hasBindingOverload) { + blankLine() + line("fun ${prop.kotlinName}(binding: Binding<$typeName>) { set(\"${prop.originalName}\", binding) }") + } + + // Generate expression/tagged value overload + if (prop.hasExpressionOverload) { + blankLine() + val setCall = "set(\"${prop.originalName}\", taggedValue)" + line("fun ${prop.kotlinName}(taggedValue: TaggedValue<$typeName>) { $setCall }") + } + } + + private fun CodeWriter.generateAssetWrapperProperty(prop: PropertyInfo) { + // Write-only property for assets + line("var ${prop.kotlinName}: FluentBuilderBase<*>?") + indent() + line("get() = null // Write-only") + line("set(value) { if (value != null) set(\"${prop.originalName}\", AssetWrapperBuilder(value)) }") + dedent() + + // DSL lambda function + blankLine() + line("fun > ${prop.kotlinName}(builder: T) {") + indent() + line("set(\"${prop.originalName}\", AssetWrapperBuilder(builder))") + dedent() + line("}") + } + + private fun CodeWriter.generateAssetArrayProperty(prop: PropertyInfo) { + // Write-only property for asset arrays + // Don't manually wrap - let the build pipeline handle array elements + line("var ${prop.kotlinName}: List>?") + indent() + line("get() = null // Write-only") + line("set(value) { set(\"${prop.originalName}\", value) }") + dedent() + + // Varargs function + blankLine() + line("fun ${prop.kotlinName}(vararg builders: FluentBuilderBase<*>) {") + indent() + line("set(\"${prop.originalName}\", builders.toList())") + dedent() + line("}") + } + + private fun CodeWriter.generateArrayProperty(prop: PropertyInfo) { + val elementType = prop.typeInfo.elementType?.typeName ?: "Any?" + val listType = "List<$elementType>" + val nullableType = "$listType?" + + simpleProperty( + name = prop.kotlinName, + type = nullableType, + getterExpr = "peek(\"${prop.originalName}\") as? $listType", + setterExpr = "set(\"${prop.originalName}\", value)", + ) + + // Varargs function + blankLine() + line("fun ${prop.kotlinName}(vararg items: $elementType) {") + indent() + line("set(\"${prop.originalName}\", items.toList())") + dedent() + line("}") + } + + private fun CodeWriter.generateNestedObjectProperty(prop: PropertyInfo) { + val className = + requireNotNull(prop.nestedObjectClassName) { + "nestedObjectClassName required for nested object property: ${prop.originalName}" + } + + line("var ${prop.kotlinName}: $className?") + indent() + line("get() = null // Write-only") + line("set(value) { if (value != null) set(\"${prop.originalName}\", value) }") + dedent() + + // DSL lambda function + blankLine() + line("fun ${prop.kotlinName}(init: $className.() -> Unit) {") + indent() + line("set(\"${prop.originalName}\", $className().apply(init))") + dedent() + line("}") + } + + private fun CodeWriter.generateBuildMethod() { + line("override fun build(context: BuildContext?) = buildWithDefaults(context)") + } + + private fun CodeWriter.generateCloneMethod(className: String) { + line("override fun clone() = $className().also { cloneStorageTo(it) }") + } + + private fun CodeWriter.generateDslFunction( + functionName: String, + className: String, + description: String?, + ) { + description?.let { kdoc(it) } + line("fun $functionName(init: $className.() -> Unit = {}) = $className().apply(init)") + } + + companion object { + /** + * Primitive types that should have Binding and Expression overloads. + */ + 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 + } +} diff --git a/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/CodeWriter.kt b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/CodeWriter.kt new file mode 100644 index 00000000..e98cb339 --- /dev/null +++ b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/CodeWriter.kt @@ -0,0 +1,234 @@ +package com.intuit.playertools.fluent.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/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/Generator.kt b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/Generator.kt new file mode 100644 index 00000000..a311ff67 --- /dev/null +++ b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/Generator.kt @@ -0,0 +1,123 @@ +package com.intuit.playertools.fluent.generator + +import com.intuit.playertools.xlr.XlrDeserializer +import com.intuit.playertools.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, file.nameWithoutExtension) + } + + /** + * Generate a Kotlin builder from XLR JSON content. + */ + fun generateFromJson( + jsonContent: String, + sourceName: String = "Unknown", + ): 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/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/Main.kt b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/Main.kt new file mode 100644 index 00000000..f8f5036a --- /dev/null +++ b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/Main.kt @@ -0,0 +1,179 @@ +package com.intuit.playertools.fluent.generator + +import java.io.File +import kotlin.system.exitProcess + +/** + * CLI entry point for the Kotlin DSL generator. + * + * Usage: + * ``` + * kotlin-dsl-generator --input --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 + * - --help, -h: Show help message + */ +fun main(args: Array) { + val parsedArgs = parseArgs(args) + + if (parsedArgs.showHelp || parsedArgs.inputPaths.isEmpty()) { + printHelp() + return + } + + if (parsedArgs.outputDir == null) { + System.err.println("Error: Output directory is required. Use --output or -o.") + exitProcess(1) + return + } + + if (parsedArgs.packageName == null) { + System.err.println("Error: Package name is required. Use --package or -p.") + exitProcess(1) + return + } + + 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) + return + } + + 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 showHelp: Boolean = false, +) + +private fun parseArgs(args: Array): ParsedArgs { + var inputPaths = mutableListOf() + var outputDir: File? = null + var packageName: 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] + } + } + + else -> { + // Treat unknown args as input files + if (!args[i].startsWith("-")) { + inputPaths.add(File(args[i])) + } + } + } + i++ + } + + return ParsedArgs(inputPaths, outputDir, packageName, 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. + | + |Usage: + | kotlin-dsl-generator [options] [input-files...] + | + |Options: + | -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) + | -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 ActionAsset.json TextAsset.json -o out -p com.test + """.trimMargin(), + ) +} diff --git a/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/TypeMapper.kt b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/TypeMapper.kt new file mode 100644 index 00000000..4b9505dd --- /dev/null +++ b/language/generators/kotlin/src/main/kotlin/com/intuit/playertools/fluent/generator/TypeMapper.kt @@ -0,0 +1,277 @@ +package com.intuit.playertools.fluent.generator + +import com.intuit.playertools.xlr.AnyType +import com.intuit.playertools.xlr.ArrayType +import com.intuit.playertools.xlr.BooleanType +import com.intuit.playertools.xlr.NeverType +import com.intuit.playertools.xlr.NodeType +import com.intuit.playertools.xlr.NullType +import com.intuit.playertools.xlr.NumberType +import com.intuit.playertools.xlr.ObjectType +import com.intuit.playertools.xlr.OrType +import com.intuit.playertools.xlr.ParamTypeNode +import com.intuit.playertools.xlr.RecordType +import com.intuit.playertools.xlr.RefType +import com.intuit.playertools.xlr.StringType +import com.intuit.playertools.xlr.UndefinedType +import com.intuit.playertools.xlr.UnknownType +import com.intuit.playertools.xlr.VoidType +import com.intuit.playertools.xlr.isAssetRef +import com.intuit.playertools.xlr.isAssetWrapperRef +import com.intuit.playertools.xlr.isBindingRef +import com.intuit.playertools.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 { + // Check if all types are primitives with const values (literal union) + val types = node.orTypes + + // If it's a simple union of primitives, use Any + // Could be enhanced to use sealed classes for known variants + 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/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/ClassGeneratorTest.kt b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/ClassGeneratorTest.kt new file mode 100644 index 00000000..37a7c08c --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/ClassGeneratorTest.kt @@ -0,0 +1,232 @@ +package com.intuit.playertools.fluent.generator + +import com.intuit.playertools.xlr.ObjectProperty +import com.intuit.playertools.xlr.RefType +import com.intuit.playertools.xlr.StringType +import com.intuit.playertools.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", + 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", + 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", + 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", + properties = emptyMap(), + ) + + val result = ClassGenerator.generate(document, "com.example") + + result.code shouldContain "import com.intuit.playertools.fluent.FluentDslMarker" + result.code shouldContain "import com.intuit.playertools.fluent.core.BuildContext" + result.code shouldContain "import com.intuit.playertools.fluent.core.FluentBuilderBase" + result.code shouldContain "import com.intuit.playertools.fluent.tagged.Binding" + result.code shouldContain "import com.intuit.playertools.fluent.tagged.TaggedValue" + } + + it("generates build and clone methods") { + val document = + XlrDocument( + name = "SimpleAsset", + source = "test", + properties = emptyMap(), + ) + + 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", + properties = emptyMap(), + 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/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/GeneratorIntegrationTest.kt b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/GeneratorIntegrationTest.kt new file mode 100644 index 00000000..666339ee --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/GeneratorIntegrationTest.kt @@ -0,0 +1,120 @@ +package com.intuit.playertools.fluent.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/playertools/fluent/generator/fixtures/$name", + ) + return if (resource != null) { + resource.readText() + } else { + val file = + File( + "language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/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.playertools.fluent.FluentDslMarker" + code shouldContain "import com.intuit.playertools.fluent.core.AssetWrapperBuilder" + code shouldContain "import com.intuit.playertools.fluent.core.BuildContext" + code shouldContain "import com.intuit.playertools.fluent.core.FluentBuilderBase" + code shouldContain "import com.intuit.playertools.fluent.tagged.Binding" + code shouldContain "import com.intuit.playertools.fluent.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.playertools.xlr.XlrDeserializer + .deserialize(json) + val code = Generator.generateCode(document, "com.test") + + code shouldContain "class TextBuilder" + } + } + }) diff --git a/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/ProjectConfig.kt b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/ProjectConfig.kt new file mode 100644 index 00000000..73827a99 --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/ProjectConfig.kt @@ -0,0 +1,8 @@ +package com.intuit.playertools.fluent.generator + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.spec.IsolationMode + +object ProjectConfig : AbstractProjectConfig() { + override val isolationMode = IsolationMode.InstancePerLeaf +} diff --git a/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/TypeMapperTest.kt b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/TypeMapperTest.kt new file mode 100644 index 00000000..1cb0f1a6 --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/TypeMapperTest.kt @@ -0,0 +1,277 @@ +package com.intuit.playertools.fluent.generator + +import com.intuit.playertools.xlr.AnyType +import com.intuit.playertools.xlr.ArrayType +import com.intuit.playertools.xlr.BooleanType +import com.intuit.playertools.xlr.NeverType +import com.intuit.playertools.xlr.NullType +import com.intuit.playertools.xlr.NumberType +import com.intuit.playertools.xlr.ObjectType +import com.intuit.playertools.xlr.OrType +import com.intuit.playertools.xlr.ParamTypeNode +import com.intuit.playertools.xlr.RecordType +import com.intuit.playertools.xlr.RefType +import com.intuit.playertools.xlr.StringType +import com.intuit.playertools.xlr.UndefinedType +import com.intuit.playertools.xlr.UnknownType +import com.intuit.playertools.xlr.VoidType +import io.kotest.core.spec.style.DescribeSpec +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 union type to Any?") { + val result = + TypeMapper.mapToKotlinType( + OrType(orTypes = listOf(StringType(), NumberType())), + ) + result.typeName shouldBe "Any?" + result.isNullable shouldBe true + } + } + + 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/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/XlrDeserializerTest.kt b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/XlrDeserializerTest.kt new file mode 100644 index 00000000..caa47ef3 --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/XlrDeserializerTest.kt @@ -0,0 +1,233 @@ +package com.intuit.playertools.fluent.generator + +import com.intuit.playertools.xlr.ArrayType +import com.intuit.playertools.xlr.BooleanType +import com.intuit.playertools.xlr.ObjectType +import com.intuit.playertools.xlr.OrType +import com.intuit.playertools.xlr.RecordType +import com.intuit.playertools.xlr.RefType +import com.intuit.playertools.xlr.StringType +import com.intuit.playertools.xlr.XlrDeserializer +import com.intuit.playertools.xlr.extractAssetTypeConstant +import com.intuit.playertools.xlr.isAssetWrapperRef +import com.intuit.playertools.xlr.isBindingRef +import com.intuit.playertools.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.extends.shouldNotBeNull() + doc.extends!!.ref shouldBe "Asset<\"action\">" + + val assetType = extractAssetTypeConstant(doc.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.properties.keys shouldBe setOf("value", "label", "exp", "accessibility", "metaData") + } + + it("should parse string property") { + val valueProp = doc.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.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.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.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.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.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.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.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.extends.shouldNotBeNull() + val assetType = extractAssetTypeConstant(doc.extends) + assetType shouldBe "text" + } + + it("should parse required value property") { + val valueProp = doc.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/playertools/fluent/generator/fixtures/$name") + return if (resource != null) { + resource.readText() + } else { + val file = + File("language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/$name") + file.readText() + } +} diff --git a/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/ActionAsset.json b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/ActionAsset.json new file mode 100644 index 00000000..635c9786 --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/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/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/ChoiceAsset.json b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/ChoiceAsset.json new file mode 100644 index 00000000..ff8e2bcb --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/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/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/CollectionAsset.json b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/CollectionAsset.json new file mode 100644 index 00000000..2a9d4913 --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/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/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/ImageAsset.json b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/ImageAsset.json new file mode 100644 index 00000000..2a6f3642 --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/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/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/InfoAsset.json b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/InfoAsset.json new file mode 100644 index 00000000..42949695 --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/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/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/InputAsset.json b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/InputAsset.json new file mode 100644 index 00000000..ae5a4513 --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/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/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/TextAsset.json b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/generator/fixtures/TextAsset.json new file mode 100644 index 00000000..9dbf634a --- /dev/null +++ b/language/generators/kotlin/src/test/kotlin/com/intuit/playertools/fluent/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 diff --git a/xlr/types/kotlin/.editorconfig b/xlr/types/kotlin/.editorconfig new file mode 100644 index 00000000..9eab273a --- /dev/null +++ b/xlr/types/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/xlr/types/kotlin/BUILD.bazel b/xlr/types/kotlin/BUILD.bazel new file mode 100644 index 00000000..8ac466d4 --- /dev/null +++ b/xlr/types/kotlin/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library") +load("@rules_kotlin//kotlin:lint.bzl", "ktlint_config", "ktlint_fix", "ktlint_test") + +package(default_visibility = ["//visibility:public"]) + +ktlint_config( + name = "ktlint_config", + editorconfig = ".editorconfig", +) + +kt_jvm_library( + name = "xlr-types", + srcs = glob(["src/main/kotlin/**/*.kt"]), + kotlinc_opts = "//:kt_opts", + deps = [ + "@maven//:org_jetbrains_kotlin_kotlin_stdlib", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + ], +) + +ktlint_test( + name = "ktlint", + srcs = glob([ + "src/main/kotlin/**/*.kt", + ]), + config = ":ktlint_config", +) + +ktlint_fix( + name = "ktlint_fix", + srcs = glob([ + "src/main/kotlin/**/*.kt", + ]), + config = ":ktlint_config", +) diff --git a/xlr/types/kotlin/src/main/kotlin/com/intuit/playertools/xlr/XlrDeserializer.kt b/xlr/types/kotlin/src/main/kotlin/com/intuit/playertools/xlr/XlrDeserializer.kt new file mode 100644 index 00000000..1b01e5f7 --- /dev/null +++ b/xlr/types/kotlin/src/main/kotlin/com/intuit/playertools/xlr/XlrDeserializer.kt @@ -0,0 +1,267 @@ +package com.intuit.playertools.xlr + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.double +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * XLR JSON deserializer. + * Parses XLR JSON schemas into Kotlin type definitions. + */ +object XlrDeserializer { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Deserialize an XLR document from JSON string. + */ + fun deserialize(jsonString: String): XlrDocument { + val element = json.parseToJsonElement(jsonString).jsonObject + return parseDocument(element) + } + + /** + * Parse an XLR document from a JsonObject. + */ + fun parseDocument(obj: JsonObject): XlrDocument = + XlrDocument( + name = obj["name"]?.jsonPrimitive?.content ?: "", + source = obj["source"]?.jsonPrimitive?.content ?: "", + type = obj["type"]?.jsonPrimitive?.content ?: "object", + properties = parseProperties(obj["properties"]), + extends = obj["extends"]?.let { parseRefType(it.jsonObject) }, + additionalProperties = obj["additionalProperties"], + genericTokens = obj["genericTokens"]?.jsonArray?.map { parseParamTypeNode(it.jsonObject) }, + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + /** + * Parse a NodeType from a JsonElement. + */ + fun parseNode(element: JsonElement): NodeType { + if (element is JsonNull) { + return NullType() + } + + val obj = element.jsonObject + val type = + obj["type"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Node missing 'type' field: $obj") + + return when (type) { + "string" -> parseStringType(obj) + "number" -> parseNumberType(obj) + "boolean" -> parseBooleanType(obj) + "null" -> parseNullType(obj) + "any" -> parseAnyType(obj) + "unknown" -> parseUnknownType(obj) + "undefined" -> parseUndefinedType(obj) + "void" -> parseVoidType(obj) + "never" -> parseNeverType(obj) + "ref" -> parseRefType(obj) + "object" -> parseObjectType(obj) + "array" -> parseArrayType(obj) + "tuple" -> parseTupleType(obj) + "record" -> parseRecordType(obj) + "or" -> parseOrType(obj) + "and" -> parseAndType(obj) + "template" -> parseTemplateLiteralType(obj) + "conditional" -> parseConditionalType(obj) + "function" -> parseFunctionType(obj) + else -> throw IllegalArgumentException("Unknown type: $type in object: $obj") + } + } + + private fun parseStringType(obj: JsonObject): StringType = + StringType( + const = obj["const"]?.jsonPrimitive?.contentOrNull, + enum = obj["enum"]?.jsonArray?.map { it.jsonPrimitive.content }, + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseNumberType(obj: JsonObject): NumberType = + NumberType( + const = obj["const"]?.jsonPrimitive?.doubleOrNull, + enum = obj["enum"]?.jsonArray?.map { it.jsonPrimitive.double }, + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseBooleanType(obj: JsonObject): BooleanType = + BooleanType( + const = obj["const"]?.jsonPrimitive?.booleanOrNull, + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseNullType(obj: JsonObject): NullType = + NullType( + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseAnyType(obj: JsonObject): AnyType = + AnyType( + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseUnknownType(obj: JsonObject): UnknownType = + UnknownType( + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseUndefinedType(obj: JsonObject): UndefinedType = + UndefinedType( + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseVoidType(obj: JsonObject): VoidType = + VoidType( + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseNeverType(obj: JsonObject): NeverType = + NeverType( + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseRefType(obj: JsonObject): RefType = + RefType( + ref = obj["ref"]?.jsonPrimitive?.content ?: "", + genericArguments = obj["genericArguments"]?.jsonArray?.map { parseNode(it) }, + property = obj["property"]?.jsonPrimitive?.contentOrNull, + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseObjectType(obj: JsonObject): ObjectType = + ObjectType( + properties = parseProperties(obj["properties"]), + extends = obj["extends"]?.let { parseRefType(it.jsonObject) }, + additionalProperties = obj["additionalProperties"], + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseArrayType(obj: JsonObject): ArrayType { + val elementType = + obj["elementType"] + ?: throw IllegalArgumentException("Array missing 'elementType': $obj") + return ArrayType( + elementType = parseNode(elementType), + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + } + + private fun parseTupleType(obj: JsonObject): TupleType = + TupleType( + elementTypes = + obj["elementTypes"]?.jsonArray?.map { parseTupleMember(it.jsonObject) } + ?: emptyList(), + minItems = obj["minItems"]?.jsonPrimitive?.int ?: 0, + additionalItems = obj["additionalItems"], + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseTupleMember(obj: JsonObject): TupleMember = + TupleMember( + name = obj["name"]?.jsonPrimitive?.contentOrNull, + type = parseNode(obj["type"] ?: throw IllegalArgumentException("TupleMember missing 'type'")), + optional = obj["optional"]?.jsonPrimitive?.booleanOrNull, + ) + + private fun parseRecordType(obj: JsonObject): RecordType = + RecordType( + keyType = parseNode(obj["keyType"] ?: throw IllegalArgumentException("Record missing 'keyType'")), + valueType = parseNode(obj["valueType"] ?: throw IllegalArgumentException("Record missing 'valueType'")), + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseOrType(obj: JsonObject): OrType = + OrType( + orTypes = obj["or"]?.jsonArray?.map { parseNode(it) } ?: emptyList(), + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseAndType(obj: JsonObject): AndType = + AndType( + andTypes = obj["and"]?.jsonArray?.map { parseNode(it) } ?: emptyList(), + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseTemplateLiteralType(obj: JsonObject): TemplateLiteralType = + TemplateLiteralType( + format = obj["format"]?.jsonPrimitive?.content ?: "", + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseConditionalType(obj: JsonObject): ConditionalType = + ConditionalType( + check = obj["check"]?.jsonObject?.mapValues { parseNode(it.value) } ?: emptyMap(), + value = obj["value"]?.jsonObject?.mapValues { parseNode(it.value) } ?: emptyMap(), + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseFunctionType(obj: JsonObject): FunctionType = + FunctionType( + parameters = + obj["parameters"]?.jsonArray?.map { parseFunctionParameter(it.jsonObject) } + ?: emptyList(), + returnType = obj["returnType"]?.let { parseNode(it) }, + title = obj["title"]?.jsonPrimitive?.contentOrNull, + description = obj["description"]?.jsonPrimitive?.contentOrNull, + ) + + private fun parseFunctionParameter(obj: JsonObject): FunctionParameter = + FunctionParameter( + name = obj["name"]?.jsonPrimitive?.content ?: "", + type = parseNode(obj["type"] ?: throw IllegalArgumentException("Parameter missing 'type'")), + optional = obj["optional"]?.jsonPrimitive?.booleanOrNull, + default = obj["default"]?.let { parseNode(it) }, + ) + + private fun parseProperties(element: JsonElement?): Map { + if (element == null || element is JsonNull) return emptyMap() + return element.jsonObject.mapValues { (_, propElement) -> + val propObj = propElement.jsonObject + ObjectProperty( + required = propObj["required"]?.jsonPrimitive?.boolean ?: false, + node = parseNode(propObj["node"] ?: throw IllegalArgumentException("Property missing 'node'")), + ) + } + } + + private fun parseParamTypeNode(obj: JsonObject): ParamTypeNode = + ParamTypeNode( + symbol = obj["symbol"]?.jsonPrimitive?.content ?: "", + constraints = obj["constraints"]?.let { parseNode(it) }, + default = obj["default"]?.let { parseNode(it) }, + ) +} diff --git a/xlr/types/kotlin/src/main/kotlin/com/intuit/playertools/xlr/XlrGuards.kt b/xlr/types/kotlin/src/main/kotlin/com/intuit/playertools/xlr/XlrGuards.kt new file mode 100644 index 00000000..dcaff3b3 --- /dev/null +++ b/xlr/types/kotlin/src/main/kotlin/com/intuit/playertools/xlr/XlrGuards.kt @@ -0,0 +1,141 @@ +package com.intuit.playertools.xlr + +/** + * Type guard functions for XLR node types. + * Provides convenient type checking and casting utilities. + */ + +fun isStringType(node: NodeType): Boolean = node is StringType + +fun isNumberType(node: NodeType): Boolean = node is NumberType + +fun isBooleanType(node: NodeType): Boolean = node is BooleanType + +fun isNullType(node: NodeType): Boolean = node is NullType + +fun isAnyType(node: NodeType): Boolean = node is AnyType + +fun isUnknownType(node: NodeType): Boolean = node is UnknownType + +fun isUndefinedType(node: NodeType): Boolean = node is UndefinedType + +fun isVoidType(node: NodeType): Boolean = node is VoidType + +fun isNeverType(node: NodeType): Boolean = node is NeverType + +fun isRefType(node: NodeType): Boolean = node is RefType + +fun isObjectType(node: NodeType): Boolean = node is ObjectType + +fun isArrayType(node: NodeType): Boolean = node is ArrayType + +fun isTupleType(node: NodeType): Boolean = node is TupleType + +fun isRecordType(node: NodeType): Boolean = node is RecordType + +fun isOrType(node: NodeType): Boolean = node is OrType + +fun isAndType(node: NodeType): Boolean = node is AndType + +fun isTemplateLiteralType(node: NodeType): Boolean = node is TemplateLiteralType + +fun isConditionalType(node: NodeType): Boolean = node is ConditionalType + +fun isFunctionType(node: NodeType): Boolean = node is FunctionType + +/** + * Check if a node is a primitive type (string, number, boolean, null, etc.) + */ +fun isPrimitiveType(node: NodeType): Boolean = + when (node) { + is StringType, is NumberType, is BooleanType, is NullType, + is AnyType, is UnknownType, is UndefinedType, is VoidType, is NeverType, + -> true + + else -> false + } + +/** + * Check if a ref node references an AssetWrapper type. + */ +fun isAssetWrapperRef(node: NodeType): Boolean { + if (node !is RefType) return false + return node.ref.startsWith("AssetWrapper") +} + +/** + * Check if a ref node references an Asset type. + */ +fun isAssetRef(node: NodeType): Boolean { + if (node !is RefType) return false + return node.ref.startsWith("Asset<") || node.ref == "Asset" +} + +/** + * Check if a ref node references a Binding type. + */ +fun isBindingRef(node: NodeType): Boolean { + if (node !is RefType) return false + return node.ref == "Binding" || node.ref.startsWith("Binding<") +} + +/** + * Check if a ref node references an Expression type. + */ +fun isExpressionRef(node: NodeType): Boolean { + if (node !is RefType) return false + return node.ref == "Expression" || node.ref.startsWith("Expression<") +} + +/** + * Extract the asset type constant from an extends clause. + * E.g., Asset<"action"> -> "action" + */ +fun extractAssetTypeConstant(extendsRef: RefType?): String? { + if (extendsRef == null) return null + if (!extendsRef.ref.startsWith("Asset<")) return null + + val genericArgs = extendsRef.genericArguments ?: return null + if (genericArgs.isEmpty()) return null + + val firstArg = genericArgs.first() + if (firstArg is StringType && firstArg.const != null) { + return firstArg.const + } + + return null +} + +/** + * Check if a string type has a const value (literal type). + */ +fun hasConstValue(node: StringType): Boolean = node.const != null + +/** + * Check if a node has any const value. + */ +fun hasAnyConstValue(node: NodeType): Boolean = + when (node) { + is StringType -> node.const != null + is NumberType -> node.const != null + is BooleanType -> node.const != null + else -> false + } + +/** + * Check if an OrType contains only primitives with const values (Literal type). + */ +fun isLiteralUnion(node: OrType): Boolean = node.orTypes.all { hasAnyConstValue(it) } + +/** + * Get all const values from a literal union. + */ +fun getLiteralValues(node: OrType): List = + node.orTypes.mapNotNull { type -> + when (type) { + is StringType -> type.const + is NumberType -> type.const + is BooleanType -> type.const + else -> null + } + } diff --git a/xlr/types/kotlin/src/main/kotlin/com/intuit/playertools/xlr/XlrTypes.kt b/xlr/types/kotlin/src/main/kotlin/com/intuit/playertools/xlr/XlrTypes.kt new file mode 100644 index 00000000..2c68d8f2 --- /dev/null +++ b/xlr/types/kotlin/src/main/kotlin/com/intuit/playertools/xlr/XlrTypes.kt @@ -0,0 +1,341 @@ +package com.intuit.playertools.xlr + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Annotations that can be attached to any XLR type. + */ +@Serializable +data class Annotations( + val name: String? = null, + val title: String? = null, + val description: String? = null, + val examples: JsonElement? = null, + val default: JsonElement? = null, + val see: JsonElement? = null, + val comment: String? = null, + val meta: Map? = null, +) + +/** + * Base sealed interface for all XLR node types. + */ +sealed interface NodeType { + val type: String +} + +/** + * String type node. + */ +@Serializable +@SerialName("string") +data class StringType( + override val type: String = "string", + val const: String? = null, + val enum: List? = null, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Number type node. + */ +@Serializable +@SerialName("number") +data class NumberType( + override val type: String = "number", + val const: Double? = null, + val enum: List? = null, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Boolean type node. + */ +@Serializable +@SerialName("boolean") +data class BooleanType( + override val type: String = "boolean", + val const: Boolean? = null, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Null type node. + */ +@Serializable +@SerialName("null") +data class NullType( + override val type: String = "null", + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Any type node. + */ +@Serializable +@SerialName("any") +data class AnyType( + override val type: String = "any", + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Unknown type node. + */ +@Serializable +@SerialName("unknown") +data class UnknownType( + override val type: String = "unknown", + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Undefined type node. + */ +@Serializable +@SerialName("undefined") +data class UndefinedType( + override val type: String = "undefined", + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Void type node. + */ +@Serializable +@SerialName("void") +data class VoidType( + override val type: String = "void", + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Never type node. + */ +@Serializable +@SerialName("never") +data class NeverType( + override val type: String = "never", + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Reference to another type. + */ +@Serializable +@SerialName("ref") +data class RefType( + override val type: String = "ref", + val ref: String, + val genericArguments: List? = null, + val property: String? = null, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Property definition within an object type. + */ +@Serializable +data class ObjectProperty( + val required: Boolean, + val node: NodeType, +) + +/** + * Object type node with properties. + */ +@Serializable +@SerialName("object") +data class ObjectType( + override val type: String = "object", + val properties: Map = emptyMap(), + val extends: RefType? = null, + val additionalProperties: JsonElement? = null, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Array type node. + */ +@Serializable +@SerialName("array") +data class ArrayType( + override val type: String = "array", + val elementType: NodeType, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Tuple member definition. + */ +@Serializable +data class TupleMember( + val name: String? = null, + val type: NodeType, + val optional: Boolean? = null, +) + +/** + * Tuple type node. + */ +@Serializable +@SerialName("tuple") +data class TupleType( + override val type: String = "tuple", + val elementTypes: List, + val minItems: Int, + val additionalItems: JsonElement? = null, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Record type node (key-value mapping). + */ +@Serializable +@SerialName("record") +data class RecordType( + override val type: String = "record", + val keyType: NodeType, + val valueType: NodeType, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Union type node (or). + */ +@Serializable +@SerialName("or") +data class OrType( + override val type: String = "or", + @SerialName("or") + val orTypes: List, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Intersection type node (and). + */ +@Serializable +@SerialName("and") +data class AndType( + override val type: String = "and", + @SerialName("and") + val andTypes: List, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Template literal type node. + */ +@Serializable +@SerialName("template") +data class TemplateLiteralType( + override val type: String = "template", + val format: String, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Conditional type node. + */ +@Serializable +@SerialName("conditional") +data class ConditionalType( + override val type: String = "conditional", + val check: Map, + val value: Map, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Function parameter definition. + */ +@Serializable +data class FunctionParameter( + val name: String, + val type: NodeType, + val optional: Boolean? = null, + val default: NodeType? = null, +) + +/** + * Function type node. + */ +@Serializable +@SerialName("function") +data class FunctionType( + override val type: String = "function", + val parameters: List, + val returnType: NodeType? = null, + val title: String? = null, + val description: String? = null, +) : NodeType + +/** + * Generic type parameter definition. + */ +@Serializable +data class ParamTypeNode( + val symbol: String, + val constraints: NodeType? = null, + val default: NodeType? = null, +) + +/** + * Named type wrapper that adds name and source information to any node type. + * Note: The node property is transient and must be set after deserialization if needed. + */ +@Serializable +data class NamedType( + val name: String, + val source: String, + val genericTokens: List? = null, + @kotlinx.serialization.Transient + val node: T? = null, +) + +/** + * Complete XLR document representing a named object type (asset definition). + */ +@Serializable +data class XlrDocument( + val name: String, + val source: String, + val type: String = "object", + val properties: Map = emptyMap(), + val extends: RefType? = null, + val additionalProperties: JsonElement? = null, + val genericTokens: List? = null, + val title: String? = null, + val description: String? = null, +) { + fun toObjectType(): ObjectType = + ObjectType( + type = type, + properties = properties, + extends = extends, + additionalProperties = additionalProperties, + title = title, + description = description, + ) +}