From 4846190ba9381ae0fca233e012c8826a5e72a9fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Mon, 18 May 2026 01:14:22 +0200 Subject: [PATCH 1/2] build: bleep M10 + built-in publishing, scalafmt + -no-indent, coursier channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything on this branch that isn't the interactive TUI / closed-beta work. Splits cleanly from the TUI delta in the next commit. ──────────────────────────────────────────────────────────────────────── bleep M3 → M10 + built-in publishing ──────────────────────────────────────────────────────────────────────── - bleep.yaml: `\$version: 1.0.0-M3` → `1.0.0-M10` - Migrated from custom publish scripts to bleep's built-in `publish` subcommand: * removed `scripts.Publish` (used CiReleasePlugin + manual coursier Info / packageLibraries plumbing) * removed `scripts.PublishLocal` (manual `commands.publishLocal`) * removed `scripts.projectsToPublish` (filter list) * removed `build.bleep::bleep-plugin-ci-release` from typr-scripts deps (added `bleep-core` directly because CompileBenchmark / GeneratedShowcase / GitOps were resolving `bleep.*` and `ryddig.*` transitively through it) * removed `my-publish-local:` + `publish:` script entries * added `template-publishable` (groupId dev.typr, sonatypeProfileName com.olvind, MIT, single developer) and wired all 11 publishable projects (typr, typr-codegen, typr-dsl, typr-dsl-{scala,kotlin,anorm,doobie,zio-jdbc}, typr-runtime-{anorm,doobie,zio-jdbc}) to extend it as a list Verified via `bleep publish local-ivy --dry-run`: 204 files, all POMs carry the correct groupId / description / url / license / developer. Distribution stays via Maven Central. `coursier-channel.json` at the repo root is a Coursier channel descriptor (same pattern bleep uses) pinning `dev.typr:typr_3:latest.release`, mainClass `typr.cli.Main`, `-XX:+UseG1GC`. Users install with: cs install --channel https://raw.githubusercontent.com/typr-dev/typr/main/coursier-channel.json typr ──────────────────────────────────────────────────────────────────────── scalafmt + `-no-indent` ──────────────────────────────────────────────────────────────────────── - `.scalafmt.conf`: added scala3 dialect overrides for the scala-3 source roots that weren't already covered — typr/src/scala, typr-codegen/src/scala, typr-scripts/src/scala, typr-scripts-sourcegen/src/scala. Without these, scalafmt's default `Scala213Source3` dialect chokes on `enum` / `given` / optional-braces syntax that the Scala 3 code uses. - `bleep.yaml`: added `-no-indent` to `template-scala-3` scala options so the compiler enforces brace syntax for all Scala 3 code (no significant-indentation drift between authors). - Ran `scalac -no-indent -rewrite` once over the affected projects to auto-convert existing indent-style → brace-style; ~115 files changed. One manual fix afterwards: a match expression in `typr/cli/app/screens/OutputEditor.scala` lost a comma between a `} match` clause and the next named argument. (The TUI source files included in the rewrite belong with the next commit, not this one — they're added by the TUI commit on top of this state.) ──────────────────────────────────────────────────────────────────────── Misc ──────────────────────────────────────────────────────────────────────── - `TypoLogger.Console` now prefixes lines with `typr:` instead of the leftover `typo:`. - Site: getting-started rewritten around closed-beta access (drops placeholder install / quickstart, points at oyvind@typr.dev for seats); landing-page polish (announcement bar, mobile layout). - `typr.yaml` is heavily reordered by ConfigWriter's sortKeys + dropNullKeys pass — functionally identical. - `.gitignore` ignores `.claude/scheduled_tasks.lock`; that file is removed from the tracked tree. - Small regenerated test row tweaks under `testers/.../product_summary` and `testers/.../inventory_check` from a regen pass against the current schema. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + .scalafmt.conf | 14 +- bleep.yaml | 67 +- coursier-channel.json | 12 + .../ProductDetailsWithSalesSqlRow.java | 4 +- .../product_summary/ProductSummarySqlRow.java | 4 +- .../ProductDetailsWithSalesSqlRow.kt | 2 +- .../product_summary/ProductSummarySqlRow.kt | 2 +- .../ProductDetailsWithSalesSqlRow.scala | 2 +- .../ProductSummarySqlRow.scala | 2 +- .../inventory_check/InventoryCheckSqlRow.java | 4 +- .../inventory_check/InventoryCheckSqlRow.kt | 2 +- .../InventoryCheckSqlRow.scala | 2 +- .../adventureworks/person/MultiRepoTest.scala | 6 +- .../adventureworks/person/MultiRepoTest.scala | 2 +- .../adventureworks/person/MultiRepoTest.scala | 2 +- .../adventureworks/person/MultiRepoTest.scala | 2 +- .../adventureworks/person/MultiRepoTest.scala | 2 +- .../src/scala/typr/NonEmptyList.scala | 2 +- typr-codegen/src/scala/typr/TypoLogger.scala | 4 +- .../src/scala/typr/generateFromDb.scala | 2 +- .../ComputedBridgeCompositeType.scala | 2 +- .../typr/internal/ComputedRowUnsaved.scala | 2 +- .../typr/internal/ComputedTestInserts.scala | 12 +- .../src/scala/typr/internal/FkAnalysis.scala | 4 +- .../src/scala/typr/internal/IdComputed.scala | 6 +- .../typr/internal/TypeMapperJvmNew.scala | 72 +- .../typr/internal/TypeMapperJvmOld.scala | 4 +- .../src/scala/typr/internal/TypeMatcher.scala | 2 +- .../internal/analysis/DecomposedSql.scala | 4 +- .../analysis/NullabilityFromExplain.scala | 2 +- .../typr/internal/analysis/ParsedName.scala | 6 +- .../typr/internal/codegen/DbLibAnorm.scala | 14 +- .../typr/internal/codegen/DbLibDoobie.scala | 38 +- .../internal/codegen/DbLibFoundations.scala | 4 +- .../internal/codegen/DbLibTextSupport.scala | 22 +- .../typr/internal/codegen/DbLibZioJdbc.scala | 4 +- .../typr/internal/codegen/FilesTable.scala | 2 +- .../typr/internal/codegen/JsonLibPlay.scala | 8 +- .../typr/internal/codegen/LangJava.scala | 17 +- .../typr/internal/codegen/LangKotlin.scala | 24 +- .../typr/internal/codegen/LangScala.scala | 64 +- .../internal/codegen/PostgresAdapter.scala | 2 +- .../scala/typr/internal/codegen/SqlCast.scala | 10 +- .../codegen/addPackageAndImports.scala | 34 +- .../scala/typr/internal/db2/Db2MetaDb.scala | 2 +- .../typr/internal/db2/Db2TypeMapperDb.scala | 4 +- .../typr/internal/duckdb/DuckDbMetaDb.scala | 2 +- .../internal/external/ExternalTools.scala | 2 +- .../scala/typr/internal/findTypeFromFk.scala | 2 +- .../src/scala/typr/internal/generate.scala | 14 +- .../typr/internal/mariadb/MariaMetaDb.scala | 2 +- .../src/scala/typr/internal/minimize.scala | 4 +- .../typr/internal/oracle/OracleMetaDb.scala | 2 +- .../src/scala/typr/internal/pg/PgMetaDb.scala | 2 +- .../typr/internal/pg/PgTypeMapperDb.scala | 2 +- .../internal/sqlserver/SqlServerMetaDb.scala | 2 +- .../sqlserver/SqlServerTypeMapperDb.scala | 2 +- .../scala/typr/openapi/OpenApiCodegen.scala | 2 - .../scala/typr/openapi/OpenApiOptions.scala | 2 +- .../typr/openapi/codegen/ApiCodegen.scala | 10 +- .../typr/openapi/parser/ApiExtractor.scala | 4 +- .../src/scala/typr/dsl/SelectBuilderSql.scala | 1 + .../scala/typr/dsl/SelectBuilderMock.scala | 13 +- typr-dsl-shared/typr/dsl/OrderByOrSeek.scala | 2 +- .../scala/typr/dsl/SelectBuilderMock.scala | 13 +- typr-scripts/src/scala/scripts/Publish.scala | 75 - .../src/scala/scripts/PublishLocal.scala | 23 - .../src/scala/scripts/projectsToPublish.scala | 30 - typr.yaml | 1371 +++++++---------- typr/src/scala/typr/avro/AvroCodegen.scala | 2 +- .../avro/codegen/AvroWireFormatSupport.scala | 2 +- .../typr/avro/codegen/FileAvroWrapper.scala | 4 +- .../typr/avro/codegen/ProducerCodegen.scala | 4 +- .../typr/avro/codegen/ProtocolCodegen.scala | 2 - .../avro/codegen/TopicBindingsCodegen.scala | 4 +- .../typr/avro/codegen/UnionTypeCodegen.scala | 2 +- .../avro/codegen/VersionedRecordCodegen.scala | 2 +- .../scala/typr/avro/parser/AvroParser.scala | 8 +- .../typr/avro/parser/ProtocolParser.scala | 19 +- .../avro/parser/SchemaRegistryClient.scala | 2 +- .../src/scala/typr/bridge/CompositeType.scala | 2 +- typr/src/scala/typr/cli/commands/Check.scala | 8 +- .../scala/typr/cli/commands/Generate.scala | 50 +- .../cli/commands/SourceEntityLoader.scala | 2 +- typr/src/scala/typr/cli/commands/Watch.scala | 3 - .../scala/typr/cli/config/ConfigParser.scala | 1 - .../typr/cli/config/ConfigToOptions.scala | 11 +- .../typr/cli/config/EnvSubstitution.scala | 2 +- .../scala/typr/cli/util/PatternMatcher.scala | 12 +- typr/src/scala/typr/grpc/GrpcCodegen.scala | 2 +- .../typr/grpc/codegen/FilesGrpcService.scala | 1 - .../typr/grpc/codegen/OneOfCodegen.scala | 1 - .../grpc/computed/ComputedGrpcService.scala | 2 +- 94 files changed, 1001 insertions(+), 1234 deletions(-) create mode 100644 coursier-channel.json delete mode 100644 typr-scripts/src/scala/scripts/Publish.scala delete mode 100644 typr-scripts/src/scala/scripts/PublishLocal.scala delete mode 100644 typr-scripts/src/scala/scripts/projectsToPublish.scala diff --git a/.gitignore b/.gitignore index aa346a888b..afb14cea03 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ duckdb-test/ typr.sh .mcp.json .claude/settings.local.json +.claude/scheduled_tasks.lock diff --git a/.scalafmt.conf b/.scalafmt.conf index 8759634e20..668cd88923 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version=3.8.1 +version=3.11.1 maxColumn = 200 runner.dialect = Scala213Source3 @@ -19,6 +19,18 @@ fileOverride { "glob:**/typr-dsl-scala/src/scala/**" { runner.dialect = scala3 } + "glob:**/typr/src/scala/**" { + runner.dialect = scala3 + } + "glob:**/typr-codegen/src/scala/**" { + runner.dialect = scala3 + } + "glob:**/typr-scripts/src/scala/**" { + runner.dialect = scala3 + } + "glob:**/typr-scripts-sourcegen/src/scala/**" { + runner.dialect = scala3 + } "glob:**/typr/generated-and-checked-in/**" { runner.dialect = scala3 } diff --git a/bleep.yaml b/bleep.yaml index fe2612b1e7..506a540153 100644 --- a/bleep.yaml +++ b/bleep.yaml @@ -1,5 +1,5 @@ $schema: https://raw.githubusercontent.com/oyvindberg/bleep/master/schema.json -$version: 1.0.0-M3 +$version: 1.0.0-M10 resolvers: - https://packages.confluent.io/maven/ jvm: @@ -7,6 +7,7 @@ jvm: projects: typr-dsl: dependencies: dev.typr.foundations:foundations-jdbc:1.0.0-RC6 + extends: template-publishable java: options: --release 24 -proc:none platform: @@ -22,7 +23,9 @@ projects: - configuration: provided module: org.postgresql:postgresql:42.7.3 dependsOn: typr-dsl - extends: template-kotlin + extends: + - template-kotlin + - template-publishable kotlin: options: -Xnested-type-aliases sourcegen: @@ -36,7 +39,9 @@ projects: - configuration: provided module: org.postgresql:postgresql:42.7.3 dependsOn: typr-dsl - extends: template-scala-3 + extends: + - template-scala-3 + - template-publishable sourcegen: - project: typr-scripts-sourcegen main: scripts.GeneratedRowParsers @@ -895,7 +900,9 @@ projects: - org.typelevel::cats-effect:3.5.7 dependsOn: - typr-codegen - extends: template-scala-3 + extends: + - template-scala-3 + - template-publishable platform: name: jvm mainClass: typr.cli.Main @@ -924,7 +931,9 @@ projects: - org.postgresql:postgresql:42.7.3 - org.slf4j:slf4j-nop:2.0.13 dependsOn: typr-dsl-scala - extends: template-scala-3 + extends: + - template-scala-3 + - template-publishable platform: mainClass: com.foo.App scala: @@ -934,34 +943,46 @@ projects: - ./generated-and-checked-in-jsonschema typr-dsl-anorm: dependsOn: typr-runtime-anorm - extends: template-cross + extends: + - template-cross + - template-publishable sources: ../typr-dsl-shared typr-dsl-doobie: dependsOn: typr-runtime-doobie - extends: template-cross + extends: + - template-cross + - template-publishable sources: ../typr-dsl-shared typr-dsl-zio-jdbc: dependsOn: typr-runtime-zio-jdbc - extends: template-cross + extends: + - template-cross + - template-publishable sources: ../typr-dsl-shared typr-runtime-anorm: dependencies: - dev.typr.foundations:foundations-jdbc:1.0.0-RC6 - org.playframework.anorm::anorm:2.7.0 - extends: template-cross + extends: + - template-cross + - template-publishable typr-runtime-doobie: dependencies: - dev.typr.foundations:foundations-jdbc:1.0.0-RC6 - org.tpolecat::doobie-postgres:1.0.0-RC9 - extends: template-cross + extends: + - template-cross + - template-publishable typr-runtime-zio-jdbc: dependencies: - dev.typr.foundations:foundations-jdbc:1.0.0-RC6 - dev.zio::zio-jdbc:0.1.2 - extends: template-cross + extends: + - template-cross + - template-publishable typr-scripts: dependencies: - - build.bleep::bleep-plugin-ci-release:${BLEEP_VERSION} + - build.bleep::bleep-core:${BLEEP_VERSION} - com.ibm.db2:jcc:11.5.9.0 dependsOn: typr-codegen extends: template-scala-3 @@ -981,12 +1002,6 @@ scripts: generate-sources: main: scripts.GeneratedSources project: typr-scripts - my-publish-local: - main: scripts.PublishLocal - project: typr-scripts - publish: - main: scripts.Publish - project: typr-scripts templates: template-common: java: @@ -1010,8 +1025,22 @@ templates: template-scala-3: extends: template-common scala: - options: -release 24 -source 3.4 + options: -release 24 -source 3.4 -no-indent version: 3.8.3 + template-publishable: + publish: + groupId: dev.typr + sonatypeProfileName: com.olvind + description: Typed postgres boilerplate generation + url: https://github.com/oyvindberg/typr/ + developers: + - id: oyvindberg + name: Øyvind Raddum Berg + url: https://github.com/oyvindberg + licenses: + - name: MIT + url: http://opensource.org/licenses/MIT + distribution: repo template-kotlin: kotlin: version: 2.3.0 diff --git a/coursier-channel.json b/coursier-channel.json new file mode 100644 index 0000000000..d35911e778 --- /dev/null +++ b/coursier-channel.json @@ -0,0 +1,12 @@ +{ + "typr": { + "repositories": ["central"], + "dependencies": ["dev.typr:typr_3:latest.release"], + "mainClass": "typr.cli.Main", + "javaOptions": ["-XX:+UseG1GC"], + "properties": { + "name": "typr", + "description": "Type-safe code generator for JVM — databases, OpenAPI, Avro, gRPC" + } + } +} diff --git a/testers/duckdb/java/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.java b/testers/duckdb/java/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.java index 68facebc84..4264f14524 100644 --- a/testers/duckdb/java/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.java +++ b/testers/duckdb/java/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.java @@ -31,7 +31,7 @@ public record ProductDetailsWithSalesSqlRow( @JsonProperty("times_ordered") Optional timesOrdered, /** Points to {@link testdb.order_items.OrderItemsRow#quantity()} */ @JsonProperty("total_quantity_sold") Optional totalQuantitySold, - /** Points to {@link testdb.order_items.OrderItemsRow#quantity()} */ + /** Points to {@link testdb.order_items.OrderItemsRow#unitPrice()} */ @JsonProperty("total_revenue") Optional totalRevenue, /** Points to {@link testdb.order_items.OrderItemsRow#orderId()} */ Optional popularity @@ -71,7 +71,7 @@ public ProductDetailsWithSalesSqlRow withTotalQuantitySold(Optional totalQ return new ProductDetailsWithSalesSqlRow(productId, sku, name, price, metadata, timesOrdered, totalQuantitySold, totalRevenue, popularity); } - /** Points to {@link testdb.order_items.OrderItemsRow#quantity()} */ + /** Points to {@link testdb.order_items.OrderItemsRow#unitPrice()} */ public ProductDetailsWithSalesSqlRow withTotalRevenue(Optional totalRevenue) { return new ProductDetailsWithSalesSqlRow(productId, sku, name, price, metadata, timesOrdered, totalQuantitySold, totalRevenue, popularity); } diff --git a/testers/duckdb/java/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.java b/testers/duckdb/java/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.java index 96f29f11e7..dac412b70d 100644 --- a/testers/duckdb/java/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.java +++ b/testers/duckdb/java/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.java @@ -27,7 +27,7 @@ public record ProductSummarySqlRow( @JsonProperty("order_count") Long orderCount, /** Points to {@link testdb.order_items.OrderItemsRow#quantity()} */ @JsonProperty("total_quantity") Optional totalQuantity, - /** Points to {@link testdb.order_items.OrderItemsRow#quantity()} */ + /** Points to {@link testdb.order_items.OrderItemsRow#unitPrice()} */ @JsonProperty("total_revenue") Optional totalRevenue ) implements Tuple7, Optional> { /** Points to {@link testdb.products.ProductsRow#productId()} */ @@ -59,7 +59,7 @@ public ProductSummarySqlRow withTotalQuantity(Optional totalQuantity) { return new ProductSummarySqlRow(productId, productName, sku, price, orderCount, totalQuantity, totalRevenue); } - /** Points to {@link testdb.order_items.OrderItemsRow#quantity()} */ + /** Points to {@link testdb.order_items.OrderItemsRow#unitPrice()} */ public ProductSummarySqlRow withTotalRevenue(Optional totalRevenue) { return new ProductSummarySqlRow(productId, productName, sku, price, orderCount, totalQuantity, totalRevenue); } diff --git a/testers/duckdb/kotlin/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.kt b/testers/duckdb/kotlin/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.kt index cb085e1a2e..7e0ad668f8 100644 --- a/testers/duckdb/kotlin/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.kt +++ b/testers/duckdb/kotlin/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.kt @@ -30,7 +30,7 @@ data class ProductDetailsWithSalesSqlRow( @field:JsonProperty("times_ordered") val timesOrdered: kotlin.Long?, /** Points to [testdb.order_items.OrderItemsRow.quantity] */ @field:JsonProperty("total_quantity_sold") val totalQuantitySold: kotlin.Long?, - /** Points to [testdb.order_items.OrderItemsRow.quantity] */ + /** Points to [testdb.order_items.OrderItemsRow.unitPrice] */ @field:JsonProperty("total_revenue") val totalRevenue: kotlin.Double?, /** Points to [testdb.order_items.OrderItemsRow.orderId] */ val popularity: kotlin.String? diff --git a/testers/duckdb/kotlin/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.kt b/testers/duckdb/kotlin/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.kt index ca5b9f9beb..688ddca7b7 100644 --- a/testers/duckdb/kotlin/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.kt +++ b/testers/duckdb/kotlin/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.kt @@ -26,7 +26,7 @@ data class ProductSummarySqlRow( @field:JsonProperty("order_count") val orderCount: kotlin.Long, /** Points to [testdb.order_items.OrderItemsRow.quantity] */ @field:JsonProperty("total_quantity") val totalQuantity: kotlin.Long?, - /** Points to [testdb.order_items.OrderItemsRow.quantity] */ + /** Points to [testdb.order_items.OrderItemsRow.unitPrice] */ @field:JsonProperty("total_revenue") val totalRevenue: BigDecimal? ) : Tuple7 { override fun _1(): ProductsId = productId diff --git a/testers/duckdb/scala/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.scala b/testers/duckdb/scala/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.scala index 17d05c818e..f7fa2c49a8 100644 --- a/testers/duckdb/scala/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.scala +++ b/testers/duckdb/scala/generated-and-checked-in/testdb/product_details_with_sales/ProductDetailsWithSalesSqlRow.scala @@ -29,7 +29,7 @@ case class ProductDetailsWithSalesSqlRow( @JsonProperty("times_ordered") timesOrdered: Option[Long], /** Points to [[testdb.order_items.OrderItemsRow.quantity]] */ @JsonProperty("total_quantity_sold") totalQuantitySold: Option[Long], - /** Points to [[testdb.order_items.OrderItemsRow.quantity]] */ + /** Points to [[testdb.order_items.OrderItemsRow.unitPrice]] */ @JsonProperty("total_revenue") totalRevenue: Option[Double], /** Points to [[testdb.order_items.OrderItemsRow.orderId]] */ popularity: Option[String] diff --git a/testers/duckdb/scala/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.scala b/testers/duckdb/scala/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.scala index da6bbac9eb..76ba8b5d2d 100644 --- a/testers/duckdb/scala/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.scala +++ b/testers/duckdb/scala/generated-and-checked-in/testdb/product_summary/ProductSummarySqlRow.scala @@ -25,7 +25,7 @@ case class ProductSummarySqlRow( @JsonProperty("order_count") orderCount: Long, /** Points to [[testdb.order_items.OrderItemsRow.quantity]] */ @JsonProperty("total_quantity") totalQuantity: Option[Long], - /** Points to [[testdb.order_items.OrderItemsRow.quantity]] */ + /** Points to [[testdb.order_items.OrderItemsRow.unitPrice]] */ @JsonProperty("total_revenue") totalRevenue: Option[BigDecimal] ) extends Tuple7[ProductsId, String, String, BigDecimal, Long, Option[Long], Option[BigDecimal]] { override def `_1`: ProductsId = productId diff --git a/testers/mariadb/java/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.java b/testers/mariadb/java/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.java index 9cba562aa4..9fb1969aa2 100644 --- a/testers/mariadb/java/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.java +++ b/testers/mariadb/java/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.java @@ -35,7 +35,7 @@ public record InventoryCheckSqlRow( @JsonProperty("quantity_on_hand") Integer quantityOnHand, /** Points to {@link testdb.inventory.InventoryRow#quantityReserved()} */ @JsonProperty("quantity_reserved") Integer quantityReserved, - /** Points to {@link testdb.inventory.InventoryRow#quantityOnHand()} */ + /** Points to {@link testdb.inventory.InventoryRow#quantityReserved()} */ Integer available, /** Points to {@link testdb.inventory.InventoryRow#reorderPoint()} */ @JsonProperty("reorder_point") Integer reorderPoint, @@ -87,7 +87,7 @@ public InventoryCheckSqlRow withQuantityReserved(Integer quantityReserved) { return new InventoryCheckSqlRow(inventoryId, productId, sku, productName, warehouseId, warehouseCode, warehouseName, quantityOnHand, quantityReserved, available, reorderPoint, binLocation); } - /** Points to {@link testdb.inventory.InventoryRow#quantityOnHand()} */ + /** Points to {@link testdb.inventory.InventoryRow#quantityReserved()} */ public InventoryCheckSqlRow withAvailable(Integer available) { return new InventoryCheckSqlRow(inventoryId, productId, sku, productName, warehouseId, warehouseCode, warehouseName, quantityOnHand, quantityReserved, available, reorderPoint, binLocation); } diff --git a/testers/mariadb/kotlin/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.kt b/testers/mariadb/kotlin/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.kt index ee83353a87..ab609b9bd5 100644 --- a/testers/mariadb/kotlin/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.kt +++ b/testers/mariadb/kotlin/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.kt @@ -34,7 +34,7 @@ data class InventoryCheckSqlRow( @field:JsonProperty("quantity_on_hand") val quantityOnHand: Int, /** Points to [testdb.inventory.InventoryRow.quantityReserved] */ @field:JsonProperty("quantity_reserved") val quantityReserved: Int, - /** Points to [testdb.inventory.InventoryRow.quantityOnHand] */ + /** Points to [testdb.inventory.InventoryRow.quantityReserved] */ val available: Int, /** Points to [testdb.inventory.InventoryRow.reorderPoint] */ @field:JsonProperty("reorder_point") val reorderPoint: Int, diff --git a/testers/mariadb/scala/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.scala b/testers/mariadb/scala/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.scala index 47d842ee4a..ef595dfa50 100644 --- a/testers/mariadb/scala/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.scala +++ b/testers/mariadb/scala/generated-and-checked-in/testdb/inventory_check/InventoryCheckSqlRow.scala @@ -34,7 +34,7 @@ case class InventoryCheckSqlRow( @JsonProperty("quantity_on_hand") quantityOnHand: Int, /** Points to [[testdb.inventory.InventoryRow.quantityReserved]] */ @JsonProperty("quantity_reserved") quantityReserved: Int, - /** Points to [[testdb.inventory.InventoryRow.quantityOnHand]] */ + /** Points to [[testdb.inventory.InventoryRow.quantityReserved]] */ available: Int, /** Points to [[testdb.inventory.InventoryRow.reorderPoint]] */ @JsonProperty("reorder_point") reorderPoint: Int, diff --git a/testers/pg/scala/anorm/src/scala/adventureworks/person/MultiRepoTest.scala b/testers/pg/scala/anorm/src/scala/adventureworks/person/MultiRepoTest.scala index 0e7a5ef592..3464ed8ede 100644 --- a/testers/pg/scala/anorm/src/scala/adventureworks/person/MultiRepoTest.scala +++ b/testers/pg/scala/anorm/src/scala/adventureworks/person/MultiRepoTest.scala @@ -51,7 +51,7 @@ class PersonWithAddressesRepo( pa.addresses.map { case (addressTypeName, wanted) => oldStoredAddressTypes.get(addressTypeName) match { case Some(addresstypeId) => (addresstypeId, wanted) - case None => + case None => val inserted = addresstypeRepo.insert(AddresstypeRowUnsaved(name = addressTypeName)) (inserted.addresstypeid, wanted) } @@ -69,7 +69,7 @@ class PersonWithAddressesRepo( oldAttachedAddresses.foreach { case (_, ba) => currentAddressesByType.get(ba.addresstypeid) match { case Some(address) if address.addressid == ba.addressid => - case _ => + case _ => businessentityAddressRepo.deleteById(ba.compositeId) } } @@ -77,7 +77,7 @@ class PersonWithAddressesRepo( currentAddressesByType.map { case (addresstypeId, address) => oldAttachedAddresses.get((address.addressid, addresstypeId)) match { case Some(bea) => bea - case None => + case None => val newRow = BusinessentityaddressRowUnsaved(pa.person.businessentityid, address.addressid, addresstypeId) businessentityAddressRepo.insert(newRow) } diff --git a/testers/pg/scala/doobie/src/scala/adventureworks/person/MultiRepoTest.scala b/testers/pg/scala/doobie/src/scala/adventureworks/person/MultiRepoTest.scala index 3b5dbb0d26..1a90d4151d 100644 --- a/testers/pg/scala/doobie/src/scala/adventureworks/person/MultiRepoTest.scala +++ b/testers/pg/scala/doobie/src/scala/adventureworks/person/MultiRepoTest.scala @@ -61,7 +61,7 @@ case class PersonWithAddressesRepo( currentAttachedAddresses <- currentAddressesWithAddresstype.traverse { case (addresstypeId, address) => oldAttachedAddresses.find(x => x.addressid == address.addressid && x.addresstypeid == addresstypeId) match { case Some(bea) => bea.pure[ConnectionIO] - case None => + case None => businessentityAddressRepo.insert( BusinessentityaddressRowUnsaved(pa.person.businessentityid, address.addressid, addresstypeId) ) diff --git a/testers/pg/scala/javatypes/src/scala/adventureworks/person/MultiRepoTest.scala b/testers/pg/scala/javatypes/src/scala/adventureworks/person/MultiRepoTest.scala index e1abc9e96c..193dcce6d3 100644 --- a/testers/pg/scala/javatypes/src/scala/adventureworks/person/MultiRepoTest.scala +++ b/testers/pg/scala/javatypes/src/scala/adventureworks/person/MultiRepoTest.scala @@ -67,7 +67,7 @@ class PersonWithAddressesRepo( val key = AddressIdAddresstypeIdKey(address.addressid, addresstypeId) oldAttachedAddresses.get(key) match { case Some(existing) => existing - case None => + case None => val newRow = BusinessentityaddressRowUnsaved( pa.person.businessentityid, address.addressid, diff --git a/testers/pg/scala/scalatypes/src/scala/adventureworks/person/MultiRepoTest.scala b/testers/pg/scala/scalatypes/src/scala/adventureworks/person/MultiRepoTest.scala index bbe2199ba8..9f78700633 100644 --- a/testers/pg/scala/scalatypes/src/scala/adventureworks/person/MultiRepoTest.scala +++ b/testers/pg/scala/scalatypes/src/scala/adventureworks/person/MultiRepoTest.scala @@ -65,7 +65,7 @@ class PersonWithAddressesRepo( val key = AddressIdAddresstypeIdKey(address.addressid, addresstypeId) oldAttachedAddresses.get(key) match { case Some(existing) => existing - case None => + case None => val newRow = BusinessentityaddressRowUnsaved( pa.person.businessentityid, address.addressid, diff --git a/testers/pg/scala/zio-jdbc/src/scala/adventureworks/person/MultiRepoTest.scala b/testers/pg/scala/zio-jdbc/src/scala/adventureworks/person/MultiRepoTest.scala index 1fd35fd09d..623e5a91b3 100644 --- a/testers/pg/scala/zio-jdbc/src/scala/adventureworks/person/MultiRepoTest.scala +++ b/testers/pg/scala/zio-jdbc/src/scala/adventureworks/person/MultiRepoTest.scala @@ -60,7 +60,7 @@ case class PersonWithAddressesRepo( currentAttachedAddresses <- currentAddressesWithAddresstype.forEach { case (addresstypeId, address) => oldAttachedAddresses.find(x => x.addressid == address.addressid && x.addresstypeid == addresstypeId) match { case Some(bea) => ZIO.succeed(bea) - case None => + case None => businessentityAddressRepo .insert( BusinessentityaddressRowUnsaved(pa.person.businessentityid, address.addressid, addresstypeId) diff --git a/typr-codegen/src/scala/typr/NonEmptyList.scala b/typr-codegen/src/scala/typr/NonEmptyList.scala index 911058a885..640e77d279 100644 --- a/typr-codegen/src/scala/typr/NonEmptyList.scala +++ b/typr-codegen/src/scala/typr/NonEmptyList.scala @@ -72,7 +72,7 @@ object NonEmptyList { def fromList[T](ts: List[T]): Option[NonEmptyList[T]] = ts match { case head :: tail => Some(NonEmptyList(head, tail)) - case Nil => + case Nil => None } } diff --git a/typr-codegen/src/scala/typr/TypoLogger.scala b/typr-codegen/src/scala/typr/TypoLogger.scala index 83ac44ba08..cb4959f97d 100644 --- a/typr-codegen/src/scala/typr/TypoLogger.scala +++ b/typr-codegen/src/scala/typr/TypoLogger.scala @@ -16,8 +16,8 @@ trait TypoLogger { object TypoLogger { object Console extends TypoLogger { - override def warn(str: String): Unit = System.err.println(s"typo: $str") - override def info(str: String): Unit = System.out.println(s"typo: $str") + override def warn(str: String): Unit = System.err.println(s"typr: $str") + override def info(str: String): Unit = System.out.println(s"typr: $str") } object Noop extends TypoLogger { diff --git a/typr-codegen/src/scala/typr/generateFromDb.scala b/typr-codegen/src/scala/typr/generateFromDb.scala index 6b3e68820d..488838e4c4 100644 --- a/typr-codegen/src/scala/typr/generateFromDb.scala +++ b/typr-codegen/src/scala/typr/generateFromDb.scala @@ -50,7 +50,7 @@ object generateFromDb { Await.result(combined, Duration.Inf) match { case Right(generated) => generated - case Left(error) => + case Left(error) => error match { case internal.generate.GenerateError.IncompatibleTypes(errors) => errors.foreach(options.logger.warn) diff --git a/typr-codegen/src/scala/typr/internal/ComputedBridgeCompositeType.scala b/typr-codegen/src/scala/typr/internal/ComputedBridgeCompositeType.scala index f16d732657..c3d4f317a3 100644 --- a/typr-codegen/src/scala/typr/internal/ComputedBridgeCompositeType.scala +++ b/typr-codegen/src/scala/typr/internal/ComputedBridgeCompositeType.scala @@ -84,7 +84,7 @@ object ComputedBridgeCompositeType { case "uuid" => TypesJava.UUID case "bytearray" | "bytes" | "byte[]" => jvm.Type.ArrayOf(lang.Byte) case "json" => jvm.Type.Qualified("com.fasterxml.jackson.databind.JsonNode") - case other => + case other => if (other.contains(".")) { jvm.Type.Qualified(other) } else { diff --git a/typr-codegen/src/scala/typr/internal/ComputedRowUnsaved.scala b/typr-codegen/src/scala/typr/internal/ComputedRowUnsaved.scala index bd83aa07e5..7687d5c3e1 100644 --- a/typr-codegen/src/scala/typr/internal/ComputedRowUnsaved.scala +++ b/typr-codegen/src/scala/typr/internal/ComputedRowUnsaved.scala @@ -13,7 +13,7 @@ object ComputedRowUnsaved { val categorizedColumns: NonEmptyList[CategorizedColumn] = cols.map { case col if col.dbCol.maybeGenerated.exists(_.ALWAYS) => AlwaysGeneratedCol(col) - case col if col.dbCol.isDefaulted => + case col if col.dbCol.isDefaulted => val wrappedType = jvm.Type.TApply(default.Defaulted, List(col.tpe)) DefaultedCol( col = col.copy(typoType = col.typoType.withJvmType(wrappedType)), diff --git a/typr-codegen/src/scala/typr/internal/ComputedTestInserts.scala b/typr-codegen/src/scala/typr/internal/ComputedTestInserts.scala index eb93c64a6e..9347e984b2 100644 --- a/typr-codegen/src/scala/typr/internal/ComputedTestInserts.scala +++ b/typr-codegen/src/scala/typr/internal/ComputedTestInserts.scala @@ -61,7 +61,7 @@ object ComputedTestInserts { go(x.underlying, x.col.dbCol.tpe, None).map(default => jvm.New(x.tpe, List(jvm.Arg.Pos(default))).code) case x: IdComputed.UnaryInherited => go(x.underlying, x.col.dbCol.tpe, None) case x: IdComputed.UnaryNoIdType => go(x.underlying, x.col.dbCol.tpe, None) - case x: IdComputed.UnaryOpenEnum => + case x: IdComputed.UnaryOpenEnum => go(x.underlying, x.col.dbCol.tpe, None).map(default => code"${x.tpe}.apply($default)") case _: IdComputed.UnaryUserSpecified => None } @@ -161,7 +161,7 @@ object ComputedTestInserts { case TypesScala.Char => Some(lang.Random.nextPrintableChar(r)) case lang.Byte => Some(lang.toByte(lang.Random.nextIntBounded(r, lang.maxValue(lang.Byte)))) case lang.Short => Some(lang.toShort(lang.Random.nextIntBounded(r, lang.maxValue(lang.Short)))) - case lang.Int => + case lang.Int => dbType match { case db.PgType.Int2 => Some(lang.Random.nextIntBounded(r, lang.maxValue(lang.Short))) case db.MariaType.MediumInt => Some(lang.Random.nextIntBounded(r, code"8388607")) @@ -174,10 +174,10 @@ object ComputedTestInserts { case lang.BigDecimal => Some(lang.bigDecimalFromDouble(lang.Random.nextDouble(r))) case TypesJava.UUID => Some(lang.Random.randomUUID(r)) // Raw Java date/time types (used by DbLib.Typo) - case TypesJava.LocalDate => Some(defaultLocalDate) - case TypesJava.LocalTime => Some(defaultLocalTime) - case TypesJava.LocalDateTime => Some(defaultLocalDateTime) - case TypesJava.OffsetTime => Some(code"$defaultLocalTime.atOffset($defaultZoneOffset)") + case TypesJava.LocalDate => Some(defaultLocalDate) + case TypesJava.LocalTime => Some(defaultLocalTime) + case TypesJava.LocalDateTime => Some(defaultLocalDateTime) + case TypesJava.OffsetTime => Some(code"$defaultLocalTime.atOffset($defaultZoneOffset)") case TypesJava.OffsetDateTime => Some(code"${TypesJava.OffsetDateTime}.of($defaultLocalDateTime, $defaultZoneOffset)") case TypesJava.Instant => diff --git a/typr-codegen/src/scala/typr/internal/FkAnalysis.scala b/typr-codegen/src/scala/typr/internal/FkAnalysis.scala index 5f5931ff47..f2c10416e3 100644 --- a/typr-codegen/src/scala/typr/internal/FkAnalysis.scala +++ b/typr-codegen/src/scala/typr/internal/FkAnalysis.scala @@ -52,8 +52,8 @@ object FkAnalysis { lazy val exprForColumn: Map[jvm.Ident, jvm.Code] = exprsForColumn.map { case (colName, exprs) => exprs match { - case Nil => sys.error("unexpected") - case List(expr) => (colName, expr) + case Nil => sys.error("unexpected") + case List(expr) => (colName, expr) case expr :: exprs => val requires = exprs.map(e => code"""require($expr == $e, "${expr.render(lang).lines.mkString("\n")} != ${e.render(lang).lines.mkString("\n")}")""") val finalExpr = code"""|{ diff --git a/typr-codegen/src/scala/typr/internal/IdComputed.scala b/typr-codegen/src/scala/typr/internal/IdComputed.scala index 171a12af5b..d02e6010d8 100644 --- a/typr-codegen/src/scala/typr/internal/IdComputed.scala +++ b/typr-codegen/src/scala/typr/internal/IdComputed.scala @@ -19,14 +19,14 @@ object IdComputed { /** TypoType for the ID type itself (not the underlying column) */ def typoType: TypoType = this match { - case x: UnaryNormal => TypoType.Generated(x.tpe, col.dbCol.tpe, x.tpe) + case x: UnaryNormal => TypoType.Generated(x.tpe, col.dbCol.tpe, x.tpe) case x: UnaryInherited => x.tpe match { case q: jvm.Type.Qualified => TypoType.Generated(q, col.dbCol.tpe, q) case _ => col.typoType } - case _: UnaryNoIdType => col.typoType - case x: UnaryOpenEnum => TypoType.Generated(x.tpe, col.dbCol.tpe, x.tpe) + case _: UnaryNoIdType => col.typoType + case x: UnaryOpenEnum => TypoType.Generated(x.tpe, col.dbCol.tpe, x.tpe) case x: UnaryUserSpecified => x.tpe match { case q: jvm.Type.Qualified => TypoType.UserDefined(q, col.dbCol.tpe, Left(q)) diff --git a/typr-codegen/src/scala/typr/internal/TypeMapperJvmNew.scala b/typr-codegen/src/scala/typr/internal/TypeMapperJvmNew.scala index 9c47b7d686..bdd00aa1e1 100644 --- a/typr-codegen/src/scala/typr/internal/TypeMapperJvmNew.scala +++ b/typr-codegen/src/scala/typr/internal/TypeMapperJvmNew.scala @@ -122,7 +122,7 @@ case class TypeMapperJvmNew( case db.PgType.Array(elementType) => lang.ListType.tpe.of(baseType(elementType)) case db.PgType.Boolean => lang.Boolean case db.PgType.Bytea => lang.ByteArray - case db.PgType.Bpchar(maybeN) => + case db.PgType.Bpchar(maybeN) => maybeN match { case Some(n) if n != 2147483647 => lang.String.withComment(s"bpchar, max $n chars") case _ => lang.String.withComment(s"bpchar") @@ -180,7 +180,7 @@ case class TypeMapperJvmNew( case db.PgType.TimestampTz => TypesJava.Instant case db.PgType.UUID => TypesJava.UUID case db.PgType.Xml => TypesJava.runtime.Xml - case db.PgType.VarChar(maybeN) => + case db.PgType.VarChar(maybeN) => maybeN match { case Some(n) if n != 2147483647 => lang.String.withComment(s"max $n chars") case _ => lang.String @@ -227,7 +227,7 @@ case class TypeMapperJvmNew( case db.MariaType.Timestamp(_) => TypesJava.LocalDateTime case db.MariaType.Year => TypesJava.Year case db.MariaType.Enum(_) => lang.String // MariaDB inline ENUMs are stored as strings - case db.MariaType.Set(values) => + case db.MariaType.Set(values) => mariaSetLookup.get(values.sorted) match { case Some(setType) => setType.tpe case None => TypesJava.maria.MariaSet @@ -247,39 +247,39 @@ case class TypeMapperJvmNew( } case x: db.DuckDbType => x match { - case db.DuckDbType.TinyInt => lang.Byte - case db.DuckDbType.SmallInt => lang.Short - case db.DuckDbType.Integer => lang.Int - case db.DuckDbType.BigInt => lang.Long - case db.DuckDbType.HugeInt => TypesJava.BigInteger - case db.DuckDbType.UTinyInt => TypesJava.unsigned.Uint1 - case db.DuckDbType.USmallInt => TypesJava.unsigned.Uint2 - case db.DuckDbType.UInteger => TypesJava.unsigned.Uint4 - case db.DuckDbType.UBigInt => TypesJava.unsigned.Uint8 - case db.DuckDbType.UHugeInt => TypesJava.BigInteger - case db.DuckDbType.Float => lang.Float - case db.DuckDbType.Double => lang.Double - case db.DuckDbType.Decimal(_, _) => lang.BigDecimal - case db.DuckDbType.Boolean => lang.Boolean - case db.DuckDbType.VarChar(_) => lang.String - case db.DuckDbType.Char(_) => lang.String - case db.DuckDbType.Text => lang.String - case db.DuckDbType.Blob => lang.ByteArrayType - case db.DuckDbType.Bit(_) => lang.ByteArrayType - case db.DuckDbType.Date => TypesJava.LocalDate - case db.DuckDbType.Time => TypesJava.LocalTime - case db.DuckDbType.Timestamp => TypesJava.LocalDateTime - case db.DuckDbType.TimestampTz => TypesJava.Instant - case db.DuckDbType.TimestampS => TypesJava.LocalDateTime - case db.DuckDbType.TimestampMS => TypesJava.LocalDateTime - case db.DuckDbType.TimestampNS => TypesJava.LocalDateTime - case db.DuckDbType.TimeTz => TypesJava.OffsetTime - case db.DuckDbType.Interval => TypesJava.Duration - case db.DuckDbType.UUID => TypesJava.UUID - case db.DuckDbType.Json => TypesJava.runtime.Json - case db.DuckDbType.Enum(name, _) => jvm.Type.Qualified(naming.enumName(db.RelationName(None, name))) - case db.DuckDbType.ListType(_) => lang.String.withComment("LIST type - mapped to String") - case db.DuckDbType.ArrayType(_, _) => lang.String.withComment("ARRAY type - mapped to String") + case db.DuckDbType.TinyInt => lang.Byte + case db.DuckDbType.SmallInt => lang.Short + case db.DuckDbType.Integer => lang.Int + case db.DuckDbType.BigInt => lang.Long + case db.DuckDbType.HugeInt => TypesJava.BigInteger + case db.DuckDbType.UTinyInt => TypesJava.unsigned.Uint1 + case db.DuckDbType.USmallInt => TypesJava.unsigned.Uint2 + case db.DuckDbType.UInteger => TypesJava.unsigned.Uint4 + case db.DuckDbType.UBigInt => TypesJava.unsigned.Uint8 + case db.DuckDbType.UHugeInt => TypesJava.BigInteger + case db.DuckDbType.Float => lang.Float + case db.DuckDbType.Double => lang.Double + case db.DuckDbType.Decimal(_, _) => lang.BigDecimal + case db.DuckDbType.Boolean => lang.Boolean + case db.DuckDbType.VarChar(_) => lang.String + case db.DuckDbType.Char(_) => lang.String + case db.DuckDbType.Text => lang.String + case db.DuckDbType.Blob => lang.ByteArrayType + case db.DuckDbType.Bit(_) => lang.ByteArrayType + case db.DuckDbType.Date => TypesJava.LocalDate + case db.DuckDbType.Time => TypesJava.LocalTime + case db.DuckDbType.Timestamp => TypesJava.LocalDateTime + case db.DuckDbType.TimestampTz => TypesJava.Instant + case db.DuckDbType.TimestampS => TypesJava.LocalDateTime + case db.DuckDbType.TimestampMS => TypesJava.LocalDateTime + case db.DuckDbType.TimestampNS => TypesJava.LocalDateTime + case db.DuckDbType.TimeTz => TypesJava.OffsetTime + case db.DuckDbType.Interval => TypesJava.Duration + case db.DuckDbType.UUID => TypesJava.UUID + case db.DuckDbType.Json => TypesJava.runtime.Json + case db.DuckDbType.Enum(name, _) => jvm.Type.Qualified(naming.enumName(db.RelationName(None, name))) + case db.DuckDbType.ListType(_) => lang.String.withComment("LIST type - mapped to String") + case db.DuckDbType.ArrayType(_, _) => lang.String.withComment("ARRAY type - mapped to String") case db.DuckDbType.MapType(keyType, valueType) => lang.MapOps.tpe.of(baseType(keyType), baseType(valueType)) case s: db.DuckDbType.StructType => diff --git a/typr-codegen/src/scala/typr/internal/TypeMapperJvmOld.scala b/typr-codegen/src/scala/typr/internal/TypeMapperJvmOld.scala index 42bbf5344b..954b0572f7 100644 --- a/typr-codegen/src/scala/typr/internal/TypeMapperJvmOld.scala +++ b/typr-codegen/src/scala/typr/internal/TypeMapperJvmOld.scala @@ -16,7 +16,7 @@ case class TypeMapperJvmOld(lang: Lang, typeOverride: TypeOverride, nullabilityO case db.PgType.Array(elementType) => jvm.Type.ArrayOf(baseType(elementType)) case db.PgType.Boolean => lang.Boolean case db.PgType.Bytea => customTypes.TypoBytea.typoType - case db.PgType.Bpchar(maybeN) => + case db.PgType.Bpchar(maybeN) => maybeN match { case Some(n) if n > 0 && n != 2147483647 => lang.String.withComment(s"bpchar, max $n chars") case _ => lang.String.withComment(s"bpchar") @@ -74,7 +74,7 @@ case class TypeMapperJvmOld(lang: Lang, typeOverride: TypeOverride, nullabilityO case db.PgType.TimestampTz => customTypes.TypoInstant.typoType case db.PgType.UUID => customTypes.TypoUUID.typoType case db.PgType.Xml => customTypes.TypoXml.typoType - case db.PgType.VarChar(maybeN) => + case db.PgType.VarChar(maybeN) => maybeN match { case Some(n) if n > 0 && n != 2147483647 => lang.String.withComment(s"max $n chars") case _ => lang.String diff --git a/typr-codegen/src/scala/typr/internal/TypeMatcher.scala b/typr-codegen/src/scala/typr/internal/TypeMatcher.scala index f5c6f9a17f..b4fec7cb7c 100644 --- a/typr-codegen/src/scala/typr/internal/TypeMatcher.scala +++ b/typr-codegen/src/scala/typr/internal/TypeMatcher.scala @@ -388,7 +388,7 @@ object TypeMatcher { */ private def extractAnnotations(comment: Option[String]): List[String] = comment match { - case None => Nil + case None => Nil case Some(text) => val pattern = """@(\w+)(?:\(([^)]*)\))?""".r pattern diff --git a/typr-codegen/src/scala/typr/internal/analysis/DecomposedSql.scala b/typr-codegen/src/scala/typr/internal/analysis/DecomposedSql.scala index 0e0cc85abb..0abf32b772 100644 --- a/typr-codegen/src/scala/typr/internal/analysis/DecomposedSql.scala +++ b/typr-codegen/src/scala/typr/internal/analysis/DecomposedSql.scala @@ -12,7 +12,7 @@ case class DecomposedSql(frags: List[DecomposedSql.Fragment]) { var paramNum = 0 frags.collect { case DecomposedSql.SqlText(text) => text - case _: DecomposedSql.Param => + case _: DecomposedSql.Param => val rendered = f(paramNum) paramNum += 1 rendered @@ -24,7 +24,7 @@ case class DecomposedSql(frags: List[DecomposedSql.Fragment]) { frags .collect { case DecomposedSql.SqlText(text) => jvm.Code.Str(text) - case _: DecomposedSql.Param => + case _: DecomposedSql.Param => val rendered = f(paramNum) paramNum += 1 rendered diff --git a/typr-codegen/src/scala/typr/internal/analysis/NullabilityFromExplain.scala b/typr-codegen/src/scala/typr/internal/analysis/NullabilityFromExplain.scala index 48efb414be..03781a9a30 100644 --- a/typr-codegen/src/scala/typr/internal/analysis/NullabilityFromExplain.scala +++ b/typr-codegen/src/scala/typr/internal/analysis/NullabilityFromExplain.scala @@ -83,7 +83,7 @@ object NullabilityFromExplain { def fromPlan(hasPlans: HasPlans): NullableColumns = { def go(plan: Plan): NullableColumns = plan match { - case Plan(_, _, None, _) => NullableColumns(plan, Nil) + case Plan(_, _, None, _) => NullableColumns(plan, Nil) case Plan(_, Some(joinType), Some(outputs), Some(List(left, right))) => val leftPlan = go(left) val rightPlan = go(right) diff --git a/typr-codegen/src/scala/typr/internal/analysis/ParsedName.scala b/typr-codegen/src/scala/typr/internal/analysis/ParsedName.scala index 203aff27c6..144c2d95b3 100644 --- a/typr-codegen/src/scala/typr/internal/analysis/ParsedName.scala +++ b/typr-codegen/src/scala/typr/internal/analysis/ParsedName.scala @@ -43,7 +43,7 @@ object OverriddenType { } else { WellKnownPrimitive.fromName(typeStr) match { case Some(primitive) => Right(Primitive(primitive)) - case None => + case None => val validNames = WellKnownPrimitive.all.flatMap { p => // Get the canonical names for error message p match { @@ -83,8 +83,8 @@ object ParsedName { else (name, None) val (dbName, overriddenType) = shortened.split(":").toList match { - case Nil => sys.error("shouldn't happen (tm)") - case name :: Nil => (db.ColName(name), None) + case Nil => sys.error("shouldn't happen (tm)") + case name :: Nil => (db.ColName(name), None) case name :: tpeStr :: _ => OverriddenType.parse(tpeStr) match { case Right(ot) => (db.ColName(name), Some(ot)) diff --git a/typr-codegen/src/scala/typr/internal/codegen/DbLibAnorm.scala b/typr-codegen/src/scala/typr/internal/codegen/DbLibAnorm.scala index 979072a9bd..7de2999a12 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/DbLibAnorm.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/DbLibAnorm.scala @@ -485,7 +485,7 @@ class DbLibAnorm(pkg: jvm.QIdent, inlineImplicits: Boolean, default: ComputedDef val writeableColumnsNotId = writeableColumnsWithId.toList.filterNot(c => id.cols.exists(_.name == c.name)) val conflictAction = writeableColumnsNotId match { - case Nil => code"do nothing" + case Nil => code"do nothing" case nonEmpty => code"""|do update set | ${nonEmpty.map { c => code"${c.dbName.code} = EXCLUDED.${c.dbName.code}" }.mkCode(",\n")}""".stripMargin @@ -521,7 +521,7 @@ class DbLibAnorm(pkg: jvm.QIdent, inlineImplicits: Boolean, default: ComputedDef val writeableColumnsNotId = writeableColumnsWithId.toList.filterNot(c => id.cols.exists(_.name == c.name)) val conflictAction = writeableColumnsNotId match { - case Nil => code"do nothing" + case Nil => code"do nothing" case nonEmpty => code"""|do update set | ${nonEmpty.map { c => code"${c.dbName.code} = EXCLUDED.${c.dbName.code}" }.mkCode(",\n")}""".stripMargin @@ -643,7 +643,7 @@ class DbLibAnorm(pkg: jvm.QIdent, inlineImplicits: Boolean, default: ComputedDef val renderedWithCasts: jvm.Code = cols.toList.flatMap(c => sqlCast.fromPg(c.dbCol.tpe)) match { case Nil => renderedScript.code - case _ => + case _ => val row = jvm.Ident("row") code"""|with $row as ( @@ -816,7 +816,13 @@ class DbLibAnorm(pkg: jvm.QIdent, inlineImplicits: Boolean, default: ComputedDef ) ), Some( - jvm.Given(tparams = Nil, name = toStatementName, implicitParams = Nil, tpe = ToStatement.of(wrapperType), body = code"${lookupToStatementFor(underlying)}.contramap(_.value)") + jvm.Given( + tparams = Nil, + name = toStatementName, + implicitParams = Nil, + tpe = ToStatement.of(wrapperType), + body = code"${lookupToStatementFor(underlying)}.contramap(_.value)" + ) ), Some( jvm.Given( diff --git a/typr-codegen/src/scala/typr/internal/codegen/DbLibDoobie.scala b/typr-codegen/src/scala/typr/internal/codegen/DbLibDoobie.scala index f1ef7919c5..b3bb93530d 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/DbLibDoobie.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/DbLibDoobie.scala @@ -358,7 +358,7 @@ class DbLibDoobie(pkg: jvm.QIdent, inlineImplicits: Boolean, default: ComputedDe val writeableColumnsNotId = writeableColumnsWithId.toList.filterNot(c => id.cols.exists(_.name == c.name)) val conflictAction = writeableColumnsNotId match { - case Nil => code"do nothing" + case Nil => code"do nothing" case nonEmpty => code"""|do update set | ${nonEmpty.map { c => code"${c.dbName.code} = EXCLUDED.${c.dbName.code}" }.mkCode(",\n")}""".stripMargin @@ -386,7 +386,7 @@ class DbLibDoobie(pkg: jvm.QIdent, inlineImplicits: Boolean, default: ComputedDe val writeableColumnsNotId = writeableColumnsWithId.toList.filterNot(c => id.cols.exists(_.name == c.name)) val conflictAction = writeableColumnsNotId match { - case Nil => code"do nothing" + case Nil => code"do nothing" case nonEmpty => code"""|do update set | ${nonEmpty.map { c => code"${c.dbName.code} = EXCLUDED.${c.dbName.code}" }.mkCode(",\n")}""".stripMargin @@ -468,7 +468,7 @@ class DbLibDoobie(pkg: jvm.QIdent, inlineImplicits: Boolean, default: ComputedDe val renderedWithCasts: jvm.Code = cols.toList.flatMap(c => sqlCast.fromPg(c.dbCol.tpe)) match { case Nil => renderedScript.code - case _ => + case _ => val row = jvm.Ident("row") code"""|with $row as ( @@ -740,14 +740,14 @@ class DbLibDoobie(pkg: jvm.QIdent, inlineImplicits: Boolean, default: ComputedDe if (!inlineImplicits) Get.of(tpe).code else jvm.Type.base(tpe) match { - case TypesScala.BigDecimal => code"$Meta.ScalaBigDecimalMeta.get" - case TypesScala.Boolean => code"$Meta.BooleanMeta.get" - case TypesScala.Byte => code"$Meta.ByteMeta.get" - case TypesScala.Double => code"$Meta.DoubleMeta.get" - case TypesScala.Float => code"$Meta.FloatMeta.get" - case TypesScala.Int => code"$Meta.IntMeta.get" - case TypesScala.Long => code"$Meta.LongMeta.get" - case TypesJava.String => code"$Meta.StringMeta.get" + case TypesScala.BigDecimal => code"$Meta.ScalaBigDecimalMeta.get" + case TypesScala.Boolean => code"$Meta.BooleanMeta.get" + case TypesScala.Byte => code"$Meta.ByteMeta.get" + case TypesScala.Double => code"$Meta.DoubleMeta.get" + case TypesScala.Float => code"$Meta.FloatMeta.get" + case TypesScala.Int => code"$Meta.IntMeta.get" + case TypesScala.Long => code"$Meta.LongMeta.get" + case TypesJava.String => code"$Meta.StringMeta.get" case jvm.Type.ArrayOf(TypesScala.Byte) => code"$Meta.ByteArrayMeta.get" case x: jvm.Type.Qualified if x.value.idents.startsWith(pkg.idents) => @@ -765,14 +765,14 @@ class DbLibDoobie(pkg: jvm.QIdent, inlineImplicits: Boolean, default: ComputedDe if (!inlineImplicits) Put.of(tpe).code else jvm.Type.base(tpe) match { - case TypesScala.BigDecimal => code"$Meta.ScalaBigDecimalMeta.put" - case TypesScala.Boolean => code"$Meta.BooleanMeta.put" - case TypesScala.Byte => code"$Meta.ByteMeta.put" - case TypesScala.Double => code"$Meta.DoubleMeta.put" - case TypesScala.Float => code"$Meta.FloatMeta.put" - case TypesScala.Int => code"$Meta.IntMeta.put" - case TypesScala.Long => code"$Meta.LongMeta.put" - case TypesJava.String => code"$Meta.StringMeta.put" + case TypesScala.BigDecimal => code"$Meta.ScalaBigDecimalMeta.put" + case TypesScala.Boolean => code"$Meta.BooleanMeta.put" + case TypesScala.Byte => code"$Meta.ByteMeta.put" + case TypesScala.Double => code"$Meta.DoubleMeta.put" + case TypesScala.Float => code"$Meta.FloatMeta.put" + case TypesScala.Int => code"$Meta.IntMeta.put" + case TypesScala.Long => code"$Meta.LongMeta.put" + case TypesJava.String => code"$Meta.StringMeta.put" case jvm.Type.ArrayOf(TypesScala.Byte) => code"$Meta.ByteArrayMeta.put" case x: jvm.Type.Qualified if x.value.idents.startsWith(pkg.idents) => diff --git a/typr-codegen/src/scala/typr/internal/codegen/DbLibFoundations.scala b/typr-codegen/src/scala/typr/internal/codegen/DbLibFoundations.scala index 8fbca99084..7bd786e2d9 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/DbLibFoundations.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/DbLibFoundations.scala @@ -1201,7 +1201,7 @@ class DbLibFoundations( } else { // PostgreSQL-specific conflict action syntax val conflictAction = writeableColumnsWithId.toList.filterNot(c => id.cols.exists(_.name == c.name)) match { - case Nil => code"do nothing" + case Nil => code"do nothing" case nonEmpty => code"""|do update set | ${nonEmpty.map { c => code"${quotedColName(c)} = EXCLUDED.${quotedColName(c)}" }.mkCode(",\n")}""".stripMargin @@ -1399,7 +1399,7 @@ class DbLibFoundations( val renderedWithCasts: jvm.Code = cols.toList.flatMap(c => adapter.readCast(c.dbCol.tpe)) match { case Nil => renderedScript.code - case _ => + case _ => val row = jvm.Ident("row") code"""|with $row as ( diff --git a/typr-codegen/src/scala/typr/internal/codegen/DbLibTextSupport.scala b/typr-codegen/src/scala/typr/internal/codegen/DbLibTextSupport.scala index f674bb39a0..44f5663997 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/DbLibTextSupport.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/DbLibTextSupport.scala @@ -22,17 +22,17 @@ class DbLibTextSupport(pkg: jvm.QIdent, inlineImplicits: Boolean, externalText: if (!inlineImplicits) Text.of(tpe).code else jvm.Type.base(tpe) match { - case TypesScala.BigDecimal => code"$Text.bigDecimalInstance" - case TypesScala.Boolean => code"$Text.booleanInstance" - case TypesScala.Double => code"$Text.doubleInstance" - case TypesScala.Float => code"$Text.floatInstance" - case TypesScala.Int => code"$Text.intInstance" - case TypesScala.Long => code"$Text.longInstance" - case TypesJava.String => code"$Text.stringInstance" - case jvm.Type.ArrayOf(TypesScala.Byte) => code"$Text.byteArrayInstance" - case lang.Optional(targ) => code"$Text.option(${dialect.usingCall}${lookupTextFor(targ)})" - case jvm.Type.TApply(default.Defaulted, List(targ)) => code"${default.Defaulted}.$textName(${dialect.usingCall}${lookupTextFor(targ)})" - case x: jvm.Type.Qualified if x.value.idents.startsWith(pkg.idents) => code"$tpe.$textName" + case TypesScala.BigDecimal => code"$Text.bigDecimalInstance" + case TypesScala.Boolean => code"$Text.booleanInstance" + case TypesScala.Double => code"$Text.doubleInstance" + case TypesScala.Float => code"$Text.floatInstance" + case TypesScala.Int => code"$Text.intInstance" + case TypesScala.Long => code"$Text.longInstance" + case TypesJava.String => code"$Text.stringInstance" + case jvm.Type.ArrayOf(TypesScala.Byte) => code"$Text.byteArrayInstance" + case lang.Optional(targ) => code"$Text.option(${dialect.usingCall}${lookupTextFor(targ)})" + case jvm.Type.TApply(default.Defaulted, List(targ)) => code"${default.Defaulted}.$textName(${dialect.usingCall}${lookupTextFor(targ)})" + case x: jvm.Type.Qualified if x.value.idents.startsWith(pkg.idents) => code"$tpe.$textName" case jvm.Type.ArrayOf(targ: jvm.Type.Qualified) if targ.value.idents.startsWith(pkg.idents) => val summoner = if (dialect == Dialect.Scala2XSource3) "implicitly" else "summon" code"$Text.iterableInstance[${TypesScala.Array}, $targ](${dialect.usingCall}${lookupTextFor(targ)}, $summoner)" diff --git a/typr-codegen/src/scala/typr/internal/codegen/DbLibZioJdbc.scala b/typr-codegen/src/scala/typr/internal/codegen/DbLibZioJdbc.scala index c54e85b3b5..5b6539f7c5 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/DbLibZioJdbc.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/DbLibZioJdbc.scala @@ -451,7 +451,7 @@ class DbLibZioJdbc(pkg: jvm.QIdent, inlineImplicits: Boolean, dslEnabled: Boolea val writeableColumnsNotId = writeableColumnsWithId.toList.filterNot(c => id.cols.exists(_.name == c.name)) val conflictAction = writeableColumnsNotId match { - case Nil => code"do nothing" + case Nil => code"do nothing" case nonEmpty => code"""|do update set | ${nonEmpty.map { c => code"${c.dbName.code} = EXCLUDED.${c.dbName.code}" }.mkCode(",\n")}""".stripMargin @@ -537,7 +537,7 @@ class DbLibZioJdbc(pkg: jvm.QIdent, inlineImplicits: Boolean, dslEnabled: Boolea val renderedWithCasts: jvm.Code = cols.toList.flatMap(c => sqlCast.fromPg(c.dbCol.tpe)) match { case Nil => renderedScript.code - case _ => + case _ => val row = jvm.Ident("row") code"""|with $row as ( diff --git a/typr-codegen/src/scala/typr/internal/codegen/FilesTable.scala b/typr-codegen/src/scala/typr/internal/codegen/FilesTable.scala index 3f3a3e6c69..911cbca5bd 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/FilesTable.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/FilesTable.scala @@ -218,7 +218,7 @@ case class FilesTable(lang: Lang, table: ComputedTable, fkAnalysis: FkAnalysis, // shortcut for id files wrapping a domain val maybeFromString: Option[jvm.Method] = x.openEnum match { - case OpenEnum.Text(_) => None + case OpenEnum.Text(_) => None case OpenEnum.TextDomain(db.PgType.DomainRef(name, _, _), _) => domainsByName.get(name).map { domain => val name = domain.underlying.constraintDefinition match { diff --git a/typr-codegen/src/scala/typr/internal/codegen/JsonLibPlay.scala b/typr-codegen/src/scala/typr/internal/codegen/JsonLibPlay.scala index 695ed78cf1..c207e2d472 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/JsonLibPlay.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/JsonLibPlay.scala @@ -201,7 +201,13 @@ case class JsonLibPlay(pkg: jvm.QIdent, default: ComputedDefault, inlineImplicit def wrapperTypeInstances(wrapperType: jvm.Type.Qualified, fieldName: jvm.Ident, underlying: jvm.Type): JsonLib.Instances = JsonLib.Instances.fromGivens( List( - jvm.Given(tparams = Nil, name = readsName, implicitParams = Nil, tpe = Reads.of(wrapperType), body = code"${lookupReadsFor(underlying)}.map(${wrapperType.value.name}.apply)"), + jvm.Given( + tparams = Nil, + name = readsName, + implicitParams = Nil, + tpe = Reads.of(wrapperType), + body = code"${lookupReadsFor(underlying)}.map(${wrapperType.value.name}.apply)" + ), jvm.Given(tparams = Nil, name = writesName, implicitParams = Nil, tpe = Writes.of(wrapperType), body = code"${lookupWritesFor(underlying)}.contramap(_.$fieldName)") ) ) diff --git a/typr-codegen/src/scala/typr/internal/codegen/LangJava.scala b/typr-codegen/src/scala/typr/internal/codegen/LangJava.scala index 24719e2865..30ad770313 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/LangJava.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/LangJava.scala @@ -6,7 +6,7 @@ import typr.jvm.Code.TreeOps import typr.jvm.Type case object LangJava extends Lang { - override val `;` : jvm.Code = code";" + override val `;`: jvm.Code = code";" override val dsl: DslQualifiedNames = DslQualifiedNames.Java override val typeSupport: TypeSupport = TypeSupportJava @@ -177,7 +177,7 @@ case object LangJava extends Lang { code"$tpe.$typeArgStr${jvm.Ident("of")}(${args.map(a => renderTree(a, ctx)).mkCode(", ")})" case jvm.Param(anns, cs, name, tpe, _) => code"${renderComments(cs).getOrElse(jvm.Code.Empty)}${renderAnnotationsInline(anns)}$tpe $name" case jvm.QIdent(value) => value.map(i => renderTree(i, ctx)).mkCode(".") - case jvm.StrLit(str) => + case jvm.StrLit(str) => val escaped = str .replace("\\", "\\\\") .replace("\"", "\\\"") @@ -205,7 +205,7 @@ case object LangJava extends Lang { case jvm.Type.Primitive(name) => name case jvm.RuntimeInterpolation(value) => value case jvm.Import(_, _) => jvm.Code.Empty // Import node just triggers import, no code output - case jvm.IfExpr(pred, thenp, elsep) => + case jvm.IfExpr(pred, thenp, elsep) => code"""|($pred | ? $thenp | : $elsep)""".stripMargin @@ -395,7 +395,10 @@ case object LangJava extends Lang { val body = jvm.New(target, cls.params.map { p => jvm.Arg.Pos(p.name) }) // Move param comment to method level, clear param comment val paramWithoutComment = param.copy(annotations = Nil, comments = jvm.Comments.Empty) - renderTree(jvm.Method(Nil, param.comments, Nil, name, List(paramWithoutComment), Nil, clsType, Nil, jvm.Body.Expr(body.code), isOverride = false, isDefault = false), memberCtx) + renderTree( + jvm.Method(Nil, param.comments, Nil, name, List(paramWithoutComment), Nil, clsType, Nil, jvm.Body.Expr(body.code), isOverride = false, isDefault = false), + memberCtx + ) } // For wrapper types with a single value field, generate toString() that returns just the value @@ -654,7 +657,7 @@ case object LangJava extends Lang { params match { case Nil => code"()" case List(one) if !hasComments => code"(${renderTree(one, ctx)})" - case more => + case more => code"""|( | ${more.init.map(p => code"${renderTree(p, ctx)},").mkCode("\n")} | ${more.lastOption.map(p => code"${renderTree(p, ctx)}").getOrElse(jvm.Code.Empty)} @@ -682,7 +685,7 @@ case object LangJava extends Lang { def renderComments(comments: jvm.Comments): Option[jvm.Code] = { comments.lines match { - case Nil => None + case Nil => None case title :: Nil => Some(code"""/** $title */\n""") case title :: rest => @@ -696,7 +699,7 @@ case object LangJava extends Lang { val argsCode = ann.args match { case Nil => code"" case List(jvm.Annotation.Arg.Positional(value)) => code"($value)" - case args => + case args => val rendered = args .map { case jvm.Annotation.Arg.Named(name, value) => code"$name = $value" diff --git a/typr-codegen/src/scala/typr/internal/codegen/LangKotlin.scala b/typr-codegen/src/scala/typr/internal/codegen/LangKotlin.scala index cf1add49a4..e747a290eb 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/LangKotlin.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/LangKotlin.scala @@ -6,7 +6,7 @@ import typr.jvm.Type import typr.jvm.Code.TreeOps case class LangKotlin(typeSupport: TypeSupport) extends Lang { - override val `;` : jvm.Code = jvm.Code.Empty // Kotlin doesn't need semicolons + override val `;`: jvm.Code = jvm.Code.Empty // Kotlin doesn't need semicolons override val dsl: DslQualifiedNames = DslQualifiedNames.Kotlin // Type system types - Kotlin uses Kotlin's type system @@ -240,7 +240,7 @@ case class LangKotlin(typeSupport: TypeSupport) extends Lang { case jvm.IgnoreResult(expr) => expr case jvm.NotNull(expr) => code"$expr!!" case jvm.ConstructorMethodRef(tpe) => code"::$tpe" - case jvm.ClassOf(tpe) => + case jvm.ClassOf(tpe) => def stripTypeParams(t: jvm.Type): jvm.Type = t match { case jvm.Type.TApply(underlying, _) => stripTypeParams(underlying) case other => other @@ -334,12 +334,12 @@ case class LangKotlin(typeSupport: TypeSupport) extends Lang { case jvm.Cast(targetType, expr) => // Kotlin cast: expr as Type code"($expr as $targetType)" - case jvm.FieldGetterRef(rowType, field) => code"$rowType::$field" + case jvm.FieldGetterRef(rowType, field) => code"$rowType::$field" case jvm.Param(_, cs, name, tpe, default) => val defaultCode = default.map(d => code" = $d").getOrElse(jvm.Code.Empty) code"${renderComments(cs).getOrElse(jvm.Code.Empty)}$name: $tpe$defaultCode" case jvm.QIdent(value) => value.map(i => renderTree(i, ctx)).mkCode(".") - case jvm.StrLit(str) => + case jvm.StrLit(str) => val escaped = str .replace("\\", "\\\\") .replace("\"", "\\\"") @@ -347,7 +347,7 @@ case class LangKotlin(typeSupport: TypeSupport) extends Lang { .replace("\r", "\\r") .replace("\t", "\\t") Quote + escaped + Quote - case jvm.Summon(_) => sys.error("kotlin doesn't support `summon`") + case jvm.Summon(_) => sys.error("kotlin doesn't support `summon`") case jvm.Type.Abstract(value, variance) => variance match { case jvm.Variance.Invariant => value.code @@ -383,7 +383,7 @@ case class LangKotlin(typeSupport: TypeSupport) extends Lang { case jvm.Type.UserDefined(underlying) => code"/* user-picked */ $underlying" case jvm.Type.Void => code"Unit" case jvm.Type.Wildcard => code"*" - case jvm.Type.Primitive(name) => + case jvm.Type.Primitive(name) => name match { case "int" => "kotlin.Int" case "long" => "kotlin.Long" @@ -397,7 +397,7 @@ case class LangKotlin(typeSupport: TypeSupport) extends Lang { } case jvm.RuntimeInterpolation(value) => value case jvm.Import(_, _) => jvm.Code.Empty // Import node just triggers import, no code output - case jvm.IfExpr(pred, thenp, elsep) => + case jvm.IfExpr(pred, thenp, elsep) => code"(if ($pred) $thenp else $elsep)" case jvm.TypeSwitch(value, cases, nullCase, defaultCase, _) => // Use `when (val __r = value)` to bind value once and avoid double evaluation @@ -771,11 +771,11 @@ case class LangKotlin(typeSupport: TypeSupport) extends Lang { // Interfaces don't need () when extending other interfaces val isInterface = cls.classType == jvm.ClassType.Interface val extendsAndImplements: Option[jvm.Code] = (cls.`extends`, cls.implements) match { - case (None, Nil) => None + case (None, Nil) => None case (Some(ext), Nil) => if (isInterface) Some(code" : $ext") else Some(code" : $ext()") - case (None, impls) => Some(code" : " ++ impls.map(x => code"$x").mkCode(", ")) + case (None, impls) => Some(code" : " ++ impls.map(x => code"$x").mkCode(", ")) case (Some(ext), impls) => if (isInterface) Some(code" : $ext, " ++ impls.map(x => code"$x").mkCode(", ")) else Some(code" : $ext(), " ++ impls.map(x => code"$x").mkCode(", ")) @@ -876,7 +876,7 @@ case class LangKotlin(typeSupport: TypeSupport) extends Lang { withVal match { case Nil => code"()" case List(one) => code"($one)" - case more => + case more => code"""|( | ${more.init.map(p => code"$p,").mkCode("\n")} | ${more.lastOption.getOrElse(jvm.Code.Empty)} @@ -902,7 +902,7 @@ case class LangKotlin(typeSupport: TypeSupport) extends Lang { def renderComments(comments: jvm.Comments): Option[jvm.Code] = { comments.lines match { - case Nil => None + case Nil => None case title :: Nil => Some(code"""/** $title */\n""") case title :: rest => @@ -929,7 +929,7 @@ case class LangKotlin(typeSupport: TypeSupport) extends Lang { private def renderAnnotationArgs(args: List[jvm.Annotation.Arg]): jvm.Code = args match { case Nil => jvm.Code.Empty case List(jvm.Annotation.Arg.Positional(value)) => code"(${renderAnnotationValue(value)})" - case args => + case args => val rendered = args .map { case jvm.Annotation.Arg.Named(name, value) => code"$name = ${renderAnnotationValue(value)}" diff --git a/typr-codegen/src/scala/typr/internal/codegen/LangScala.scala b/typr-codegen/src/scala/typr/internal/codegen/LangScala.scala index 204638e363..f33b50e449 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/LangScala.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/LangScala.scala @@ -5,7 +5,7 @@ import typr.jvm.Code.TreeOps import typr.jvm.Type case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifiedNames) extends Lang { - override val `;` : jvm.Code = code"" + override val `;`: jvm.Code = code"" // Type system types - Scala always uses Scala's type system override val nothingType: jvm.Type = TypesScala.Nothing @@ -74,7 +74,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi override def renderTree(tree: jvm.Tree, ctx: Ctx): jvm.Code = tree match { case jvm.IfExpr(cond, thenp, elsep) => code"(if ($cond) $thenp else $elsep)" - case jvm.If(branches, elseBody) => + case jvm.If(branches, elseBody) => val ifParts = branches.zipWithIndex.map { case (jvm.If.Branch(cond, body), idx) => val keyword = if (idx == 0) "if" else "else if" code"""|$keyword ($cond) { @@ -104,7 +104,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi case jvm.ConstructorMethodRef(tpe) => code"$tpe.apply" case jvm.ClassOf(tpe) => code"classOf[$tpe]" case jvm.JavaClassOf(tpe) => code"classOf[$tpe]" // Same as ClassOf for Scala - case jvm.Call(target, argGroups) => + case jvm.Call(target, argGroups) => val renderedGroups = argGroups.map { group => val argsStr = group.args.map(_.value).mkCode(", ") if (group.isImplicit) code"(using $argsStr)" else code"($argsStr)" @@ -133,29 +133,29 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi .replace("\r", "\\r") .replace("\t", "\\t") Quote + escaped + Quote - case jvm.Summon(tpe) => code"implicitly[$tpe]" + case jvm.Summon(tpe) => code"implicitly[$tpe]" case jvm.Type.Abstract(value, variance) => variance match { case jvm.Variance.Invariant => value.code case jvm.Variance.Covariant => code"+$value" case jvm.Variance.Contravariant => code"-$value" } - case jvm.Type.ArrayOf(value) => code"Array[$value]" - case jvm.Type.KotlinNullable(underlying) => renderTree(underlying, ctx) // Scala doesn't have Kotlin's T? syntax, render underlying type - case jvm.Type.Commented(underlying, comment) => code"$comment $underlying" - case jvm.Type.Annotated(underlying, annotation) => code"$underlying @$annotation" - case jvm.Type.Function0(ret) => code"=> $ret" - case jvm.Type.Function1(t1, ret) => code"$t1 => $ret" - case jvm.Type.Function2(t1, t2, ret) => code"($t1, $t2) => $ret" - case jvm.Type.Qualified(value) => value.code - case jvm.Type.TApply(underlying, targs) => code"$underlying[${targs.map(t => renderTree(t, ctx)).mkCode(", ")}]" - case jvm.Type.UserDefined(underlying) => code"/* user-picked */ $underlying" - case jvm.Type.Void => code"Unit" - case jvm.Type.Wildcard => code"?" - case jvm.Type.Primitive(name) => javaPrimitiveToScala(name) - case p: jvm.Param[jvm.Type] => renderParam(p, false) - case jvm.RuntimeInterpolation(value) => code"$${$value" - case jvm.Import(_, _) => jvm.Code.Empty // Import node just triggers import, no code output + case jvm.Type.ArrayOf(value) => code"Array[$value]" + case jvm.Type.KotlinNullable(underlying) => renderTree(underlying, ctx) // Scala doesn't have Kotlin's T? syntax, render underlying type + case jvm.Type.Commented(underlying, comment) => code"$comment $underlying" + case jvm.Type.Annotated(underlying, annotation) => code"$underlying @$annotation" + case jvm.Type.Function0(ret) => code"=> $ret" + case jvm.Type.Function1(t1, ret) => code"$t1 => $ret" + case jvm.Type.Function2(t1, t2, ret) => code"($t1, $t2) => $ret" + case jvm.Type.Qualified(value) => value.code + case jvm.Type.TApply(underlying, targs) => code"$underlying[${targs.map(t => renderTree(t, ctx)).mkCode(", ")}]" + case jvm.Type.UserDefined(underlying) => code"/* user-picked */ $underlying" + case jvm.Type.Void => code"Unit" + case jvm.Type.Wildcard => code"?" + case jvm.Type.Primitive(name) => javaPrimitiveToScala(name) + case p: jvm.Param[jvm.Type] => renderParam(p, false) + case jvm.RuntimeInterpolation(value) => code"$${$value" + case jvm.Import(_, _) => jvm.Code.Empty // Import node just triggers import, no code output case jvm.TypeSwitch(value, cases, nullCase, _, unchecked) => val nullCaseCode = nullCase.map(body => code"case null => $body").toList val typeCases = cases.map { case jvm.TypeSwitch.Case(pat, ident, body) => code"case $ident: $pat => $body" } @@ -214,8 +214,8 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi val typeArgStr = if (typeArgs.isEmpty) jvm.Code.Empty else code"[${typeArgs.map(t => renderTree(t, ctx)).mkCode(", ")}]" val argStr = if (args.isEmpty) code"()" else code"(${args.map(a => renderTree(a, ctx)).mkCode(", ")})" code"$target.$methodName$typeArgStr$argStr" - case jvm.Return(expr) => code"return $expr" - case jvm.Throw(expr) => code"throw $expr" + case jvm.Return(expr) => code"return $expr" + case jvm.Throw(expr) => code"throw $expr" case jvm.Lambda(params, body) => val paramsCode = params match { case Nil => code"() => " @@ -288,7 +288,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi ) ++ code": $tpe" body match { - case jvm.Body.Abstract => signature + case jvm.Body.Abstract => signature case jvm.Body.Expr(expr) => val rendered = expr.render(this) if (rendered.lines.length == 1) @@ -387,7 +387,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi case nonEmpty => Some(nonEmpty.map(x => code" with $x").mkCode(" ")) }, sum.members match { - case Nil => None + case Nil => None case nonEmpty => Some( code"""| { @@ -396,7 +396,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi ) }, sum.staticMembers.sortBy(_.name).map(m => renderTree(m, ctx)) ++ sum.flattenedSubtypes.sortBy(_.name.name).map(t => renderTree(t, ctx)) match { - case Nil => None + case Nil => None case nonEmpty => Some(code"""| | @@ -443,7 +443,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi case nonEmpty => Some(nonEmpty.map(x => code" with $x").mkCode(" ")) }, cls.members match { - case Nil => None + case Nil => None case nonEmpty => Some( code"""| { @@ -454,7 +454,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi cls.staticMembers .sortBy(_.name) .map(m => renderTree(m, ctx)) match { - case Nil => None + case Nil => None case nonEmpty => Some(code"""| | @@ -502,7 +502,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi case nonEmpty => Some(nonEmpty.map(x => code" with $x").mkCode(" ")) }, cls.members match { - case Nil => None + case Nil => None case nonEmpty => Some( code"""| { @@ -513,7 +513,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi cls.staticMembers .sortBy(_.name) .map(m => renderTree(m, ctx)) match { - case Nil => None + case Nil => None case nonEmpty => Some(code"""| | @@ -602,7 +602,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi } private def renderAnnotationArgs(args: List[jvm.Annotation.Arg]): jvm.Code = args match { - case Nil => jvm.Code.Empty + case Nil => jvm.Code.Empty case args => val rendered = args .map { @@ -667,7 +667,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi def renderComments(comments: jvm.Comments): Option[jvm.Code] = { comments.lines match { - case Nil => None + case Nil => None case title :: Nil => Some(code"""/** $title */ """) @@ -681,7 +681,7 @@ case class LangScala(dialect: Dialect, typeSupport: TypeSupport, dsl: DslQualifi def withBody(init: jvm.Code, body: List[jvm.Code]) = { body match { - case Nil => init + case Nil => init case body => val renderedBody = body.mkCode("\n").render(this) if (renderedBody.lines.length == 1) diff --git a/typr-codegen/src/scala/typr/internal/codegen/PostgresAdapter.scala b/typr-codegen/src/scala/typr/internal/codegen/PostgresAdapter.scala index 09cc2c6bfa..fc71a7b640 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/PostgresAdapter.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/PostgresAdapter.scala @@ -40,7 +40,7 @@ class PostgresAdapter(needsTimestampCasts: Boolean) extends DbAdapter { case db.Unknown(sqlType) => Some(SqlCastValue(sqlType)) case db.PgType.EnumRef(enm) => Some(SqlCastValue(enm.name.value)) case db.PgType.Boolean | db.PgType.Text | db.PgType.VarChar(_) => None - case _: db.PgType => + case _: db.PgType => udtName.map { case ArrayName(x) => SqlCastValue(x + "[]") case other => SqlCastValue(other) diff --git a/typr-codegen/src/scala/typr/internal/codegen/SqlCast.scala b/typr-codegen/src/scala/typr/internal/codegen/SqlCast.scala index 49b9c2822f..128491b4bc 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/SqlCast.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/SqlCast.scala @@ -30,7 +30,7 @@ class SqlCast(needsTimestampCasts: Boolean) { // DuckDB types - handle similar to PostgreSQL case db.DuckDbType.Enum(name, _) => Some(SqlCastValue(name)) case db.DuckDbType.Boolean | db.DuckDbType.Text | db.DuckDbType.VarChar(_) => None - case _: db.DuckDbType => + case _: db.DuckDbType => udtName.map { case ArrayName(x) => SqlCastValue(x + "[]") case other => SqlCastValue(other) @@ -40,7 +40,7 @@ class SqlCast(needsTimestampCasts: Boolean) { // PostgreSQL types case db.PgType.EnumRef(enm) => Some(SqlCastValue(enm.name.value)) case db.PgType.Boolean | db.PgType.Text | db.PgType.VarChar(_) => None - case _: db.PgType => + case _: db.PgType => udtName.map { case ArrayName(x) => SqlCastValue(x + "[]") case other => SqlCastValue(other) @@ -60,9 +60,9 @@ class SqlCast(needsTimestampCasts: Boolean) { Some(SqlCastValue("text")) case db.PgType.Array(db.Unknown(_)) | db.PgType.Array(db.PgType.DomainRef(_, _, db.Unknown(_))) => Some(SqlCastValue("text[]")) - case _: db.MariaType => None - case _: db.DuckDbType => None // DuckDB doesn't need special casts for reading - case _: db.SqlServerType => None // SQL Server doesn't need special casts for reading + case _: db.MariaType => None + case _: db.DuckDbType => None // DuckDB doesn't need special casts for reading + case _: db.SqlServerType => None // SQL Server doesn't need special casts for reading case db.PgType.DomainRef(_, _, underlying) => fromPg(underlying) case db.PgType.PGmoney => diff --git a/typr-codegen/src/scala/typr/internal/codegen/addPackageAndImports.scala b/typr-codegen/src/scala/typr/internal/codegen/addPackageAndImports.scala index 9a14fdc21a..6bafa41b6d 100644 --- a/typr-codegen/src/scala/typr/internal/codegen/addPackageAndImports.scala +++ b/typr-codegen/src/scala/typr/internal/codegen/addPackageAndImports.scala @@ -91,8 +91,8 @@ object addPackageAndImports { val importFn = if (isStatic) staticImport else typeImport val _ = importFn(imp) // Register the import, discard shortened version jvm.Import(imp, isStatic) // Return unchanged - case jvm.IgnoreResult(expr) => jvm.IgnoreResult(expr.mapTrees(shortenNames(_, typeImport, staticImport))) - case jvm.NotNull(expr) => jvm.NotNull(expr.mapTrees(shortenNames(_, typeImport, staticImport))) + case jvm.IgnoreResult(expr) => jvm.IgnoreResult(expr.mapTrees(shortenNames(_, typeImport, staticImport))) + case jvm.NotNull(expr) => jvm.NotNull(expr.mapTrees(shortenNames(_, typeImport, staticImport))) case jvm.IfExpr(pred, thenp, elsep) => jvm.IfExpr( pred.mapTrees(t => shortenNames(t, typeImport, staticImport)), @@ -128,7 +128,7 @@ object addPackageAndImports { case jvm.JavaClassOf(tpe) => jvm.JavaClassOf(shortenNamesType(tpe, typeImport)) case adt: jvm.Adt => shortenNamesAdt(adt, typeImport, staticImport) case cls: jvm.Class => shortenNamesClass(cls, typeImport, staticImport) - case jvm.Call(target, argGroups) => + case jvm.Call(target, argGroups) => jvm.Call( target.mapTrees(t => shortenNames(t, typeImport, staticImport)), argGroups.map(group => @@ -138,19 +138,19 @@ object addPackageAndImports { ) ) ) - case jvm.Apply0(ref) => jvm.Apply0(shortenNamesParam(ref, typeImport, staticImport).narrow) - case jvm.Apply1(ref, arg1) => jvm.Apply1(shortenNamesParam(ref, typeImport, staticImport).narrow, arg1.mapTrees(t => shortenNames(t, typeImport, staticImport))) + case jvm.Apply0(ref) => jvm.Apply0(shortenNamesParam(ref, typeImport, staticImport).narrow) + case jvm.Apply1(ref, arg1) => jvm.Apply1(shortenNamesParam(ref, typeImport, staticImport).narrow, arg1.mapTrees(t => shortenNames(t, typeImport, staticImport))) case jvm.Apply2(ref, arg1, arg2) => jvm.Apply2( shortenNamesParam(ref, typeImport, staticImport).narrow, arg1.mapTrees(t => shortenNames(t, typeImport, staticImport)), arg2.mapTrees(t => shortenNames(t, typeImport, staticImport)) ) - case jvm.Select(target, name) => jvm.Select(target.mapTrees(t => shortenNames(t, typeImport, staticImport)), name) - case jvm.ArrayIndex(target, num) => jvm.ArrayIndex(target.mapTrees(t => shortenNames(t, typeImport, staticImport)), num) - case jvm.ApplyNullary(target, name) => jvm.ApplyNullary(target.mapTrees(t => shortenNames(t, typeImport, staticImport)), name) - case jvm.Arg.Named(name, value) => jvm.Arg.Named(name, value.mapTrees(t => shortenNames(t, typeImport, staticImport))) - case jvm.Arg.Pos(value) => jvm.Arg.Pos(value.mapTrees(t => shortenNames(t, typeImport, staticImport))) + case jvm.Select(target, name) => jvm.Select(target.mapTrees(t => shortenNames(t, typeImport, staticImport)), name) + case jvm.ArrayIndex(target, num) => jvm.ArrayIndex(target.mapTrees(t => shortenNames(t, typeImport, staticImport)), num) + case jvm.ApplyNullary(target, name) => jvm.ApplyNullary(target.mapTrees(t => shortenNames(t, typeImport, staticImport)), name) + case jvm.Arg.Named(name, value) => jvm.Arg.Named(name, value.mapTrees(t => shortenNames(t, typeImport, staticImport))) + case jvm.Arg.Pos(value) => jvm.Arg.Pos(value.mapTrees(t => shortenNames(t, typeImport, staticImport))) case jvm.Enum(anns, comments, tpe, values, members, instances) => jvm.Enum( anns, @@ -177,7 +177,7 @@ object addPackageAndImports { implementsInterface.map(shortenNamesType(_, typeImport)), members.map(shortenNamesClassMember(_, typeImport, staticImport)) ) - case jvm.InferredTargs(target) => jvm.InferredTargs(target.mapTrees(t => shortenNames(t, typeImport, staticImport))) + case jvm.InferredTargs(target) => jvm.InferredTargs(target.mapTrees(t => shortenNames(t, typeImport, staticImport))) case jvm.GenericMethodCall(target, methodName, typeArgs, args) => jvm.GenericMethodCall( target.mapTrees(t => shortenNames(t, typeImport, staticImport)), @@ -185,8 +185,8 @@ object addPackageAndImports { typeArgs.map(shortenNamesType(_, typeImport)), args.map(shortenNamesArg(_, typeImport, staticImport)) ) - case jvm.Return(expr) => jvm.Return(expr.mapTrees(t => shortenNames(t, typeImport, staticImport))) - case jvm.Throw(expr) => jvm.Throw(expr.mapTrees(t => shortenNames(t, typeImport, staticImport))) + case jvm.Return(expr) => jvm.Return(expr.mapTrees(t => shortenNames(t, typeImport, staticImport))) + case jvm.Throw(expr) => jvm.Throw(expr.mapTrees(t => shortenNames(t, typeImport, staticImport))) case jvm.Lambda(params, body) => val newParams = params.map(p => jvm.LambdaParam(p.name, p.tpe.map(shortenNamesType(_, typeImport)))) jvm.Lambda(newParams, shortenNamesBody(body, typeImport, staticImport)) @@ -196,9 +196,9 @@ object addPackageAndImports { jvm.SamLambda(newSamType, newLambda) case jvm.Cast(targetType, expr) => jvm.Cast(shortenNamesType(targetType, typeImport), expr.mapTrees(t => shortenNames(t, typeImport, staticImport))) - case jvm.ByName(body) => jvm.ByName(shortenNamesBody(body, typeImport, staticImport)) - case jvm.FieldGetterRef(rowType, fld) => jvm.FieldGetterRef(shortenNamesType(rowType, typeImport), fld) - case jvm.SelfNullary(name) => jvm.SelfNullary(name) + case jvm.ByName(body) => jvm.ByName(shortenNamesBody(body, typeImport, staticImport)) + case jvm.FieldGetterRef(rowType, fld) => jvm.FieldGetterRef(shortenNamesType(rowType, typeImport), fld) + case jvm.SelfNullary(name) => jvm.SelfNullary(name) case jvm.TypedFactoryCall(tpe, typeArgs, args) => jvm.TypedFactoryCall(shortenNamesType(tpe, typeImport), typeArgs.map(shortenNamesType(_, typeImport)), args.map(shortenNamesArg(_, typeImport, staticImport))) case jvm.StringInterpolate(i, prefix, content) => jvm.StringInterpolate(shortenNamesType(i, staticImport), prefix, content.mapTrees(t => shortenNames(t, typeImport, staticImport))) @@ -210,7 +210,7 @@ object addPackageAndImports { case x: jvm.QIdent => x case x: jvm.StrLit => x case x: jvm.Summon => jvm.Summon(shortenNamesType(x.tpe, typeImport)) - case jvm.LocalVar(name, tpe, value) => + case jvm.LocalVar(name, tpe, value) => jvm.LocalVar(name, tpe.map(shortenNamesType(_, typeImport)), value.mapTrees(t => shortenNames(t, typeImport, staticImport))) case jvm.MutableVar(name, tpe, value) => jvm.MutableVar(name, tpe.map(shortenNamesType(_, typeImport)), value.mapTrees(t => shortenNames(t, typeImport, staticImport))) diff --git a/typr-codegen/src/scala/typr/internal/db2/Db2MetaDb.scala b/typr-codegen/src/scala/typr/internal/db2/Db2MetaDb.scala index d411936ed7..c042eaced3 100644 --- a/typr-codegen/src/scala/typr/internal/db2/Db2MetaDb.scala +++ b/typr-codegen/src/scala/typr/internal/db2/Db2MetaDb.scala @@ -279,7 +279,7 @@ object Db2MetaDb { def filter(schemaMode: SchemaMode): Input = { schemaMode match { - case SchemaMode.MultiSchema => this + case SchemaMode.MultiSchema => this case SchemaMode.SingleSchema(wantedSchema) => def keep(os: Option[String]): Boolean = os.contains(wantedSchema) def keepStr(s: String): Boolean = s == wantedSchema diff --git a/typr-codegen/src/scala/typr/internal/db2/Db2TypeMapperDb.scala b/typr-codegen/src/scala/typr/internal/db2/Db2TypeMapperDb.scala index 48a3c387fa..c7062827e6 100644 --- a/typr-codegen/src/scala/typr/internal/db2/Db2TypeMapperDb.scala +++ b/typr-codegen/src/scala/typr/internal/db2/Db2TypeMapperDb.scala @@ -146,8 +146,8 @@ case class Db2TypeMapperDb( db.DB2Type.Blob // Date/Time types - case "DATE" => db.DB2Type.Date - case "TIME" => db.DB2Type.Time + case "DATE" => db.DB2Type.Date + case "TIME" => db.DB2Type.Time case "TIMESTAMP" => db.DB2Type.Timestamp(scale) // Scale is fractional seconds precision diff --git a/typr-codegen/src/scala/typr/internal/duckdb/DuckDbMetaDb.scala b/typr-codegen/src/scala/typr/internal/duckdb/DuckDbMetaDb.scala index 9889cdf48a..8c6c8a9cf8 100644 --- a/typr-codegen/src/scala/typr/internal/duckdb/DuckDbMetaDb.scala +++ b/typr-codegen/src/scala/typr/internal/duckdb/DuckDbMetaDb.scala @@ -181,7 +181,7 @@ object DuckDbMetaDb { ) { def filter(schemaMode: SchemaMode): Input = { schemaMode match { - case SchemaMode.MultiSchema => this + case SchemaMode.MultiSchema => this case SchemaMode.SingleSchema(wantedSchema) => def keep(os: Option[String]): Boolean = os.contains(wantedSchema) diff --git a/typr-codegen/src/scala/typr/internal/external/ExternalTools.scala b/typr-codegen/src/scala/typr/internal/external/ExternalTools.scala index 1fc821e702..1d6db51ba1 100644 --- a/typr-codegen/src/scala/typr/internal/external/ExternalTools.scala +++ b/typr-codegen/src/scala/typr/internal/external/ExternalTools.scala @@ -69,7 +69,7 @@ object ExternalTools { def withDb2Dialect(logger: TypoLogger, tools: ExternalTools): ExternalTools = synchronized { tools.sqlglotDb2 match { case Some(_) => tools // Already downloaded - case None => + case None => logger.info("Ensuring sqlglot-db2-dialect is available...") if (!Files.exists(tools.config.downloadsDir)) { diff --git a/typr-codegen/src/scala/typr/internal/findTypeFromFk.scala b/typr-codegen/src/scala/typr/internal/findTypeFromFk.scala index fa5e3e6bc3..15208c3799 100644 --- a/typr-codegen/src/scala/typr/internal/findTypeFromFk.scala +++ b/typr-codegen/src/scala/typr/internal/findTypeFromFk.scala @@ -30,7 +30,7 @@ object findTypeFromFk { all.distinctByCompat { e => jvm.Type.base(e.merge) } match { case Nil => None case e :: Nil => Some(e.merge) - case all => + case all => val fromSelf = all.collectFirst { case Left(tpe) => tpe } val fromOthers = all.collectFirst { case Right(tpe) => tpe } val renderedTypes = all.map { e => lang.renderTree(e.merge, lang.Ctx.Empty) } diff --git a/typr-codegen/src/scala/typr/internal/generate.scala b/typr-codegen/src/scala/typr/internal/generate.scala index f6167a2b32..575540a15d 100644 --- a/typr-codegen/src/scala/typr/internal/generate.scala +++ b/typr-codegen/src/scala/typr/internal/generate.scala @@ -95,7 +95,14 @@ object generate { case DbLibName.Anorm => new DbLibAnorm(pkg, publicOptions.inlineImplicits, default, publicOptions.enableStreamingInserts, requireScalaWithLegacyDsl("anorm")) case DbLibName.Doobie => - new DbLibDoobie(pkg, publicOptions.inlineImplicits, default, publicOptions.enableStreamingInserts, publicOptions.fixVerySlowImplicit, requireScalaWithLegacyDsl("doobie")) + new DbLibDoobie( + pkg, + publicOptions.inlineImplicits, + default, + publicOptions.enableStreamingInserts, + publicOptions.fixVerySlowImplicit, + requireScalaWithLegacyDsl("doobie") + ) case DbLibName.Typo => new DbLibFoundations(language, default, publicOptions.enableStreamingInserts, metaDb.dbType.adapter(needsTimestampCasts = false), naming) case DbLibName.ZioJdbc => @@ -197,7 +204,7 @@ object generate { computedSqlFiles.flatMap(x => FilesSqlFile(language, x, naming, options).all) val relationFilesByName = computedRelations.flatMap { - case viewComputed: ComputedView => FilesView(language, viewComputed, options).all.map(x => (viewComputed.view.name, x)) + case viewComputed: ComputedView => FilesView(language, viewComputed, options).all.map(x => (viewComputed.view.name, x)) case tableComputed: ComputedTable => val fkAnalysis = FkAnalysis(computedRelationsByName, tableComputed, options.lang) FilesTable(language, tableComputed, fkAnalysis, options, domainsByName).all.map(x => (tableComputed.dbTable.name, x)) @@ -332,8 +339,7 @@ object generate { val keptMostFiles: List[jvm.File] = { val keptRelations: immutable.Iterable[jvm.File] = - if (options.keepDependencies) relationFilesByName.map { case (_, f) => f } - else relationFilesByName.collect { case (name, f) if selector.include(name) => f } + if (options.keepDependencies) relationFilesByName.map { case (_, f) => f } else relationFilesByName.collect { case (name, f) if selector.include(name) => f } // pgCompositeTypeFiles are entry points only for DbLibFoundations (which has PgStruct support) // For other dbLibs (like Anorm used by generate-sources), they're not included diff --git a/typr-codegen/src/scala/typr/internal/mariadb/MariaMetaDb.scala b/typr-codegen/src/scala/typr/internal/mariadb/MariaMetaDb.scala index c245199f67..3ce1e6610b 100644 --- a/typr-codegen/src/scala/typr/internal/mariadb/MariaMetaDb.scala +++ b/typr-codegen/src/scala/typr/internal/mariadb/MariaMetaDb.scala @@ -202,7 +202,7 @@ object MariaMetaDb { def filter(schemaMode: SchemaMode): Input = { schemaMode match { - case SchemaMode.MultiSchema => this + case SchemaMode.MultiSchema => this case SchemaMode.SingleSchema(wantedSchema) => def keep(os: Option[String]): Boolean = os.contains(wantedSchema) diff --git a/typr-codegen/src/scala/typr/internal/minimize.scala b/typr-codegen/src/scala/typr/internal/minimize.scala index fa48865d01..57f92b7be5 100644 --- a/typr-codegen/src/scala/typr/internal/minimize.scala +++ b/typr-codegen/src/scala/typr/internal/minimize.scala @@ -75,7 +75,7 @@ object minimize { case jvm.Call(target, argGroups) => go(target) argGroups.foreach(group => group.args.foreach(goTree)) - case jvm.Ident(_) => () + case jvm.Ident(_) => () case x: jvm.QIdent => if (!b(x)) { b += x @@ -128,7 +128,7 @@ object minimize { case jvm.Param(_, _, _, tpe, maybeCode) => goTree(tpe) maybeCode.foreach(go) - case jvm.StrLit(_) => () + case jvm.StrLit(_) => () case jvm.LocalVar(name, tpe, value) => goTree(name) tpe.foreach(goTree) diff --git a/typr-codegen/src/scala/typr/internal/oracle/OracleMetaDb.scala b/typr-codegen/src/scala/typr/internal/oracle/OracleMetaDb.scala index 5e6bf3f8de..2ad881dde6 100644 --- a/typr-codegen/src/scala/typr/internal/oracle/OracleMetaDb.scala +++ b/typr-codegen/src/scala/typr/internal/oracle/OracleMetaDb.scala @@ -244,7 +244,7 @@ object OracleMetaDb { ) { def filter(schemaMode: SchemaMode): Input = { schemaMode match { - case SchemaMode.MultiSchema => this + case SchemaMode.MultiSchema => this case SchemaMode.SingleSchema(wantedSchema) => def keep(os: Option[String]): Boolean = os.exists(_.equalsIgnoreCase(wantedSchema)) diff --git a/typr-codegen/src/scala/typr/internal/pg/PgMetaDb.scala b/typr-codegen/src/scala/typr/internal/pg/PgMetaDb.scala index 85da14ca2c..0d8b932721 100644 --- a/typr-codegen/src/scala/typr/internal/pg/PgMetaDb.scala +++ b/typr-codegen/src/scala/typr/internal/pg/PgMetaDb.scala @@ -39,7 +39,7 @@ object PgMetaDb { ) { def filter(schemaMode: SchemaMode): Input = { schemaMode match { - case SchemaMode.MultiSchema => this + case SchemaMode.MultiSchema => this case SchemaMode.SingleSchema(wantedSchema) => def keep(os: Option[String]): Boolean = os.contains(wantedSchema) diff --git a/typr-codegen/src/scala/typr/internal/pg/PgTypeMapperDb.scala b/typr-codegen/src/scala/typr/internal/pg/PgTypeMapperDb.scala index 069b853946..35cf1c60d3 100644 --- a/typr-codegen/src/scala/typr/internal/pg/PgTypeMapperDb.scala +++ b/typr-codegen/src/scala/typr/internal/pg/PgTypeMapperDb.scala @@ -94,7 +94,7 @@ case class PgTypeMapperDb(enums: List[db.StringEnum], domains: List[db.Domain], case "vector" => db.PgType.Vector case "bit" => db.PgType.Bit(characterMaximumLength) case "varbit" => db.PgType.Varbit(characterMaximumLength) - case ArrayName(underlying) => + case ArrayName(underlying) => db.PgType.Array(dbTypeFrom(underlying, characterMaximumLength)(logWarning)) case typeName => enumsByName diff --git a/typr-codegen/src/scala/typr/internal/sqlserver/SqlServerMetaDb.scala b/typr-codegen/src/scala/typr/internal/sqlserver/SqlServerMetaDb.scala index b50bcabf9c..0ae785bd5b 100644 --- a/typr-codegen/src/scala/typr/internal/sqlserver/SqlServerMetaDb.scala +++ b/typr-codegen/src/scala/typr/internal/sqlserver/SqlServerMetaDb.scala @@ -248,7 +248,7 @@ object SqlServerMetaDb { def filter(schemaMode: SchemaMode): Input = { schemaMode match { - case SchemaMode.MultiSchema => this + case SchemaMode.MultiSchema => this case SchemaMode.SingleSchema(wantedSchema) => def keep(os: Option[String]): Boolean = os.contains(wantedSchema) def keepString(s: String): Boolean = s == wantedSchema diff --git a/typr-codegen/src/scala/typr/internal/sqlserver/SqlServerTypeMapperDb.scala b/typr-codegen/src/scala/typr/internal/sqlserver/SqlServerTypeMapperDb.scala index 6f42676302..888ef487e3 100644 --- a/typr-codegen/src/scala/typr/internal/sqlserver/SqlServerTypeMapperDb.scala +++ b/typr-codegen/src/scala/typr/internal/sqlserver/SqlServerTypeMapperDb.scala @@ -172,7 +172,7 @@ case class SqlServerTypeMapperDb(domains: List[db.Domain]) extends TypeMapperDb db.SqlServerType.Time(datetimePrecision.map(_.toInt)) case "datetime" => db.SqlServerType.DateTime case "smalldatetime" => db.SqlServerType.SmallDateTime - case "datetime2" => + case "datetime2" => db.SqlServerType.DateTime2(datetimePrecision.map(_.toInt)) case "datetimeoffset" => db.SqlServerType.DateTimeOffset(datetimePrecision.map(_.toInt)) diff --git a/typr-codegen/src/scala/typr/openapi/OpenApiCodegen.scala b/typr-codegen/src/scala/typr/openapi/OpenApiCodegen.scala index 6d972e2a81..a5e5b93bf3 100644 --- a/typr-codegen/src/scala/typr/openapi/OpenApiCodegen.scala +++ b/typr-codegen/src/scala/typr/openapi/OpenApiCodegen.scala @@ -5,10 +5,8 @@ import typr.effects.EffectTypeOps import typr.internal.codegen.LangScala import typr.openapi.codegen.{ ApiCodegen, - CirceSupport, FrameworkSupport, Http4sSupport, - JacksonSupport, JaxRsSupport, JdkHttpClientSupport, Jsr380ValidationSupport, diff --git a/typr-codegen/src/scala/typr/openapi/OpenApiOptions.scala b/typr-codegen/src/scala/typr/openapi/OpenApiOptions.scala index 1e53e3784d..697cff3e65 100644 --- a/typr-codegen/src/scala/typr/openapi/OpenApiOptions.scala +++ b/typr-codegen/src/scala/typr/openapi/OpenApiOptions.scala @@ -1,7 +1,7 @@ package typr.openapi import typr.{jvm, TypeDefinitions} -import typr.effects.{EffectType, EffectTypeOps} +import typr.effects.EffectType import typr.openapi.codegen.{JacksonSupport, JsonLibSupport} /** Configuration options for OpenAPI code generation */ diff --git a/typr-codegen/src/scala/typr/openapi/codegen/ApiCodegen.scala b/typr-codegen/src/scala/typr/openapi/codegen/ApiCodegen.scala index 2cc1ec07da..0e982568e6 100644 --- a/typr-codegen/src/scala/typr/openapi/codegen/ApiCodegen.scala +++ b/typr-codegen/src/scala/typr/openapi/codegen/ApiCodegen.scala @@ -478,7 +478,7 @@ class ApiCodegen( case "4xx" => code"${statusCodeIdent.code} >= 400 && ${statusCodeIdent.code} < 500" case "5xx" => code"${statusCodeIdent.code} >= 500 && ${statusCodeIdent.code} < 600" case "default" => code"true" - case s => + case s => val statusInt = scala.util.Try(s.toInt).getOrElse(500) code"${statusCodeIdent.code} == $statusInt" } @@ -663,7 +663,7 @@ class ApiCodegen( case "4xx" => code"${statusCodeIdent.code} >= 400 && ${statusCodeIdent.code} < 500" case "5xx" => code"${statusCodeIdent.code} >= 500 && ${statusCodeIdent.code} < 600" case "default" => code"true" - case s => + case s => val statusInt = scala.util.Try(s.toInt).getOrElse(500) code"${statusCodeIdent.code} == $statusInt" } @@ -814,7 +814,7 @@ class ApiCodegen( case "4xx" => code"${statusCodeIdent.code} >= 400 && ${statusCodeIdent.code} < 500" case "5xx" => code"${statusCodeIdent.code} >= 500 && ${statusCodeIdent.code} < 600" case "default" => code"true" - case s => + case s => val statusInt = scala.util.Try(s.toInt).getOrElse(500) code"${statusCodeIdent.code} == $statusInt" } @@ -1687,7 +1687,7 @@ $ifElseCode""" case "4xx" => code"${statusCodeIdent.code} >= 400 && ${statusCodeIdent.code} < 500" case "5xx" => code"${statusCodeIdent.code} >= 500 && ${statusCodeIdent.code} < 600" case "default" => code"true" // default case matches everything - case s => + case s => val statusInt = scala.util.Try(s.toInt).getOrElse(500) code"${statusCodeIdent.code} == $statusInt" } @@ -1869,7 +1869,7 @@ $ifElseCode""" case "4xx" => code"$statusCodeExpr >= 400 && $statusCodeExpr < 500" case "5xx" => code"$statusCodeExpr >= 500 && $statusCodeExpr < 600" case "default" => code"true" // default case matches everything - case s => + case s => val statusInt = scala.util.Try(s.toInt).getOrElse(500) code"$statusCodeExpr == $statusInt" } diff --git a/typr-codegen/src/scala/typr/openapi/parser/ApiExtractor.scala b/typr-codegen/src/scala/typr/openapi/parser/ApiExtractor.scala index 2e8100d5d7..14f103e6a7 100644 --- a/typr-codegen/src/scala/typr/openapi/parser/ApiExtractor.scala +++ b/typr-codegen/src/scala/typr/openapi/parser/ApiExtractor.scala @@ -306,7 +306,7 @@ object ApiExtractor { private def responseStatusCodeOrder(statusCode: String): Int = { scala.util.Try(statusCode.toInt).toOption match { case Some(c) => c - case None => + case None => statusCode.toLowerCase match { case "default" => 1000 case "2xx" => 200 @@ -323,7 +323,7 @@ object ApiExtractor { case "2xx" => ResponseStatus.Success2XX case "4xx" => ResponseStatus.ClientError4XX case "5xx" => ResponseStatus.ServerError5XX - case s => + case s => scala.util.Try(s.toInt).toOption match { case Some(c) => ResponseStatus.Specific(c) case None => ResponseStatus.Default diff --git a/typr-dsl-anorm/src/scala/typr/dsl/SelectBuilderSql.scala b/typr-dsl-anorm/src/scala/typr/dsl/SelectBuilderSql.scala index bdc98309f2..ed9c27754e 100644 --- a/typr-dsl-anorm/src/scala/typr/dsl/SelectBuilderSql.scala +++ b/typr-dsl-anorm/src/scala/typr/dsl/SelectBuilderSql.scala @@ -175,6 +175,7 @@ object SelectBuilderSql { rowParser = (i: Int) => for { r1 <- leftInstance.rowParser(i) + /** note, `RowParser` has a `?` combinator, but it doesn't work. fails with exception instead of [[anorm.Error]] */ r2 <- RowParser[Option[Row2]] { row => try rightInstance.rowParser(i + leftInstance.columns.size)(row).map(Some.apply) diff --git a/typr-dsl-doobie/src/scala/typr/dsl/SelectBuilderMock.scala b/typr-dsl-doobie/src/scala/typr/dsl/SelectBuilderMock.scala index 3238797cf8..c9a879385c 100644 --- a/typr-dsl-doobie/src/scala/typr/dsl/SelectBuilderMock.scala +++ b/typr-dsl-doobie/src/scala/typr/dsl/SelectBuilderMock.scala @@ -36,12 +36,13 @@ final case class SelectBuilderMock[Fields, Row]( for { lefts <- this.toList rights <- otherMock.toList - } yield for { - left <- lefts - right <- rights - newRow = (left, right) - if newStructure.untypedEval(pred(newStructure.fields), newRow).getOrElse(false) - } yield newRow + } yield + for { + left <- lefts + right <- rights + newRow = (left, right) + if newStructure.untypedEval(pred(newStructure.fields), newRow).getOrElse(false) + } yield newRow SelectBuilderMock[Fields ~ Fields2, Row ~ Row2](newStructure, newRows, SelectParams.empty) } diff --git a/typr-dsl-shared/typr/dsl/OrderByOrSeek.scala b/typr-dsl-shared/typr/dsl/OrderByOrSeek.scala index b54aed26cd..915c6e14f6 100644 --- a/typr-dsl-shared/typr/dsl/OrderByOrSeek.scala +++ b/typr-dsl-shared/typr/dsl/OrderByOrSeek.scala @@ -15,7 +15,7 @@ object OrderByOrSeek { val maybeSeekPredicate: Option[SqlExpr[Boolean]] = seeks match { - case Nil => None + case Nil => None case nonEmpty => val seekOrderBys: List[SortOrder[?]] = nonEmpty.map { case seek: Seek[Fields, _] @unchecked /* for 2.13*/ => seek.f(fields) } diff --git a/typr-dsl-zio-jdbc/src/scala/typr/dsl/SelectBuilderMock.scala b/typr-dsl-zio-jdbc/src/scala/typr/dsl/SelectBuilderMock.scala index 98fcd78bb9..b74bc18d01 100644 --- a/typr-dsl-zio-jdbc/src/scala/typr/dsl/SelectBuilderMock.scala +++ b/typr-dsl-zio-jdbc/src/scala/typr/dsl/SelectBuilderMock.scala @@ -36,12 +36,13 @@ final case class SelectBuilderMock[Fields, Row]( for { lefts <- self.toChunk rights <- otherMock.toChunk - } yield for { - left <- lefts - right <- rights - newRow = (left, right) - if newStructure.untypedEval(pred(newStructure.fields), newRow).getOrElse(false) - } yield newRow + } yield + for { + left <- lefts + right <- rights + newRow = (left, right) + if newStructure.untypedEval(pred(newStructure.fields), newRow).getOrElse(false) + } yield newRow SelectBuilderMock[Fields ~ Fields2, Row ~ Row2](newStructure, newRows, SelectParams.empty) } diff --git a/typr-scripts/src/scala/scripts/Publish.scala b/typr-scripts/src/scala/scripts/Publish.scala deleted file mode 100644 index 25a096c7c9..0000000000 --- a/typr-scripts/src/scala/scripts/Publish.scala +++ /dev/null @@ -1,75 +0,0 @@ -package scripts - -import bleep.* -import bleep.nosbt.InteractionService -import bleep.packaging.{CoordinatesFor, PackagedLibrary, PublishLayout, packageLibraries} -import bleep.plugin.cirelease.CiReleasePlugin -import bleep.plugin.dynver.DynVerPlugin -import bleep.plugin.pgp.PgpPlugin -import bleep.plugin.sonatype.Sonatype -import coursier.Info - -import scala.collection.immutable.SortedMap - -object Publish extends BleepScript("Publish") { - val groupId = "dev.typr" - - def run(started: Started, commands: Commands, args: List[String]): Unit = { - commands.compile(started.build.explodedProjects.keys.filter(projectsToPublish.include).toList) - - val dynVer = new DynVerPlugin(baseDirectory = started.buildPaths.buildDir.toFile, dynverSonatypeSnapshots = true) - val pgp = new PgpPlugin( - logger = started.logger, - maybeCredentials = None, - interactionService = InteractionService.DoesNotMaskYourPasswordExclamationOneOne - ) - val sonatype = new Sonatype( - logger = started.logger, - sonatypeBundleDirectory = started.buildPaths.dotBleepDir / "sonatype-bundle", - sonatypeProfileName = "com.olvind", - bundleName = "typr", - version = dynVer.version - ) - val ciRelease = new CiReleasePlugin(started.logger, sonatype, dynVer, pgp) - - started.logger.info(dynVer.version) - - val info = Info( - "Typed postgres boilerplate generation", - "https://github.com/oyvindberg/typr/", - List( - Info.Developer( - "oyvindberg", - "Øyvind Raddum Berg", - "https://github.com/oyvindberg" - ) - ), - publication = None, - scm = CiReleasePlugin.inferScmInfo, - licenseInfo = List( - Info.License( - "MIT", - Some("http://opensource.org/licenses/MIT"), - distribution = Some("repo"), - comments = None - ) - ) - ) - - val packagedLibraries: SortedMap[model.CrossProjectName, PackagedLibrary] = - packageLibraries( - started, - coordinatesFor = CoordinatesFor.Default(groupId = groupId, version = dynVer.version), - shouldInclude = projectsToPublish.include, - publishLayout = PublishLayout.Maven(info) - ) - - val files: Map[RelPath, Array[Byte]] = - packagedLibraries.flatMap { case (_, PackagedLibrary(_, files)) => files.all } - - files.foreach { case (path, bytes) => - started.logger.withContext("path", path.asString).withContext("bytes.length", bytes.length).debug("will publish") - } - ciRelease.ciRelease(files) - } -} diff --git a/typr-scripts/src/scala/scripts/PublishLocal.scala b/typr-scripts/src/scala/scripts/PublishLocal.scala deleted file mode 100644 index 19f9825c85..0000000000 --- a/typr-scripts/src/scala/scripts/PublishLocal.scala +++ /dev/null @@ -1,23 +0,0 @@ -package scripts - -import bleep.* -import bleep.commands.PublishLocal.{LocalIvy, Options} -import bleep.packaging.ManifestCreator -import bleep.plugin.dynver.DynVerPlugin - -object PublishLocal extends BleepScript("PublishLocal") { - def run(started: Started, commands: Commands, args: List[String]): Unit = { - val dynVer = new DynVerPlugin(baseDirectory = started.buildPaths.buildDir.toFile, dynverSonatypeSnapshots = true) - val projects = started.build.explodedProjects.keys.toArray.filter(projectsToPublish.include) - - commands.publishLocal( - Options( - groupId = "dev.typr", - version = dynVer.version, - publishTarget = LocalIvy, - projects = projects, - manifestCreator = ManifestCreator.default - ) - ) - } -} diff --git a/typr-scripts/src/scala/scripts/projectsToPublish.scala b/typr-scripts/src/scala/scripts/projectsToPublish.scala deleted file mode 100644 index e70de83568..0000000000 --- a/typr-scripts/src/scala/scripts/projectsToPublish.scala +++ /dev/null @@ -1,30 +0,0 @@ -package scripts - -import bleep.model - -object projectsToPublish { - // will publish these with dependencies - def include(crossName: model.CrossProjectName): Boolean = - crossName.name.value match { - // CLI app - case "typr" => true - - // typr's upstream deps — needed so the published POM resolves - case "typr-codegen" => true - case "typr-dsl" => true - case "typr-dsl-scala" => true - case "typr-dsl-kotlin" => true - - // legacy DSL integrations (still published for backwards-compat consumers) - case "typr-dsl-anorm" => true - case "typr-dsl-doobie" => true - case "typr-dsl-zio-jdbc" => true - - // legacy runtime libs paired with the legacy DSLs - case "typr-runtime-anorm" => true - case "typr-runtime-doobie" => true - case "typr-runtime-zio-jdbc" => true - - case _ => false - } -} diff --git a/typr.yaml b/typr.yaml index 6395c5871f..bc9758d4ca 100644 --- a/typr.yaml +++ b/typr.yaml @@ -1,32 +1,223 @@ +boundaries: + grpc-services: + proto_path: testers/grpc/protos + type: grpc + oracle: + service: FREEPDB1 + host: localhost + sql_scripts: sql-scripts/oracle + username: typr + types: + Email: + db: + column: email + selectors: + exclude_tables: + - test_genkeys_1767399918202 + - test_genkeys_1767491891912 + - test_json_rt_1767496451381 + - test_table_1767489875539 + precision_types: + - PRECISION_TYPES + - PRECISION_TYPES_NULL + schema_mode: single_schema:TYPR + port: 1521 + type: oracle + password: typr_password + avro-events: + default_header_schema: standard + header_schemas: + standard: + fields: + - name: correlationId + required: true + type: uuid + - name: timestamp + required: true + type: instant + - name: source + required: false + type: string + schemas: + - testers/avro/schemas + enable_precise_types: true + type: avro + topic_headers: + order-events: standard + generate_kafka_rpc: true + postgres: + schemas: + - humanresources + - person + - production + - public + - sales + password: password + type: postgresql + username: postgres + port: 6432 + types: + LastName: + db: + column: lastname + Description: + db: + column: description + CurrentFlag: + db: + column: currentflag + MiddleName: + db: + column: middlename + FirstName: + db: + column: firstname + OnlineOrderFlag: + db: + column: onlineorderflag + SalariedFlag: + db: + column: salariedflag + ActiveFlag: + db: + column: activeflag + type_override: + sales.creditcard.creditcardid: adventureworks.userdefined.CustomCreditcardId + selectors: + open_enums: + - title + - title_domain + - issue142 + precision_types: + - precision_types + - precision_types_null + tables: + - department + - employee + - employeedepartmenthistory + - shift + - vemployee + - address + - addresstype + - businessentity + - businessentityaddress + - countryregion + - emailaddress + - password + - person + - stateprovince + - product + - productcategory + - productcosthistory + - productmodel + - productsubcategory + - unitmeasure + - flaff + - identity-test + - issue142 + - issue142_2 + - only_pk_columns + - pgtest + - pgtestnull + - title + - title_domain + - titledperson + - users + - salesperson + - salesterritory + - precision_types + - precision_types_null + database: Adventureworks + sql_scripts: sql-scripts/postgres + host: localhost + sqlserver: + database: typr + port: 1433 + schema_mode: single_schema:dbo + host: localhost + selectors: + precision_types: + - precision_types + - precision_types_null + username: sa + password: YourStrong@Passw0rd + types: + Email: + db: + column: + - email + - '*_email' + type: sqlserver + sql_scripts: sql-scripts/sqlserver + mariadb: + selectors: + precision_types: + - precision_types + - precision_types_null + sql_scripts: sql-scripts/mariadb + host: localhost + schema_mode: single_schema:typr + port: 3307 + types: + IsDefault: + db: + column: is_default + IsVerifiedPurchase: + db: + column: is_verified_purchase + Email: + db: + column: + - email + - contact_email + LastName: + db: + column: last_name + IsPrimary: + db: + column: is_primary + IsActive: + db: + column: is_active + FirstName: + db: + column: first_name + IsApproved: + db: + column: is_approved + username: typr + type: mariadb + database: typr + password: password + api: + generate_models: true + generate_server: true + spec: testers/combined/specs/combined-api.yaml + type: openapi + duckdb: + schema_sql: sql-init/duckdb/00-schema.sql + selectors: + precision_types: + - precision_types + - precision_types_null + sql_scripts: sql-scripts/duckdb + path: ':memory:' + types: + Email: + db: + column: email + type: duckdb + db2: + username: db2inst1 + password: password + schema_mode: single_schema:DB2INST1 + host: localhost + sql_scripts: sql-scripts/db2 + type: db2 + database: typr + port: 50000 outputs: - mariadb-java: - path: testers/mariadb/java/generated-and-checked-in - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - json: jackson - language: java - sources: mariadb - package: testdb - db_lib: foundations - duckdb-scala: - db_lib: foundations - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - language: scala - scala: - dialect: scala3 - dsl: scala - package: testdb - json: jackson - sources: duckdb - path: testers/duckdb/scala/generated-and-checked-in - postgres-zio-jdbc-scala3: - language: scala - json: zio-json + postgres-scala-scalatypes: matchers: mock_repos: exclude: @@ -37,58 +228,49 @@ outputs: readonly: - purchaseorderdetail test_inserts: all - path: testers/pg/scala/zio-jdbc/generated-and-checked-in-3 + path: testers/pg/scala/scalatypes/generated-and-checked-in + json: jackson + language: scala package: adventureworks - scala: - dialect: scala3 - dsl: legacy sources: postgres - db_lib: zio-jdbc - sqlserver-scala: db_lib: foundations - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - json: jackson - package: testdb - sources: sqlserver - language: scala - path: testers/sqlserver/scala/generated-and-checked-in scala: dialect: scala3 dsl: scala - oracle-scala-new: + duckdb-java: + path: testers/duckdb/java/generated-and-checked-in + package: testdb json: jackson + sources: duckdb + language: java + db_lib: foundations matchers: mock_repos: all primary_key_types: all test_inserts: all - sources: oracle - package: oracledb + grpc-java-spring: + package: com.example.grpc + path: testers/grpc/java-spring/generated-and-checked-in + framework: spring + language: java + sources: grpc-services + grpc-kotlin-quarkus: + framework: quarkus + package: com.example.grpc + language: kotlin + sources: grpc-services + path: testers/grpc/kotlin-quarkus/generated-and-checked-in + effect_type: mutiny_uni + postgres-anorm-scala3: + path: testers/pg/scala/anorm/generated-and-checked-in-3 + json: play-json scala: dialect: scala3 - dsl: scala - path: testers/oracle/scala-new/generated-and-checked-in - db_lib: foundations - language: scala - db2-kotlin: - sources: db2 - language: kotlin - package: testdb - db_lib: foundations - path: testers/db2/kotlin/generated-and-checked-in - json: jackson - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - postgres-anorm-scala2: + dsl: legacy + package: adventureworks + sources: postgres language: scala - path: testers/pg/scala/anorm/generated-and-checked-in-2.13 db_lib: anorm - package: adventureworks - json: play-json matchers: mock_repos: exclude: @@ -99,26 +281,23 @@ outputs: readonly: - purchaseorderdetail test_inserts: all - sources: postgres - scala: - dialect: scala2 - dsl: legacy - db2-java: - path: testers/db2/java/generated-and-checked-in - sources: db2 - json: jackson - language: java - db_lib: foundations + oracle-kotlin: matchers: mock_repos: all primary_key_types: all test_inserts: all - package: testdb - postgres-doobie-scala3: - path: testers/pg/scala/doobie/generated-and-checked-in-3 + language: kotlin + path: testers/oracle/kotlin/generated-and-checked-in + db_lib: foundations + package: oracledb + json: jackson + sources: oracle + postgres-scala-javatypes: + db_lib: foundations + package: adventureworks + path: testers/pg/scala/javatypes/generated-and-checked-in + json: jackson language: scala - db_lib: doobie - json: circe matchers: mock_repos: exclude: @@ -129,18 +308,27 @@ outputs: readonly: - purchaseorderdetail test_inserts: all - package: adventureworks scala: dialect: scala3 - dsl: legacy + dsl: java + use_native_types: false sources: postgres - postgres-scala-scalatypes: - path: testers/pg/scala/scalatypes/generated-and-checked-in + avro-scala-cats: + effect_type: cats_io language: scala - json: jackson - db_lib: foundations + path: testers/avro/scala-cats/generated-and-checked-in + package: com.example.events + framework: cats + scala: + dialect: scala3 + dsl: scala + sources: avro-events + postgres-zio-jdbc-scala3: + json: zio-json + path: testers/pg/scala/zio-jdbc/generated-and-checked-in-3 + db_lib: zio-jdbc package: adventureworks - sources: postgres + language: scala matchers: mock_repos: exclude: @@ -151,210 +339,131 @@ outputs: readonly: - purchaseorderdetail test_inserts: all + sources: postgres scala: dialect: scala3 - dsl: scala - postgres-kotlin: + dsl: legacy + duckdb-scala: json: jackson - language: kotlin db_lib: foundations - sources: postgres - package: adventureworks - path: testers/pg/kotlin/generated-and-checked-in + package: testdb + sources: duckdb + language: scala matchers: - mock_repos: - exclude: - - purchaseorderdetail - primary_key_types: - exclude: - - billofmaterials - readonly: - - purchaseorderdetail + mock_repos: all + primary_key_types: all test_inserts: all - mariadb-scala: - package: testdb - sources: mariadb - path: testers/mariadb/scala/generated-and-checked-in + path: testers/duckdb/scala/generated-and-checked-in scala: dialect: scala3 dsl: scala + grpc-kotlin: + language: kotlin + package: com.example.grpc + path: testers/grpc/kotlin/generated-and-checked-in + sources: grpc-services + duckdb-kotlin: + language: kotlin + db_lib: foundations + package: testdb + json: jackson + sources: duckdb + matchers: + mock_repos: all + primary_key_types: all + test_inserts: all + path: testers/duckdb/kotlin/generated-and-checked-in + mariadb-java: json: jackson + package: testdb + language: java matchers: mock_repos: all primary_key_types: all test_inserts: all db_lib: foundations - language: scala - sqlserver-java: - sources: sqlserver + path: testers/mariadb/java/generated-and-checked-in + sources: mariadb + mariadb-kotlin: + package: testdb + json: jackson + sources: mariadb + matchers: + mock_repos: all + primary_key_types: all + test_inserts: all + path: testers/mariadb/kotlin/generated-and-checked-in + language: kotlin + db_lib: foundations + db2-java: matchers: mock_repos: all primary_key_types: all test_inserts: all package: testdb - language: java db_lib: foundations - path: testers/sqlserver/java/generated-and-checked-in + language: java json: jackson - postgres-zio-jdbc-scala2: - sources: postgres + sources: db2 + path: testers/db2/java/generated-and-checked-in + sqlserver-scala: language: scala - package: adventureworks - db_lib: zio-jdbc - json: zio-json - matchers: - mock_repos: - exclude: - - purchaseorderdetail - primary_key_types: - exclude: - - billofmaterials - readonly: - - purchaseorderdetail - test_inserts: all - scala: - dialect: scala2 - dsl: legacy - path: testers/pg/scala/zio-jdbc/generated-and-checked-in-2.13 - postgres-anorm-scala3: - json: play-json - db_lib: anorm - sources: postgres - package: adventureworks + json: jackson + sources: sqlserver + path: testers/sqlserver/scala/generated-and-checked-in scala: dialect: scala3 - dsl: legacy - language: scala - path: testers/pg/scala/anorm/generated-and-checked-in-3 + dsl: scala + db_lib: foundations matchers: - mock_repos: - exclude: - - purchaseorderdetail - primary_key_types: - exclude: - - billofmaterials - readonly: - - purchaseorderdetail + mock_repos: all + primary_key_types: all test_inserts: all - oracle-scala: + package: testdb + mariadb-scala: + matchers: + mock_repos: all + primary_key_types: all + test_inserts: all + db_lib: foundations + path: testers/mariadb/scala/generated-and-checked-in + package: testdb json: jackson - path: testers/oracle/scala/generated-and-checked-in scala: dialect: scala3 - dsl: java - use_native_types: false + dsl: scala + sources: mariadb + language: scala + db2-scala: + db_lib: foundations + path: testers/db2/scala/generated-and-checked-in + package: testdb language: scala matchers: mock_repos: all primary_key_types: all test_inserts: all - sources: oracle - package: oracledb - db_lib: foundations - postgres-doobie-scala2: scala: - dialect: scala2 - dsl: legacy - json: circe - matchers: - mock_repos: - exclude: - - purchaseorderdetail - primary_key_types: - exclude: - - billofmaterials - readonly: - - purchaseorderdetail - test_inserts: all - sources: postgres - db_lib: doobie - package: adventureworks - language: scala - path: testers/pg/scala/doobie/generated-and-checked-in-2.13 - postgres-scala-javatypes: - language: scala - package: adventureworks - path: testers/pg/scala/javatypes/generated-and-checked-in - matchers: - mock_repos: - exclude: - - purchaseorderdetail - primary_key_types: - exclude: - - billofmaterials - readonly: - - purchaseorderdetail - test_inserts: all - scala: - dialect: scala3 - dsl: java - use_native_types: false - db_lib: foundations - sources: postgres - json: jackson - duckdb-java: - sources: duckdb - language: java - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - db_lib: foundations - package: testdb - json: jackson - path: testers/duckdb/java/generated-and-checked-in - mariadb-kotlin: - path: testers/mariadb/kotlin/generated-and-checked-in - sources: mariadb - json: jackson - package: testdb - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - language: kotlin - db_lib: foundations - db2-scala: - path: testers/db2/scala/generated-and-checked-in - json: jackson - language: scala + dialect: scala3 + dsl: scala + sources: db2 + json: jackson + sqlserver-kotlin: + sources: sqlserver + package: testdb + language: kotlin + json: jackson matchers: mock_repos: all primary_key_types: all test_inserts: all - package: testdb - db_lib: foundations - scala: - dialect: scala3 - dsl: scala - sources: db2 - duckdb-kotlin: - json: jackson - package: testdb - path: testers/duckdb/kotlin/generated-and-checked-in - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - sources: duckdb - db_lib: foundations - language: kotlin - sqlserver-kotlin: path: testers/sqlserver/kotlin/generated-and-checked-in - sources: sqlserver db_lib: foundations - language: kotlin - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - json: jackson - package: testdb - postgres-java: + postgres-anorm-scala2: sources: postgres - json: jackson package: adventureworks - db_lib: foundations + path: testers/pg/scala/anorm/generated-and-checked-in-2.13 + db_lib: anorm matchers: mock_repos: exclude: @@ -362,347 +471,231 @@ outputs: primary_key_types: exclude: - billofmaterials - readonly: - - purchaseorderdetail - test_inserts: all - language: java - path: testers/pg/java/generated-and-checked-in - oracle-kotlin: - language: kotlin - sources: oracle - json: jackson - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - package: oracledb - db_lib: foundations - path: testers/oracle/kotlin/generated-and-checked-in - oracle-java: - sources: oracle - matchers: - mock_repos: all - primary_key_types: all - test_inserts: all - package: oracledb - path: testers/oracle/java/generated-and-checked-in - db_lib: foundations - json: jackson - language: java - avro-scala-cats: - sources: avro-events - path: testers/avro/scala-cats/generated-and-checked-in - package: com.example.events - language: scala - scala: - dialect: scala3 - dsl: scala - framework: cats - effect_type: cats_io - grpc-scala-cats: - sources: grpc-services - path: testers/grpc/scala-cats/generated-and-checked-in - package: com.example.grpc - language: scala - scala: - dialect: scala3 - dsl: scala - framework: cats - effect_type: cats_io - grpc-scala: - sources: grpc-services - path: testers/grpc/scala/generated-and-checked-in - package: com.example.grpc - language: scala - scala: - dialect: scala3 - dsl: scala - grpc-java: - sources: grpc-services - path: testers/grpc/java/generated-and-checked-in - package: com.example.grpc - language: java - grpc-java-spring: - sources: grpc-services - path: testers/grpc/java-spring/generated-and-checked-in - package: com.example.grpc - language: java - framework: spring - grpc-java-quarkus: - sources: grpc-services - path: testers/grpc/java-quarkus/generated-and-checked-in - package: com.example.grpc - language: java - framework: quarkus - grpc-kotlin: - sources: grpc-services - path: testers/grpc/kotlin/generated-and-checked-in - package: com.example.grpc - language: kotlin - grpc-kotlin-quarkus: - sources: grpc-services - path: testers/grpc/kotlin-quarkus/generated-and-checked-in - package: com.example.grpc - language: kotlin - framework: quarkus - effect_type: mutiny_uni - combined-java: - path: testers/combined/java/generated-and-checked-in - sources: - - postgres - - mariadb - - api - - avro-events - language: java - package: combined - db_lib: foundations - json: jackson - effect_type: completable_future - matchers: - mock_repos: all - primary_key_types: all - combined-kotlin: - path: testers/combined/kotlin/generated-and-checked-in - sources: - - postgres - - mariadb - - api - - avro-events - language: kotlin - package: combined - db_lib: foundations - json: jackson - effect_type: mutiny_uni - matchers: - mock_repos: all - primary_key_types: all -boundaries: - sqlserver: - host: localhost - password: YourStrong@Passw0rd - selectors: - precision_types: - - precision_types - - precision_types_null - username: sa - port: 1433 - types: - Email: - db: - column: - - email - - '*_email' - database: typr - schema_mode: single_schema:dbo - sql_scripts: sql-scripts/sqlserver - type: sqlserver - duckdb: - type: duckdb - path: ':memory:' - sql_scripts: sql-scripts/duckdb - types: - Email: - db: - column: email - selectors: - precision_types: - - precision_types - - precision_types_null - schema_sql: sql-init/duckdb/00-schema.sql - postgres: - password: password - sql_scripts: sql-scripts/postgres - types: - Description: - db: - column: description - LastName: - db: - column: lastname - MiddleName: - db: - column: middlename - FirstName: - db: - column: firstname - CurrentFlag: - db: - column: currentflag - ActiveFlag: - db: - column: activeflag - OnlineOrderFlag: - db: - column: onlineorderflag - SalariedFlag: - db: - column: salariedflag - selectors: - open_enums: - - title - - title_domain - - issue142 - precision_types: - - precision_types - - precision_types_null - tables: - - department - - employee - - employeedepartmenthistory - - shift - - vemployee - - address - - addresstype - - businessentity - - businessentityaddress - - countryregion - - emailaddress - - password - - person - - stateprovince - - product - - productcategory - - productcosthistory - - productmodel - - productsubcategory - - unitmeasure - - flaff - - identity-test - - issue142 - - issue142_2 - - only_pk_columns - - pgtest - - pgtestnull - - title - - title_domain - - titledperson - - users - - salesperson - - salesterritory - - precision_types - - precision_types_null - port: 6432 - username: postgres - type: postgresql - host: localhost - database: Adventureworks - type_override: - sales.creditcard.creditcardid: adventureworks.userdefined.CustomCreditcardId - schemas: - - humanresources - - person - - production - - public - - sales - oracle: - sql_scripts: sql-scripts/oracle - port: 1521 - selectors: - exclude_tables: - - test_genkeys_1767399918202 - - test_genkeys_1767491891912 - - test_json_rt_1767496451381 - - test_table_1767489875539 - precision_types: - - PRECISION_TYPES - - PRECISION_TYPES_NULL - type: oracle - service: FREEPDB1 - types: - Email: - db: - column: email - host: localhost - schema_mode: single_schema:TYPR - password: typr_password - username: typr - mariadb: - database: typr - sql_scripts: sql-scripts/mariadb - password: password - host: localhost - schema_mode: single_schema:typr - selectors: - precision_types: - - precision_types - - precision_types_null - port: 3307 - types: - IsDefault: - db: - column: is_default - IsActive: - db: - column: is_active - IsApproved: - db: - column: is_approved - FirstName: - db: - column: first_name - IsPrimary: - db: - column: is_primary - IsVerifiedPurchase: - db: - column: is_verified_purchase - LastName: - db: - column: last_name - Email: - db: - column: - - email - - contact_email - type: mariadb - username: typr - db2: - password: password - database: typr - type: db2 - sql_scripts: sql-scripts/db2 - username: db2inst1 - port: 50000 - host: localhost - schema_mode: single_schema:DB2INST1 - avro-events: - type: avro - schemas: - - testers/avro/schemas - enable_precise_types: true - generate_kafka_rpc: true - header_schemas: - standard: - fields: - - name: correlationId - type: uuid - required: true - - name: timestamp - type: instant - required: true - - name: source - type: string - required: false - topic_headers: - order-events: standard - default_header_schema: standard - grpc-services: - type: grpc - proto_path: testers/grpc/protos - api: - type: openapi - spec: testers/combined/specs/combined-api.yaml - generate_models: true - generate_server: true + readonly: + - purchaseorderdetail + test_inserts: all + scala: + dialect: scala2 + dsl: legacy + json: play-json + language: scala + db2-kotlin: + matchers: + mock_repos: all + primary_key_types: all + test_inserts: all + path: testers/db2/kotlin/generated-and-checked-in + json: jackson + language: kotlin + package: testdb + sources: db2 + db_lib: foundations + oracle-scala-new: + path: testers/oracle/scala-new/generated-and-checked-in + language: scala + sources: oracle + package: oracledb + json: jackson + scala: + dialect: scala3 + dsl: scala + matchers: + mock_repos: all + primary_key_types: all + test_inserts: all + db_lib: foundations + postgres-zio-jdbc-scala2: + sources: postgres + scala: + dialect: scala2 + dsl: legacy + language: scala + json: zio-json + matchers: + mock_repos: + exclude: + - purchaseorderdetail + primary_key_types: + exclude: + - billofmaterials + readonly: + - purchaseorderdetail + test_inserts: all + db_lib: zio-jdbc + package: adventureworks + path: testers/pg/scala/zio-jdbc/generated-and-checked-in-2.13 + oracle-scala: + matchers: + mock_repos: all + primary_key_types: all + test_inserts: all + scala: + dialect: scala3 + dsl: java + use_native_types: false + json: jackson + language: scala + sources: oracle + db_lib: foundations + path: testers/oracle/scala/generated-and-checked-in + package: oracledb + postgres-java: + package: adventureworks + sources: postgres + language: java + matchers: + mock_repos: + exclude: + - purchaseorderdetail + primary_key_types: + exclude: + - billofmaterials + readonly: + - purchaseorderdetail + test_inserts: all + path: testers/pg/java/generated-and-checked-in + db_lib: foundations + json: jackson + postgres-doobie-scala3: + json: circe + language: scala + matchers: + mock_repos: + exclude: + - purchaseorderdetail + primary_key_types: + exclude: + - billofmaterials + readonly: + - purchaseorderdetail + test_inserts: all + scala: + dialect: scala3 + dsl: legacy + sources: postgres + package: adventureworks + db_lib: doobie + path: testers/pg/scala/doobie/generated-and-checked-in-3 + grpc-java-quarkus: + package: com.example.grpc + sources: grpc-services + framework: quarkus + path: testers/grpc/java-quarkus/generated-and-checked-in + language: java + sqlserver-java: + matchers: + mock_repos: all + primary_key_types: all + test_inserts: all + language: java + path: testers/sqlserver/java/generated-and-checked-in + db_lib: foundations + sources: sqlserver + package: testdb + json: jackson + grpc-scala: + path: testers/grpc/scala/generated-and-checked-in + language: scala + scala: + dialect: scala3 + dsl: scala + package: com.example.grpc + sources: grpc-services + grpc-scala-cats: + package: com.example.grpc + sources: grpc-services + scala: + dialect: scala3 + dsl: scala + effect_type: cats_io + path: testers/grpc/scala-cats/generated-and-checked-in + framework: cats + language: scala + combined-kotlin: + language: kotlin + package: combined + matchers: + mock_repos: all + primary_key_types: all + json: jackson + path: testers/combined/kotlin/generated-and-checked-in + db_lib: foundations + sources: + - postgres + - mariadb + - api + - avro-events + effect_type: mutiny_uni + combined-java: + json: jackson + sources: + - postgres + - mariadb + - api + - avro-events + path: testers/combined/java/generated-and-checked-in + package: combined + language: java + db_lib: foundations + matchers: + mock_repos: all + primary_key_types: all + effect_type: completable_future + postgres-kotlin: + json: jackson + package: adventureworks + matchers: + mock_repos: + exclude: + - purchaseorderdetail + primary_key_types: + exclude: + - billofmaterials + readonly: + - purchaseorderdetail + test_inserts: all + sources: postgres + path: testers/pg/kotlin/generated-and-checked-in + db_lib: foundations + language: kotlin + postgres-doobie-scala2: + language: scala + matchers: + mock_repos: + exclude: + - purchaseorderdetail + primary_key_types: + exclude: + - billofmaterials + readonly: + - purchaseorderdetail + test_inserts: all + db_lib: doobie + package: adventureworks + json: circe + scala: + dialect: scala2 + dsl: legacy + path: testers/pg/scala/doobie/generated-and-checked-in-2.13 + sources: postgres + oracle-java: + sources: oracle + json: jackson + db_lib: foundations + language: java + matchers: + mock_repos: all + primary_key_types: all + test_inserts: all + package: oracledb + path: testers/oracle/java/generated-and-checked-in + grpc-java: + language: java + package: com.example.grpc + path: testers/grpc/java/generated-and-checked-in + sources: grpc-services types: Customer: - alignedSources: - duckdb:customers: - entity: customers - mode: superset - mariadb:customers: - entity: customers - mode: superset fields: created_at: array: false @@ -716,203 +709,13 @@ types: array: false nullable: true type: String - kind: domain + alignedSources: + duckdb:customers: + entity: customers + mode: superset + mariadb:customers: + entity: customers + mode: superset primary: sqlserver:customers -processes: - order_fulfillment: - version: 1 - - sources: - events: avro-events - api: api - db: postgres - - states: - Draft: - fields: - items: List[String] - customer_id: String - Submitted: - includes: [Draft] - fields: - submitted_at: Instant - PaymentPending: - includes: [Submitted] - fields: - payment_intent_id: String - Paid: - includes: [Submitted, "events:PaymentCharged"] - Shipped: - includes: [Paid, "events:OrderShipped"] - ReviewPending: - includes: [Shipped] - WebhookNotified: - includes: [Shipped] - Completed: - includes: [Shipped, "events:OrderDelivered"] - Cancelled: - fields: - reason: String - cancelled_at: Instant - - events: - OrderSubmitted: - fields: - submitted_at: Instant - PaymentRequested: - fields: - payment_intent_id: String - PaymentCharged: - ref: events:PaymentCharged - fields: - payment_id: String - transaction_id: String - amount: Long - paid_at: Instant - OrderShipped: - fields: - tracking: String - shipped_at: Instant - ReviewTimerFired: - fields: {} - WebhookSent: - fields: {} - OrderDelivered: - fields: - delivered_at: Instant - OrderCancelled: - fields: - reason: String - cancelled_at: Instant - InventoryReleased: - fields: {} - PaymentRefunded: - fields: - refund_id: String - - signals: - SubmitOrderRequest: - fields: {} - PaymentCallback: - ref: events:PaymentCallback - correlation_key: order_id - fields: - order_id: String - payment_id: String - transaction_id: String - amount: Long - paid_at: Instant - DeliveryConfirmation: - correlation_key: tracking_number - fields: - tracking_number: String - delivered_at: Instant - CancelOrderRequest: - fields: - reason: String - - errors: - PaymentDeclined: - fields: - reason: String - InsufficientFunds: - fields: - available: Long - required: Long - - steps: - submit: - type: signal - signal: SubmitOrderRequest - from: Draft - to: Submitted - event: OrderSubmitted - produce: - topic: order-events - - request_payment: - type: io - from: Submitted - to: PaymentPending - event: PaymentRequested - call: - operation: createPaymentIntent - - await_payment: - type: signal - signal: PaymentCallback - from: PaymentPending - to: Paid - event: PaymentCharged - errors: [PaymentDeclined, InsufficientFunds] - on_error: cancel - timeout: P3D - on_timeout: cancel - compensation: refund_payment - consume: - topic: payment-callbacks - - ship: - type: io - from: Paid - to: Shipped - event: OrderShipped - retry: - max_attempts: 3 - backoff: exponential - compensation: release_inventory - persist: - table: orders - source: db - - wait_for_review: - type: timer - duration: PT24H - from: Shipped - to: ReviewPending - event: ReviewTimerFired - - notify_webhook: - type: http - url: "https://hooks.example.com/order-shipped" - method: POST - from: ReviewPending - to: WebhookNotified - event: WebhookSent - timeout: PT30S - - deliver: - type: signal - signal: DeliveryConfirmation - from: WebhookNotified - to: Completed - event: OrderDelivered - - cancel: - type: signal - signal: CancelOrderRequest - from: [Draft, Submitted, PaymentPending] - to: Cancelled - event: OrderCancelled - interrupts: true - - compensation_steps: - refund_payment: - input_state: Paid - input_event: PaymentCharged - event: PaymentRefunded - release_inventory: - input_state: Shipped - input_event: OrderShipped - event: InventoryReleased - - flow: - - submit - - request_payment - - await_payment - - ship - - wait_for_review - - notify_webhook - - deliver - + kind: domain version: 1 diff --git a/typr/src/scala/typr/avro/AvroCodegen.scala b/typr/src/scala/typr/avro/AvroCodegen.scala index 56e1ac2475..fdb435fe40 100644 --- a/typr/src/scala/typr/avro/AvroCodegen.scala +++ b/typr/src/scala/typr/avro/AvroCodegen.scala @@ -4,7 +4,7 @@ import typr.avro.codegen._ import typr.avro.parser.{AvroParseError, AvroParser, ProtocolParser, SchemaRegistryClient} import typr.internal.codegen.FilePreciseType import typr.openapi.codegen.{JsonLibSupport, NoJsonLibSupport} -import typr.{jvm, Lang, Naming, Scope} +import typr.{jvm, Lang, Naming} /** Main entry point for Avro/Kafka code generation */ object AvroCodegen { diff --git a/typr/src/scala/typr/avro/codegen/AvroWireFormatSupport.scala b/typr/src/scala/typr/avro/codegen/AvroWireFormatSupport.scala index be214b15fd..23c59c2eea 100644 --- a/typr/src/scala/typr/avro/codegen/AvroWireFormatSupport.scala +++ b/typr/src/scala/typr/avro/codegen/AvroWireFormatSupport.scala @@ -2,7 +2,7 @@ package typr.avro.codegen import typr.avro._ import typr.jvm -import typr.jvm.Code.{CodeOps, TreeOps, TypeOps} +import typr.jvm.Code.{CodeOps, TypeOps} import typr.Lang import typr.internal.codegen.{LangScala, LangKotlin} diff --git a/typr/src/scala/typr/avro/codegen/FileAvroWrapper.scala b/typr/src/scala/typr/avro/codegen/FileAvroWrapper.scala index 8d7b569aef..28c510de06 100644 --- a/typr/src/scala/typr/avro/codegen/FileAvroWrapper.scala +++ b/typr/src/scala/typr/avro/codegen/FileAvroWrapper.scala @@ -1,9 +1,9 @@ package typr.avro.codegen import typr.avro.ComputedAvroWrapper -import typr.internal.codegen.{CodeInterpolator, toCode} +import typr.internal.codegen.CodeInterpolator import typr.jvm -import typr.jvm.Code.{CodeOps, TreeOps} +import typr.jvm.Code.TreeOps import typr.openapi.codegen.JsonLibSupport import typr.{Lang, Scope} diff --git a/typr/src/scala/typr/avro/codegen/ProducerCodegen.scala b/typr/src/scala/typr/avro/codegen/ProducerCodegen.scala index a80909a80c..430100185d 100644 --- a/typr/src/scala/typr/avro/codegen/ProducerCodegen.scala +++ b/typr/src/scala/typr/avro/codegen/ProducerCodegen.scala @@ -1,8 +1,8 @@ package typr.avro.codegen import typr.avro._ -import typr.effects.{EffectType, EffectTypeOps} -import typr.jvm.Code.{CodeOps, TreeOps, TypeOps} +import typr.effects.EffectTypeOps +import typr.jvm.Code.{CodeOps, TreeOps} import typr.internal.codegen._ import typr.{jvm, Lang, Naming, Scope} diff --git a/typr/src/scala/typr/avro/codegen/ProtocolCodegen.scala b/typr/src/scala/typr/avro/codegen/ProtocolCodegen.scala index c3dd27cf03..57d73e37cb 100644 --- a/typr/src/scala/typr/avro/codegen/ProtocolCodegen.scala +++ b/typr/src/scala/typr/avro/codegen/ProtocolCodegen.scala @@ -2,8 +2,6 @@ package typr.avro.codegen import typr.avro._ import typr.effects.EffectTypeOps -import typr.internal.codegen._ -import typr.jvm.Code.TypeOps import typr.{jvm, Lang, Naming, Scope} /** Generates typed service interfaces from Avro protocols (.avpr files) */ diff --git a/typr/src/scala/typr/avro/codegen/TopicBindingsCodegen.scala b/typr/src/scala/typr/avro/codegen/TopicBindingsCodegen.scala index 1f6e352f14..7c3621ddd9 100644 --- a/typr/src/scala/typr/avro/codegen/TopicBindingsCodegen.scala +++ b/typr/src/scala/typr/avro/codegen/TopicBindingsCodegen.scala @@ -1,11 +1,9 @@ package typr.avro.codegen import typr.avro._ -import typr.jvm.Code.{CodeOps, TreeOps, TypeOps} +import typr.jvm.Code.TreeOps import typr.{jvm, Lang, Naming, Scope} -import scala.collection.mutable - /** Generates type-safe topic binding constants. * * Creates a Topics class with TypedTopic constants that provide compile-time type safety for topic key/value types and their serdes. diff --git a/typr/src/scala/typr/avro/codegen/UnionTypeCodegen.scala b/typr/src/scala/typr/avro/codegen/UnionTypeCodegen.scala index 42cb94b6cf..ddde165390 100644 --- a/typr/src/scala/typr/avro/codegen/UnionTypeCodegen.scala +++ b/typr/src/scala/typr/avro/codegen/UnionTypeCodegen.scala @@ -1,7 +1,7 @@ package typr.avro.codegen import typr.avro._ -import typr.jvm.Code.{CodeOps, TreeOps, TypeOps} +import typr.jvm.Code.{TreeOps, TypeOps} import typr.{jvm, Lang, Naming, Scope, TypesJava} import typr.internal.codegen._ diff --git a/typr/src/scala/typr/avro/codegen/VersionedRecordCodegen.scala b/typr/src/scala/typr/avro/codegen/VersionedRecordCodegen.scala index 21118d9045..52ba8a5d5b 100644 --- a/typr/src/scala/typr/avro/codegen/VersionedRecordCodegen.scala +++ b/typr/src/scala/typr/avro/codegen/VersionedRecordCodegen.scala @@ -2,7 +2,7 @@ package typr.avro.codegen import typr.avro._ import typr.{jvm, Lang, Naming, Scope} -import typr.jvm.Code.{CodeOps, TreeOps, TypeOps} +import typr.jvm.Code.TreeOps import typr.internal.codegen._ /** Generates versioned record types and type aliases for schema evolution. diff --git a/typr/src/scala/typr/avro/parser/AvroParser.scala b/typr/src/scala/typr/avro/parser/AvroParser.scala index 0f23d0ca2a..c0182e43e9 100644 --- a/typr/src/scala/typr/avro/parser/AvroParser.scala +++ b/typr/src/scala/typr/avro/parser/AvroParser.scala @@ -18,7 +18,7 @@ object AvroParser { case s: java.lang.String => s""""${escapeJsonString(s)}"""" case n: java.lang.Number => n.toString case b: java.lang.Boolean => b.toString - case m: java.util.Map[_, _] => + case m: java.util.Map[_, _] => val entries = m.asScala.map { case (k, v) => s""""$k": ${defaultValueToJson(v.asInstanceOf[AnyRef])}""" } s"{${entries.mkString(", ")}}" case l: java.util.List[_] => @@ -86,7 +86,7 @@ object AvroParser { resolutionResults.collectFirst { case Left(e) => e } match { case Some(error) => Left(error) - case None => + case None => val resolved = resolutionResults.collect { case Right(r) => r } val resolvedContents = resolved.map { case (file, json, _) => file -> json }.toMap val dependencies = resolved.map { case (file, _, deps) => file -> deps }.toMap @@ -336,7 +336,7 @@ object AvroParser { schema.getType match { case Schema.Type.NULL => AvroType.Null case Schema.Type.BOOLEAN => AvroType.Boolean - case Schema.Type.INT => + case Schema.Type.INT => logicalType match { case Some("date") => AvroType.Date case Some("time-millis") => AvroType.TimeMillis @@ -356,7 +356,7 @@ object AvroParser { } case Schema.Type.FLOAT => AvroType.Float case Schema.Type.DOUBLE => AvroType.Double - case Schema.Type.BYTES => + case Schema.Type.BYTES => logicalType match { case Some("decimal") => val precision = schema.getObjectProp("precision").asInstanceOf[java.lang.Integer].intValue() diff --git a/typr/src/scala/typr/avro/parser/ProtocolParser.scala b/typr/src/scala/typr/avro/parser/ProtocolParser.scala index 5177ea8894..a1d9336cba 100644 --- a/typr/src/scala/typr/avro/parser/ProtocolParser.scala +++ b/typr/src/scala/typr/avro/parser/ProtocolParser.scala @@ -162,13 +162,12 @@ object ProtocolParser { private def convertMessage(name: String, message: AnyRef): AvroMessage = { // Protocol.Message is a nested Java class, access via reflection-like duck typing val protoMessage = message.asInstanceOf[{ - def getRequest(): Schema - def getResponse(): Schema - def getErrors(): Schema - def getDoc(): String - def isOneWay(): Boolean - } - ] + def getRequest(): Schema + def getResponse(): Schema + def getErrors(): Schema + def getDoc(): String + def isOneWay(): Boolean + }] val request = protoMessage.getRequest().getFields.asScala.toList.map(convertField) val response = convertType(protoMessage.getResponse()) @@ -197,7 +196,7 @@ object ProtocolParser { schema.getType match { case Schema.Type.NULL => AvroType.Null case Schema.Type.BOOLEAN => AvroType.Boolean - case Schema.Type.INT => + case Schema.Type.INT => logicalType match { case Some("date") => AvroType.Date case Some("time-millis") => AvroType.TimeMillis @@ -217,7 +216,7 @@ object ProtocolParser { } case Schema.Type.FLOAT => AvroType.Float case Schema.Type.DOUBLE => AvroType.Double - case Schema.Type.BYTES => + case Schema.Type.BYTES => logicalType match { case Some("decimal") => val precision = schema.getObjectProp("precision").asInstanceOf[java.lang.Integer].intValue() @@ -269,7 +268,7 @@ object ProtocolParser { case s: java.lang.String => s""""${escapeJsonString(s)}"""" case n: java.lang.Number => n.toString case b: java.lang.Boolean => b.toString - case m: java.util.Map[_, _] => + case m: java.util.Map[_, _] => val entries = m.asScala.map { case (k, v) => s""""$k": ${defaultValueToJson(v.asInstanceOf[AnyRef])}""" } s"{${entries.mkString(", ")}}" case l: java.util.List[_] => diff --git a/typr/src/scala/typr/avro/parser/SchemaRegistryClient.scala b/typr/src/scala/typr/avro/parser/SchemaRegistryClient.scala index 06cfe4b0f3..75decd3379 100644 --- a/typr/src/scala/typr/avro/parser/SchemaRegistryClient.scala +++ b/typr/src/scala/typr/avro/parser/SchemaRegistryClient.scala @@ -92,7 +92,7 @@ object SchemaRegistryClient { if (fetchAllVersions) { // Fetch all versions for this subject fetchAllVersionsForSubject(client, baseUrl, subject) match { - case Left(error) => List(Left(error)) + case Left(error) => List(Left(error)) case Right(versionedSchemas) => versionedSchemas.map { vs => parseSchemaJsonWithVersion(vs.schemaJson, topicName, directoryGroup, schemaRole, Some(vs.version)) diff --git a/typr/src/scala/typr/bridge/CompositeType.scala b/typr/src/scala/typr/bridge/CompositeType.scala index 4def390b33..b9cb4b8302 100644 --- a/typr/src/scala/typr/bridge/CompositeType.scala +++ b/typr/src/scala/typr/bridge/CompositeType.scala @@ -181,7 +181,7 @@ object AlignmentStatus { val warnings = List.newBuilder[String] results.foreach { - case FieldAlignmentResult.Aligned(_, _, true) => + case FieldAlignmentResult.Aligned(_, _, true) => case FieldAlignmentResult.Aligned(c, s, false) => warnings += s"Field '$c' aligned to '$s' but types may differ" diff --git a/typr/src/scala/typr/cli/commands/Check.scala b/typr/src/scala/typr/cli/commands/Check.scala index 71b46ae486..b69590fd4e 100644 --- a/typr/src/scala/typr/cli/commands/Check.scala +++ b/typr/src/scala/typr/cli/commands/Check.scala @@ -8,7 +8,7 @@ import typr.bridge.api.BridgeApiImpl import typr.bridge.model.* import typr.cli.config.* import typr.config.generated.{BridgeType, DomainGenerateOptions, DomainType, FieldSpecObject, FieldSpecString} -import typr.config.generated.{AlignedSource as ConfigAlignedSource, FieldOverrideEnum, FieldOverrideObject} +import typr.config.generated.{FieldOverrideEnum, FieldOverrideObject} import typr.config.generated.{FieldOverride as ConfigFieldOverride} import java.nio.file.Files @@ -198,13 +198,13 @@ object Check { case "custom" => val kind = obj.merge_from match { case Some(fields) => CustomKind.MergeFrom(fields) - case None => + case None => obj.split_from match { case Some(field) => CustomKind.SplitFrom(field) - case None => + case None => obj.computed_from match { case Some(fields) => CustomKind.ComputedFrom(fields) - case None => + case None => CustomKind.Enrichment(obj.enrichment) } } diff --git a/typr/src/scala/typr/cli/commands/Generate.scala b/typr/src/scala/typr/cli/commands/Generate.scala index f50e227831..7c88ba92cb 100644 --- a/typr/src/scala/typr/cli/commands/Generate.scala +++ b/typr/src/scala/typr/cli/commands/Generate.scala @@ -212,15 +212,44 @@ object Generate { sourceFilter: Option[String], quiet: Boolean, debug: Boolean + ): IO[ExitCode] = run(configPath, sourceFilter, quiet, debug, TypoLogger.Console, _ => ()) + + /** Overload accepting an explicit [[TypoLogger]] — used by the TUI shell so it can capture structured log entries instead of letting them stream to stdout (which would corrupt the alternate-screen + * buffer). The 4-arg overload above keeps the CLI call site short and preserves its console-logging behaviour. + */ + def run( + configPath: String, + sourceFilter: Option[String], + quiet: Boolean, + debug: Boolean, + typoLogger: TypoLogger + ): IO[ExitCode] = run(configPath, sourceFilter, quiet, debug, typoLogger, _ => ()) + + /** Overload that also hands the caller a reference to the [[ProgressTracker]] once it's constructed. The TUI uses this to render a live per-output status table by polling tracker state each render + * — no log-line parsing. + */ + def run( + configPath: String, + sourceFilter: Option[String], + quiet: Boolean, + debug: Boolean, + typoLogger: TypoLogger, + onTrackerReady: ProgressTracker => Unit ): IO[ExitCode] = { val result = for { - _ <- IO.unlessA(quiet)(IO.println(s"Reading config from: $configPath")) + // Closed-beta expiry warning — surfaces in CI logs so users see the countdown well before + // the build refuses. Quiet mode suppresses everything; the warn-on-expired hard refusal + // lives in typr.cli.Main so it can short-circuit before any IO sets up. + _ <- IO.unlessA(quiet || !typr.cli.beta.BetaGate.isInWarningWindow())( + IO(typoLogger.warn(typr.cli.beta.BetaGate.warningLine())) + ) + _ <- IO.unlessA(quiet)(IO(typoLogger.info(s"Reading config from: $configPath"))) yamlContent <- IO(Files.readString(Paths.get(configPath))) substituted <- IO.fromEither(EnvSubstitution.substitute(yamlContent).left.map(new Exception(_))) config <- IO.fromEither(ConfigParser.parse(substituted).left.map(new Exception(_))) - _ <- IO.whenA(debug)(IO.println(s"Parsed config with ${config.boundaries.map(_.size).getOrElse(0)} boundaries, ${config.outputs.map(_.size).getOrElse(0)} outputs")) + _ <- IO.whenA(debug)(IO(typoLogger.info(s"Parsed config with ${config.boundaries.map(_.size).getOrElse(0)} boundaries, ${config.outputs.map(_.size).getOrElse(0)} outputs"))) parsedBoundaries <- config.boundaries .getOrElse(Map.empty) @@ -256,31 +285,30 @@ object Generate { buildDir = Path.of(System.getProperty("user.dir")) outputNames = filtered.keys.toList.sorted tracker = new ProgressTracker(outputNames) + _ <- IO(onTrackerReady(tracker)) - typoLogger = TypoLogger.Console externalTools = ExternalTools.init(typoLogger, ExternalToolsConfig.default) results <- runWithoutTui(filtered, parsedBoundaries, config.types, buildDir, typoLogger, externalTools, tracker) _ <- IO.unlessA(quiet)(IO { val (successful, failed, skipped) = tracker.summary val elapsed = tracker.elapsedSeconds - println() - println(s"Summary: $successful succeeded, $failed failed, $skipped skipped (${f"$elapsed%.1f"}s)") + typoLogger.info(s"Summary: $successful succeeded, $failed failed, $skipped skipped (${f"$elapsed%.1f"}s)") tracker.failedEntries.foreach { case (name, err) => - println(s" ✗ $name: $err") + typoLogger.warn(s" ✗ $name: $err") } }) errors = results.collect { case Left(e) => e } successCount = results.count(_.isRight) - _ <- IO.unlessA(quiet)(IO.println(s"\nGeneration complete: $successCount/${results.size} outputs succeeded")) + _ <- IO.unlessA(quiet)(IO(typoLogger.info(s"Generation complete: $successCount/${results.size} outputs succeeded"))) } yield if (errors.isEmpty) ExitCode.Success else ExitCode.Error result.handleErrorWith { e => - IO.println(s"Error: ${e.getMessage}") *> - IO.whenA(debug)(IO.println(e.getStackTrace.mkString("\n"))) *> + IO(typoLogger.warn(s"Error: ${e.getMessage}")) *> + IO.whenA(debug)(IO(typoLogger.warn(e.getStackTrace.mkString("\n")))) *> IO.pure(ExitCode.Error) } } @@ -505,7 +533,7 @@ object Generate { Right(totalFiles) } match { case Success(result) => result - case Failure(e) => + case Failure(e) => val errorMsg = Option(e.getMessage).getOrElse(e.getClass.getSimpleName) tracker.update(outputName, OutputStatus.Failed(errorMsg)) Left(s"$outputName: $errorMsg") @@ -1125,7 +1153,7 @@ object Generate { written } match { case Success(files) => Right(files) - case Failure(e) => + case Failure(e) => val errorMsg = Option(e.getMessage).getOrElse(e.getClass.getSimpleName) tracker.update(outputName, OutputStatus.Failed(errorMsg)) Left(s"$outputName: $errorMsg") diff --git a/typr/src/scala/typr/cli/commands/SourceEntityLoader.scala b/typr/src/scala/typr/cli/commands/SourceEntityLoader.scala index 511b735763..d35c8563d9 100644 --- a/typr/src/scala/typr/cli/commands/SourceEntityLoader.scala +++ b/typr/src/scala/typr/cli/commands/SourceEntityLoader.scala @@ -94,7 +94,7 @@ object SourceEntityLoader { quiet: Boolean ): Either[String, LoadedSource] = { ConfigParser.parseSource(sourceJson) match { - case Left(err) => Left(err) + case Left(err) => Left(err) case Right(parsed) => parsed match { case ParsedSource.Database(dbConfig) => diff --git a/typr/src/scala/typr/cli/commands/Watch.scala b/typr/src/scala/typr/cli/commands/Watch.scala index dddb3113b6..ba7450c5ff 100644 --- a/typr/src/scala/typr/cli/commands/Watch.scala +++ b/typr/src/scala/typr/cli/commands/Watch.scala @@ -7,7 +7,6 @@ import typr.cli.config.* import java.nio.file.* import java.nio.file.StandardWatchEventKinds.* import scala.jdk.CollectionConverters.* -import scala.concurrent.duration.DurationInt object Watch { def run(configPath: String, sourceFilter: Option[String]): IO[ExitCode] = { @@ -131,8 +130,6 @@ object Watch { println("Regenerating...") try { - import scala.concurrent.Await - import scala.concurrent.duration.Duration import cats.effect.unsafe.implicits.global Generate.run(configPath, sourceFilter, quiet = true, debug = false).unsafeRunSync() diff --git a/typr/src/scala/typr/cli/config/ConfigParser.scala b/typr/src/scala/typr/cli/config/ConfigParser.scala index b48f384243..0e4c90c6d8 100644 --- a/typr/src/scala/typr/cli/config/ConfigParser.scala +++ b/typr/src/scala/typr/cli/config/ConfigParser.scala @@ -4,7 +4,6 @@ import io.circe.Json import io.circe.yaml.v12.parser import typr.config.generated.AvroBoundary import typr.config.generated.DatabaseBoundary -import typr.config.generated.DatabaseBoundary import typr.config.generated.DuckdbBoundary import typr.config.generated.GrpcBoundary import typr.config.generated.JsonschemaBoundary diff --git a/typr/src/scala/typr/cli/config/ConfigToOptions.scala b/typr/src/scala/typr/cli/config/ConfigToOptions.scala index 924f934312..32843e29bd 100644 --- a/typr/src/scala/typr/cli/config/ConfigToOptions.scala +++ b/typr/src/scala/typr/cli/config/ConfigToOptions.scala @@ -1,6 +1,5 @@ package typr.cli.config -import io.circe.Encoder import typr.* import typr.avro.* import typr.cli.util.PatternMatcher @@ -321,7 +320,7 @@ object ConfigToOptions { private def toLang(s: String, dialect: String, dsl: String, useNativeTypes: Boolean): Either[String, Lang] = s.toLowerCase match { case "java" => Right(LangJava) case "kotlin" => Right(LangKotlin(TypeSupportKotlin)) - case "scala" => + case "scala" => val scalaDialect = dialect.toLowerCase match { case "scala2" | "scala2.13" => Dialect.Scala2XSource3 case "scala3" | "scala3.3" => Dialect.Scala3 @@ -464,7 +463,7 @@ object ConfigToOptions { overrides match { case None => TypeOverride.Empty case Some(map) if map.isEmpty => TypeOverride.Empty - case Some(map) => + case Some(map) => TypeOverride.relation { case (relation, col) if map.contains(s"$relation.$col") => map(s"$relation.$col") @@ -473,7 +472,7 @@ object ConfigToOptions { private def convertDbMatch(db: Option[typr.config.generated.DbMatch]): typr.DbMatch = db match { - case None => typr.DbMatch.Empty + case None => typr.DbMatch.Empty case Some(m) => typr.DbMatch( database = stringOrArrayToList(m.source), @@ -492,7 +491,7 @@ object ConfigToOptions { private def convertModelMatch(model: Option[typr.config.generated.ModelMatch]): typr.ModelMatch = model match { - case None => typr.ModelMatch.Empty + case None => typr.ModelMatch.Empty case Some(m) => typr.ModelMatch( spec = stringOrArrayToList(m.source), @@ -508,7 +507,7 @@ object ConfigToOptions { private def convertApiMatch(api: Option[typr.config.generated.ApiMatch]): typr.ApiMatch = api match { - case None => typr.ApiMatch.Empty + case None => typr.ApiMatch.Empty case Some(m) => typr.ApiMatch( location = m.location.getOrElse(Nil).flatMap(locationFromString), diff --git a/typr/src/scala/typr/cli/config/EnvSubstitution.scala b/typr/src/scala/typr/cli/config/EnvSubstitution.scala index eed65930fd..1eb8d1172b 100644 --- a/typr/src/scala/typr/cli/config/EnvSubstitution.scala +++ b/typr/src/scala/typr/cli/config/EnvSubstitution.scala @@ -13,7 +13,7 @@ object EnvSubstitution { val varName = m.group(1) sys.env.get(varName) match { case Some(value) => Matcher.quoteReplacement(value) - case None => + case None => errors = s"Environment variable not set: $varName" :: errors m.matched } diff --git a/typr/src/scala/typr/cli/util/PatternMatcher.scala b/typr/src/scala/typr/cli/util/PatternMatcher.scala index 60f58c7865..806b493a55 100644 --- a/typr/src/scala/typr/cli/util/PatternMatcher.scala +++ b/typr/src/scala/typr/cli/util/PatternMatcher.scala @@ -38,7 +38,7 @@ object PatternMatcher { def fromFeatureMatcherWithDefault(matcher: Option[FeatureMatcher], default: Selector): Selector = matcher match { - case None => default + case None => default case Some(m: FeatureMatcherString) => if (m.value == "all") Selector.All else patternToSelector(m.value) @@ -46,12 +46,12 @@ object PatternMatcher { toSelector(m.value) case Some(m: FeatureMatcherObject) => val includeSelector = m.include match { - case None => Selector.All + case None => Selector.All case Some(json) => json.asString match { case Some("all") => Selector.All case Some(pattern) => patternToSelector(pattern) - case None => + case None => json.asArray.map(_.flatMap(_.asString).toList) match { case Some(patterns) => toSelector(patterns) case None => Selector.All @@ -70,7 +70,7 @@ object PatternMatcher { def fromMatcherValue(matcher: Option[MatcherValue]): Selector = matcher match { - case None => Selector.All + case None => Selector.All case Some(m: MatcherValueString) => if (m.value == "all") Selector.All else patternToSelector(m.value) @@ -78,12 +78,12 @@ object PatternMatcher { toSelector(m.value) case Some(m: MatcherValueObject) => val includeSelector = m.include match { - case None => Selector.All + case None => Selector.All case Some(json) => json.asString match { case Some("all") => Selector.All case Some(pattern) => patternToSelector(pattern) - case None => + case None => json.asArray.map(_.flatMap(_.asString).toList) match { case Some(patterns) => toSelector(patterns) case None => Selector.All diff --git a/typr/src/scala/typr/grpc/GrpcCodegen.scala b/typr/src/scala/typr/grpc/GrpcCodegen.scala index 43aee5ab54..66acf4a358 100644 --- a/typr/src/scala/typr/grpc/GrpcCodegen.scala +++ b/typr/src/scala/typr/grpc/GrpcCodegen.scala @@ -4,7 +4,7 @@ import typr.grpc.codegen._ import typr.grpc.computed.ComputedGrpcService import typr.grpc.parser.ProtobufParser import typr.internal.codegen._ -import typr.jvm.Code.{CodeOps, TreeOps} +import typr.jvm.Code.TreeOps import typr.{jvm, Lang, Naming, Scope} /** Main entry point for gRPC/Protobuf code generation */ diff --git a/typr/src/scala/typr/grpc/codegen/FilesGrpcService.scala b/typr/src/scala/typr/grpc/codegen/FilesGrpcService.scala index c93a3a0f94..633704ae40 100644 --- a/typr/src/scala/typr/grpc/codegen/FilesGrpcService.scala +++ b/typr/src/scala/typr/grpc/codegen/FilesGrpcService.scala @@ -3,7 +3,6 @@ package typr.grpc.codegen import typr.effects.EffectTypeOps import typr.grpc.{GrpcOptions, RpcPattern} import typr.grpc.computed.{ComputedGrpcMethod, ComputedGrpcService} -import typr.internal.codegen._ import typr.jvm.Code.{CodeOps, TreeOps, TypeOps} import typr.{jvm, Lang, Scope} diff --git a/typr/src/scala/typr/grpc/codegen/OneOfCodegen.scala b/typr/src/scala/typr/grpc/codegen/OneOfCodegen.scala index a5d590a213..49078fa7c7 100644 --- a/typr/src/scala/typr/grpc/codegen/OneOfCodegen.scala +++ b/typr/src/scala/typr/grpc/codegen/OneOfCodegen.scala @@ -2,7 +2,6 @@ package typr.grpc.codegen import typr.grpc._ import typr.{jvm, Lang, Naming, Scope} -import typr.jvm.Code.{CodeOps, TreeOps} /** Generates jvm.File for Protobuf oneof types. * diff --git a/typr/src/scala/typr/grpc/computed/ComputedGrpcService.scala b/typr/src/scala/typr/grpc/computed/ComputedGrpcService.scala index 7f27632162..208793ba98 100644 --- a/typr/src/scala/typr/grpc/computed/ComputedGrpcService.scala +++ b/typr/src/scala/typr/grpc/computed/ComputedGrpcService.scala @@ -1,6 +1,6 @@ package typr.grpc.computed -import typr.grpc.{GrpcOptions, ProtoService, ProtoType} +import typr.grpc.{ProtoService, ProtoType} import typr.grpc.codegen.{GrpcFramework, ProtobufTypeMapper} import typr.{jvm, Lang, Naming} From 09006e15f89c244e03d90b2d6c3131a74c3f7305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Mon, 18 May 2026 01:15:08 +0200 Subject: [PATCH 2/2] typr: interactive TUI (jatatui) + closed-beta gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI shell already existed (generate / watch / check, decline + cats-effect). This commit layers two new surfaces on top. ──────────────────────────────────────────────────────────────────────── 1. Interactive TUI — `typr tui` (new app/ + screens/) ──────────────────────────────────────────────────────────────────────── A jatatui-based editor for `typr.yaml` so you can create / edit / delete sources, outputs, and types without hand-writing the YAML. Built on **jatatui** (`com.olvind.jatatui` 0.30.0) — a Java port of tui-rs with a React-style component layer. Six artifacts wired into the typr project: jatatui-{core,widgets,crossterm,react,components} + crossterm (JNI). Several higher-level components started life inside `typr.cli.app.components` and were lifted upstream during the work; what's still local is `CaretCell` (typr-specific clickable tree caret) and `JatatuiInterop` (Scala 3 doesn't auto-SAM-convert bound `() => Unit` to `Runnable`, and `Boolean => Element` doesn't fit `j.u.f.Function[j.l.Boolean, Element]` because of primitive boxing, so we wrap with `Run.given` Conversion + a `Link.focusable` façade). Entry point: `typr tui` → `App.run` opens the terminal in raw mode and runs a hand-rolled loop (resize / key / mouse → renderer.dispatch). `Shell` mounts a Router over an `AppApi` context that screens read via `RenderContext.useContext`. `AppApi` (typr/cli/app/AppApi.scala): - config: TyprConfig (parsed typr.yaml) - configPath: Path - updateConfig(f) — mutates + writes via ConfigWriter - sourceCache: Map[String, LoadedSource] (one per source) - loadStatus: Map[String, LoadStatus] (NotLoaded / Loading / Loaded / Failed) - quit() — flips the running flag `LoadedSource` (typr/cli/app/LoadedSource.scala) is a sealed sum (`Db(MetaDb)` | `Spec(ParsedSpec)` | `Avro(List[AvroSchemaFile])` | `Proto(List[ProtoFile])`) with one `load(json, buildDir)` codec that dispatches off the typed `ParsedSource` — no 4× isXxx() checks re-parsing the JSON. Shell forks one daemon thread per source on mount; caches and statuses fill in per-source as each load completes. Screens render purely from `sourceCache` + `loadStatus` — no per-screen re-fetch. Screens (typr/cli/app/screens/): Splash logo + tagline; 2s auto-dismiss (only after acceptance); chip with closed-beta status + expiry; `t` re-opens BetaNotice (read-only) BetaNotice full-screen terms modal; Initial gates the rest of the app, ReadOnly is reached via `t` MainMenu 6-card grid: Sources / Schemas / Outputs / Domain Types / Field Types / Generate SourceList / SourceEditor / SourceForm / SourceWizard add/edit/delete sources; live form; "test connection" button (real JDBC probe); "used by N outputs:" with clickable links OutputList / OutputEditor / OutputWizard add/edit/delete outputs TypeList / TypeEditor / TypeWizard field types or domain types depending on kind; FieldType editor: deep editor for api/db/model match patterns + validation rules + "preview matches" against cached MetaDb's; DomainType editor: primary + description + fields rows (add/delete/rename via FieldSpecObject) + aligned-source rows + generate options, with a live alignment matrix below (✓ match / ⚠ missing / · extras footer) SchemaPicker / SchemaBrowser / SpecBrowser / AvroBrowser / ProtoBrowser per-source-kind browsers; fuzzy `/` search; `e` extracts a field as a FieldType; `c` pushes DomainFromEntity DomainFromEntity two-pane discovery: left = every entity from loaded sources grouped by source; right = live preview of the would-be DomainType + alignment suggestions across other sources FieldTypeForm / DomainTypeForm create-new flows Generate status + per-source load badges (left) + per-output ProgressTracker rows (right) + log panel; daemon ticker keeps duration counters smooth Utilities: ConnectionTest opens a Hikari JDBC connection + reads metadata TypePreview runs FieldType DbMatch patterns against a cached MetaDb, returns matched columns EntityCatalog uniform "record-shaped thing" across all four source kinds; field-name normalisation (snake↔camel) for cross-source alignment scoring New subcommands wired in Main.scala: typr tui — opens the editor typr init — drops a starter typr.yaml + opens the TUI typr terms — prints closed-beta terms ──────────────────────────────────────────────────────────────────────── 2. Closed-beta acceptance gate + July 1, 2026 timebomb ──────────────────────────────────────────────────────────────────────── Typr launches as a partially paid product. Before any tier / pricing details land in the binary, every first-run user sees a four-bullet terms modal: (1) not open source; (2) commercial DBs won't stay free; (3) a limit on how many boundaries take part in domain-type alignment per generate run is coming; (4) this build expires 2026-07-01 — keep yours fresh from typr.dev. `typr.cli.beta`: - BetaTerms — display text, one-liner, termsVersion (bumpable to force re-prompt on material edits), expiresOn (2026-07-01), warningWindowDays (14) - BetaGate — XDG-aware acceptance file at `$XDG_CONFIG_HOME/typr/beta-accepted.txt` (with `~/.config/typr/...` / `%APPDATA%/typr/...` fallbacks); plain-text accepted-at / binary-version / terms-version keys; queries isCurrent / daysUntilExpiry / isExpired / isInWarningWindow plus three pre-formatted user-facing messages CLI gating (Main.scala): - Every subcommand takes `--accept`; passing it writes the acceptance file inline before running the command. - GateLevel.CliStrict (generate / watch / check) refuses when not accepted, with a hint pointing at `--accept` or `typr terms`. - GateLevel.TuiOrInit (tui / init) skips the CLI refusal — the user accepts inside the in-TUI modal instead. - GateLevel.Informational (terms) bypasses both gates so users can always read what they're agreeing to. - Expiry runs first regardless: post-2026-07-01 every command prints a stderr block pointing at https://typr.dev/get and exits 78. Warning window: from 14 days before expiry, the CLI emits a logger.warn countdown line (visible in CI logs). The TUI splash chip turns yellow and counts down to the day. ──────────────────────────────────────────────────────────────────────── Known followups ──────────────────────────────────────────────────────────────────────── - ConfigWriter loses untyped fields on round-trip (e.g. saga / state-machine YAML blocks). TUI edits that write typr.yaml will silently drop content not modelled by the generated boundary case classes. Either preserve unknown keys, or restrict TUI saves to the typed surface. - Tier-based codegen limits (per the pricing tiers) are out of scope here — replaced with the simpler "closed beta · these things will eventually be paid" gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- bleep.yaml | 7 + typr/src/scala/typr/cli/Main.scala | 85 ++- typr/src/scala/typr/cli/app/App.scala | 119 +++ typr/src/scala/typr/cli/app/AppApi.scala | 57 ++ typr/src/scala/typr/cli/app/AvroFetch.scala | 57 ++ .../scala/typr/cli/app/ConnectionTest.scala | 86 +++ .../scala/typr/cli/app/EntityCatalog.scala | 264 +++++++ .../src/scala/typr/cli/app/LoadedSource.scala | 49 ++ typr/src/scala/typr/cli/app/MetaDbFetch.scala | 109 +++ typr/src/scala/typr/cli/app/ProtoFetch.scala | 43 ++ typr/src/scala/typr/cli/app/Shell.scala | 89 +++ typr/src/scala/typr/cli/app/SpecFetch.scala | 43 ++ typr/src/scala/typr/cli/app/TypePreview.scala | 84 +++ .../typr/cli/app/components/CaretCell.scala | 35 + .../cli/app/components/JatatuiInterop.scala | 54 ++ .../typr/cli/app/screens/AvroBrowser.scala | 520 +++++++++++++ .../typr/cli/app/screens/BetaNotice.scala | 105 +++ .../cli/app/screens/DomainFromEntity.scala | 398 ++++++++++ .../typr/cli/app/screens/DomainTypeForm.scala | 184 +++++ .../typr/cli/app/screens/FieldTypeForm.scala | 289 +++++++ .../scala/typr/cli/app/screens/Generate.scala | 341 +++++++++ .../scala/typr/cli/app/screens/MainMenu.scala | 143 ++++ .../typr/cli/app/screens/OutputEditor.scala | 186 +++++ .../typr/cli/app/screens/OutputList.scala | 181 +++++ .../typr/cli/app/screens/OutputWizard.scala | 145 ++++ .../typr/cli/app/screens/Placeholder.scala | 31 + .../typr/cli/app/screens/ProtoBrowser.scala | 561 ++++++++++++++ .../typr/cli/app/screens/SchemaBrowser.scala | 416 ++++++++++ .../typr/cli/app/screens/SchemaPicker.scala | 236 ++++++ .../typr/cli/app/screens/SourceEditor.scala | 252 +++++++ .../typr/cli/app/screens/SourceForm.scala | 87 +++ .../typr/cli/app/screens/SourceList.scala | 238 ++++++ .../typr/cli/app/screens/SourceWizard.scala | 163 ++++ .../typr/cli/app/screens/SpecBrowser.scala | 656 ++++++++++++++++ .../scala/typr/cli/app/screens/Splash.scala | 75 ++ .../typr/cli/app/screens/TypeEditor.scala | 711 ++++++++++++++++++ .../scala/typr/cli/app/screens/TypeList.scala | 262 +++++++ .../typr/cli/app/screens/TypeWizard.scala | 147 ++++ typr/src/scala/typr/cli/beta/BetaGate.scala | 138 ++++ typr/src/scala/typr/cli/beta/BetaTerms.scala | 52 ++ 40 files changed, 7694 insertions(+), 4 deletions(-) create mode 100644 typr/src/scala/typr/cli/app/App.scala create mode 100644 typr/src/scala/typr/cli/app/AppApi.scala create mode 100644 typr/src/scala/typr/cli/app/AvroFetch.scala create mode 100644 typr/src/scala/typr/cli/app/ConnectionTest.scala create mode 100644 typr/src/scala/typr/cli/app/EntityCatalog.scala create mode 100644 typr/src/scala/typr/cli/app/LoadedSource.scala create mode 100644 typr/src/scala/typr/cli/app/MetaDbFetch.scala create mode 100644 typr/src/scala/typr/cli/app/ProtoFetch.scala create mode 100644 typr/src/scala/typr/cli/app/Shell.scala create mode 100644 typr/src/scala/typr/cli/app/SpecFetch.scala create mode 100644 typr/src/scala/typr/cli/app/TypePreview.scala create mode 100644 typr/src/scala/typr/cli/app/components/CaretCell.scala create mode 100644 typr/src/scala/typr/cli/app/components/JatatuiInterop.scala create mode 100644 typr/src/scala/typr/cli/app/screens/AvroBrowser.scala create mode 100644 typr/src/scala/typr/cli/app/screens/BetaNotice.scala create mode 100644 typr/src/scala/typr/cli/app/screens/DomainFromEntity.scala create mode 100644 typr/src/scala/typr/cli/app/screens/DomainTypeForm.scala create mode 100644 typr/src/scala/typr/cli/app/screens/FieldTypeForm.scala create mode 100644 typr/src/scala/typr/cli/app/screens/Generate.scala create mode 100644 typr/src/scala/typr/cli/app/screens/MainMenu.scala create mode 100644 typr/src/scala/typr/cli/app/screens/OutputEditor.scala create mode 100644 typr/src/scala/typr/cli/app/screens/OutputList.scala create mode 100644 typr/src/scala/typr/cli/app/screens/OutputWizard.scala create mode 100644 typr/src/scala/typr/cli/app/screens/Placeholder.scala create mode 100644 typr/src/scala/typr/cli/app/screens/ProtoBrowser.scala create mode 100644 typr/src/scala/typr/cli/app/screens/SchemaBrowser.scala create mode 100644 typr/src/scala/typr/cli/app/screens/SchemaPicker.scala create mode 100644 typr/src/scala/typr/cli/app/screens/SourceEditor.scala create mode 100644 typr/src/scala/typr/cli/app/screens/SourceForm.scala create mode 100644 typr/src/scala/typr/cli/app/screens/SourceList.scala create mode 100644 typr/src/scala/typr/cli/app/screens/SourceWizard.scala create mode 100644 typr/src/scala/typr/cli/app/screens/SpecBrowser.scala create mode 100644 typr/src/scala/typr/cli/app/screens/Splash.scala create mode 100644 typr/src/scala/typr/cli/app/screens/TypeEditor.scala create mode 100644 typr/src/scala/typr/cli/app/screens/TypeList.scala create mode 100644 typr/src/scala/typr/cli/app/screens/TypeWizard.scala create mode 100644 typr/src/scala/typr/cli/beta/BetaGate.scala create mode 100644 typr/src/scala/typr/cli/beta/BetaTerms.scala diff --git a/bleep.yaml b/bleep.yaml index 506a540153..950a748e95 100644 --- a/bleep.yaml +++ b/bleep.yaml @@ -898,6 +898,13 @@ projects: - io.circe::circe-generic:0.14.10 - io.circe::circe-yaml-v12:1.15.0 - org.typelevel::cats-effect:3.5.7 + - com.olvind.jatatui:crossterm:0.30.0 + - com.olvind.jatatui:jatatui-core:0.30.0 + - com.olvind.jatatui:jatatui-widgets:0.30.0 + - com.olvind.jatatui:jatatui-crossterm:0.30.0 + - com.olvind.jatatui:jatatui-react:0.30.0 + - com.olvind.jatatui:jatatui-components:0.30.0 + - com.lihaoyi::cask:0.9.5 dependsOn: - typr-codegen extends: diff --git a/typr/src/scala/typr/cli/Main.scala b/typr/src/scala/typr/cli/Main.scala index c8fbdc58ac..9533851f0e 100644 --- a/typr/src/scala/typr/cli/Main.scala +++ b/typr/src/scala/typr/cli/Main.scala @@ -5,6 +5,7 @@ import cats.effect.IO import cats.implicits.* import com.monovore.decline.Opts import com.monovore.decline.effect.CommandIOApp +import java.nio.file.Paths object Main extends CommandIOApp( @@ -13,6 +14,8 @@ object Main version = "0.1.0" ) { + private val binaryVersion = "0.1.0" + override def runtimeConfig = super.runtimeConfig.copy(cpuStarvationCheckInitialDelay = scala.concurrent.duration.Duration.Inf) @@ -32,21 +35,95 @@ object Main private val debugFlag: Opts[Boolean] = Opts.flag("debug", help = "Verbose output").orFalse + /** Closed-beta acceptance flag. One-time: writes the acceptance file. Available on every subcommand that performs real work (generate / watch / check / tui / init); a no-op on `terms` which is + * informational but accepted anyway so users can read + accept in one step. + */ + private val acceptFlag: Opts[Boolean] = + Opts.flag("accept", help = "Accept the closed-beta terms (one-time; persists in ~/.config/typr)").orFalse + + /** What kind of beta gating to apply. CLI commands refuse without acceptance; TUI/init flows carry the user through an in-app modal so they only need the expiry check pre-launch. + */ + private enum GateLevel { + case CliStrict // expiry + acceptance required + case TuiOrInit // expiry only; acceptance enforced inside the TUI + case Informational // no gate (used by `typr terms`) + } + + private def gated(level: GateLevel, accept: Boolean, cmd: IO[ExitCode]): IO[ExitCode] = + IO.blocking { + // 1. Expired binaries refuse universally (except `terms`, which stays informational so + // users can still read the wall they hit). + if (level != GateLevel.Informational && typr.cli.beta.BetaGate.isExpired()) { + System.err.println(typr.cli.beta.BetaGate.expiredMessage) + Left(ExitCode(78)) + // 2. `--accept` writes the file (if needed) and then continues. + } else if (accept && !typr.cli.beta.BetaGate.isCurrent) { + try { + typr.cli.beta.BetaGate.markAccepted(binaryVersion) + System.out.println(s"✓ Beta terms accepted. (Saved to ${typr.cli.beta.BetaGate.configPath})") + } catch { + case e: Throwable => + // Don't refuse on a write failure — the user accepted explicitly via the flag; printing + // a warning lets them know they'll see the prompt again next run. + System.err.println(s"warning: could not persist acceptance: ${e.getMessage}") + } + Right(()) + // 3. CLI commands require acceptance. TUI flows allow the modal to handle it. + } else if (level == GateLevel.CliStrict && !typr.cli.beta.BetaGate.isCurrent) { + System.err.println(typr.cli.beta.BetaGate.notAcceptedMessage) + Left(ExitCode(78)) + } else Right(()) + }.flatMap { + case Left(code) => IO.pure(code) + case Right(_) => cmd + } + + /** The only path from `Opts.subcommand` to a runnable `commands.*` body in this object. Every CLI verb declares itself through [[cmd]] and so cannot bypass the closed-beta gate — adding a new + * unguarded command requires either editing this helper (visible in review) or reaching outside it (which would stand out as an `Opts.subcommand` call in plain sight). + */ + private def cmd(name: String, help: String, level: GateLevel)(inner: Opts[IO[ExitCode]]): Opts[IO[ExitCode]] = + Opts.subcommand(name, help = help)( + (inner, acceptFlag).mapN { (run, accept) => gated(level, accept, run) } + ) + private val generateCmd: Opts[IO[ExitCode]] = - Opts.subcommand("generate", help = "Generate code from config")( + cmd("generate", "Generate code from config", GateLevel.CliStrict)( (configOpt, sourceOpt, quietFlag, debugFlag).mapN(commands.Generate.run) ) private val watchCmd: Opts[IO[ExitCode]] = - Opts.subcommand("watch", help = "Watch SQL files and regenerate on changes")( + cmd("watch", "Watch SQL files and regenerate on changes", GateLevel.CliStrict)( (configOpt, sourceOpt).mapN(commands.Watch.run) ) private val checkCmd: Opts[IO[ExitCode]] = - Opts.subcommand("check", help = "Validate Bridge type alignment across sources")( + cmd("check", "Validate Bridge type alignment across sources", GateLevel.CliStrict)( (configOpt, quietFlag, debugFlag).mapN(commands.Check.run) ) + private val tuiCmd: Opts[IO[ExitCode]] = + cmd("tui", "Interactive config editor", GateLevel.TuiOrInit)( + configOpt.map(path => app.App.run(Paths.get(path))) + ) + + private val initCmd: Opts[IO[ExitCode]] = + cmd("init", "Initialize a new typr.yaml in the current directory", GateLevel.TuiOrInit)( + configOpt.map(path => app.App.init(Paths.get(path))) + ) + + /** `typr terms` — print the current closed-beta terms to stdout and exit. Declared at [[GateLevel.Informational]] so the expiry / acceptance refusals don't fire; users can still read what they're + * agreeing to even after the wall. + */ + private val termsCmd: Opts[IO[ExitCode]] = + cmd("terms", "Print the current closed-beta terms", GateLevel.Informational)( + Opts(IO.blocking { + System.out.println(typr.cli.beta.BetaTerms.displayText) + if (typr.cli.beta.BetaGate.isInWarningWindow()) + System.out.println(typr.cli.beta.BetaGate.warningLine()) + ExitCode.Success + }) + ) + override def main: Opts[IO[ExitCode]] = - generateCmd orElse checkCmd orElse watchCmd + generateCmd orElse checkCmd orElse tuiCmd orElse watchCmd orElse initCmd orElse termsCmd } diff --git a/typr/src/scala/typr/cli/app/App.scala b/typr/src/scala/typr/cli/app/App.scala new file mode 100644 index 0000000000..2371069b07 --- /dev/null +++ b/typr/src/scala/typr/cli/app/App.scala @@ -0,0 +1,119 @@ +package typr.cli.app + +import cats.effect.{ExitCode, IO} +import jatatui.crossterm.Jatatui +import jatatui.react.{Element, KeyEvent as JKeyEvent, MouseEvent as JMouseEvent, Renderer} +import tui.crossterm.{Command, CrosstermJni, Duration, Event, KeyCode, KeyEventKind, MouseEventKind} +import typr.cli.config.{ConfigParser, EnvSubstitution} + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Path, StandardOpenOption} + +/** New TUI shell built on jatatui-react. Replaces the legacy `typr.cli.tui` package screen by screen — see [[screens]]. Bootstrap owns the loop directly (not [[jatatui.react.ReactApp.run]]) because + * we need a quit flag the app can flip without relying on Esc-to-quit. + */ +object App { + + /** Entry point invoked by `typr tui`. Loads typr.yaml, then runs the React loop. Bail with an error if the config doesn't exist or fails to parse — same behaviour as the legacy `Interactive.run`, + * so the user gets a useful message instead of an empty screen. + */ + def run(configPath: Path): IO[ExitCode] = + IO.blocking { + // Expiry check happens before any terminal setup — we want a plain stderr block, not a + // half-initialised TUI. Acceptance is enforced inside the TUI itself via [[BetaNotice]]. + if (typr.cli.beta.BetaGate.isExpired()) { + System.err.println(typr.cli.beta.BetaGate.expiredMessage) + ExitCode(78) + } else { + val absolutePath = configPath.toAbsolutePath + if (!Files.exists(absolutePath)) { + System.err.println(s"Error: Config file not found: $absolutePath") + System.err.println(s"Hint: run `typr init` to create one in the current directory.") + ExitCode.Error + } else { + val yaml = Files.readString(absolutePath) + val substituted = EnvSubstitution.substitute(yaml).getOrElse(yaml) + ConfigParser.parse(substituted) match { + case Right(config) => + val running = new java.util.concurrent.atomic.AtomicBoolean(true) + val root = Shell.element(config, absolutePath, () => running.set(false)) + loop(root, running) + ExitCode.Success + case Left(err) => + System.err.println(s"Error: Failed to parse config: $err") + ExitCode.Error + } + } + } + } + + /** Create an empty typr.yaml at `configPath` (if missing) and drop into the editor. The skeleton is intentionally minimal — empty `sources` / `outputs` / `types` maps — so the editor's add-source / + * add-output / add-type wizards have something to extend. + */ + def init(configPath: Path): IO[ExitCode] = + IO.blocking { + val absolutePath = configPath.toAbsolutePath + if (Files.exists(absolutePath)) + System.out.println(s"$absolutePath already exists; opening it.") + else + Files.write( + absolutePath, + InitialYaml.getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE, + StandardOpenOption.WRITE + ) + () + }.flatMap(_ => run(configPath)) + + private val InitialYaml: String = + """# typr config — see https://github.com/oyvindberg/typr for docs + |sources: {} + |outputs: {} + |types: {} + |""".stripMargin + + private def loop(root: Element, running: java.util.concurrent.atomic.AtomicBoolean): Unit = + Jatatui.runIo { terminal => + val jni = new CrosstermJni + jni.execute(new Command.EnableMouseCapture) + try { + val renderer = new Renderer + // 100ms — same cadence as ReactApp's internal loop. Background work (e.g. source loaders) + // can call `renderer.requestRerender()` to wake the loop sooner. + val pollTimeout = new Duration(0L, 100_000_000) + while (running.get()) { + if (renderer.takeDirty()) { + val _ = terminal.draw(jframe => renderer.render(jframe, root)) + } + if (jni.poll(pollTimeout)) jni.read() match { + case k: Event.Key if k.keyEvent.kind == KeyEventKind.Press => + val code = k.keyEvent.code + val mods = k.keyEvent.modifiers + code match { + case _: KeyCode.Tab => renderer.tab() + case _: KeyCode.BackTab => renderer.shiftTab() + case _ => val _ = renderer.dispatchKey(new JKeyEvent(code, mods)) + } + case m: Event.Mouse => + val me = m.mouseEvent + val kind = me.kind match { + case _: MouseEventKind.Down => JMouseEvent.Kind.DOWN + case _: MouseEventKind.Up => JMouseEvent.Kind.UP + case _: MouseEventKind.Drag => JMouseEvent.Kind.DRAG + case _: MouseEventKind.Moved => JMouseEvent.Kind.MOVE + case _: MouseEventKind.ScrollUp => JMouseEvent.Kind.SCROLL_UP + case _: MouseEventKind.ScrollDown => JMouseEvent.Kind.SCROLL_DOWN + case _ => null + } + if (kind != null) { + val _ = renderer.dispatchMouse(new JMouseEvent(me.column, me.row, me.modifiers, kind)) + } + case _: Event.Resize => renderer.requestRerender() + case _ => () + } + } + } finally + try jni.execute(new Command.DisableMouseCapture) + catch case _: RuntimeException => () // best-effort restore + } +} diff --git a/typr/src/scala/typr/cli/app/AppApi.scala b/typr/src/scala/typr/cli/app/AppApi.scala new file mode 100644 index 0000000000..36a225b098 --- /dev/null +++ b/typr/src/scala/typr/cli/app/AppApi.scala @@ -0,0 +1,57 @@ +package typr.cli.app + +import jatatui.react.{Context, RenderContext} +import typr.cli.config.TyprConfig + +import java.util.concurrent.ConcurrentHashMap +import scala.collection.concurrent + +/** Per-source background-load state. The shell kicks off a fetch for every configured source on startup; screens read `loadStatus` to render badges, and read [[AppApi.sourceCache]] once a source + * flips to `Loaded` for instant access. + */ +sealed trait LoadStatus +object LoadStatus { + case object NotLoaded extends LoadStatus + case object Loading extends LoadStatus + case object Loaded extends LoadStatus + final case class Failed(error: String) extends LoadStatus +} + +/** App-wide capabilities provided by [[Shell]] via Context. + * + * `sourceCache` is the single process-wide cache keyed on source name and typed as [[LoadedSource]] — one sealed sum across all source kinds. Populated up-front by [[Shell]]'s background loader on + * app start, so the domain-type discovery flows have data to work with without any explicit "open the browser first" step. The cache stays populated for the rest of the process — screens never + * refetch what's already there. + * + * `loadStatus` mirrors the cache key set with one of four states (NotLoaded / Loading / Loaded / Failed) so screens can disambiguate "not configured" from "still loading" from "failed". + */ +trait AppApi { + def config: TyprConfig + def configPath: java.nio.file.Path + def updateConfig(f: TyprConfig => TyprConfig): Either[Throwable, Unit] + def quit(): Unit + def sourceCache: concurrent.Map[String, LoadedSource] + def loadStatus: concurrent.Map[String, LoadStatus] +} + +object AppApi { + + /** Sensible no-op default for screens rendered outside the provider (e.g. tests). Reads return an empty config; writes are dropped; quit does nothing. + */ + val NO_OP: AppApi = new AppApi { + import scala.jdk.CollectionConverters.* + private val empty = TyprConfig(None, None, None, None, None) + private val cache: concurrent.Map[String, LoadedSource] = new ConcurrentHashMap[String, LoadedSource]().asScala + private val status: concurrent.Map[String, LoadStatus] = new ConcurrentHashMap[String, LoadStatus]().asScala + override def config: TyprConfig = empty + override def configPath: java.nio.file.Path = java.nio.file.Paths.get("typr.yaml") + override def updateConfig(f: TyprConfig => TyprConfig): Either[Throwable, Unit] = Right(()) + override def quit(): Unit = () + override def sourceCache: concurrent.Map[String, LoadedSource] = cache + override def loadStatus: concurrent.Map[String, LoadStatus] = status + } + + val CONTEXT: Context[AppApi] = Context.create(NO_OP) + + def use(ctx: RenderContext): AppApi = ctx.useContext(CONTEXT) +} diff --git a/typr/src/scala/typr/cli/app/AvroFetch.scala b/typr/src/scala/typr/cli/app/AvroFetch.scala new file mode 100644 index 0000000000..dfeb631f4a --- /dev/null +++ b/typr/src/scala/typr/cli/app/AvroFetch.scala @@ -0,0 +1,57 @@ +package typr.cli.app + +import io.circe.Json +import typr.avro.AvroSchemaFile +import typr.avro.parser.AvroParser +import typr.cli.config.{ConfigParser, ParsedSource} +import typr.config.generated.AvroBoundary + +import java.nio.file.Path + +/** Parse Avro schemas for one source. Decodes the source JSON via [[ConfigParser.parseSource]] into a typed [[AvroBoundary]]; collects directory paths from `schemas` (multi) and `schema` (single); + * runs [[AvroParser.parseDirectory]] on each. Concatenates the file lists. + * + * Failures surface as `Left(message)`. + */ +object AvroFetch { + + def fetch(json: Json, buildDir: Path): Either[String, List[AvroSchemaFile]] = + for { + parsed <- ConfigParser.parseSource(json) + boundary <- extractAvro(parsed) + paths <- nonEmptyPaths(boundary) + files <- collectAll(paths, buildDir) + } yield files + + private def extractAvro(parsed: ParsedSource): Either[String, AvroBoundary] = parsed match { + case ParsedSource.Avro(b) => Right(b) + case other => Left(s"not an avro source: ${other.sourceType}") + } + + private def nonEmptyPaths(b: AvroBoundary): Either[String, List[String]] = { + val paths = b.schemas.toList.flatten ::: b.schema.toList + if (paths.isEmpty) Left("avro source has no `schemas` or `schema` field") + else Right(paths) + } + + /** Parse each path; bail on the first failure with a contextual error message. */ + private def collectAll(paths: List[String], buildDir: Path): Either[String, List[AvroSchemaFile]] = { + val zero: Either[String, List[AvroSchemaFile]] = Right(Nil) + val combined = paths.foldLeft(zero) { (acc, rel) => + acc.flatMap { soFar => + val resolved = buildDir.resolve(rel) + if (!java.nio.file.Files.exists(resolved)) + Left(s"avro schema path not found: $resolved") + else { + val dir = if (java.nio.file.Files.isDirectory(resolved)) resolved else resolved.getParent + AvroParser.parseDirectory(dir) match { + case Right(files) => Right(soFar ++ files) + case Left(error) => Left(s"failed to parse $rel: $error") + } + } + } + } + combined + } + +} diff --git a/typr/src/scala/typr/cli/app/ConnectionTest.scala b/typr/src/scala/typr/cli/app/ConnectionTest.scala new file mode 100644 index 0000000000..89fb7f0b8e --- /dev/null +++ b/typr/src/scala/typr/cli/app/ConnectionTest.scala @@ -0,0 +1,86 @@ +package typr.cli.app + +import com.zaxxer.hikari.HikariDataSource +import io.circe.Json +import typr.TypoDataSource +import typr.cli.config.{ConfigParser, ParsedSource} +import typr.config.generated.{DatabaseBoundary, DuckdbBoundary} + +/** Synchronous "does this source actually connect / parse?" probe. Returns `Left(message)` on failure, `Right(message)` with a short success line on success (e.g. database product / version). + * Designed to be invoked from a daemon thread; never throws. + */ +object ConnectionTest { + + def run(sourceJson: Json): Either[String, String] = + ConfigParser.parseSource(sourceJson) match { + case Right(ParsedSource.Database(db)) => tryDatabase(db) + case Right(ParsedSource.DuckDb(duck)) => tryDuckDb(duck) + case Right(other) => + Left(s"${other.sourceType} sources don't support connection testing — try the browser screen to validate the spec/schema.") + case Left(err) => Left(err) + } + + private def tryDatabase(db: DatabaseBoundary): Either[String, String] = { + val kind = db.`type`.getOrElse("").toLowerCase + val host = db.host.getOrElse("localhost") + val database = db.database.getOrElse("") + val username = db.username.getOrElse("") + val password = db.password.getOrElse("") + + val ds: Either[String, TypoDataSource] = kind match { + case "postgresql" => + Right(TypoDataSource.hikariPostgres(host, db.port.map(_.toInt).getOrElse(5432), database, username, password)) + case "mariadb" | "mysql" => + Right(TypoDataSource.hikariMariaDb(host, db.port.map(_.toInt).getOrElse(3306), database, username, password)) + case "sqlserver" => + Right(TypoDataSource.hikariSqlServer(host, db.port.map(_.toInt).getOrElse(1433), database, username, password)) + case "oracle" => + val service = db.service.orElse(db.sid).getOrElse("") + Right(TypoDataSource.hikariOracle(host, db.port.map(_.toInt).getOrElse(1521), service, username, password)) + case "db2" => + Right(TypoDataSource.hikariDb2(host, db.port.map(_.toInt).getOrElse(50000), database, username, password)) + case other => + Left(s"unknown database type: $other") + } + + ds.flatMap { d => + try probe(d, "") + finally close(d) + } + } + + private def tryDuckDb(duck: DuckdbBoundary): Either[String, String] = { + val d = TypoDataSource.hikariDuckDbInMemory(duck.path) + try probe(d, "duckdb") + finally close(d) + } + + private def probe(ds: TypoDataSource, fallbackLabel: String): Either[String, String] = + try { + val conn = ds.ds.getConnection + try { + val meta = conn.getMetaData + val product = Option(meta.getDatabaseProductName).getOrElse(fallbackLabel) + val version = Option(meta.getDatabaseProductVersion).getOrElse("") + Right(if (version.isEmpty) s"connected to $product" else s"connected to $product $version") + } finally conn.close() + } catch { + case t: Throwable => + Left(Option(t.getMessage).getOrElse(t.toString)) + } + + private def close(ds: TypoDataSource): Unit = + try + ds.ds match { + case h: HikariDataSource => h.close() + case _ => () + } + catch case _: Throwable => () + + /** Whether the test button should be offered for a given source — only db/duckdb sources today. */ + def isTestable(json: Json): Boolean = + ConfigParser.parseSource(json).toOption.exists { + case _: ParsedSource.Database | _: ParsedSource.DuckDb => true + case _ => false + } +} diff --git a/typr/src/scala/typr/cli/app/EntityCatalog.scala b/typr/src/scala/typr/cli/app/EntityCatalog.scala new file mode 100644 index 0000000000..3f1457349a --- /dev/null +++ b/typr/src/scala/typr/cli/app/EntityCatalog.scala @@ -0,0 +1,264 @@ +package typr.cli.app + +import typr.{MetaDb, Nullability, db} +import typr.avro.{AvroField, AvroRecord, AvroSchema, AvroSchemaFile, AvroType} +import typr.grpc.{ProtoField, ProtoFieldLabel, ProtoFile, ProtoMessage, ProtoType} +import typr.openapi.{ModelClass, ParsedSpec, PrimitiveType, TypeInfo} + +/** A uniform "entity" abstraction across all source kinds — a record-like thing with a name, a path within its source, and a list of named, typed fields. Drives the "create domain type from a real + * entity" flow: instead of asking the user to invent a `boundary:entity` string, we show real entities (tables, models, records, messages) from already-loaded sources and let them pick. + */ +final case class Entity( + sourceName: String, + kind: Entity.Kind, + /** The entity's path within its source as it should appear in `primary`: + * - db: "schema.table" or "table" + * - spec: model name + * - avro: fully-qualified record name + * - proto: fully-qualified message name + */ + path: String, + /** A clean PascalCase suggestion for the [[typr.config.generated.DomainType]] name. */ + suggestedName: String, + fields: List[Entity.Field] +) { + + /** The `primary` string this entity would produce in a DomainType. */ + def primaryKey: String = s"$sourceName:$path" + + /** Lowercase, normalised field-name set used for alignment overlap scoring. */ + def fieldNameSet: Set[String] = fields.iterator.map(f => Entity.normalise(f.name)).toSet +} + +object Entity { + + enum Kind { + case Db, Spec, Avro, Proto + } + + final case class Field(name: String, typeLabel: String, optional: Boolean) + + /** Convert a snake_case / kebab-case / dot.separated name to PascalCase. Used to suggest a domain-type name from an entity path. + */ + def pascalCase(s: String): String = { + val cleaned = s.replaceAll("[^A-Za-z0-9]+", " ").trim + if (cleaned.isEmpty) "Type" + else cleaned.split("\\s+").iterator.map(w => w.headOption.fold("")(_.toUpper.toString) + w.drop(1)).mkString + } + + /** Normalised form of a field name for alignment overlap. Lowercase, leading underscore stripped, snake↔camel collapsed. + */ + def normalise(name: String): String = { + val stripped = name.dropWhile(c => c == '_' || c == '-') + val parts = scala.collection.mutable.ListBuffer.empty[String] + val cur = new StringBuilder + stripped.foreach { ch => + if (ch == '_' || ch == '-') { + if (cur.nonEmpty) { parts += cur.toString.toLowerCase; cur.clear() } + } else if (ch.isUpper && cur.nonEmpty && !cur.last.isUpper) { + parts += cur.toString.toLowerCase + cur.clear() + cur += ch + } else cur += ch + } + if (cur.nonEmpty) parts += cur.toString.toLowerCase + parts.mkString + } +} + +/** Enumerate entities across all loaded sources, and compute alignment suggestions between them. + * + * The catalog only sees sources whose [[LoadedSource]] is already in [[AppApi.sourceCache]]. Sources still loading are simply absent from the list; sources that failed don't contribute either. As + * individual sources complete in the background, calling [[fromCache]] again picks up the new entities — no caching in here, just walks the latest cache state. + */ +object EntityCatalog { + + /** One alignment-suggestion result: a candidate entity from a different source, with the count of fields whose normalised names overlap. + */ + final case class Alignment(target: Entity, matched: Int, total: Int) { + def score: Double = if (total == 0) 0.0 else matched.toDouble / total + def looksAligned: Boolean = matched >= 2 && score >= 0.5 + } + + def fromCache(cache: collection.Map[String, LoadedSource]): List[Entity] = + cache.toList.sortBy(_._1).flatMap { case (sourceName, loaded) => + loaded match { + case LoadedSource.Db(metaDb) => fromDb(sourceName, metaDb) + case LoadedSource.Spec(spec) => fromSpec(sourceName, spec) + case LoadedSource.Avro(files) => fromAvro(sourceName, files) + case LoadedSource.Proto(files) => fromProto(sourceName, files) + } + } + + /** Suggest aligned-source candidates for `primary` from `all`. Candidates must come from a different source, must share at least 2 field names with the target, and must cover ≥50% of the target's + * fields. Sorted best-first. + * + * Field-name comparison normalises snake_case / camelCase / leading underscores to a single lower-case word stream — so `customer_id`, `customerId`, and `CustomerID` all match. + */ + def alignmentSuggestions(primary: Entity, all: List[Entity]): List[Alignment] = { + val primaryFields = primary.fieldNameSet + if (primaryFields.isEmpty) return Nil + all.iterator + .filter(e => e.sourceName != primary.sourceName) + .map { candidate => + val intersect = primaryFields.intersect(candidate.fieldNameSet).size + Alignment(candidate, intersect, primaryFields.size) + } + .filter(_.looksAligned) + .toList + .sortBy(a => (-a.matched, -a.score, a.target.path)) + } + + // ─────────────────────────────────── per-kind enumeration ─────────────────────────────────── + + private def fromDb(sourceName: String, metaDb: MetaDb): List[Entity] = + metaDb.relations.values + .flatMap(_.get.toList) + .toList + .collect { case t: db.Table => t } + .sortBy(_.name.value) + .map { table => + val path = table.name.schema match { + case Some(s) if s.nonEmpty => s"$s.${table.name.name}" + case _ => table.name.name + } + val fields = table.cols.toList.map { c => + Entity.Field( + name = c.name.value, + typeLabel = c.tpe.toString, + optional = c.nullability != Nullability.NoNulls + ) + } + Entity(sourceName, Entity.Kind.Db, path, Entity.pascalCase(table.name.name), fields) + } + + private def fromSpec(sourceName: String, spec: ParsedSpec): List[Entity] = + spec.models.collect { case obj: ModelClass.ObjectType => obj }.sortBy(_.name).map { obj => + val fields = obj.properties.map(p => + Entity.Field( + name = p.name, + typeLabel = renderTypeInfo(p.typeInfo), + optional = !p.required || p.nullable + ) + ) + Entity(sourceName, Entity.Kind.Spec, obj.name, Entity.pascalCase(obj.name), fields) + } + + private def fromAvro(sourceName: String, files: List[AvroSchemaFile]): List[Entity] = { + val schemas: List[AvroSchema] = files.flatMap(f => f.primarySchema :: f.inlineSchemas) + schemas.collect { case r: AvroRecord => r }.sortBy(_.fullName).map { r => + Entity(sourceName, Entity.Kind.Avro, r.fullName, Entity.pascalCase(r.name), r.fields.map(toEntityField)) + } + } + + private def fromProto(sourceName: String, files: List[ProtoFile]): List[Entity] = { + def walk(m: ProtoMessage): List[ProtoMessage] = m :: m.nestedMessages.flatMap(walk) + val messages = files.flatMap(_.messages.flatMap(walk)).filterNot(_.isMapEntry).sortBy(_.fullName) + messages.map { m => + Entity(sourceName, Entity.Kind.Proto, m.fullName, Entity.pascalCase(m.name), m.fields.map(toEntityField)) + } + } + + // ─────────────────────────────────── field rendering ─────────────────────────────────── + + private def toEntityField(f: AvroField): Entity.Field = + Entity.Field(name = f.name, typeLabel = renderAvroType(f.fieldType), optional = f.isOptional) + + private def toEntityField(f: ProtoField): Entity.Field = { + val opt = f.proto3Optional || + f.label == ProtoFieldLabel.Optional || + f.fieldType.isInstanceOf[ProtoType.Message] + Entity.Field(name = f.name, typeLabel = renderProtoType(f.fieldType), optional = opt) + } + + private def renderTypeInfo(t: TypeInfo): String = t match { + case TypeInfo.Primitive(p) => renderPrimitive(p) + case TypeInfo.ListOf(inner) => s"List[${renderTypeInfo(inner)}]" + case TypeInfo.Optional(inner) => s"Optional[${renderTypeInfo(inner)}]" + case TypeInfo.MapOf(k, v) => s"Map[${renderTypeInfo(k)}, ${renderTypeInfo(v)}]" + case TypeInfo.Ref(name) => name + case TypeInfo.Any => "Any" + case TypeInfo.InlineEnum(vs) => s"enum(${vs.take(3).mkString("|")}${if (vs.size > 3) "…" else ""})" + } + + private def renderPrimitive(p: PrimitiveType): String = p match { + case PrimitiveType.String => "String" + case PrimitiveType.Int32 => "Int" + case PrimitiveType.Int64 => "Long" + case PrimitiveType.Float => "Float" + case PrimitiveType.Double => "Double" + case PrimitiveType.Boolean => "Boolean" + case PrimitiveType.Date => "Date" + case PrimitiveType.DateTime => "DateTime" + case PrimitiveType.Time => "Time" + case PrimitiveType.UUID => "UUID" + case PrimitiveType.URI => "URI" + case PrimitiveType.Email => "Email" + case PrimitiveType.Binary => "Binary" + case PrimitiveType.Byte => "Byte" + case PrimitiveType.BigDecimal => "BigDecimal" + } + + private def renderAvroType(t: AvroType): String = t match { + case AvroType.Null => "null" + case AvroType.Boolean => "boolean" + case AvroType.Int => "int" + case AvroType.Long => "long" + case AvroType.Float => "float" + case AvroType.Double => "double" + case AvroType.Bytes => "bytes" + case AvroType.String => "string" + case AvroType.Array(items) => s"array[${renderAvroType(items)}]" + case AvroType.Map(values) => s"map[string, ${renderAvroType(values)}]" + case AvroType.Union(members) => + AvroType.unwrapNullable(AvroType.Union(members)) match { + case Some(inner) => s"${renderAvroType(inner)}?" + case None => members.map(renderAvroType).mkString(" | ") + } + case AvroType.Named(fullName) => fullName + case AvroType.Record(r) => r.fullName + case AvroType.EnumType(en) => en.fullName + case AvroType.Fixed(f) => f.fullName + case AvroType.UUID => "uuid" + case AvroType.Date => "date" + case AvroType.TimeMillis => "time-ms" + case AvroType.TimeMicros => "time-us" + case AvroType.TimestampMillis => "timestamp-ms" + case AvroType.TimestampMicros => "timestamp-us" + case AvroType.LocalTimestampMillis => "local-timestamp-ms" + case AvroType.LocalTimestampMicros => "local-timestamp-us" + case AvroType.TimeNanos => "time-ns" + case AvroType.TimestampNanos => "timestamp-ns" + case AvroType.LocalTimestampNanos => "local-timestamp-ns" + case AvroType.DecimalBytes(p, s) => s"decimal($p,$s)" + case AvroType.DecimalFixed(p, s, _) => s"decimal($p,$s)" + case AvroType.Duration => "duration" + } + + private def renderProtoType(t: ProtoType): String = t match { + case ProtoType.Double => "double" + case ProtoType.Float => "float" + case ProtoType.Int32 => "int32" + case ProtoType.Int64 => "int64" + case ProtoType.UInt32 => "uint32" + case ProtoType.UInt64 => "uint64" + case ProtoType.SInt32 => "sint32" + case ProtoType.SInt64 => "sint64" + case ProtoType.Fixed32 => "fixed32" + case ProtoType.Fixed64 => "fixed64" + case ProtoType.SFixed32 => "sfixed32" + case ProtoType.SFixed64 => "sfixed64" + case ProtoType.Bool => "bool" + case ProtoType.String => "string" + case ProtoType.Bytes => "bytes" + case ProtoType.Message(fn) => shortName(fn) + case ProtoType.Enum(fn) => shortName(fn) + case ProtoType.Map(k, v) => s"map<${renderProtoType(k)}, ${renderProtoType(v)}>" + case ProtoType.Timestamp => "Timestamp" + case ProtoType.Duration => "Duration" + case other => other.toString.takeWhile(_ != '(') + } + + private def shortName(fullName: String): String = + fullName.split('.').lastOption.getOrElse(fullName) +} diff --git a/typr/src/scala/typr/cli/app/LoadedSource.scala b/typr/src/scala/typr/cli/app/LoadedSource.scala new file mode 100644 index 0000000000..cb888d89cf --- /dev/null +++ b/typr/src/scala/typr/cli/app/LoadedSource.scala @@ -0,0 +1,49 @@ +package typr.cli.app + +import io.circe.Json +import typr.MetaDb +import typr.TypoLogger +import typr.avro.AvroSchemaFile +import typr.cli.config.{ConfigParser, ParsedSource} +import typr.grpc.ProtoFile +import typr.openapi.ParsedSpec + +import java.nio.file.Path +import scala.concurrent.ExecutionContext + +/** Loaded form of a configured source. One case per source kind; each carries the format's typed-result value (a MetaDb, a ParsedSpec, a list of AvroSchemaFiles, …). Lives in [[AppApi.sourceCache]] + * keyed by source name; every screen that needs source data reads from there and pattern-matches. + * + * The point of having this as a sum type rather than four parallel maps is twofold: + * 1. **Dispatch in one place.** [[load]] is the single function that knows which fetcher to call per source kind; the rest of the codebase reads `Option[LoadedSource]` and matches. + * 2. **Codec by construction.** Each variant binds the format's natural typed result; illegal cross-kind states (e.g. "spec data in the avro slot") cannot be expressed. + */ +sealed trait LoadedSource + +object LoadedSource { + + final case class Db(metaDb: MetaDb) extends LoadedSource + final case class Spec(spec: ParsedSpec) extends LoadedSource + final case class Avro(files: List[AvroSchemaFile]) extends LoadedSource + final case class Proto(files: List[ProtoFile]) extends LoadedSource + + /** Load one source synchronously. Caller decides on threading; this is a pure function (plus the unavoidable I/O for the fetcher itself). + * + * Dispatch goes through [[ConfigParser.parseSource]] which parses the JSON exactly once into a typed [[ParsedSource]], then picks the matching fetcher off the sealed trait. + */ + def load(name: String, json: Json, buildDir: Path): Either[String, LoadedSource] = { + given ExecutionContext = ExecutionContext.global + ConfigParser.parseSource(json).flatMap { + case _: ParsedSource.Database | _: ParsedSource.DuckDb => + // MetaDbFetch.fetch can throw (Hikari connect, Python install, await). Wrap once. + try Right(Db(MetaDbFetch.fetch(name, json, TypoLogger.Noop))) + catch case e: Throwable => Left(Option(e.getMessage).getOrElse(e.toString)) + case _: ParsedSource.OpenApi | _: ParsedSource.JsonSchema => + SpecFetch.fetch(json, buildDir).map(Spec.apply) + case _: ParsedSource.Avro => + AvroFetch.fetch(json, buildDir).map(Avro.apply) + case _: ParsedSource.Grpc => + ProtoFetch.fetch(json, buildDir).map(Proto.apply) + } + } +} diff --git a/typr/src/scala/typr/cli/app/MetaDbFetch.scala b/typr/src/scala/typr/cli/app/MetaDbFetch.scala new file mode 100644 index 0000000000..f5e2b759ef --- /dev/null +++ b/typr/src/scala/typr/cli/app/MetaDbFetch.scala @@ -0,0 +1,109 @@ +package typr.cli.app + +import com.zaxxer.hikari.HikariDataSource +import io.circe.Json +import typr.cli.config.{ConfigParser, ConfigToOptions, ParsedSource} +import typr.config.generated.{DatabaseBoundary, DuckdbBoundary} +import typr.TypoDataSource +import typr.internal.external.{ExternalTools, ExternalToolsConfig} +import typr.{MetaDb, TypoLogger} + +import scala.concurrent.duration.Duration +import scala.concurrent.{Await, ExecutionContext} + +/** Synchronously fetch a `MetaDb` for one source. Designed to be called from a daemon thread inside [[screens.SchemaBrowser]]; pings logger as it goes. ExternalTools.init may download Python on first + * run, so the first fetch on a fresh machine is slow — subsequent runs reuse the cached install. + */ +object MetaDbFetch { + + def fetch(name: String, json: Json, logger: TypoLogger)(implicit ec: ExecutionContext): MetaDb = + ConfigParser + .parseSource(json) + .fold( + err => throw new Exception(s"parse source config: $err"), + { + case ParsedSource.Database(db) => fetchDatabase(name, db, logger) + case ParsedSource.DuckDb(duck) => fetchDuckDb(name, duck, logger) + case _ => throw new Exception("not a database / duckdb source") + } + ) + + private def fetchDatabase(name: String, dbConfig: DatabaseBoundary, logger: TypoLogger)(implicit + ec: ExecutionContext + ): MetaDb = { + val sourceConfig = ConfigToOptions + .convertDatabaseBoundary(name, dbConfig) + .fold( + err => throw new Exception(s"convert source config: $err"), + identity + ) + val ds = buildDataSource(dbConfig) + try { + val externalTools = ExternalTools.init(logger, ExternalToolsConfig.default) + Await.result( + MetaDb.fromDb(logger, ds, sourceConfig.selector, sourceConfig.schemaMode, externalTools), + Duration.Inf + ) + } finally close(ds) + } + + private def fetchDuckDb(name: String, duckConfig: DuckdbBoundary, logger: TypoLogger)(implicit + ec: ExecutionContext + ): MetaDb = { + val sourceConfig = ConfigToOptions + .convertDuckDbSource(name, duckConfig) + .fold( + err => throw new Exception(s"convert source config: $err"), + identity + ) + val ds = TypoDataSource.hikariDuckDbInMemory(duckConfig.path) + try { + // Apply schema.sql if present so the resulting in-memory DB actually has tables. + duckConfig.schema_sql.foreach { sqlPath => + val conn = ds.ds.getConnection + try { + val stmt = conn.createStatement() + stmt.execute(java.nio.file.Files.readString(java.nio.file.Paths.get(sqlPath))) + stmt.close() + } finally conn.close() + } + val externalTools = ExternalTools.init(logger, ExternalToolsConfig.default) + Await.result( + MetaDb.fromDb(logger, ds, sourceConfig.selector, sourceConfig.schemaMode, externalTools), + Duration.Inf + ) + } finally close(ds) + } + + private def buildDataSource(dbConfig: DatabaseBoundary): TypoDataSource = { + val dbType = dbConfig.`type`.getOrElse("").toLowerCase + val host = dbConfig.host.getOrElse("") + val database = dbConfig.database.getOrElse("") + val username = dbConfig.username.getOrElse("") + val password = dbConfig.password.getOrElse("") + dbType match { + case "postgresql" => + TypoDataSource.hikariPostgres(host, dbConfig.port.map(_.toInt).getOrElse(5432), database, username, password) + case "mariadb" | "mysql" => + TypoDataSource.hikariMariaDb(host, dbConfig.port.map(_.toInt).getOrElse(3306), database, username, password) + case "sqlserver" => + TypoDataSource.hikariSqlServer(host, dbConfig.port.map(_.toInt).getOrElse(1433), database, username, password) + case "oracle" => + val service = dbConfig.service.orElse(dbConfig.sid).getOrElse("") + TypoDataSource.hikariOracle(host, dbConfig.port.map(_.toInt).getOrElse(1521), service, username, password) + case "db2" => + TypoDataSource.hikariDb2(host, dbConfig.port.map(_.toInt).getOrElse(50000), database, username, password) + case other => + throw new Exception(s"unknown database type: $other") + } + } + + private def close(ds: TypoDataSource): Unit = + try + ds.ds match { + case h: HikariDataSource => h.close() + case _ => () + } + catch case _: Throwable => () + +} diff --git a/typr/src/scala/typr/cli/app/ProtoFetch.scala b/typr/src/scala/typr/cli/app/ProtoFetch.scala new file mode 100644 index 0000000000..69e7007f4c --- /dev/null +++ b/typr/src/scala/typr/cli/app/ProtoFetch.scala @@ -0,0 +1,43 @@ +package typr.cli.app + +import io.circe.Json +import typr.cli.config.{ConfigParser, ParsedSource} +import typr.config.generated.GrpcBoundary +import typr.grpc.ProtoFile +import typr.grpc.parser.ProtobufParser + +import java.nio.file.Path + +/** Parse a gRPC source. Decodes the source JSON via [[ConfigParser.parseSource]] into a typed [[GrpcBoundary]]; reads `proto_path` (or first of `proto_paths`) plus optional `include_paths`; invokes + * protoc through [[ProtobufParser.parseDirectory]]. + * + * Failures surface as `Left(message)`. + */ +object ProtoFetch { + + def fetch(json: Json, buildDir: Path): Either[String, List[ProtoFile]] = + for { + parsed <- ConfigParser.parseSource(json) + boundary <- extractGrpc(parsed) + protoPath <- protoPathFor(boundary) + resolved = buildDir.resolve(protoPath) + _ <- Either.cond( + java.nio.file.Files.isDirectory(resolved), + (), + s"proto directory not found: $resolved" + ) + includes = boundary.include_paths.toList.flatten.map(p => buildDir.resolve(p)) + files <- ProtobufParser.parseDirectory(resolved, includes).left.map(err => s"failed to parse $protoPath: $err") + } yield files + + private def extractGrpc(parsed: ParsedSource): Either[String, GrpcBoundary] = parsed match { + case ParsedSource.Grpc(b) => Right(b) + case other => Left(s"not a grpc source: ${other.sourceType}") + } + + private def protoPathFor(b: GrpcBoundary): Either[String, String] = + b.proto_path + .orElse(b.proto_paths.flatMap(_.headOption)) + .toRight("grpc source has no `proto_path` or `proto_paths` field") + +} diff --git a/typr/src/scala/typr/cli/app/Shell.scala b/typr/src/scala/typr/cli/app/Shell.scala new file mode 100644 index 0000000000..742870a432 --- /dev/null +++ b/typr/src/scala/typr/cli/app/Shell.scala @@ -0,0 +1,89 @@ +package typr.cli.app + +import jatatui.components.router.{Router, Screen as RouterScreen} +import jatatui.react.Components.* +import jatatui.react.Element +import typr.cli.config.{ConfigWriter, TyprConfig} + +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import scala.collection.concurrent +import scala.jdk.CollectionConverters.* + +/** Root of the new TUI. Wraps the screen stack in [[Router]] and provides the [[AppApi]] context so screens can read/mutate config without prop-drilling. + * + * On mount, Shell also kicks off a background load for every configured source so [[AppApi.sourceCache]] is populated by the time the user reaches any flow that depends on source data — entity + * discovery, schema browser, type preview, etc. Loads run on daemon threads; load status is tracked per-source in [[AppApi.loadStatus]] so screens can render progress badges and disambiguate "not + * configured" from "still loading" from "failed". + */ +object Shell { + + def element(initialConfig: TyprConfig, configPath: Path, quitHost: () => Unit): Element = + component { ctx => + val configState = ctx.useState(() => initialConfig) + val hostConfigPath = configPath + val cacheRef = ctx.useRef(() => new ConcurrentHashMap[String, LoadedSource]().asScala: concurrent.Map[String, LoadedSource]) + val statusRef = ctx.useRef(() => new ConcurrentHashMap[String, LoadStatus]().asScala: concurrent.Map[String, LoadStatus]) + val rerender = ctx.requestRerender() + + val api: AppApi = new AppApi { + override def config: TyprConfig = configState.latest() + override def configPath: java.nio.file.Path = hostConfigPath + override def sourceCache: concurrent.Map[String, LoadedSource] = cacheRef.get + override def loadStatus: concurrent.Map[String, LoadStatus] = statusRef.get + + override def updateConfig(f: TyprConfig => TyprConfig): Either[Throwable, Unit] = { + val next = f(configState.latest()) + configState.set(next) + ConfigWriter.write(hostConfigPath, next) + } + + override def quit(): Unit = quitHost() + } + + // One-shot startup load. useEffect with empty deps runs once per mount; for the Shell that + // means once per app run. Fans out one daemon thread per source — independent and + // typically I/O-bound (Hikari connect, file I/O, protoc exec), so concurrency helps. + ctx.useEffect(() => kickOffSourceLoads(api, rerender)) + + // First screen depends on the beta gate. If the user hasn't accepted (or accepted under an + // older terms-version), show the gate modal — it writes the file on accept and replaces + // itself with Splash. Otherwise straight to Splash. + val initialScreen = + if (typr.cli.beta.BetaGate.isCurrent) + RouterScreen.of("splash", screens.Splash.element()) + else + RouterScreen.of("terms", screens.BetaNotice.element(screens.BetaNotice.Mode.Initial, "0.1.0")) + + provide( + AppApi.CONTEXT, + api, + Router.of(initialScreen) + ) + } + + private def kickOffSourceLoads(api: AppApi, rerender: Runnable): Unit = { + val buildDir = api.configPath.toAbsolutePath.getParent + api.config.sources.getOrElse(Map.empty).foreach { case (name, json) => + if (!api.loadStatus.contains(name)) { + api.loadStatus.update(name, LoadStatus.Loading) + val t = new Thread( + () => { + val result = LoadedSource.load(name, json, buildDir) match { + case Right(loaded) => + api.sourceCache.update(name, loaded) + LoadStatus.Loaded + case Left(err) => + LoadStatus.Failed(err) + } + api.loadStatus.update(name, result) + rerender.run() + }, + s"init-load-$name" + ) + t.setDaemon(true) + t.start() + } + } + } +} diff --git a/typr/src/scala/typr/cli/app/SpecFetch.scala b/typr/src/scala/typr/cli/app/SpecFetch.scala new file mode 100644 index 0000000000..d5d1ae5564 --- /dev/null +++ b/typr/src/scala/typr/cli/app/SpecFetch.scala @@ -0,0 +1,43 @@ +package typr.cli.app + +import io.circe.Json +import typr.cli.config.{ConfigParser, ParsedSource} +import typr.config.generated.{JsonschemaBoundary, OpenapiBoundary} +import typr.openapi.ParsedSpec +import typr.openapi.parser.OpenApiParser + +import java.nio.file.Path + +/** Parse an OpenAPI or JSON-schema spec for one source. The source JSON is decoded via [[ConfigParser.parseSource]] into a typed [[OpenapiBoundary]] / [[JsonschemaBoundary]]; we then read `spec` (or + * first of `specs`) and resolve it relative to the buildDir. + * + * All failures surface as `Left(message)` so the caller can render them in the UI rather than unwinding through a thread's uncaught exception handler. + */ +object SpecFetch { + + def fetch(json: Json, buildDir: Path): Either[String, ParsedSpec] = + for { + parsed <- ConfigParser.parseSource(json) + specPath <- specPathFor(parsed) + resolved = buildDir.resolve(specPath) + _ <- Either.cond(java.nio.file.Files.exists(resolved), (), s"spec file not found: $resolved") + spec <- OpenApiParser.parseFile(resolved).left.map(errs => s"failed to parse $specPath: ${errs.take(3).mkString("; ")}") + } yield spec + + private def specPathFor(parsed: ParsedSource): Either[String, String] = parsed match { + case ParsedSource.OpenApi(b) => specPathFromOpenapi(b) + case ParsedSource.JsonSchema(b) => specPathFromJsonschema(b) + case other => Left(s"not an openapi / jsonschema source: ${other.sourceType}") + } + + private def specPathFromOpenapi(b: OpenapiBoundary): Either[String, String] = + b.spec + .orElse(b.specs.flatMap(_.headOption)) + .toRight("openapi source has no `spec` or `specs` field") + + private def specPathFromJsonschema(b: JsonschemaBoundary): Either[String, String] = + b.spec + .orElse(b.specs.flatMap(_.headOption)) + .toRight("jsonschema source has no `spec` or `specs` field") + +} diff --git a/typr/src/scala/typr/cli/app/TypePreview.scala b/typr/src/scala/typr/cli/app/TypePreview.scala new file mode 100644 index 0000000000..7b6d48ce06 --- /dev/null +++ b/typr/src/scala/typr/cli/app/TypePreview.scala @@ -0,0 +1,84 @@ +package typr.cli.app + +import typr.MetaDb +import typr.config.generated.{FieldType, StringOrArray, StringOrArrayArray, StringOrArrayString} +import typr.internal.TypeMatcher +import typr.{db, ApiMatch, DbMatch, ModelMatch, TypeDefinitions, TypeEntry} + +/** Runs a [[FieldType]]'s db-match patterns against an already-introspected [[MetaDb]] and reports which columns match. Companion utility for the FieldType deep editor — lets the user see, at a + * glance, what their patterns actually catch in real data without running a full generate. + * + * Stays cheap: no I/O, no introspection. The caller passes in a MetaDb that was already loaded (typically from `AppApi.metaDbCache`, populated by the schema browser). + */ +object TypePreview { + + /** One match: fully-qualified column name and the database type we discovered there. */ + final case class Match(qualifiedColumn: String, dbType: String) + + /** Per-source preview result. `matches` is sorted lexically; the caller usually truncates before displaying. + */ + final case class Result(sourceName: String, matches: List[Match]) + + /** Run all the db-match patterns from `ft` against the relations in `metaDb`. Skips columns inside views (typr's TypeMatcher.findDbMatches only matches tables) and any FieldType whose `db` block is + * empty (no patterns = nothing to match). + */ + def runDb(typeName: String, ft: FieldType, sourceName: String, metaDb: MetaDb): Result = { + val entry = TypeEntry( + name = typeName, + db = toDbMatch(ft), + model = ModelMatch.Empty, + api = ApiMatch.Empty + ) + if (entry.db == DbMatch.Empty) return Result(sourceName, Nil) + + val defs = TypeDefinitions(List(entry)) + val builder = List.newBuilder[Match] + + metaDb.relations.foreach { case (_, lazyRel) => + lazyRel.forceGet match { + case table: db.Table => + table.cols.toList.foreach { col => + val ctx = TypeMatcher.DbColumnContext.from(sourceName, table, col) + if (TypeMatcher.findDbMatches(defs, ctx).nonEmpty) { + val qualified = table.name.schema match { + case Some(s) => s"$s.${table.name.name}.${col.name.value}" + case None => s"${table.name.name}.${col.name.value}" + } + builder += Match(qualified, dbTypeLabel(col.tpe)) + } + } + case _: db.View => () + } + } + + Result(sourceName, builder.result().sortBy(_.qualifiedColumn)) + } + + /** Maps the editor's [[typr.config.generated.DbMatch]] (string-or-array fields) onto the matcher's [[typr.DbMatch]] (flat List[String] fields). + */ + private def toDbMatch(ft: FieldType): DbMatch = ft.db match { + case None => DbMatch.Empty + case Some(m) => + DbMatch( + database = toList(m.source), + schema = toList(m.schema), + table = toList(m.table), + column = toList(m.column), + dbType = toList(m.db_type), + domain = toList(m.domain), + primaryKey = m.primary_key, + nullable = m.nullable, + references = toList(m.references), + comment = toList(m.comment), + annotation = toList(m.annotation) + ) + } + + private def toList(opt: Option[StringOrArray]): List[String] = opt match { + case Some(StringOrArrayString(s)) => List(s) + case Some(StringOrArrayArray(arr)) => arr + case _ => Nil + } + + private def dbTypeLabel(tpe: db.Type): String = tpe.toString +} diff --git a/typr/src/scala/typr/cli/app/components/CaretCell.scala b/typr/src/scala/typr/cli/app/components/CaretCell.scala new file mode 100644 index 0000000000..c58e4817ef --- /dev/null +++ b/typr/src/scala/typr/cli/app/components/CaretCell.scala @@ -0,0 +1,35 @@ +package typr.cli.app.components + +import jatatui.core.style.{Color, Style} +import jatatui.react.Components.* +import jatatui.react.{Element, MouseEvent} + +/** Clickable caret prefix for an expandable tree row. + * + * Sits at the start of each row that can expand/collapse — 6 cells wide (" ▸ " or " ▾ ") to match the indentation the tree browsers use. Clicking the cell toggles the row's expansion immediately (no + * double-click), without changing the SelectableList's selection cursor — `stopPropagation` keeps the click from bubbling to the row's outer click handler. + * + * Usage: + * {{{ + * row( + * length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(key))), + * fill(1, widget(restOfRowWidget)) + * ) + * }}} + */ +object CaretCell { + + /** Width in cells, including leading indent + caret + trailing space. */ + val WidthCols: Int = 6 + + def of(isOpen: Boolean, selected: Boolean, onToggle: () => Unit): Element = + component { c => + c.onClick { (e: MouseEvent) => + onToggle() + e.stopPropagation() + } + val sym = if (isOpen) "▾" else "▸" + val st = Style.empty.withFg(if (selected) Color.CYAN else Color.DARK_GRAY) + text(s" $sym ", st) + } +} diff --git a/typr/src/scala/typr/cli/app/components/JatatuiInterop.scala b/typr/src/scala/typr/cli/app/components/JatatuiInterop.scala new file mode 100644 index 0000000000..ed53838f35 --- /dev/null +++ b/typr/src/scala/typr/cli/app/components/JatatuiInterop.scala @@ -0,0 +1,54 @@ +package typr.cli.app.components + +import jatatui.components.link.Link as JLink +import jatatui.react.Element + +import java.util.function.Function as JFunction + +/** Scala-friendly wrappers around the Java-typed jatatui-components APIs whose signatures don't auto-convert cleanly from Scala lambdas (boxed-Boolean Function generics) or from bound `() => Unit` + * values that don't trigger SAM conversion to `Runnable`. + */ +object Run { + + /** Lift a Scala `() => Unit` to a Java `Runnable`. Useful when passing a bound function value (not a fresh lambda) to a jatatui API that expects `Runnable`. + */ + def apply(f: () => Unit): Runnable = new Runnable { def run(): Unit = f() } + + /** Implicit conversion so a bound `() => Unit` slots into a `Runnable` parameter without needing `Run(...)` at every call site. Fresh lambdas SAM-convert on their own; this only kicks in for + * previously-bound `val`s. + */ + given Conversion[() => Unit, Runnable] = f => new Runnable { def run(): Unit = f() } +} + +/** Scala-friendly façade over [[jatatui.components.link.Link]]. Same shape, but takes Scala `Boolean => Element` content lambdas instead of `java.util.function.Function`. + */ +object Link { + + /** Tab-focusable link. Activation = Enter when focused OR mouse click anywhere in the body's area. `content(focused)` receives the live focus state. + */ + def focusable(autoFocus: Boolean, onActivate: Runnable, content: Boolean => Element): Element = + JLink.focusable( + autoFocus, + onActivate, + new JFunction[java.lang.Boolean, Element] { + def apply(focused: java.lang.Boolean): Element = content(focused.booleanValue) + } + ) + + /** Tab-focusable link with an explicit focus id (for imperative `ctx.focus(id)` / stable Tab order across reorders). + */ + def focusable( + focusId: String, + autoFocus: Boolean, + onActivate: Runnable, + content: Boolean => Element + ): Element = + JLink.focusable( + focusId, + autoFocus, + onActivate, + new JFunction[java.lang.Boolean, Element] { + def apply(focused: java.lang.Boolean): Element = content(focused.booleanValue) + } + ) +} diff --git a/typr/src/scala/typr/cli/app/screens/AvroBrowser.scala b/typr/src/scala/typr/cli/app/screens/AvroBrowser.scala new file mode 100644 index 0000000000..0ddba4af94 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/AvroBrowser.scala @@ -0,0 +1,520 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.chrome.ScreenFrame +import jatatui.components.picker.{Picker, PickerProps} +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.components.search.FuzzyMatch +import jatatui.components.selectablelist.{SelectableList, SelectableListProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.avro.* +import typr.cli.app.{AppApi, LoadStatus, LoadedSource} +import typr.cli.app.components.CaretCell + +import scala.jdk.CollectionConverters.* + +/** Per-source Avro schema browser. Mirrors [[SpecBrowser]]: background-fetched, cached, picker search, expandable tree. + * + * The tree is grouped by directory: each [[AvroSchemaFile.directoryGroup]] becomes a section header, with its primary schemas listed underneath. Inline schemas live alongside their primary so the + * eye can see what nested types a record introduces. Records expand into a list of fields with type renderings; enums into their symbols; fixed into a one-line summary. + */ +object AvroBrowser { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val infoStyle: Style = Style.empty.withFg(Color.GRAY) + private val sectionStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val recordStyle: Style = Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD) + private val enumStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val fixedStyle: Style = Style.empty.withFg(Color.YELLOW).withAddModifier(Modifier.BOLD) + private val errorStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val fieldNameStyle: Style = Style.empty.withFg(Color.CYAN) + private val fieldTypeStyle: Style = Style.empty.withFg(Color.YELLOW) + + sealed trait TreeRow + object TreeRow { + final case class SectionHeader(label: String) extends TreeRow + final case class RecordRow(rec: AvroRecord, isOpen: Boolean) extends TreeRow + final case class EnumRow(e: AvroEnum, isOpen: Boolean) extends TreeRow + final case class FixedRow(f: AvroFixed) extends TreeRow + final case class ErrorRow(err: AvroError, isOpen: Boolean) extends TreeRow + final case class FieldRow(name: String, tpe: String, optional: Boolean, nameWidth: Int, typeWidth: Int) extends TreeRow + final case class SymbolRow(value: String, isDefault: Boolean) extends TreeRow + case object Blank extends TreeRow + } + + private final case class SearchItem(label: String, kind: SearchKind, anchorKey: String) + private sealed trait SearchKind + private object SearchKind { + case object Record extends SearchKind + case object Enum extends SearchKind + case object Fixed extends SearchKind + case object Error extends SearchKind + case object Field extends SearchKind + case object Symbol extends SearchKind + } + + def element(name: String): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + + val title = column(length(1, empty()), length(1, text(s" avro · $name", titleStyle))) + val status = app.loadStatus.getOrElse(name, LoadStatus.NotLoaded) + val body = app.sourceCache.get(name) match { + case Some(LoadedSource.Avro(files)) => loadedBody(files) + case Some(_) => failedBody(s"source '$name' is not an avro source") + case None => + status match { + case LoadStatus.Failed(err) => failedBody(err) + case LoadStatus.NotLoaded => notInConfigBody(name) + case _ => loadingBody() + } + } + ScreenFrame.withTitle("schemas", back, title, body) + } + + private def loadingBody(): Element = column( + length(1, text(" parsing avro schemas…", Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD))), + length(1, empty()), + length(1, text(" walking directories, resolving $refs, building schemas.", infoStyle)), + fill(1, empty()) + ) + + private def failedBody(err: String): Element = column( + length(1, text(s" ✗ ${err.take(400)}", errStyle)), + length(1, empty()), + length(1, text(" esc to return", hintStyle)), + fill(1, empty()) + ) + + private def notInConfigBody(name: String): Element = column( + length(1, text(s" source '$name' not in config", errStyle)), + length(1, empty()), + length(1, text(" esc to return", hintStyle)), + fill(1, empty()) + ) + + private def loadedBody(files: List[AvroSchemaFile]): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val expanded = ctx.useState(() => Set.empty[String]) + val searchOpen = ctx.useState(() => false) + + ctx.onKey( + new KeyCode.Char('/'), + (e: jatatui.react.KeyEvent) => { + if (!searchOpen.get) { + searchOpen.set(true) + e.stopPropagation() + } + } + ) + + val schemas = allSchemas(files) + val rows: List[TreeRow] = treeRows(files, schemas, expanded.get) + + val anchorIdx: Map[String, Int] = + rows.zipWithIndex.collect { + case (TreeRow.RecordRow(r, _), i) => recordKey(r) -> i + case (TreeRow.EnumRow(e, _), i) => enumKey(e) -> i + case (TreeRow.FixedRow(f), i) => fixedKey(f) -> i + case (TreeRow.ErrorRow(er, _), i) => errorKey(er) -> i + }.toMap + + val selectedIdx = ctx.useState(() => + rows.zipWithIndex + .collectFirst { case (TreeRow.RecordRow(_, _), i) => i } + .getOrElse( + rows.zipWithIndex.collectFirst { case (TreeRow.EnumRow(_, _), i) => i }.getOrElse(0) + ) + ) + + ctx.onKey( + new KeyCode.Right, + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case TreeRow.RecordRow(r, false) => + expanded.update(_ + recordKey(r)); e.stopPropagation() + case TreeRow.RecordRow(_, true) if hasChildAt(rows, sel + 1) => + selectedIdx.set(sel + 1); e.stopPropagation() + case TreeRow.EnumRow(en, false) => + expanded.update(_ + enumKey(en)); e.stopPropagation() + case TreeRow.EnumRow(_, true) if hasChildAt(rows, sel + 1) => + selectedIdx.set(sel + 1); e.stopPropagation() + case TreeRow.ErrorRow(er, false) => + expanded.update(_ + errorKey(er)); e.stopPropagation() + case TreeRow.ErrorRow(_, true) if hasChildAt(rows, sel + 1) => + selectedIdx.set(sel + 1); e.stopPropagation() + case _ => () + } + } + ) + ctx.onKey( + new KeyCode.Left, + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case TreeRow.RecordRow(r, true) => + expanded.update(_ - recordKey(r)); e.stopPropagation() + case TreeRow.EnumRow(en, true) => + expanded.update(_ - enumKey(en)); e.stopPropagation() + case TreeRow.ErrorRow(er, true) => + expanded.update(_ - errorKey(er)); e.stopPropagation() + case _: TreeRow.FieldRow | _: TreeRow.SymbolRow => + val parent = (sel - 1 to 0 by -1).find(i => + rows(i).isInstanceOf[TreeRow.RecordRow] + || rows(i).isInstanceOf[TreeRow.EnumRow] + || rows(i).isInstanceOf[TreeRow.ErrorRow] + ) + parent.foreach(selectedIdx.set) + e.stopPropagation() + case _ => () + } + } + ) + + // 'c' on a Record / Error: create a DomainType anchored on this avro record. (FieldType + // doesn't have an avro-specific match pattern, so there's no 'e' shortcut here.) + ctx.onKey( + new KeyCode.Char('c'), + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case _: TreeRow.RecordRow | _: TreeRow.ErrorRow => + router.push(RouterScreen.of("create domain type", DomainFromEntity.element)) + e.stopPropagation() + case _ => () + } + } + ) + + val searchItems = buildIndex(schemas) + val indexedRef = ctx.useRef(() => FuzzyMatch.index[SearchItem](searchItems.asJava, (it: SearchItem) => it.label)) + + val recCount = schemas.collect { case r: AvroRecord => r }.size + val enumCount = schemas.collect { case e: AvroEnum => e }.size + val fixedCount = schemas.collect { case f: AvroFixed => f }.size + val errCount = schemas.collect { case e: AvroError => e }.size + + val info = Line.from( + Span.styled(s" ${files.size} file${plural(files.size)}", sectionStyle), + Span.styled(s" · $recCount record${plural(recCount)}", infoStyle), + Span.styled(s" · $enumCount enum${plural(enumCount)}", infoStyle), + Span.styled(s" · $fixedCount fixed", infoStyle), + Span.styled(if (errCount > 0) s" · $errCount error${plural(errCount)}" else "", infoStyle), + Span.styled(s" · press / to search", hintStyle) + ) + + val infoLine: Widget = (area, buffer) => Paragraph.of(info).render(area, buffer) + + val tree = column( + length(1, widget(infoLine)), + length(1, empty()), + fill( + 1, + SelectableList.of( + SelectableListProps + .of[TreeRow]( + rows.asJava, + isActivatable, + (row, selected) => renderTreeRow(row, selected, key => expanded.update(t => toggle(t, key))), + selectedIdx.get, + idx => selectedIdx.set(idx) + ) + .withOnActivate { (row: TreeRow) => + row match { + case TreeRow.RecordRow(r, _) => + expanded.update(t => toggle(t, recordKey(r))) + case TreeRow.EnumRow(en, _) => + expanded.update(t => toggle(t, enumKey(en))) + case TreeRow.ErrorRow(er, _) => + expanded.update(t => toggle(t, errorKey(er))) + case _ => () + } + } + .withAutoFocus(true) + ) + ), + length(1, text(" ↑↓ navigate · ←→ collapse/expand · c create domain type · / search · esc back", hintStyle)) + ) + + if (!searchOpen.get) tree + else { + val filter: PickerProps.Filter[SearchItem] = (query: String) => + if (query.isEmpty) java.util.List.of() + else FuzzyMatch.rank(query, indexedRef.get).stream.limit(120).toList + + val rowRenderer: PickerProps.RowRenderer[SearchItem] = (item, selected) => hitRow(item, selected) + + stack( + tree, + Picker.of( + PickerProps + .of[SearchItem]( + "search", + filter, + rowRenderer, + (item: SearchItem) => { + expanded.update(_ + item.anchorKey) + anchorIdx.get(item.anchorKey).foreach(selectedIdx.set) + searchOpen.set(false) + }, + () => searchOpen.set(false) + ) + ) + ) + } + } + + // ───────────────────────────────────── tree building ───────────────────────────────────────── + + private def allSchemas(files: List[AvroSchemaFile]): List[AvroSchema] = + files.flatMap(f => f.primarySchema :: f.inlineSchemas) + + private def treeRows(files: List[AvroSchemaFile], schemas: List[AvroSchema], expanded: Set[String]): List[TreeRow] = { + val out = List.newBuilder[TreeRow] + val grouped: List[(Option[String], List[AvroSchemaFile])] = + files.groupBy(_.directoryGroup).toList.sortBy(_._1.getOrElse("")) + + grouped.foreach { case (group, gFiles) => + val label = group.map(g => s"$g/").getOrElse("(root)") + out += TreeRow.SectionHeader(s"$label ${gFiles.size} file${plural(gFiles.size)}") + // For each file, append primary + inline schemas + val sorted = gFiles.sortBy(_.primarySchema.fullName.toLowerCase) + sorted.foreach { file => + val ordered = file.primarySchema :: file.inlineSchemas + ordered.foreach { + case r: AvroRecord => + val key = recordKey(r) + val isOpen = expanded.contains(key) + out += TreeRow.RecordRow(r, isOpen) + if (isOpen) fieldRowsFor(r.fields).foreach(out += _) + case en: AvroEnum => + val key = enumKey(en) + val isOpen = expanded.contains(key) + out += TreeRow.EnumRow(en, isOpen) + if (isOpen) en.symbols.foreach(s => out += TreeRow.SymbolRow(s, en.defaultSymbol.contains(s))) + case f: AvroFixed => + out += TreeRow.FixedRow(f) + case er: AvroError => + val key = errorKey(er) + val isOpen = expanded.contains(key) + out += TreeRow.ErrorRow(er, isOpen) + if (isOpen) fieldRowsFor(er.fields).foreach(out += _) + } + } + out += TreeRow.Blank + } + val _ = schemas // explicit to silence unused warning when index changes + out.result() + } + + private def fieldRowsFor(fields: List[AvroField]): List[TreeRow] = { + val rendered = fields.map(f => (f.name, renderType(f.fieldType), f.isOptional)) + val nameWidth = rendered.map(_._1.length).maxOption.getOrElse(0) + val typeWidth = rendered.map(_._2.length).maxOption.getOrElse(0) + rendered.map { case (n, t, opt) => TreeRow.FieldRow(n, t, opt, nameWidth, typeWidth) } + } + + // ───────────────────────────────────── row rendering ───────────────────────────────────────── + + private def renderTreeRow(row: TreeRow, selected: Boolean, toggleExpand: String => Unit): Element = row match { + case TreeRow.SectionHeader(label) => + text(s" $label", sectionStyle) + + case TreeRow.Blank => empty() + + case TreeRow.RecordRow(r, isOpen) => + val nameStyle = if (selected) recordStyle.withBg(Color.BLUE) else recordStyle + val tagStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(r.fullName, nameStyle), + Span.styled(s" record (${r.fields.size} fields)", tagStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(recordKey(r)))), + fill(1, widget(w)) + ) + + case TreeRow.EnumRow(e, isOpen) => + val nameStyle = if (selected) enumStyle.withBg(Color.BLUE) else enumStyle + val tagStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(e.fullName, nameStyle), + Span.styled(s" enum (${e.symbols.size} values)", tagStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(enumKey(e)))), + fill(1, widget(w)) + ) + + case TreeRow.FixedRow(f) => + val nameStyle = if (selected) fixedStyle.withBg(Color.BLUE) else fixedStyle + val tagStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" ▪ ", Style.empty.withFg(if (selected) Color.CYAN else Color.DARK_GRAY)), + Span.styled(f.fullName, nameStyle), + Span.styled(s" fixed (${f.size} bytes)", tagStyle) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + + case TreeRow.ErrorRow(er, isOpen) => + val nameStyle = if (selected) errorStyle.withBg(Color.BLUE) else errorStyle + val tagStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(er.namespace.map(ns => s"$ns.${er.name}").getOrElse(er.name), nameStyle), + Span.styled(s" error (${er.fields.size} fields)", tagStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(errorKey(er)))), + fill(1, widget(w)) + ) + + case TreeRow.FieldRow(name, tpe, optional, nameWidth, typeWidth) => + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" ", Style.empty), + Span.styled(name.padTo(nameWidth, ' '), fieldNameStyle), + Span.styled(" ", Style.empty), + Span.styled(tpe.padTo(typeWidth, ' '), fieldTypeStyle), + Span.styled(if (optional) " optional" else " required", Style.empty.withFg(Color.DARK_GRAY)) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + + case TreeRow.SymbolRow(value, isDefault) => + val w: Widget = (area, buffer) => { + val parts = List.newBuilder[Span] + parts += Span.styled(" · ", Style.empty.withFg(Color.DARK_GRAY)) + parts += Span.styled(value, fieldTypeStyle) + if (isDefault) parts += Span.styled(" (default)", Style.empty.withFg(Color.GREEN)) + val line = Line.from(parts.result()*) + Paragraph.of(line).render(area, buffer) + } + widget(w) + } + + private def isActivatable(row: TreeRow): Boolean = row match { + case _: TreeRow.SectionHeader => false + case TreeRow.Blank => false + case _ => true + } + + private def hasChildAt(rows: List[TreeRow], idx: Int): Boolean = + idx >= 0 && idx < rows.size && (rows(idx) match { + case _: TreeRow.FieldRow | _: TreeRow.SymbolRow => true + case _ => false + }) + + // ───────────────────────────────────── search index ───────────────────────────────────────── + + private def buildIndex(schemas: List[AvroSchema]): List[SearchItem] = { + val b = List.newBuilder[SearchItem] + schemas.foreach { + case r: AvroRecord => + b += SearchItem(s"record: ${r.fullName}", SearchKind.Record, recordKey(r)) + r.fields.foreach { f => + b += SearchItem(s"${r.fullName}.${f.name} : ${renderType(f.fieldType)}", SearchKind.Field, recordKey(r)) + } + case e: AvroEnum => + b += SearchItem(s"enum: ${e.fullName}", SearchKind.Enum, enumKey(e)) + e.symbols.foreach(s => b += SearchItem(s"${e.fullName}.$s", SearchKind.Symbol, enumKey(e))) + case f: AvroFixed => + b += SearchItem(s"fixed: ${f.fullName}", SearchKind.Fixed, fixedKey(f)) + case er: AvroError => + b += SearchItem(s"error: ${er.fullName}", SearchKind.Error, errorKey(er)) + er.fields.foreach { f => + b += SearchItem(s"${er.fullName}.${f.name} : ${renderType(f.fieldType)}", SearchKind.Field, errorKey(er)) + } + } + b.result() + } + + private def hitRow(item: SearchItem, selected: Boolean): Element = { + val (icon, color) = item.kind match { + case SearchKind.Record => ("◈", Color.CYAN) + case SearchKind.Enum => ("▦", Color.MAGENTA) + case SearchKind.Fixed => ("▪", Color.YELLOW) + case SearchKind.Error => ("✗", Color.RED) + case SearchKind.Field => ("·", Color.WHITE) + case SearchKind.Symbol => ("·", Color.GREEN) + } + val style = + if (selected) Style.empty.withBg(Color.BLUE).withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(color) + val prefix = if (selected) " ▶ " else " " + text(s"$prefix$icon ${item.label}", style) + } + + // ───────────────────────────────────── keys + labels ───────────────────────────────────────── + + private def recordKey(r: AvroRecord): String = s"rec:${r.fullName}" + private def enumKey(e: AvroEnum): String = s"enum:${e.fullName}" + private def fixedKey(f: AvroFixed): String = s"fix:${f.fullName}" + private def errorKey(er: AvroError): String = s"err:${er.fullName}" + + private def renderType(t: AvroType): String = t match { + case AvroType.Null => "null" + case AvroType.Boolean => "boolean" + case AvroType.Int => "int" + case AvroType.Long => "long" + case AvroType.Float => "float" + case AvroType.Double => "double" + case AvroType.Bytes => "bytes" + case AvroType.String => "string" + case AvroType.Array(items) => s"array[${renderType(items)}]" + case AvroType.Map(values) => s"map[string, ${renderType(values)}]" + case AvroType.Union(members) => + AvroType.unwrapNullable(AvroType.Union(members)) match { + case Some(inner) => s"${renderType(inner)}?" + case None => members.map(renderType).mkString(" | ") + } + case AvroType.Named(fullName) => fullName + case AvroType.Record(r) => r.fullName + case AvroType.EnumType(en) => en.fullName + case AvroType.Fixed(f) => f.fullName + case AvroType.UUID => "uuid" + case AvroType.Date => "date" + case AvroType.TimeMillis => "time-ms" + case AvroType.TimeMicros => "time-us" + case AvroType.TimestampMillis => "timestamp-ms" + case AvroType.TimestampMicros => "timestamp-us" + case AvroType.LocalTimestampMillis => "local-timestamp-ms" + case AvroType.LocalTimestampMicros => "local-timestamp-us" + case AvroType.TimeNanos => "time-ns" + case AvroType.TimestampNanos => "timestamp-ns" + case AvroType.LocalTimestampNanos => "local-timestamp-ns" + case AvroType.DecimalBytes(p, s) => s"decimal($p,$s)" + case AvroType.DecimalFixed(p, s, _) => s"decimal($p,$s)" + case AvroType.Duration => "duration" + } + + private def toggle(s: Set[String], k: String): Set[String] = if (s.contains(k)) s - k else s + k + private def plural(n: Int): String = if (n == 1) "" else "s" +} diff --git a/typr/src/scala/typr/cli/app/screens/BetaNotice.scala b/typr/src/scala/typr/cli/app/screens/BetaNotice.scala new file mode 100644 index 0000000000..0cb654521b --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/BetaNotice.scala @@ -0,0 +1,105 @@ +package typr.cli.app.screens + +import jatatui.components.button.Button +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.react.Components.* +import jatatui.react.Element +import tui.crossterm.KeyCode +import typr.cli.app.AppApi +import typr.cli.beta.{BetaGate, BetaTerms} + +/** Full-screen acceptance modal — shown on first run, after a terms-version bump, or when the user presses `t` on the Splash to re-read. + * + * Two modes, controlled by [[Mode]]: + * - [[Mode.Initial]]: gates the rest of the app. Accept writes the file and replaces this screen with [[Splash]]; Quit calls `app.quit()`. + * - [[Mode.ReadOnly]]: the user already accepted and is just rereading. Accept replaced by a `[ close ]` button; Quit removed. Esc / b / close all `router.pop()`. + */ +object BetaNotice { + + enum Mode { + case Initial // gating; not yet accepted + case ReadOnly // already accepted; user opened from Splash + } + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val termsStyle: Style = Style.empty.withFg(Color.WHITE) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val warningStyle: Style = Style.empty.withFg(Color.YELLOW).withAddModifier(Modifier.BOLD) + + def element(mode: Mode, binaryVersion: String): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + + def accept(): Unit = { + try BetaGate.markAccepted(binaryVersion) + catch case _: Throwable => () // best-effort; failure is rare and not blocking + router.replace(RouterScreen.of("splash", Splash.element(binaryVersion))) + } + + def close(): Unit = router.pop() + + mode match { + case Mode.Initial => + ctx.onGlobalKey(new KeyCode.Esc, () => app.quit()) + case Mode.ReadOnly => + ctx.onGlobalKey(new KeyCode.Esc, () => close()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => close()) + } + + val title = length(1, text(" Typr Closed Beta", titleStyle)) + val intro = length(1, text(" Before you start, please read these terms:", hintStyle)) + + val expiryNotice: List[Element] = + if (BetaGate.isInWarningWindow()) { + val d = BetaGate.daysUntilExpiry() + val s = if (d == 1) "day" else "days" + List( + length(1, empty()), + length(1, text(s" This build expires in $d $s — keep an install fresh at typr.dev.", warningStyle)) + ) + } else Nil + + val termsLines: List[Element] = + BetaTerms.displayText.linesIterator + .drop(1) + .toList // skip the duplicated title line + .map(l => length(1, text(s" $l", termsStyle))) + + val buttons: Element = mode match { + case Mode.Initial => + jatatui.react.Components.row( + fill(1, empty()), + length(34, Button.of("I understand and accept", "beta-accept", true, () => accept())), + length(2, empty()), + length(12, Button.of("Quit", "beta-quit", false, () => app.quit())), + fill(1, empty()) + ) + case Mode.ReadOnly => + jatatui.react.Components.row( + fill(1, empty()), + length(14, Button.of("close", "beta-close", true, () => close())), + fill(1, empty()) + ) + } + + val body = column( + (length(1, empty()) :: + title :: + length(1, empty()) :: + intro :: + length(1, empty()) :: + termsLines ::: + expiryNotice ::: + List( + length(1, empty()), + length(3, buttons), + fill(1, empty()) + ))* + ) + + // Center horizontally — terms wrap at ~70 cols, so a 78-col body is comfortable. + jatatui.react.Components.row(fill(1, empty()), length(78, body), fill(1, empty())) + } +} diff --git a/typr/src/scala/typr/cli/app/screens/DomainFromEntity.scala b/typr/src/scala/typr/cli/app/screens/DomainFromEntity.scala new file mode 100644 index 0000000000..5a39ce3d00 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/DomainFromEntity.scala @@ -0,0 +1,398 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.button.Button +import jatatui.components.chrome.ScreenFrame +import jatatui.components.picker.{Picker, PickerProps} +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.components.search.FuzzyMatch +import jatatui.components.selectablelist.{SelectableList, SelectableListProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.cli.app.{AppApi, Entity, EntityCatalog, LoadStatus} +import typr.config.generated.{BridgeType, DomainType, FieldSpec, FieldSpecObject} + +import scala.jdk.CollectionConverters.* + +/** Create a domain type by picking a real entity from a loaded source. Two-column layout: + * + * - **Left**: every entity the [[EntityCatalog]] knows about, grouped by source. Selectable list (Tab/Up/Down move, Enter commits). Sources still loading appear as a single "(loading…)" + * placeholder so the user knows more is coming. + * - **Right**: live preview of the domain type that would be created from the currently selected entity — name, primary, fields — plus alignment suggestions: other entities from other sources + * whose field-name overlap looks like the same concept ("api:Customer — 6 of 7 fields match"). User toggles each suggestion to include it as an `alignedSources` entry. + * + * On `Create`, writes a new [[DomainType]] into `types:` and navigates straight to [[TypeEditor]] for any further tweaks. `/` opens a fuzzy picker over entity names if the list gets long. + */ +object DomainFromEntity { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val okStyle: Style = Style.empty.withFg(Color.GREEN).withAddModifier(Modifier.BOLD) + private val infoStyle: Style = Style.empty.withFg(Color.GRAY) + private val sectionStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val mutedStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val previewStyle: Style = Style.empty.withFg(Color.WHITE) + + sealed trait Row + object Row { + final case class SourceHeader(label: String, kind: Entity.Kind) extends Row + final case class EntityRow(entity: Entity) extends Row + final case class LoadingRow(sourceName: String) extends Row + final case class FailedRow(sourceName: String, message: String) extends Row + case object Blank extends Row + } + + val element: Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + val entities = EntityCatalog.fromCache(app.sourceCache) + val rows = buildRows(app, entities) + + val firstActivatable = rows.zipWithIndex.collectFirst { case (_: Row.EntityRow, i) => i } + val selectedIdx = ctx.useState(() => firstActivatable.getOrElse(0)) + val acceptedAligns = ctx.useState(() => Set.empty[String]) + val searchOpen = ctx.useState(() => false) + val saveErr = ctx.useState(() => Option.empty[String]) + + // Reset accepted alignments when the user moves to a different entity, otherwise the + // checkmarks on the right don't visibly belong to the row on the left. + val prevSelRef = ctx.useRef(() => -1) + if (prevSelRef.get != selectedIdx.get) { + prevSelRef.set(selectedIdx.get) + acceptedAligns.set(Set.empty) + saveErr.set(None) + } + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + ctx.onKey( + new KeyCode.Char('/'), + (e: jatatui.react.KeyEvent) => { + if (!searchOpen.get) { searchOpen.set(true); e.stopPropagation() } + } + ) + + val selectedEntity: Option[Entity] = rows.lift(selectedIdx.get).collect { case Row.EntityRow(e) => + e + } + + def createDraft(): Unit = selectedEntity.foreach { entity => + val suggestions = EntityCatalog.alignmentSuggestions(entity, entities) + val accepted = suggestions.filter(s => acceptedAligns.get.contains(s.target.primaryKey)) + val draft = buildDraft(entity, accepted.map(_.target)) + val existing = app.config.types.getOrElse(Map.empty) + val typeName = uniqueName(entity.suggestedName, existing.keySet) + val next = existing + (typeName -> (draft: BridgeType)) + app.updateConfig(c => c.copy(types = Some(next))) match { + case Right(_) => + // Push the editor so the user can refine. `replace` would lose the back button to + // DomainFromEntity, but the natural next step is to *edit* the type — push it on + // top so esc returns here. + router.push(RouterScreen.of(s"type · $typeName", TypeEditor.element(typeName))) + case Left(e) => + saveErr.set(Some(Option(e.getMessage).getOrElse(e.toString))) + } + } + + val title = column(length(1, empty()), length(1, text(" domain type · from a real entity", titleStyle))) + + val leftPane = renderEntityList(rows, selectedIdx) + val rightPane = renderPreview(selectedEntity, entities, acceptedAligns, () => createDraft(), saveErr.get) + + val body = column( + fill( + 1, + jatatui.react.Components.row( + length(56, leftPane), + length(2, empty()), + fill(1, rightPane) + ) + ), + length(1, text(" ↑↓ navigate · enter create · / search · esc back", hintStyle)) + ) + + val screen = ScreenFrame.withTitle("domain types", back, title, body) + + if (!searchOpen.get) screen + else { + val items: List[Entity] = entities + val indexedRef = ctx.useRef(() => FuzzyMatch.index[Entity](items.asJava, (e: Entity) => s"${e.sourceName}:${e.path}")) + val filter: PickerProps.Filter[Entity] = (q: String) => + if (q.isEmpty) java.util.List.of() + else FuzzyMatch.rank(q, indexedRef.get).stream.limit(120).toList + val rowRenderer: PickerProps.RowRenderer[Entity] = (e, sel) => searchHit(e, sel) + stack( + screen, + Picker.of( + PickerProps.of[Entity]( + "find entity", + filter, + rowRenderer, + (picked: Entity) => { + val idx = rows.indexWhere { + case Row.EntityRow(e) => e.sourceName == picked.sourceName && e.path == picked.path + case _ => false + } + if (idx >= 0) selectedIdx.set(idx) + searchOpen.set(false) + }, + () => searchOpen.set(false) + ) + ) + ) + } + } + + // ─────────────────────────────────── left pane ─────────────────────────────────── + + private def buildRows(app: AppApi, entities: List[Entity]): List[Row] = { + val out = List.newBuilder[Row] + val configured = app.config.sources.getOrElse(Map.empty).keys.toList.sorted + + configured.foreach { sourceName => + val status = app.loadStatus.getOrElse(sourceName, LoadStatus.NotLoaded) + val ents = entities.filter(_.sourceName == sourceName) + val kind = ents.headOption.map(_.kind).getOrElse(Entity.Kind.Db) + out += Row.SourceHeader(sourceName, kind) + status match { + case LoadStatus.Loaded if ents.nonEmpty => ents.foreach(e => out += Row.EntityRow(e)) + case LoadStatus.Loaded => out += Row.LoadingRow(sourceName) + case LoadStatus.Loading | LoadStatus.NotLoaded => out += Row.LoadingRow(sourceName) + case LoadStatus.Failed(err) => out += Row.FailedRow(sourceName, err) + } + out += Row.Blank + } + out.result() + } + + private def renderEntityList(rows: List[Row], sel: jatatui.react.State[Int]): Element = { + val rowsJava = rows.asJava + SelectableList.of( + SelectableListProps + .of[Row]( + rowsJava, + r => r.isInstanceOf[Row.EntityRow], + (r, isSel) => renderRow(r, isSel), + sel.get, + idx => sel.set(idx) + ) + .withAutoFocus(true) + ) + } + + private def renderRow(r: Row, selected: Boolean): Element = r match { + case Row.SourceHeader(label, kind) => + val (icon, colour) = kind match { + case Entity.Kind.Db => ("◈", Color.CYAN) + case Entity.Kind.Spec => ("◇", Color.MAGENTA) + case Entity.Kind.Avro => ("▦", Color.YELLOW) + case Entity.Kind.Proto => ("▣", Color.GREEN) + } + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(s" $icon ", Style.empty.withFg(colour)), + Span.styled(label, sectionStyle) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + + case Row.EntityRow(entity) => + val nameStyle = + if (selected) Style.empty.withFg(Color.WHITE).withBg(Color.BLUE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.WHITE) + val countStyle = if (selected) Style.empty.withFg(Color.YELLOW) else mutedStyle + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" ", Style.empty), + Span.styled(entity.path, nameStyle), + Span.styled(s" (${entity.fields.size} field${if (entity.fields.size == 1) "" else "s"})", countStyle) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + + case Row.LoadingRow(_) => + text(" ◕ loading…", Style.empty.withFg(Color.YELLOW)) + case Row.FailedRow(_, err) => + text(s" ✗ ${err.take(40)}", errStyle) + case Row.Blank => + empty() + } + + // ─────────────────────────────────── right pane ─────────────────────────────────── + + private def renderPreview( + selected: Option[Entity], + all: List[Entity], + acceptedAligns: jatatui.react.State[Set[String]], + onCreate: () => Unit, + saveErr: Option[String] + ): Element = selected match { + case None => + column( + length(1, empty()), + length(1, text(" pick an entity on the left", mutedStyle)), + length(1, empty()), + length(1, text(" once you do, you'll see:", infoStyle)), + length(1, text(" · the suggested type name + fields", infoStyle)), + length(1, text(" · matching entities in other sources you could align with", infoStyle)), + fill(1, empty()) + ) + + case Some(entity) => + val suggestions = EntityCatalog.alignmentSuggestions(entity, all) + + val previewLines: List[Element] = List( + length(1, text(" draft", sectionStyle)), + length(1, text(s" name ${entity.suggestedName}", previewStyle)), + length(1, text(s" primary ${entity.primaryKey}", previewStyle)), + length(1, text(s" fields ${entity.fields.size}", previewStyle)), + length(1, empty()) + ) + + val fieldsHeader = length(1, text(" fields preview", sectionStyle)) + val fieldRows = entity.fields.take(8).map { f => + length(1, text(s" · ${f.name.padTo(20, ' ')} ${f.typeLabel}${if (f.optional) " (optional)" else ""}", previewStyle)) + } + val fieldOverflow = + if (entity.fields.size > 8) List(length(1, text(s" (+${entity.fields.size - 8} more)", mutedStyle))) + else Nil + + val alignHeader = length( + 1, + text( + if (suggestions.isEmpty) " no alignment candidates yet (more loading?)" + else s" suggested aligned sources (${suggestions.size})", + sectionStyle + ) + ) + val alignRows: List[Element] = suggestions.map { s => + length(1, alignmentRow(s, acceptedAligns)) + } + + val errRow: Element = saveErr match { + case Some(msg) => length(1, text(s" ✗ ${msg.take(160)}", errStyle)) + case None => length(0, empty()) + } + + val createButton = length(3, Button.of("create", "create-domain", true, () => onCreate())) + + column( + (previewLines ::: + List(fieldsHeader) ::: + fieldRows ::: + fieldOverflow ::: + List(length(1, empty()), alignHeader) ::: + alignRows ::: + List(length(1, empty()), createButton, errRow, fill(1, empty())))* + ) + } + + /** A single alignment-suggestion row: clickable label that toggles whether the suggestion is included on Create. Renders the match count + percentage. + */ + private def alignmentRow(s: EntityCatalog.Alignment, accepted: jatatui.react.State[Set[String]]): Element = + component { c => + val key = s.target.primaryKey + val included = accepted.get.contains(key) + val focused = c.useFocus(java.util.Optional.of(s"align:$key"), false) + + def toggle(): Unit = + accepted.update(set => if (set.contains(key)) set - key else set + key) + + if (focused) c.onKey(new KeyCode.Enter, () => toggle()) + c.onClick(() => toggle()) + + val box = if (included) "[✓]" else "[ ]" + val pct = (s.score * 100).toInt + val label = s" $box ${s.target.primaryKey} · ${s.matched}/${s.total} fields ($pct%)" + val style = + if (focused) Style.empty.withFg(Color.WHITE).withBg(Color.BLUE).withAddModifier(Modifier.BOLD) + else if (included) okStyle + else previewStyle + text(label, style) + } + + // ─────────────────────────────────── commit ─────────────────────────────────── + + /** Build a [[DomainType]] from a primary entity + accepted alignment targets. Fields come from the primary's columns/properties/fields, encoded as the verbose [[FieldSpecObject]] form (so we + * preserve the optional flag without forcing a follow-up edit). + */ + private def buildDraft(primary: Entity, alignWith: List[Entity]): DomainType = { + val fields: Map[String, FieldSpec] = + primary.fields.iterator.map { f => + f.name -> (FieldSpecObject( + array = None, + default = None, + description = None, + nullable = if (f.optional) Some(true) else None, + `type` = f.typeLabel + ): FieldSpec) + }.toMap + + val aligned: Option[Map[String, typr.config.generated.AlignedSource]] = + if (alignWith.isEmpty) None + else + Some(alignWith.iterator.map { e => + e.primaryKey -> typr.config.generated.AlignedSource( + direction = None, + entity = Some(e.path), + exclude = None, + field_overrides = None, + include_extra = None, + mappings = None, + mode = None, + readonly = None, + type_policy = None + ) + }.toMap) + + DomainType( + alignedSources = aligned, + description = None, + fields = fields, + generate = None, + primary = Some(primary.primaryKey), + projections = None + ) + } + + /** Disambiguate against existing type names — append a numeric suffix if needed. */ + private def uniqueName(suggested: String, existing: Set[String]): String = + if (!existing.contains(suggested)) suggested + else LazyList.from(2).map(n => s"$suggested$n").find(n => !existing.contains(n)).get + + // ─────────────────────────────────── search ─────────────────────────────────── + + private def searchHit(entity: Entity, selected: Boolean): Element = { + val style = + if (selected) Style.empty.withBg(Color.BLUE).withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.GRAY) + val (icon, colour) = entity.kind match { + case Entity.Kind.Db => ("◈", Color.CYAN) + case Entity.Kind.Spec => ("◇", Color.MAGENTA) + case Entity.Kind.Avro => ("▦", Color.YELLOW) + case Entity.Kind.Proto => ("▣", Color.GREEN) + } + val prefix = if (selected) " ▶ " else " " + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(prefix, style), + Span.styled(s"$icon ", Style.empty.withFg(if (selected) Color.WHITE else colour)), + Span.styled(s"${entity.sourceName}:${entity.path}", style), + Span.styled(s" (${entity.fields.size})", Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY)) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + } +} diff --git a/typr/src/scala/typr/cli/app/screens/DomainTypeForm.scala b/typr/src/scala/typr/cli/app/screens/DomainTypeForm.scala new file mode 100644 index 0000000000..ecfecdbb51 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/DomainTypeForm.scala @@ -0,0 +1,184 @@ +package typr.cli.app.screens + +import typr.config.generated.{AlignedSource, DomainGenerateOptions, DomainType, FieldSpec, FieldSpecObject, FieldSpecString} + +/** Flat editable mirror of [[DomainType]]. Keeps a stable ordering for fields and alignedSources so editing one row doesn't reshuffle the others mid-keystroke (Map iteration order isn't preserved by + * io.circe encoders the way a List is). + */ +final case class DomainTypeForm( + primary: String, + description: String, + fields: Vector[DomainTypeForm.FieldRow], + aligned: Vector[DomainTypeForm.AlignedRow], + fieldsOpen: Boolean, + alignedOpen: Boolean, + generateOpen: Boolean, + // generate options + genBuilder: String, + genCopy: String, + genDomainType: String, + genInterface: String, + genMappers: String, + // pass-through for fields/options we don't edit yet + original: DomainType +) + +object DomainTypeForm { + + /** Editable row representing one entry of `fields: Map[String, FieldSpec]`. Round-trips the compact `FieldSpec` shape (just a type string); if the on-disk yaml used the verbose `FieldSpecObject` we + * preserve those settings (array / nullable / default / description) on `origin`, so saving doesn't drop them silently. + */ + final case class FieldRow(name: String, tpe: String, origin: Option[FieldSpec]) + + /** Editable row representing one entry of `alignedSources: Map[String, AlignedSource]`. Keeps the full typed value on `origin` for round-trip; the editable surface here is intentionally narrow + * (key, entity, mode, direction, readonly). Deep edits live on a sub-screen. + */ + final case class AlignedRow( + key: String, + entity: String, + mode: String, + direction: String, + readonly: String, + origin: AlignedSource + ) + + // ─────────────────────────────────── from DomainType ─────────────────────────────────── + + def of(dt: DomainType): DomainTypeForm = { + val fields = dt.fields.toVector + .sortBy(_._1) + .map { case (name, spec) => + FieldRow(name = name, tpe = specToTypeString(spec), origin = Some(spec)) + } + + val aligned = dt.alignedSources + .getOrElse(Map.empty) + .toVector + .sortBy(_._1) + .map { case (k, src) => + AlignedRow( + key = k, + entity = src.entity.getOrElse(""), + mode = src.mode.getOrElse(""), + direction = src.direction.getOrElse(""), + readonly = boolToStr(src.readonly), + origin = src + ) + } + + val gen = dt.generate.getOrElse(emptyGen) + + DomainTypeForm( + primary = dt.primary.getOrElse(""), + description = dt.description.getOrElse(""), + fields = fields, + aligned = aligned, + fieldsOpen = fields.nonEmpty, + alignedOpen = aligned.nonEmpty, + generateOpen = dt.generate.isDefined, + genBuilder = boolToStr(gen.builder), + genCopy = boolToStr(gen.copy), + genDomainType = boolToStr(gen.domainType), + genInterface = boolToStr(gen.interface), + genMappers = boolToStr(gen.mappers), + original = dt + ) + } + + // ─────────────────────────────────── to DomainType ─────────────────────────────────── + + def toDomainType(form: DomainTypeForm): DomainType = { + val fieldMap: Map[String, FieldSpec] = form.fields + .filter(_.name.trim.nonEmpty) + .map { row => + val name = row.name.trim + val tpe = row.tpe.trim + val spec: FieldSpec = row.origin match { + case Some(obj: FieldSpecObject) => + // Preserve verbose flags (array / nullable / default / description), update the type. + obj.copy(`type` = tpe) + case _ => + // Compact form covers the common case; default to it. + if (tpe.isEmpty) FieldSpecString(name) // best guess if user hasn't filled in + else FieldSpecString(tpe) + } + name -> spec + } + .toMap + + val alignedMap: Option[Map[String, AlignedSource]] = { + val pairs = form.aligned.filter(_.key.trim.nonEmpty).map { row => + val origin = row.origin + val updated = origin.copy( + entity = nonEmpty(row.entity).orElse(origin.entity), + mode = nonEmpty(row.mode).orElse(origin.mode), + direction = nonEmpty(row.direction).orElse(origin.direction), + readonly = parseBool(row.readonly).orElse(origin.readonly) + ) + row.key.trim -> updated + } + if (pairs.isEmpty) None else Some(pairs.toMap) + } + + val generate: Option[DomainGenerateOptions] = { + val raw = DomainGenerateOptions( + builder = parseBool(form.genBuilder), + canonical = form.original.generate.flatMap(_.canonical), + copy = parseBool(form.genCopy), + domainType = parseBool(form.genDomainType), + interface = parseBool(form.genInterface), + mappers = parseBool(form.genMappers) + ) + val empty = raw.builder.isEmpty && raw.canonical.isEmpty && raw.copy.isEmpty && + raw.domainType.isEmpty && raw.interface.isEmpty && raw.mappers.isEmpty + if (form.generateOpen && !empty) Some(raw) else None + } + + form.original.copy( + primary = nonEmpty(form.primary), + description = nonEmpty(form.description), + fields = fieldMap, + alignedSources = alignedMap, + generate = generate + ) + } + + // ─────────────────────────────────── helpers ─────────────────────────────────── + + private def specToTypeString(spec: FieldSpec): String = spec match { + case FieldSpecString(v) => v + case obj: FieldSpecObject => obj.`type` + case _ => "" + } + + private def nonEmpty(s: String): Option[String] = { + val t = s.trim + if (t.isEmpty) None else Some(t) + } + + private def boolToStr(b: Option[Boolean]): String = b match { + case Some(true) => "yes" + case Some(false) => "no" + case None => "" + } + + private def parseBool(s: String): Option[Boolean] = s.trim.toLowerCase match { + case "yes" | "true" | "y" | "1" => Some(true) + case "no" | "false" | "n" | "0" => Some(false) + case _ => None + } + + private val emptyGen = DomainGenerateOptions(None, None, None, None, None, None) + + /** Default values for a new row. */ + def newField: FieldRow = FieldRow(name = "", tpe = "", origin = None) + def newAligned: AlignedRow = AlignedRow( + key = "", + entity = "", + mode = "", + direction = "", + readonly = "", + origin = AlignedSource(None, None, None, None, None, None, None, None, None) + ) + +} diff --git a/typr/src/scala/typr/cli/app/screens/FieldTypeForm.scala b/typr/src/scala/typr/cli/app/screens/FieldTypeForm.scala new file mode 100644 index 0000000000..890e679742 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/FieldTypeForm.scala @@ -0,0 +1,289 @@ +package typr.cli.app.screens + +import typr.config.generated.{ApiMatch, DbMatch, FieldType, ModelMatch, StringOrArray, StringOrArrayArray, StringOrArrayString, ValidationRules} + +/** Flat string-only mirror of [[FieldType]]. Holds raw form values during editing; converts back to the typed config on save. Anything that's a [[StringOrArray]] is stored as a single comma-separated + * string; booleans as one of "", "yes", "no"; numerics as their decimal text. + * + * Round-trip is lossy in trivial ways (e.g. a `StringOrArrayString("foo")` and a one-element `StringOrArrayArray(List("foo"))` both encode as "foo" → on save we always emit the string variant when + * there's no comma). That matches the convention typr's existing yaml uses. + */ +final case class FieldTypeForm( + underlying: String, + apiOpen: Boolean, + dbOpen: Boolean, + modelOpen: Boolean, + validationOpen: Boolean, + // ApiMatch + apiName: String, + apiPath: String, + apiHttpMethod: String, + apiLocation: String, // comma-separated list of (path/query/header/cookie) + apiOperationId: String, + apiSource: String, + apiRequired: String, // "" | "yes" | "no" + apiExtension: String, // "k=v, k=v" + // DbMatch + dbColumn: String, + dbTable: String, + dbSchema: String, + dbType: String, + dbSource: String, + dbDomain: String, + dbComment: String, + dbReferences: String, + dbAnnotation: String, + dbNullable: String, + dbPrimaryKey: String, + // ModelMatch + modelName: String, + modelSchema: String, + modelSchemaType: String, + modelFormat: String, + modelJsonPath: String, + modelSource: String, + modelRequired: String, + modelExtension: String, + // ValidationRules + valPattern: String, + valMin: String, + valMax: String, + valExclusiveMin: String, + valExclusiveMax: String, + valMinLength: String, + valMaxLength: String, + valAllowedValues: String, // JSON-encoded list, e.g. ["foo", "bar"] + // original for round-tripping fields we don't touch + original: FieldType +) + +object FieldTypeForm { + + // ────────────────────────────────── from FieldType ────────────────────────────────── + + def of(ft: FieldType): FieldTypeForm = { + val api = ft.api.getOrElse(emptyApi) + val db = ft.db.getOrElse(emptyDb) + val mm = ft.model.getOrElse(emptyModel) + val vr = ft.validation.getOrElse(emptyVal) + FieldTypeForm( + underlying = ft.underlying.getOrElse(""), + apiOpen = ft.api.isDefined, + dbOpen = ft.db.isDefined, + modelOpen = ft.model.isDefined, + validationOpen = ft.validation.isDefined, + apiName = soaToStr(api.name), + apiPath = soaToStr(api.path), + apiHttpMethod = soaToStr(api.http_method), + apiLocation = api.location.map(_.mkString(", ")).getOrElse(""), + apiOperationId = soaToStr(api.operation_id), + apiSource = soaToStr(api.source), + apiRequired = boolToStr(api.required), + apiExtension = mapToStr(api.`extension`), + dbColumn = soaToStr(db.column), + dbTable = soaToStr(db.table), + dbSchema = soaToStr(db.schema), + dbType = soaToStr(db.db_type), + dbSource = soaToStr(db.source), + dbDomain = soaToStr(db.domain), + dbComment = soaToStr(db.comment), + dbReferences = soaToStr(db.references), + dbAnnotation = soaToStr(db.annotation), + dbNullable = boolToStr(db.nullable), + dbPrimaryKey = boolToStr(db.primary_key), + modelName = soaToStr(mm.name), + modelSchema = soaToStr(mm.schema), + modelSchemaType = soaToStr(mm.schema_type), + modelFormat = soaToStr(mm.format), + modelJsonPath = soaToStr(mm.json_path), + modelSource = soaToStr(mm.source), + modelRequired = boolToStr(mm.required), + modelExtension = mapToStr(mm.`extension`), + valPattern = vr.pattern.getOrElse(""), + valMin = doubleToStr(vr.min), + valMax = doubleToStr(vr.max), + valExclusiveMin = doubleToStr(vr.exclusive_min), + valExclusiveMax = doubleToStr(vr.exclusive_max), + valMinLength = longToStr(vr.min_length), + valMaxLength = longToStr(vr.max_length), + valAllowedValues = vr.allowed_values + .map(_.map(_.noSpaces).mkString(", ")) + .getOrElse(""), + original = ft + ) + } + + // ────────────────────────────────── to FieldType ────────────────────────────────── + + def toFieldType(form: FieldTypeForm): FieldType = { + val api: Option[ApiMatch] = { + val raw = ApiMatch( + `extension` = parseExtensionMap(form.apiExtension), + http_method = parseSoa(form.apiHttpMethod), + location = parseList(form.apiLocation), + name = parseSoa(form.apiName), + operation_id = parseSoa(form.apiOperationId), + path = parseSoa(form.apiPath), + required = parseBool(form.apiRequired), + source = parseSoa(form.apiSource) + ) + if (form.apiOpen && !isApiEmpty(raw)) Some(raw) else None + } + val db: Option[DbMatch] = { + val raw = DbMatch( + annotation = parseSoa(form.dbAnnotation), + column = parseSoa(form.dbColumn), + comment = parseSoa(form.dbComment), + db_type = parseSoa(form.dbType), + domain = parseSoa(form.dbDomain), + nullable = parseBool(form.dbNullable), + primary_key = parseBool(form.dbPrimaryKey), + references = parseSoa(form.dbReferences), + schema = parseSoa(form.dbSchema), + source = parseSoa(form.dbSource), + table = parseSoa(form.dbTable) + ) + if (form.dbOpen && !isDbEmpty(raw)) Some(raw) else None + } + val model: Option[ModelMatch] = { + val raw = ModelMatch( + `extension` = parseExtensionMap(form.modelExtension), + format = parseSoa(form.modelFormat), + json_path = parseSoa(form.modelJsonPath), + name = parseSoa(form.modelName), + required = parseBool(form.modelRequired), + schema = parseSoa(form.modelSchema), + schema_type = parseSoa(form.modelSchemaType), + source = parseSoa(form.modelSource) + ) + if (form.modelOpen && !isModelEmpty(raw)) Some(raw) else None + } + val validation: Option[ValidationRules] = { + val raw = ValidationRules( + allowed_values = parseAllowed(form.valAllowedValues), + exclusive_max = parseDouble(form.valExclusiveMax), + exclusive_min = parseDouble(form.valExclusiveMin), + max = parseDouble(form.valMax), + max_length = parseLong(form.valMaxLength), + min = parseDouble(form.valMin), + min_length = parseLong(form.valMinLength), + pattern = nonEmpty(form.valPattern) + ) + if (form.validationOpen && !isValEmpty(raw)) Some(raw) else None + } + FieldType( + api = api, + db = db, + model = model, + underlying = nonEmpty(form.underlying), + validation = validation + ) + } + + // ────────────────────────────────── primitives ────────────────────────────────── + + private def soaToStr(soa: Option[StringOrArray]): String = soa match { + case Some(StringOrArrayString(s)) => s + case Some(StringOrArrayArray(arr)) => arr.mkString(", ") + case _ => "" + } + + private def parseSoa(s: String): Option[StringOrArray] = { + val trimmed = s.trim + if (trimmed.isEmpty) None + else if (trimmed.contains(",")) Some(StringOrArrayArray(trimmed.split(",").iterator.map(_.trim).filter(_.nonEmpty).toList)) + else Some(StringOrArrayString(trimmed)) + } + + private def parseList(s: String): Option[List[String]] = { + val parts = s.split(",").iterator.map(_.trim).filter(_.nonEmpty).toList + if (parts.isEmpty) None else Some(parts) + } + + private def boolToStr(b: Option[Boolean]): String = b match { + case Some(true) => "yes" + case Some(false) => "no" + case None => "" + } + + private def parseBool(s: String): Option[Boolean] = s.trim.toLowerCase match { + case "yes" | "true" | "y" | "1" => Some(true) + case "no" | "false" | "n" | "0" => Some(false) + case _ => None + } + + private def mapToStr(m: Option[Map[String, String]]): String = + m.map(_.toList.sortBy(_._1).map { case (k, v) => s"$k=$v" }.mkString(", ")).getOrElse("") + + private def parseExtensionMap(s: String): Option[Map[String, String]] = { + val trimmed = s.trim + if (trimmed.isEmpty) None + else { + val pairs = trimmed + .split(",") + .iterator + .map(_.trim) + .filter(_.nonEmpty) + .flatMap { kv => + kv.split("=", 2) match { + case Array(k, v) => Some(k.trim -> v.trim) + case _ => None + } + } + .toMap + if (pairs.isEmpty) None else Some(pairs) + } + } + + private def doubleToStr(d: Option[Double]): String = d.map(_.toString).getOrElse("") + private def longToStr(l: Option[Long]): String = l.map(_.toString).getOrElse("") + + private def parseDouble(s: String): Option[Double] = + s.trim match { + case t if t.isEmpty => None + case t => scala.util.Try(t.toDouble).toOption + } + + private def parseLong(s: String): Option[Long] = + s.trim match { + case t if t.isEmpty => None + case t => scala.util.Try(t.toLong).toOption + } + + private def nonEmpty(s: String): Option[String] = { + val t = s.trim + if (t.isEmpty) None else Some(t) + } + + /** Treats allowed-values as a comma-separated list of strings. Loses the ability to express numeric / boolean allowed values from the UI (you can still edit those in yaml directly); for the + * field-type use cases this surfaces ("status in {active,inactive,pending}", etc.) the string variant covers what the editor actually offers. + */ + private def parseAllowed(s: String): Option[List[io.circe.Json]] = { + val parts = s.split(",").iterator.map(_.trim).filter(_.nonEmpty).toList + if (parts.isEmpty) None else Some(parts.map(io.circe.Json.fromString)) + } + + // ────────────────────────────────── empty templates / checks ────────────────────────────────── + + private val emptyApi = ApiMatch(None, None, None, None, None, None, None, None) + private val emptyDb = DbMatch(None, None, None, None, None, None, None, None, None, None, None) + private val emptyModel = ModelMatch(None, None, None, None, None, None, None, None) + private val emptyVal = ValidationRules(None, None, None, None, None, None, None, None) + + private def isApiEmpty(a: ApiMatch): Boolean = + a.name.isEmpty && a.path.isEmpty && a.http_method.isEmpty && a.location.isEmpty && + a.operation_id.isEmpty && a.source.isEmpty && a.required.isEmpty && a.`extension`.isEmpty + + private def isDbEmpty(d: DbMatch): Boolean = + d.column.isEmpty && d.table.isEmpty && d.schema.isEmpty && d.db_type.isEmpty && + d.source.isEmpty && d.domain.isEmpty && d.comment.isEmpty && d.references.isEmpty && + d.annotation.isEmpty && d.nullable.isEmpty && d.primary_key.isEmpty + + private def isModelEmpty(m: ModelMatch): Boolean = + m.name.isEmpty && m.schema.isEmpty && m.schema_type.isEmpty && m.format.isEmpty && + m.json_path.isEmpty && m.source.isEmpty && m.required.isEmpty && m.`extension`.isEmpty + + private def isValEmpty(v: ValidationRules): Boolean = + v.pattern.isEmpty && v.min.isEmpty && v.max.isEmpty && v.exclusive_min.isEmpty && + v.exclusive_max.isEmpty && v.min_length.isEmpty && v.max_length.isEmpty && v.allowed_values.isEmpty +} diff --git a/typr/src/scala/typr/cli/app/screens/Generate.scala b/typr/src/scala/typr/cli/app/screens/Generate.scala new file mode 100644 index 0000000000..ce441befd3 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/Generate.scala @@ -0,0 +1,341 @@ +package typr.cli.app.screens + +import cats.effect.ExitCode +import cats.effect.unsafe.implicits.global +import jatatui.components.chrome.ScreenFrame +import jatatui.components.router.RouterApi +import jatatui.components.scrollable.Scrollable +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.Borders +import jatatui.widgets.block.{Block, BorderType} +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.TypoLogger +import typr.cli.app.AppApi + +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicReference +import scala.jdk.CollectionConverters.* + +/** Drive a code-generation run from inside the TUI. + * + * Logging: passes a [[ListLogger]] into [[typr.cli.commands.Generate.run]] (the 5-arg overload). All `info`/`warn` lands in the panel below; nothing spills to stdout. + * + * Generation runs on a daemon thread; the panel updates live via `requestRerender` every time a new entry lands. Esc / b only navigate away once the run has settled. + */ +object Generate { + + sealed trait Status + object Status { + case object Idle extends Status + case object Running extends Status + final case class Done(exitCode: ExitCode) extends Status + final case class Failed(error: Throwable) extends Status + } + + sealed trait Level + object Level { + case object Info extends Level + case object Warn extends Level + } + + final case class LogEntry(level: Level, message: String, timestampMs: Long) + + final class ListLogger(rerender: Runnable) extends TypoLogger { + private val entries = new ConcurrentLinkedQueue[LogEntry] + + def snapshot: List[LogEntry] = entries.iterator.asScala.toList + + override def info(msg: String): Unit = push(Level.Info, msg) + override def warn(msg: String): Unit = push(Level.Warn, msg) + + private def push(level: Level, msg: String): Unit = { + entries.add(LogEntry(level, msg, System.currentTimeMillis())) + rerender.run() + } + } + + val element: Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val rerender = ctx.requestRerender() + val statusRef = ctx.useRef(() => new AtomicReference[Status](Status.Idle)) + val logger = ctx.useRef(() => new ListLogger(rerender)) + val state = ctx.useState(() => Status.Idle: Status) + val trackerRef = ctx.useRef(() => new AtomicReference[Option[typr.cli.commands.Generate.ProgressTracker]](None)) + + val live = statusRef.get.get() + if (state.get != live) state.set(live) + + ctx.useEffect(() => { + statusRef.get.set(Status.Running) + rerender.run() + + val configPathStr = app.configPath.toString + val worker = new Thread( + () => { + val result: Status = + try + Status.Done( + typr.cli.commands.Generate + .run( + configPathStr, + None, + quiet = false, + debug = false, + logger.get, + onTrackerReady = t => { trackerRef.get.set(Some(t)); rerender.run() } + ) + .unsafeRunSync() + ) + catch case e: Throwable => Status.Failed(e) + statusRef.get.set(result) + rerender.run() + }, + "typr-generate" + ) + worker.setDaemon(true) + worker.start() + }) + + // Periodically nudge a rerender while the run is in flight so per-output duration + // counters and tracker states refresh even between log-emitting events. A daemon ticker + // that exits as soon as status leaves Running keeps the cost bounded. + ctx.useEffect(() => { + val ticker = new Thread( + () => { + while (statusRef.get.get() == Status.Running) { + try Thread.sleep(500L) + catch case _: InterruptedException => () + rerender.run() + } + }, + "generate-tui-ticker" + ) + ticker.setDaemon(true) + ticker.start() + }) + + val canGoBack: Boolean = state.get match { + case Status.Idle | Status.Done(_) | Status.Failed(_) => true + case Status.Running => false + } + + val back = () => router.pop() + if (canGoBack) { + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + } + + val (label, color) = state.get match { + case Status.Idle => ("waiting to start…", Color.GRAY) + case Status.Running => ("generating…", Color.CYAN) + case Status.Done(ExitCode.Success) => ("done · success", Color.GREEN) + case Status.Done(code) => (s"done · exit ${code.code}", Color.YELLOW) + case Status.Failed(err) => + (s"failed · ${err.getClass.getSimpleName}: ${trim(err.getMessage)}", Color.RED) + } + + val hint = state.get match { + case Status.Running => "this can take a while on a clean build" + case Status.Idle => "" + case _ => "esc or b to return" + } + + val statusStyle = Style.empty.withFg(color).withAddModifier(Modifier.BOLD) + val hintStyle = Style.empty.withFg(Color.DARK_GRAY) + + val trackerOpt = trackerRef.get.get() + val sourcesPane = + scrollPanel(sourcesPanelHeader(app), sourcesPanelRows(app)) + val outputsPane = + scrollPanel(outputsPanelHeader(trackerOpt), trackerOpt.map(outputsPanelRows).getOrElse(Nil)) + + val body = column( + length(1, text(s" $label", statusStyle)), + length(1, text(s" $hint", hintStyle)), + length(1, empty()), + fill( + 2, + jatatui.react.Components.row( + fill(1, sourcesPane), + length(2, empty()), + fill(1, outputsPane) + ) + ), + fill(1, logPanel(logger.get)) + ) + + // Back button stays in the corner while generating but is inert — visual consistency + // beats an empty corner mid-run. canGoBack already gates Esc/b above. + ScreenFrame.of("menu", () => if (canGoBack) back() else (), body) + } + + private val panelTitleStyle = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val panelMutedStyle = Style.empty.withFg(Color.DARK_GRAY) + + /** Standard side-panel layout: title row at top (always visible), then a scrollable column of rows below. Both halves of the Generate split use this — sources on the left, outputs on the right. + */ + private def scrollPanel(header: Element, rows: List[Element]): Element = + column(length(1, header), fill(1, Scrollable.column(rows.asJava))) + + // ─────────────────────────────────── sources panel ─────────────────────────────────── + + private def sourcesPanelHeader(app: AppApi): Element = { + val sources = app.config.sources.getOrElse(Map.empty) + val statuses = sources.keys.map(n => app.loadStatus.getOrElse(n, typr.cli.app.LoadStatus.NotLoaded)).toList + val total = statuses.size + val loadedCount = statuses.count(_ == typr.cli.app.LoadStatus.Loaded) + val loadingCount = statuses.count(_ == typr.cli.app.LoadStatus.Loading) + val failedCount = statuses.count(_.isInstanceOf[typr.cli.app.LoadStatus.Failed]) + val extras = List( + if (loadingCount > 0) Some(s"$loadingCount loading") else None, + if (failedCount > 0) Some(s"$failedCount failed") else None + ).flatten + val suffix = if (extras.isEmpty) "" else extras.mkString(" · ", " · ", "") + text(s" sources ($loadedCount/$total loaded$suffix)", panelTitleStyle) + } + + private def sourcesPanelRows(app: AppApi): List[Element] = { + val sources = app.config.sources.getOrElse(Map.empty).toList.sortBy(_._1) + if (sources.isEmpty) return List(length(1, text(" no sources configured", panelMutedStyle))) + + val nameStyle = Style.empty.withFg(Color.WHITE) + val maxNameWidth = sources.map(_._1.length).max + 2 + + sources.map { case (sourceName, _) => + val status = app.loadStatus.getOrElse(sourceName, typr.cli.app.LoadStatus.NotLoaded) + val (icon, statusText, statusStyle) = status match { + case typr.cli.app.LoadStatus.Loaded => ("●", "ready", Style.empty.withFg(Color.GREEN)) + case typr.cli.app.LoadStatus.Loading => ("◕", "fetching…", Style.empty.withFg(Color.CYAN)) + case typr.cli.app.LoadStatus.Failed(err) => ("✗", err.take(80), Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD)) + case typr.cli.app.LoadStatus.NotLoaded => ("○", "queued", panelMutedStyle) + } + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(s" $icon ", statusStyle), + Span.styled(sourceName.padTo(maxNameWidth, ' '), nameStyle), + Span.styled(statusText, panelMutedStyle) + ) + Paragraph.of(line).render(area, buffer) + } + length(1, widget(w)) + } + } + + // ─────────────────────────────────── outputs panel ─────────────────────────────────── + + private def outputsPanelHeader(tracker: Option[typr.cli.commands.Generate.ProgressTracker]): Element = + tracker match { + case None => text(" outputs", panelTitleStyle) + case Some(t) => text(s" outputs (${t.completedCount}/${t.totalCount})", panelTitleStyle) + } + + private def outputsPanelRows(tracker: typr.cli.commands.Generate.ProgressTracker): List[Element] = { + import typr.cli.commands.Generate.{OutputProgress, OutputStatus} + val outputs = tracker.getAll + if (outputs.isEmpty) return List(length(1, text(" (waiting for outputs)", panelMutedStyle))) + + val pendingStyle = panelMutedStyle + val runningStyle = Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD) + val okStyle = Style.empty.withFg(Color.GREEN) + val failStyle = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + val skipStyle = Style.empty.withFg(Color.YELLOW) + + val maxNameWidth = outputs.map(_.name.length).max + 2 + + def cellFor(p: OutputProgress): (String, Style, String) = p.status match { + case OutputStatus.Pending => ("○", pendingStyle, "pending") + case OutputStatus.Processing(b, s) => ("◕", runningStyle, s"$s · $b".take(40)) + case OutputStatus.Completed(b, f) => ("●", okStyle, s"$b boundaries · $f files") + case OutputStatus.Failed(err) => ("✗", failStyle, s"failed: ${err.take(60)}") + case OutputStatus.Skipped => ("—", skipStyle, "skipped") + } + + val rows: List[Element] = outputs.map { p => + val (icon, iconStyle, statusText) = cellFor(p) + val files = (p.filesWritten, p.filesUnchanged, p.filesDeleted) match { + case (0, 0, 0) => "" + case (w, u, d) => + val parts = List( + if (w > 0) Some(s"+$w") else None, + if (u > 0) Some(s"=$u") else None, + if (d > 0) Some(s"-$d") else None + ).flatten + if (parts.isEmpty) "" else s" ${parts.mkString(" ")}" + } + val dur = p.durationStr match { + case "" => "" + case d => s" $d" + } + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(s" $icon ", iconStyle), + Span.styled(p.name.padTo(maxNameWidth, ' '), Style.empty.withFg(Color.WHITE)), + Span.styled(statusText, panelMutedStyle), + Span.styled(files, Style.empty.withFg(Color.GREEN)), + Span.styled(dur, panelMutedStyle) + ) + Paragraph.of(line).render(area, buffer) + } + length(1, widget(w)) + } + + val (succ, fail, skip) = tracker.summary + val (tw, tu, td) = tracker.getTotalFiles + val footer = length( + 1, + text( + f" totals $succ%d ok · $fail%d fail · $skip%d skip · +$tw =$tu -$td · ${tracker.elapsedSeconds}%.1fs", + panelMutedStyle + ) + ) + + rows ::: List(length(1, empty()), footer) + } + + private def logPanel(logger: ListLogger): Element = { + val borderStyle = Style.empty.withFg(Color.DARK_GRAY) + val titleStyle = Style.empty.withFg(Color.GRAY) + val infoStyle = Style.empty.withFg(Color.GRAY) + val warnStyle = Style.empty.withFg(Color.YELLOW) + + val w: Widget = (area, buffer) => { + if (area.width >= 4 && area.height >= 3) { + val block = Block.empty + .withTitle(Line.styled(" output ", titleStyle)) + .withBorders(Borders.ALL) + .withBorderType(BorderType.Rounded) + .withBorderStyle(borderStyle) + + val targetLines = math.max(1, area.height - 2) + val visible = logger.snapshot.takeRight(targetLines) + val lines = visible.map { e => + val style = e.level match { + case Level.Info => infoStyle + case Level.Warn => warnStyle + } + val prefix = e.level match { + case Level.Info => " " + case Level.Warn => "⚠ " + } + Line.from(Span.styled(s"$prefix${e.message}", style)) + } + val javaLines = java.util.List.copyOf(scala.jdk.CollectionConverters.SeqHasAsJava(lines).asJava) + Paragraph.of(javaLines).withBlock(block).render(area, buffer) + } + } + + widget(w) + } + + private def trim(s: String): String = { + val raw = Option(s).getOrElse("(no message)") + if (raw.length <= 120) raw else raw.take(117) + "…" + } +} diff --git a/typr/src/scala/typr/cli/app/screens/MainMenu.scala b/typr/src/scala/typr/cli/app/screens/MainMenu.scala new file mode 100644 index 0000000000..a9671002f0 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/MainMenu.scala @@ -0,0 +1,143 @@ +package typr.cli.app.screens + +import jatatui.components.chrome.ScreenFrame +import typr.cli.app.components.Link +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.Borders +import jatatui.widgets.block.{Block, BorderType} +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.cli.app.AppApi + +/** Main menu — a 3×2 grid of cards filling the available area. Tab cycles, Enter activates, click activates directly. Esc / q quits the host. + */ +object MainMenu { + + enum Action { + case Sources, Schemas, Outputs, DomainTypes, FieldTypes, Generate + } + + private case class Item( + action: Action, + icon: String, + title: String, + pitch: String, + detail: String, + hint: String + ) + + private val items: List[Item] = List( + Item(Action.Sources, "◈", "Sources", "Database Connections", "Postgres, MariaDB, Oracle, SQL Server, DuckDB", "where typr reads schemas from"), + Item(Action.Schemas, "◇", "Schema Browser", "Explore Database", "Tables, columns, types, and relationships", "see what's there before you generate"), + Item(Action.Outputs, "▣", "Outputs", "Code Targets", "Scala, Java, or Kotlin code generation", "languages, libraries, output paths"), + Item( + Action.DomainTypes, + "✦", + "Domain Types", + "Define Once, Use Everywhere", + "Canonical types aligned to multiple sources", + "e.g. Customer → postgres, mariadb, api" + ), + Item(Action.FieldTypes, "◬", "Field Types", "Field-Level Type Safety", "Map columns to custom types by pattern", "e.g. *_id → CustomerId, *email* → Email"), + Item(Action.Generate, "▶", "Generate", "Run Code Generation", "Emit repositories, row types, and DSL", "from the current configuration") + ) + + private val brandStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val taglineStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + + val element: Element = + component { ctx => + val app = AppApi.use(ctx) + val router = RouterApi.useRouter(ctx) + + ctx.onGlobalKey(new KeyCode.Esc, () => app.quit()) + ctx.onGlobalKey(new KeyCode.Char('q'), () => app.quit()) + + val title = column( + length(1, text(" typr", brandStyle)), + length(1, text(" seal your system's boundaries", taglineStyle)) + ) + + val pairs = items.grouped(2).toList + val rows: List[Element] = pairs.zipWithIndex.map { case (pair, rowIdx) => + val cards: Array[Element] = pair.zipWithIndex.map { case (item, colIdx) => + val globalIdx = rowIdx * 2 + colIdx + val (label, target) = targetFor(item.action) + card(item, autoFocus = globalIdx == 0, () => router.push(RouterScreen.of(label, target))) + }.toArray + row(cards*) + } + val grid = column(rows*) + + val body = column( + fill(1, grid), + length(1, text(" tab navigate · enter select · q quit", hintStyle)) + ) + + ScreenFrame.withTitle("exit", () => app.quit(), title, body) + } + + private def targetFor(action: Action): (String, Element) = action match { + case Action.Sources => ("sources", SourceList.element) + case Action.Outputs => ("outputs", OutputList.element) + case Action.DomainTypes => ("domain types", TypeList.element(TypeList.Kind.Domain)) + case Action.FieldTypes => ("field types", TypeList.element(TypeList.Kind.Field)) + case Action.Generate => ("generate", Generate.element) + case Action.Schemas => ("schemas", SchemaPicker.element) + } + + /** Single card. Link.focusable handles focus + click + Enter; we just paint based on focus. */ + private def card(item: Item, autoFocus: Boolean, onActivate: Runnable): Element = { + Link.focusable( + autoFocus, + onActivate, + { (focused: Boolean) => + val borderType = if (focused) BorderType.Thick else BorderType.Rounded + val borderStyle = Style.empty.withFg(if (focused) Color.YELLOW else Color.DARK_GRAY) + val iconStyle = + Style.empty + .withFg(if (focused) Color.YELLOW else Color.MAGENTA) + .withAddModifier(Modifier.BOLD) + val titleStyle = + if (focused) Style.empty.withFg(Color.BLACK).withBg(Color.YELLOW).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.GRAY).withAddModifier(Modifier.BOLD) + val pitchStyle = Style.empty.withFg(if (focused) Color.YELLOW else Color.DARK_GRAY) + val detailStyle = Style.empty.withFg(if (focused) Color.WHITE else Color.GRAY) + val cardHint = Style.empty.withFg(Color.DARK_GRAY) + + // Caret in the icon slot when focused — unambiguous "this is the one". + val iconCell = if (focused) "▶" else item.icon + val titleLine = Line.from( + Span.styled(s" $iconCell ", iconStyle), + Span.styled(s" ${item.title} ", titleStyle) + ) + + val body = java.util.List.of( + Line.empty(), + Line.from(Span.styled(s" ${item.pitch}", pitchStyle)), + Line.empty(), + Line.from(Span.styled(s" ${item.detail}", detailStyle)), + Line.empty(), + Line.from(Span.styled(s" ${item.hint}", cardHint)) + ) + + val cardWidget: Widget = (area, buffer) => { + val block = Block.empty + .withTitle(titleLine) + .withBorders(Borders.ALL) + .withBorderType(borderType) + .withBorderStyle(borderStyle) + Paragraph.of(body).withBlock(block).render(area, buffer) + } + + widget(cardWidget) + } + ) + } +} diff --git a/typr/src/scala/typr/cli/app/screens/OutputEditor.scala b/typr/src/scala/typr/cli/app/screens/OutputEditor.scala new file mode 100644 index 0000000000..edce2b7637 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/OutputEditor.scala @@ -0,0 +1,186 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.button.Button +import jatatui.components.chrome.ScreenFrame +import jatatui.components.router.RouterApi +import jatatui.components.textinput.{TextInputComponent, TextInputProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.react.Element +import jatatui.react.Components.* +import tui.crossterm.KeyCode +import typr.cli.app.AppApi +import typr.config.generated.{Output, StringOrArray, StringOrArrayArray, StringOrArrayString} + +/** Form-based editor for a single output. One [[TextInputComponent]] per attribute. Save writes the whole output map back through [[AppApi.updateConfig]]. + * + * Common attributes only for the first cut: language, path, package, sources, db_lib, json, framework, effect_type. The `matchers`, `bridge`, and scala-specific `Json` blob are deeper nested and + * worth their own dedicated editors later. + */ +object OutputEditor { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val okStyle: Style = Style.empty.withFg(Color.GREEN).withAddModifier(Modifier.BOLD) + private val valueStyle: Style = Style.empty.withFg(Color.WHITE) + private val focusedValueStyle: Style = Style.empty.withFg(Color.YELLOW).withAddModifier(Modifier.BOLD) + private val cursorStyle: Style = Style.empty.withAddModifier(Modifier.REVERSED) + + sealed trait SaveResult + object SaveResult { + case object None extends SaveResult + case object Saved extends SaveResult + final case class Failed(error: String) extends SaveResult + } + + /** Flat mutable state for the form. `package` is keyword-clashed so the field is named `pkg` here; we map back on write. Optional fields stay as `Option[String]` so we can round-trip "not set" + * cleanly. + */ + final case class FormState( + language: String, + path: String, + pkg: String, + sources: String, // comma-separated; we split on save + dbLib: String, + json: String, + framework: String, + effectType: String, + original: Output + ) + + object FormState { + def from(out: Output): FormState = FormState( + language = out.language, + path = out.path, + pkg = out.`package`, + sources = out.sources match { + case Some(StringOrArrayString(s)) => s + case Some(StringOrArrayArray(arr)) => arr.mkString(", ") + case None => "" + }, + dbLib = out.db_lib.getOrElse(""), + json = out.json.getOrElse(""), + framework = out.framework.getOrElse(""), + effectType = out.effect_type.getOrElse(""), + original = out + ) + + /** Round-trip back to an Output, preserving fields we don't expose (matchers, scala, bridge). */ + def toOutput(f: FormState): Output = { + val sourcesField: Option[StringOrArray] = { + val parts = f.sources.split(",").iterator.map(_.trim).filter(_.nonEmpty).toList + parts match { + case Nil => None + case s :: Nil => Some(StringOrArrayString(s)) + case xs => Some(StringOrArrayArray(xs)) + } + } + f.original.copy( + language = f.language, + path = f.path, + `package` = f.pkg, + sources = sourcesField, + db_lib = Option(f.dbLib).filter(_.nonEmpty), + json = Option(f.json).filter(_.nonEmpty), + framework = Option(f.framework).filter(_.nonEmpty), + effect_type = Option(f.effectType).filter(_.nonEmpty) + ) + } + } + + def element(name: String): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + + val initial = app.config.outputs.getOrElse(Map.empty).get(name).map(FormState.from) + initial match { + case None => notFound(name, back) + case Some(s) => editor(name, s, app, back) + } + } + + private def notFound(name: String, back: () => Unit): Element = { + val body = column( + fill(1, empty()), + length(1, text(s" output '$name' not found", errStyle)), + length(1, text(" press esc to return", hintStyle)), + fill(1, empty()) + ) + ScreenFrame.of("outputs", back, body) + } + + private def editor(name: String, initial: FormState, app: AppApi, back: () => Unit): Element = + component { ctx => + val form = ctx.useState(() => initial) + val saved = ctx.useState(() => SaveResult.None: SaveResult) + + def save(): Unit = { + val newOutput = FormState.toOutput(form.get) + val newOutputs = app.config.outputs.getOrElse(Map.empty) + (name -> newOutput) + app.updateConfig(c => c.copy(outputs = Some(newOutputs))) match { + case Right(_) => saved.set(SaveResult.Saved) + case Left(e) => saved.set(SaveResult.Failed(Option(e.getMessage).getOrElse(e.toString))) + } + } + + val fields: List[Element] = List( + length(3, fieldRow("language", "out:language", form.get.language, autoFocus = true, v => { form.update(_.copy(language = v)); saved.set(SaveResult.None) })), + length(3, fieldRow("path", "out:path", form.get.path, autoFocus = false, v => { form.update(_.copy(path = v)); saved.set(SaveResult.None) })), + length(3, fieldRow("package", "out:pkg", form.get.pkg, autoFocus = false, v => { form.update(_.copy(pkg = v)); saved.set(SaveResult.None) })), + length(3, fieldRow("sources", "out:sources", form.get.sources, autoFocus = false, v => { form.update(_.copy(sources = v)); saved.set(SaveResult.None) })), + length(3, fieldRow("db_lib", "out:dbLib", form.get.dbLib, autoFocus = false, v => { form.update(_.copy(dbLib = v)); saved.set(SaveResult.None) })), + length(3, fieldRow("json", "out:json", form.get.json, autoFocus = false, v => { form.update(_.copy(json = v)); saved.set(SaveResult.None) })), + length(3, fieldRow("framework", "out:framework", form.get.framework, autoFocus = false, v => { form.update(_.copy(framework = v)); saved.set(SaveResult.None) })), + length(3, fieldRow("effect_type", "out:effect", form.get.effectType, autoFocus = false, v => { form.update(_.copy(effectType = v)); saved.set(SaveResult.None) })) + ) + + val saveButton = length(3, Button.of("save", "save", true, () => save())) + + val statusLine = saved.get match { + case SaveResult.None => text("", hintStyle) + case SaveResult.Saved => text(s" ✓ saved to ${app.configPath.getFileName}", okStyle) + case SaveResult.Failed(err) => text(s" ✗ save failed: ${err.take(120)}", errStyle) + } + + val title = column( + length(1, empty()), + length(1, text(s" output · $name · ${form.get.language}", titleStyle)) + ) + + val body = column( + (fields + ::: List( + length(1, empty()), + saveButton, + length(1, statusLine), + fill(1, empty()), + length(1, text(" tab navigate · enter (on save) commit · esc back", hintStyle)) + ))* + ) + + ScreenFrame.withTitle("outputs", back, title, body) + } + + private def fieldRow( + label: String, + focusId: String, + value: String, + autoFocus: Boolean, + onChange: String => Unit + ): Element = + TextInputComponent.of( + TextInputProps + .of(value, v => onChange(v)) + .withTitle(label) + .withFocusId(focusId) + .withAutoFocus(autoFocus) + .withStyle(valueStyle) + .withFocusedStyle(focusedValueStyle) + .withCursorStyle(cursorStyle) + ) +} diff --git a/typr/src/scala/typr/cli/app/screens/OutputList.scala b/typr/src/scala/typr/cli/app/screens/OutputList.scala new file mode 100644 index 0000000000..6ce4d4d1fc --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/OutputList.scala @@ -0,0 +1,181 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.chrome.ScreenFrame +import typr.cli.app.components.Link +import jatatui.components.modal.ConfirmDialog +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.components.scrollable.Scrollable +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.Borders +import jatatui.widgets.block.{Block, BorderType} +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.cli.app.AppApi +import typr.config.generated.{Output, StringOrArray, StringOrArrayArray, StringOrArrayString} + +import scala.jdk.CollectionConverters.* + +/** List of configured outputs. Same shape as [[SourceList]]. */ +object OutputList { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + + val element: Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + val pendingDelete = ctx.useState(() => Option.empty[String]) + val deleteError = ctx.useState(() => Option.empty[String]) + + if (pendingDelete.get.isEmpty) { + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + } + + def doDelete(name: String): Unit = { + val remaining = app.config.outputs.getOrElse(Map.empty) - name + val nextMap = if (remaining.isEmpty) None else Some(remaining) + app.updateConfig(c => c.copy(outputs = nextMap)) match { + case Right(_) => + pendingDelete.set(None) + deleteError.set(None) + case Left(e) => + deleteError.set(Some(Option(e.getMessage).getOrElse(e.toString))) + pendingDelete.set(None) + } + } + + val outputs = app.config.outputs.getOrElse(Map.empty).toList.sortBy(_._1) + + val addCard = length( + 3, + addNewCard( + autoFocus = true, + onActivate = () => router.push(RouterScreen.of("add output", OutputWizard.element)) + ) + ) + val outputCards: List[Element] = outputs.map { case (name, output) => + length( + 3, + outputCard( + name, + output, + () => pendingDelete.set(Some(name)), + () => router.push(RouterScreen.of(s"output · $name", OutputEditor.element(name))) + ) + ) + } + + val title = column(length(1, empty()), length(1, text(" outputs", titleStyle))) + + val errorLine = + deleteError.get.fold(length(0, empty())) { msg => + length(1, text(s" ✗ ${msg.take(140)}", Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD))) + } + + val body = column( + fill(1, Scrollable.column((addCard :: outputCards).asJava)), + errorLine, + length(1, text(" tab navigate · enter open · d delete · wheel to scroll · esc back", hintStyle)) + ) + + val frame = ScreenFrame.withTitle("menu", back, title, body) + + val confirm = ConfirmDialog.of( + pendingDelete.get.isDefined, + " delete output ", + pendingDelete.get.fold("")(nm => s"Delete '$nm'? This cannot be undone."), + "delete", + "cancel", + true, + () => pendingDelete.get.foreach(doDelete), + () => pendingDelete.set(None) + ) + + stack(frame, confirm) + } + + private def addNewCard(autoFocus: Boolean, onActivate: Runnable): Element = + Link.focusable( + autoFocus, + onActivate, + { (focused: Boolean) => + val borderType = if (focused) BorderType.Thick else BorderType.Rounded + val borderStyle = Style.empty.withFg(if (focused) Color.GREEN else Color.DARK_GRAY) + val labelStyle = + Style.empty.withFg(if (focused) Color.GREEN else Color.GRAY).withAddModifier(Modifier.BOLD) + + val w: Widget = (area, buffer) => { + val block = Block.empty + .withBorders(Borders.ALL) + .withBorderType(borderType) + .withBorderStyle(borderStyle) + Paragraph + .of(Line.from(Span.styled(" + add new output", labelStyle))) + .withBlock(block) + .render(area, buffer) + } + widget(w) + } + ) + + private def outputCard(name: String, output: Output, onDelete: () => Unit, onActivate: Runnable): Element = + Link.focusable( + false, + onActivate, + { (focused: Boolean) => + component { c => + if (focused) c.onKey(new KeyCode.Char('d'), () => onDelete()) + + val borderType = if (focused) BorderType.Thick else BorderType.Rounded + val borderStyle = Style.empty.withFg(if (focused) Color.CYAN else Color.DARK_GRAY) + + val titleLine = Line.from( + Span.styled(s" ${languageIcon(output.language)} ", borderStyle), + Span.styled( + s"$name ", + if (focused) Style.empty.withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.GRAY).withAddModifier(Modifier.BOLD) + ), + Span.styled( + s"· ${output.language} ", + Style.empty.withFg(if (focused) Color.CYAN else Color.DARK_GRAY) + ) + ) + + val detail = s" ${output.path} · ${output.`package`} · ${sourcesLabel(output.sources)}" + val detailStyle = Style.empty.withFg(if (focused) Color.WHITE else Color.GRAY) + + val w: Widget = (area, buffer) => { + val block = Block.empty + .withTitle(titleLine) + .withBorders(Borders.ALL) + .withBorderType(borderType) + .withBorderStyle(borderStyle) + Paragraph.of(detail).withBlock(block).withStyle(detailStyle).render(area, buffer) + } + widget(w) + } + } + ) + + private def languageIcon(lang: String): String = lang match { + case "scala" => "𝓢" + case "java" => "𝓙" + case "kotlin" => "𝓚" + case _ => "◇" + } + + private def sourcesLabel(sources: Option[StringOrArray]): String = sources match { + case Some(StringOrArrayString(s)) => s + case Some(StringOrArrayArray(arr)) => arr.mkString(", ") + case None => "*" + } +} diff --git a/typr/src/scala/typr/cli/app/screens/OutputWizard.scala b/typr/src/scala/typr/cli/app/screens/OutputWizard.scala new file mode 100644 index 0000000000..db3376979f --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/OutputWizard.scala @@ -0,0 +1,145 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.button.Button +import jatatui.components.chrome.ScreenFrame +import jatatui.components.dropdown.{Dropdown, DropdownProps} +import jatatui.components.router.RouterApi +import jatatui.components.textinput.{TextInputComponent, TextInputProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.react.Components.* +import jatatui.react.Element +import tui.crossterm.KeyCode +import typr.cli.app.AppApi +import typr.config.generated.{Output, StringOrArrayString} + +import scala.jdk.CollectionConverters.* + +/** New-output wizard. Required: name, language, path, package. Optional: sources / db_lib / json / framework / effect_type (defaults to empty). Save validates name uniqueness, writes typr.yaml, pops + * back. + */ +object OutputWizard { + + private val languages: List[String] = List("scala", "java", "kotlin") + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val valueStyle: Style = Style.empty.withFg(Color.WHITE) + private val focusedValueStyle: Style = Style.empty.withFg(Color.YELLOW).withAddModifier(Modifier.BOLD) + private val cursorStyle: Style = Style.empty.withAddModifier(Modifier.REVERSED) + + sealed trait Status + object Status { + case object Idle extends Status + final case class Failed(error: String) extends Status + } + + val element: Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + + val name = ctx.useState(() => "") + val langIdx = ctx.useState(() => 0) + val path = ctx.useState(() => "") + val pkg = ctx.useState(() => "") + val sources = ctx.useState(() => "") + val status = ctx.useState(() => Status.Idle: Status) + + def save(): Unit = { + val n = name.get.trim + if (n.isEmpty) + status.set(Status.Failed("name required")) + else if (path.get.trim.isEmpty) + status.set(Status.Failed("path required")) + else if (pkg.get.trim.isEmpty) + status.set(Status.Failed("package required")) + else if (app.config.outputs.getOrElse(Map.empty).contains(n)) + status.set(Status.Failed(s"output '$n' already exists")) + else { + val newOutput = Output( + bridge = None, + db_lib = None, + effect_type = None, + framework = None, + json = None, + language = languages(langIdx.get), + matchers = None, + `package` = pkg.get.trim, + path = path.get.trim, + scala = None, + sources = Option(sources.get.trim).filter(_.nonEmpty).map(StringOrArrayString.apply) + ) + val newOutputs = app.config.outputs.getOrElse(Map.empty) + (n -> newOutput) + app.updateConfig(c => c.copy(outputs = Some(newOutputs))) match { + case Right(_) => router.pop() + case Left(e) => status.set(Status.Failed(Option(e.getMessage).getOrElse(e.toString))) + } + } + } + + def textRow(value: String, label: String, focusId: String, autoFocus: Boolean, onChange: String => Unit) = + length( + 3, + TextInputComponent.of( + TextInputProps + .of(value, v => { onChange(v); status.set(Status.Idle) }) + .withTitle(label) + .withFocusId(focusId) + .withAutoFocus(autoFocus) + .withStyle(valueStyle) + .withFocusedStyle(focusedValueStyle) + .withCursorStyle(cursorStyle) + ) + ) + + val rows = List( + textRow(name.get, "name", "wiz:name", autoFocus = true, name.set), + length( + 3, + Dropdown.of( + DropdownProps + .of[String]( + "language", + languages.asJava, + (s: String) => s, + langIdx.get, + (idx: Int) => langIdx.set(idx) + ) + .withFocusId("wiz:lang") + .withStyle(valueStyle) + .withFocusedStyle(focusedValueStyle) + ) + ), + textRow(path.get, "path", "wiz:path", autoFocus = false, path.set), + textRow(pkg.get, "package", "wiz:pkg", autoFocus = false, pkg.set), + textRow(sources.get, "sources", "wiz:sources", autoFocus = false, sources.set) + ) + + val saveButton = length(3, Button.of("save", "save", true, () => save())) + + val statusLine = status.get match { + case Status.Idle => text("", hintStyle) + case Status.Failed(err) => text(s" ✗ $err", errStyle) + } + + val title = column(length(1, empty()), length(1, text(" add output", titleStyle))) + + val body = column( + (rows + ::: List( + length(1, empty()), + saveButton, + length(1, statusLine), + fill(1, empty()), + length(1, text(" tab navigate · enter (on save) commit · esc cancel", hintStyle)) + ))* + ) + + ScreenFrame.withTitle("outputs", back, title, body) + } +} diff --git a/typr/src/scala/typr/cli/app/screens/Placeholder.scala b/typr/src/scala/typr/cli/app/screens/Placeholder.scala new file mode 100644 index 0000000000..d1ff1c4ac6 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/Placeholder.scala @@ -0,0 +1,31 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.router.RouterApi +import jatatui.core.style.{Color, Style} +import jatatui.react.Components.* +import jatatui.react.Element +import tui.crossterm.KeyCode +import jatatui.components.chrome.ScreenFrame + +/** Stand-in screen for menu destinations that haven't been ported yet. Esc / b pops the router. */ +object Placeholder { + + def element(label: String): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + + val body = column( + fill(1, empty()), + length(1, text(s" $label — not yet ported", Style.empty.withFg(Color.YELLOW))), + length(1, text(" press esc or b to return", Style.empty.withFg(Color.DARK_GRAY))), + fill(1, empty()) + ) + + ScreenFrame.of("menu", back, body) + } +} diff --git a/typr/src/scala/typr/cli/app/screens/ProtoBrowser.scala b/typr/src/scala/typr/cli/app/screens/ProtoBrowser.scala new file mode 100644 index 0000000000..afc9b116c4 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/ProtoBrowser.scala @@ -0,0 +1,561 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.chrome.ScreenFrame +import jatatui.components.picker.{Picker, PickerProps} +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.components.search.FuzzyMatch +import jatatui.components.selectablelist.{SelectableList, SelectableListProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.cli.app.{AppApi, LoadStatus, LoadedSource} +import typr.cli.app.components.CaretCell +import typr.grpc.* + +import scala.jdk.CollectionConverters.* + +/** Per-source Protobuf / gRPC schema browser. Mirrors [[SpecBrowser]] / [[AvroBrowser]]: background-fetched, cached, with search + collapsible tree. + * + * Sections in the tree: Services (each expandable into its RPC methods, color-coded by RPC pattern), Messages (collapsible into fields, oneof groups callout above the affected fields), Enums + * (collapsible into values). + */ +object ProtoBrowser { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val infoStyle: Style = Style.empty.withFg(Color.GRAY) + private val sectionStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val serviceStyle: Style = Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD) + private val messageStyle: Style = Style.empty.withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + private val enumStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val fieldNameStyle: Style = Style.empty.withFg(Color.CYAN) + private val fieldTypeStyle: Style = Style.empty.withFg(Color.YELLOW) + private val oneofStyle: Style = Style.empty.withFg(Color.GREEN) + + sealed trait TreeRow + object TreeRow { + final case class SectionHeader(label: String) extends TreeRow + final case class ServiceRow(svc: ProtoService, isOpen: Boolean) extends TreeRow + final case class MethodRow(method: ProtoMethod) extends TreeRow + final case class MessageRow(msg: ProtoMessage, isOpen: Boolean) extends TreeRow + final case class EnumRow(en: ProtoEnum, isOpen: Boolean) extends TreeRow + final case class OneofRow(name: String) extends TreeRow + final case class FieldRow(name: String, number: Int, tpe: String, label: String, oneof: Option[String], nameWidth: Int, typeWidth: Int) extends TreeRow + final case class EnumValueRow(name: String, number: Int) extends TreeRow + case object Blank extends TreeRow + } + + private final case class SearchItem(label: String, kind: SearchKind, anchorKey: String) + private sealed trait SearchKind + private object SearchKind { + case object Service extends SearchKind + case object Method extends SearchKind + case object Message extends SearchKind + case object Enum extends SearchKind + case object Field extends SearchKind + case object Value extends SearchKind + } + + def element(name: String): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + + val title = column(length(1, empty()), length(1, text(s" grpc · $name", titleStyle))) + val status = app.loadStatus.getOrElse(name, LoadStatus.NotLoaded) + val body = app.sourceCache.get(name) match { + case Some(LoadedSource.Proto(files)) => loadedBody(files) + case Some(_) => failedBody(s"source '$name' is not a grpc source") + case None => + status match { + case LoadStatus.Failed(err) => failedBody(err) + case LoadStatus.NotLoaded => notInConfigBody(name) + case _ => loadingBody() + } + } + ScreenFrame.withTitle("schemas", back, title, body) + } + + private def notInConfigBody(name: String): Element = column( + length(1, text(s" source '$name' not in config", errStyle)), + length(1, empty()), + length(1, text(" esc to return", hintStyle)), + fill(1, empty()) + ) + + private def loadingBody(): Element = column( + length(1, text(" compiling protos…", Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD))), + length(1, empty()), + length(1, text(" running protoc, building descriptors. (requires `protoc` on PATH)", infoStyle)), + fill(1, empty()) + ) + + private def failedBody(err: String): Element = column( + length(1, text(s" ✗ ${err.take(400)}", errStyle)), + length(1, empty()), + length(1, text(" hint: install protobuf (brew install protobuf) and ensure protoc is on PATH", hintStyle)), + length(1, text(" esc to return", hintStyle)), + fill(1, empty()) + ) + + private def loadedBody(files: List[ProtoFile]): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val expanded = ctx.useState(() => Set.empty[String]) + val searchOpen = ctx.useState(() => false) + + ctx.onKey( + new KeyCode.Char('/'), + (e: jatatui.react.KeyEvent) => { + if (!searchOpen.get) { + searchOpen.set(true) + e.stopPropagation() + } + } + ) + + val services = files.flatMap(_.services).sortBy(_.fullName.toLowerCase) + val messages = files.flatMap(allMessages).sortBy(_.fullName.toLowerCase) + val enums = (files.flatMap(_.enums) ::: messages.flatMap(_.nestedEnums)).sortBy(_.fullName.toLowerCase) + + val rows: List[TreeRow] = treeRows(services, messages, enums, expanded.get) + + val anchorIdx: Map[String, Int] = + rows.zipWithIndex.collect { + case (TreeRow.ServiceRow(s, _), i) => serviceKey(s) -> i + case (TreeRow.MessageRow(m, _), i) => messageKey(m) -> i + case (TreeRow.EnumRow(e, _), i) => enumKeyOf(e) -> i + }.toMap + + val selectedIdx = ctx.useState(() => + rows.zipWithIndex + .collectFirst { case (TreeRow.ServiceRow(_, _), i) => i } + .getOrElse( + rows.zipWithIndex.collectFirst { case (TreeRow.MessageRow(_, _), i) => i }.getOrElse(0) + ) + ) + + ctx.onKey( + new KeyCode.Right, + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case TreeRow.ServiceRow(s, false) => expanded.update(_ + serviceKey(s)); e.stopPropagation() + case TreeRow.ServiceRow(_, true) if hasChildAt(rows, sel + 1) => selectedIdx.set(sel + 1); e.stopPropagation() + case TreeRow.MessageRow(m, false) => expanded.update(_ + messageKey(m)); e.stopPropagation() + case TreeRow.MessageRow(_, true) if hasChildAt(rows, sel + 1) => selectedIdx.set(sel + 1); e.stopPropagation() + case TreeRow.EnumRow(en, false) => expanded.update(_ + enumKeyOf(en)); e.stopPropagation() + case TreeRow.EnumRow(_, true) if hasChildAt(rows, sel + 1) => selectedIdx.set(sel + 1); e.stopPropagation() + case _ => () + } + } + ) + ctx.onKey( + new KeyCode.Left, + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case TreeRow.ServiceRow(s, true) => expanded.update(_ - serviceKey(s)); e.stopPropagation() + case TreeRow.MessageRow(m, true) => expanded.update(_ - messageKey(m)); e.stopPropagation() + case TreeRow.EnumRow(en, true) => expanded.update(_ - enumKeyOf(en)); e.stopPropagation() + case _: TreeRow.MethodRow | _: TreeRow.FieldRow | _: TreeRow.EnumValueRow | _: TreeRow.OneofRow => + val parent = (sel - 1 to 0 by -1).find(i => + rows(i).isInstanceOf[TreeRow.ServiceRow] + || rows(i).isInstanceOf[TreeRow.MessageRow] + || rows(i).isInstanceOf[TreeRow.EnumRow] + ) + parent.foreach(selectedIdx.set) + e.stopPropagation() + case _ => () + } + } + ) + + // 'c' on a Message: create a DomainType from this proto message. + ctx.onKey( + new KeyCode.Char('c'), + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case _: TreeRow.MessageRow => + router.push(RouterScreen.of("create domain type", DomainFromEntity.element)) + e.stopPropagation() + case _ => () + } + } + ) + + val searchItems = buildIndex(services, messages, enums) + val indexedRef = ctx.useRef(() => FuzzyMatch.index[SearchItem](searchItems.asJava, (it: SearchItem) => it.label)) + + val methodCount = services.map(_.methods.size).sum + val info = Line.from( + Span.styled(s" ${files.size} file${plural(files.size)}", sectionStyle), + Span.styled(s" · ${services.size} service${plural(services.size)}", infoStyle), + Span.styled(s" · $methodCount method${plural(methodCount)}", infoStyle), + Span.styled(s" · ${messages.size} message${plural(messages.size)}", infoStyle), + Span.styled(s" · ${enums.size} enum${plural(enums.size)}", infoStyle), + Span.styled(s" · press / to search", hintStyle) + ) + val infoLine: Widget = (area, buffer) => Paragraph.of(info).render(area, buffer) + + val tree = column( + length(1, widget(infoLine)), + length(1, empty()), + fill( + 1, + SelectableList.of( + SelectableListProps + .of[TreeRow]( + rows.asJava, + isActivatable, + (row, selected) => renderTreeRow(row, selected, key => expanded.update(t => toggle(t, key))), + selectedIdx.get, + idx => selectedIdx.set(idx) + ) + .withOnActivate { (row: TreeRow) => + row match { + case TreeRow.ServiceRow(s, _) => expanded.update(t => toggle(t, serviceKey(s))) + case TreeRow.MessageRow(m, _) => expanded.update(t => toggle(t, messageKey(m))) + case TreeRow.EnumRow(en, _) => expanded.update(t => toggle(t, enumKeyOf(en))) + case _ => () + } + } + .withAutoFocus(true) + ) + ), + length(1, text(" ↑↓ navigate · ←→ collapse/expand · c create domain type · / search · esc back", hintStyle)) + ) + + if (!searchOpen.get) tree + else { + val filter: PickerProps.Filter[SearchItem] = (query: String) => + if (query.isEmpty) java.util.List.of() + else FuzzyMatch.rank(query, indexedRef.get).stream.limit(120).toList + val rowRenderer: PickerProps.RowRenderer[SearchItem] = (item, selected) => hitRow(item, selected) + stack( + tree, + Picker.of( + PickerProps + .of[SearchItem]( + "search", + filter, + rowRenderer, + (item: SearchItem) => { + expanded.update(_ + item.anchorKey) + anchorIdx.get(item.anchorKey).foreach(selectedIdx.set) + searchOpen.set(false) + }, + () => searchOpen.set(false) + ) + ) + ) + } + } + + // ───────────────────────────────────── tree building ───────────────────────────────────────── + + /** Flatten nested messages into a single list so they show up as top-level. We still note the parent via fullName so the tree is searchable. + */ + private def allMessages(file: ProtoFile): List[ProtoMessage] = { + def walk(msg: ProtoMessage): List[ProtoMessage] = + msg :: msg.nestedMessages.flatMap(walk) + file.messages.flatMap(walk).filterNot(_.isMapEntry) + } + + private def treeRows( + services: List[ProtoService], + messages: List[ProtoMessage], + enums: List[ProtoEnum], + expanded: Set[String] + ): List[TreeRow] = { + val out = List.newBuilder[TreeRow] + if (services.nonEmpty) { + out += TreeRow.SectionHeader(s"Services (${services.size})") + services.foreach { s => + val key = serviceKey(s) + val isOpen = expanded.contains(key) + out += TreeRow.ServiceRow(s, isOpen) + if (isOpen) s.methods.foreach(m => out += TreeRow.MethodRow(m)) + } + out += TreeRow.Blank + } + if (messages.nonEmpty) { + out += TreeRow.SectionHeader(s"Messages (${messages.size})") + messages.foreach { m => + val key = messageKey(m) + val isOpen = expanded.contains(key) + out += TreeRow.MessageRow(m, isOpen) + if (isOpen) { + val rendered = m.fields.map(f => (f.name, f.number, renderType(f.fieldType), labelText(f), oneofFor(f, m))) + val nameWidth = rendered.map(_._1.length).maxOption.getOrElse(0) + val typeWidth = rendered.map(_._3.length).maxOption.getOrElse(0) + // Render oneof groups inline above their fields by walking ordered list. + val seenOneofs = scala.collection.mutable.Set.empty[String] + rendered.foreach { case (n, num, t, lbl, oneof) => + oneof match { + case Some(g) if !seenOneofs.contains(g) => + seenOneofs += g + out += TreeRow.OneofRow(g) + out += TreeRow.FieldRow(n, num, t, lbl, Some(g), nameWidth, typeWidth) + case Some(g) => + out += TreeRow.FieldRow(n, num, t, lbl, Some(g), nameWidth, typeWidth) + case None => + out += TreeRow.FieldRow(n, num, t, lbl, None, nameWidth, typeWidth) + } + } + } + } + out += TreeRow.Blank + } + if (enums.nonEmpty) { + out += TreeRow.SectionHeader(s"Enums (${enums.size})") + enums.foreach { en => + val key = enumKeyOf(en) + val isOpen = expanded.contains(key) + out += TreeRow.EnumRow(en, isOpen) + if (isOpen) en.values.foreach(v => out += TreeRow.EnumValueRow(v.name, v.number)) + } + out += TreeRow.Blank + } + out.result() + } + + private def oneofFor(field: ProtoField, msg: ProtoMessage): Option[String] = + field.oneofIndex + .filter(idx => idx >= 0 && idx < msg.oneofs.size) + .map(idx => msg.oneofs(idx).name) + + // ───────────────────────────────────── row rendering ───────────────────────────────────────── + + private def renderTreeRow(row: TreeRow, selected: Boolean, toggleExpand: String => Unit): Element = row match { + case TreeRow.SectionHeader(label) => + text(s" $label", sectionStyle) + + case TreeRow.Blank => empty() + + case TreeRow.ServiceRow(s, isOpen) => + val nameStyle = if (selected) serviceStyle.withBg(Color.BLUE) else serviceStyle + val tagStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(s.fullName, nameStyle), + Span.styled(s" service (${s.methods.size} RPCs)", tagStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(serviceKey(s)))), + fill(1, widget(w)) + ) + + case TreeRow.MethodRow(m) => + val (label, style) = rpcLabel(m) + val sigStyle = + if (selected) Style.empty.withFg(Color.WHITE).withBg(Color.BLUE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.WHITE) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" ", Style.empty), + Span.styled(padLeft(label, 8), style.withAddModifier(Modifier.BOLD)), + Span.styled(" ", Style.empty), + Span.styled(m.name, sigStyle), + Span.styled(s" ${shortName(m.inputType)} → ${shortName(m.outputType)}", Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY)) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + + case TreeRow.MessageRow(m, isOpen) => + val nameStyle = if (selected) messageStyle.withBg(Color.BLUE) else messageStyle + val tagStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(m.fullName, nameStyle), + Span.styled(s" message (${m.fields.size} fields)", tagStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(messageKey(m)))), + fill(1, widget(w)) + ) + + case TreeRow.EnumRow(en, isOpen) => + val nameStyle = if (selected) enumStyle.withBg(Color.BLUE) else enumStyle + val tagStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(en.fullName, nameStyle), + Span.styled(s" enum (${en.values.size} values)", tagStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(enumKeyOf(en)))), + fill(1, widget(w)) + ) + + case TreeRow.OneofRow(name) => + text(s" ─ oneof $name ─", oneofStyle) + + case TreeRow.FieldRow(name, number, tpe, label, _, nameWidth, typeWidth) => + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" ", Style.empty), + Span.styled(padRight(s"$number.", 4), Style.empty.withFg(Color.DARK_GRAY)), + Span.styled(name.padTo(nameWidth, ' '), fieldNameStyle), + Span.styled(" ", Style.empty), + Span.styled(tpe.padTo(typeWidth, ' '), fieldTypeStyle), + Span.styled(if (label.isEmpty) "" else s" $label", Style.empty.withFg(Color.DARK_GRAY)) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + + case TreeRow.EnumValueRow(name, number) => + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" · ", Style.empty.withFg(Color.DARK_GRAY)), + Span.styled(name, fieldTypeStyle), + Span.styled(s" = $number", Style.empty.withFg(Color.DARK_GRAY)) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + } + + private def isActivatable(row: TreeRow): Boolean = row match { + case _: TreeRow.SectionHeader => false + case _: TreeRow.OneofRow => false + case TreeRow.Blank => false + case _ => true + } + + private def hasChildAt(rows: List[TreeRow], idx: Int): Boolean = + idx >= 0 && idx < rows.size && (rows(idx) match { + case _: TreeRow.MethodRow | _: TreeRow.FieldRow | _: TreeRow.EnumValueRow | _: TreeRow.OneofRow => true + case _ => false + }) + + // ───────────────────────────────────── search index ───────────────────────────────────────── + + private def buildIndex( + services: List[ProtoService], + messages: List[ProtoMessage], + enums: List[ProtoEnum] + ): List[SearchItem] = { + val b = List.newBuilder[SearchItem] + services.foreach { s => + b += SearchItem(s"service: ${s.fullName}", SearchKind.Service, serviceKey(s)) + s.methods.foreach { m => + b += SearchItem(s"${s.fullName}.${m.name} (${shortName(m.inputType)} → ${shortName(m.outputType)})", SearchKind.Method, serviceKey(s)) + } + } + messages.foreach { m => + b += SearchItem(s"message: ${m.fullName}", SearchKind.Message, messageKey(m)) + m.fields.foreach { f => + b += SearchItem(s"${m.fullName}.${f.name} : ${renderType(f.fieldType)}", SearchKind.Field, messageKey(m)) + } + } + enums.foreach { en => + b += SearchItem(s"enum: ${en.fullName}", SearchKind.Enum, enumKeyOf(en)) + en.values.foreach(v => b += SearchItem(s"${en.fullName}.${v.name}", SearchKind.Value, enumKeyOf(en))) + } + b.result() + } + + private def hitRow(item: SearchItem, selected: Boolean): Element = { + val (icon, color) = item.kind match { + case SearchKind.Service => ("◈", Color.CYAN) + case SearchKind.Method => ("→", Color.WHITE) + case SearchKind.Message => ("▦", Color.YELLOW) + case SearchKind.Enum => ("∪", Color.MAGENTA) + case SearchKind.Field => ("·", Color.WHITE) + case SearchKind.Value => ("·", Color.GREEN) + } + val style = + if (selected) Style.empty.withBg(Color.BLUE).withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(color) + val prefix = if (selected) " ▶ " else " " + text(s"$prefix$icon ${item.label}", style) + } + + // ───────────────────────────────────── keys + labels ───────────────────────────────────────── + + private def serviceKey(s: ProtoService): String = s"svc:${s.fullName}" + private def messageKey(m: ProtoMessage): String = s"msg:${m.fullName}" + private def enumKeyOf(en: ProtoEnum): String = s"enum:${en.fullName}" + + private def labelText(field: ProtoField): String = { + val parts = List.newBuilder[String] + if (field.isRepeated) parts += "repeated" + if (field.isMapField) parts += "map" + if (field.proto3Optional) parts += "optional" + field.label match { + case ProtoFieldLabel.Required if !field.isRepeated => parts += "required" + case _ => () + } + parts.result().mkString(" ") + } + + private def rpcLabel(m: ProtoMethod): (String, Style) = m.rpcPattern match { + case RpcPattern.Unary => ("UNARY", Style.empty.withFg(Color.GREEN)) + case RpcPattern.ServerStreaming => ("STREAM→", Style.empty.withFg(Color.YELLOW)) + case RpcPattern.ClientStreaming => ("←STREAM", Style.empty.withFg(Color.BLUE)) + case RpcPattern.BidiStreaming => ("↔STREAM", Style.empty.withFg(Color.MAGENTA)) + } + + private def shortName(fullName: String): String = + fullName.split('.').lastOption.getOrElse(fullName) + + private def renderType(t: ProtoType): String = t match { + case ProtoType.Double => "double" + case ProtoType.Float => "float" + case ProtoType.Int32 => "int32" + case ProtoType.Int64 => "int64" + case ProtoType.UInt32 => "uint32" + case ProtoType.UInt64 => "uint64" + case ProtoType.SInt32 => "sint32" + case ProtoType.SInt64 => "sint64" + case ProtoType.Fixed32 => "fixed32" + case ProtoType.Fixed64 => "fixed64" + case ProtoType.SFixed32 => "sfixed32" + case ProtoType.SFixed64 => "sfixed64" + case ProtoType.Bool => "bool" + case ProtoType.String => "string" + case ProtoType.Bytes => "bytes" + case ProtoType.Message(fn) => shortName(fn) + case ProtoType.Enum(fn) => shortName(fn) + case ProtoType.Map(k, v) => s"map<${renderType(k)}, ${renderType(v)}>" + case ProtoType.Timestamp => "Timestamp" + case ProtoType.Duration => "Duration" + case ProtoType.StringValue => "StringValue?" + case ProtoType.Int32Value => "Int32Value?" + case ProtoType.Int64Value => "Int64Value?" + case ProtoType.UInt32Value => "UInt32Value?" + case ProtoType.UInt64Value => "UInt64Value?" + case ProtoType.FloatValue => "FloatValue?" + case ProtoType.DoubleValue => "DoubleValue?" + case ProtoType.BoolValue => "BoolValue?" + case ProtoType.BytesValue => "BytesValue?" + case ProtoType.Any => "Any" + case ProtoType.Struct => "Struct" + case other => other.toString.takeWhile(_ != '(') + } + + private def toggle(s: Set[String], k: String): Set[String] = if (s.contains(k)) s - k else s + k + private def plural(n: Int): String = if (n == 1) "" else "s" + private def padLeft(s: String, n: Int): String = if (s.length >= n) s else " " * (n - s.length) + s + private def padRight(s: String, n: Int): String = if (s.length >= n) s else s + " " * (n - s.length) +} diff --git a/typr/src/scala/typr/cli/app/screens/SchemaBrowser.scala b/typr/src/scala/typr/cli/app/screens/SchemaBrowser.scala new file mode 100644 index 0000000000..abd1b23fba --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/SchemaBrowser.scala @@ -0,0 +1,416 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.chrome.ScreenFrame +import jatatui.components.picker.{Picker, PickerProps} +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.components.search.FuzzyMatch +import jatatui.components.selectablelist.{SelectableList, SelectableListProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.MetaDb +import typr.cli.app.{AppApi, Entity, LoadStatus, LoadedSource} +import typr.cli.app.components.CaretCell +import typr.config.generated.{BridgeType, DbMatch, FieldType, StringOrArray, StringOrArrayString} + +import scala.jdk.CollectionConverters.* + +/** Per-source schema browser. On mount it spins up a daemon thread that calls [[MetaDbFetch.fetch]] (which in turn does the boundary parse, builds a Hikari datasource, initialises ExternalTools, and + * runs `MetaDb.fromDb`). Progress is rendered live via the same [[ListLogger]] the Generate screen uses. + * + * Once loaded, relations are listed sorted by schema/name in a [[jatatui.components.selectablelist .SelectableList]] (heterogeneous rows: schema headers, relation rows, expanded column rows). Enter + * on a relation toggles its column view. + * + * `/` opens a [[jatatui.components.picker.Picker]] over the tree; the filter strategy is our own [[FuzzyMatch]] for IntelliJ-style camel/snake matching. Selecting a hit expands the parent relation + * and closes the picker. + */ +object SchemaBrowser { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val infoStyle: Style = Style.empty.withFg(Color.GRAY) + private val schemaStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val tableStyle: Style = Style.empty.withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + private val colNameStyle: Style = Style.empty.withFg(Color.CYAN) + private val colTypeStyle: Style = Style.empty.withFg(Color.YELLOW) + private val colNullStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + + /** One row of the relations tree. Sealed so the [[jatatui.components.selectablelist .SelectableListProps.RowRenderer]] can pattern-match instead of guessing. + */ + sealed trait TreeRow + object TreeRow { + final case class SchemaHeader(label: String) extends TreeRow + final case class RelationRow(rel: typr.db.Relation, isOpen: Boolean) extends TreeRow + final case class ColumnRow(name: String, tpe: String, nullable: Boolean, nameWidth: Int, typeWidth: Int) extends TreeRow + case object Blank extends TreeRow + } + + /** One row of the search index. */ + private final case class SearchItem(label: String, relation: typr.db.RelationName, column: Option[String]) + + def element(name: String): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + + val title = column(length(1, empty()), length(1, text(s" schema · $name", titleStyle))) + val status = app.loadStatus.getOrElse(name, LoadStatus.NotLoaded) + val body = app.sourceCache.get(name) match { + case Some(LoadedSource.Db(metaDb)) => loadedBody(name, metaDb, app, router) + case Some(_) => failedBody(s"source '$name' is not a database source") + case None => + status match { + case LoadStatus.Failed(err) => failedBody(err) + case LoadStatus.NotLoaded => notInConfigBody(name) + case _ => loadingBody() + } + } + ScreenFrame.withTitle("schemas", back, title, body) + } + + private def loadingBody(): Element = column( + length(1, text(" connecting…", Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD))), + length(1, empty()), + length(1, text(" introspecting the database. The Shell loads sources in the background;", infoStyle)), + length(1, text(" this view becomes live as soon as that completes.", infoStyle)), + fill(1, empty()) + ) + + private def notInConfigBody(name: String): Element = column( + length(1, text(s" source '$name' not in config", errStyle)), + length(1, empty()), + length(1, text(" esc to return", hintStyle)), + fill(1, empty()) + ) + + private def failedBody(err: String): Element = column( + length(1, text(s" ✗ ${err.take(200)}", errStyle)), + length(1, empty()), + length(1, text(" esc to return", hintStyle)), + fill(1, empty()) + ) + + private def loadedBody(sourceName: String, metaDb: MetaDb, app: AppApi, router: jatatui.components.router.RouterApi): Element = + component { ctx => + val expanded = ctx.useState(() => Set.empty[String]) + val searchOpen = ctx.useState(() => false) + + val relations = metaDb.relations.values.flatMap(_.get.toList).toList.sortBy(_.name) + + // Open the search modal on '/'. Bubble-phase + stopPropagation so the outer Esc handler + // (which closes the screen) doesn't fire too. Picker forcibly claims focus on mount as + // of jatatui 0.30.0+10 — no need for app-side ctx.focus. + ctx.onKey( + new KeyCode.Char('/'), + (e: jatatui.react.KeyEvent) => { + if (!searchOpen.get) { + searchOpen.set(true) + e.stopPropagation() + } + } + ) + + if (relations.isEmpty) + column( + fill(1, empty()), + length(1, text(" (no tables / views)", infoStyle)), + fill(1, empty()) + ) + else { + // Build the flat tree row list. + val rows: List[TreeRow] = treeRows(relations, expanded.get) + + // Find the index of a relation in `rows`, so search-commit can position selection. + val relationIdx: Map[String, Int] = + rows.zipWithIndex.collect { case (TreeRow.RelationRow(r, _), i) => r.name.value -> i }.toMap + + val selectedIdx = ctx.useState(() => rows.zipWithIndex.collectFirst { case (TreeRow.RelationRow(_, _), i) => i }.getOrElse(0)) + + // Tree-view left/right expand-collapse, bubble-phase from the SelectableList's focus. + // Right on a closed relation: open it. + // Right on an open relation: jump selection to its first column. + // Left on an open relation: close it. + // Left on a column row: jump back up to the parent relation. + ctx.onKey( + new KeyCode.Right, + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case TreeRow.RelationRow(rel, false) => + expanded.update(_ + rel.name.value) + e.stopPropagation() + case TreeRow.RelationRow(_, true) if sel + 1 < rows.size && rows(sel + 1).isInstanceOf[TreeRow.ColumnRow] => + selectedIdx.set(sel + 1) + e.stopPropagation() + case _ => () + } + } + ) + ctx.onKey( + new KeyCode.Left, + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case TreeRow.RelationRow(rel, true) => + expanded.update(_ - rel.name.value) + e.stopPropagation() + case _: TreeRow.ColumnRow => + // Walk back up to the most recent RelationRow. + val parent = (sel - 1 to 0 by -1).find(i => rows(i).isInstanceOf[TreeRow.RelationRow]) + parent.foreach(selectedIdx.set) + e.stopPropagation() + case _ => () + } + } + ) + + // 'e' on a ColumnRow: extract this column as a FieldType. Walks back to find the + // RelationRow that owns this column so the generated DbMatch can include the table. + ctx.onKey( + new KeyCode.Char('e'), + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case col: TreeRow.ColumnRow => + val parentRel = (sel - 1 to 0 by -1).iterator + .map(i => rows(i)) + .collectFirst { case TreeRow.RelationRow(r, _) => r } + parentRel.foreach(rel => extractFieldType(sourceName, rel, col, app, router)) + e.stopPropagation() + case _ => () + } + } + ) + + // 'c' on a RelationRow: create a DomainType from this relation. Pushes the entity-based + // discovery screen so the user also sees alignment suggestions from other sources. + ctx.onKey( + new KeyCode.Char('c'), + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case TreeRow.RelationRow(_, _) => + router.push(RouterScreen.of("create domain type", DomainFromEntity.element)) + e.stopPropagation() + case _ => () + } + } + ) + + // Search index (built once per mount via useRef). + val searchItems = buildIndex(relations) + val indexedRef = ctx.useRef(() => FuzzyMatch.index[SearchItem](searchItems.asJava, (it: SearchItem) => it.label)) + + val tree = column( + length( + 1, + text( + s" ${relations.size} relation${if (relations.size == 1) "" else "s"} · ${metaDb.dbType} · press / to search", + infoStyle + ) + ), + length(1, empty()), + fill( + 1, + SelectableList.of( + SelectableListProps + .of[TreeRow]( + rows.asJava, + row => row.isInstanceOf[TreeRow.RelationRow], + (row, selected) => renderTreeRow(row, selected, key => expanded.update { s => if (s.contains(key)) s - key else s + key }), + selectedIdx.get, + idx => selectedIdx.set(idx) + ) + .withOnActivate { (row: TreeRow) => + row match { + case TreeRow.RelationRow(rel, _) => + expanded.update { s => + val key = rel.name.value + if (s.contains(key)) s - key else s + key + } + case _ => () + } + } + .withAutoFocus(true) + ) + ), + length(1, text(" ↑↓ navigate · ←→ collapse/expand · e extract field type · c create domain type · / search · esc back", hintStyle)) + ) + + if (!searchOpen.get) tree + else { + val filter: PickerProps.Filter[SearchItem] = (query: String) => + if (query.isEmpty) java.util.List.of() + else FuzzyMatch.rank(query, indexedRef.get).stream.limit(100).toList + + val rowRenderer: PickerProps.RowRenderer[SearchItem] = (item, selected) => hitRow(item, selected) + + stack( + tree, + Picker.of( + PickerProps + .of[SearchItem]( + "search", + filter, + rowRenderer, + (item: SearchItem) => { + expanded.update(_ + item.relation.value) + // Jump selection to the (now-expanded) relation row so it's visible. + relationIdx.get(item.relation.value).foreach(selectedIdx.set) + searchOpen.set(false) + }, + () => searchOpen.set(false) + ) + ) + ) + } + } + } + + private def treeRows(relations: List[typr.db.Relation], expanded: Set[String]): List[TreeRow] = { + val grouped = relations.groupBy(_.name.schema.getOrElse("")).toList.sortBy(_._1) + grouped.flatMap { case (schema, rels) => + val header = TreeRow.SchemaHeader(if (schema.isEmpty) "(default)" else schema) + val entries = rels.flatMap { rel => + val key = rel.name.value + val isOpen = expanded.contains(key) + val relRow = TreeRow.RelationRow(rel, isOpen) + val colRows: List[TreeRow] = if (isOpen) columnRowsFor(rel) else Nil + relRow :: colRows + } + header :: entries ::: List(TreeRow.Blank) + } + } + + private def columnRowsFor(rel: typr.db.Relation): List[TreeRow] = { + val cols: List[(String, String, Boolean)] = rel match { + case t: typr.db.Table => + t.cols.toList.map(c => (c.name.value, columnTypeLabel(c.tpe), isNullable(c.nullability))) + case v: typr.db.View => + v.cols.toList.map { case (c, _) => (c.name.value, columnTypeLabel(c.tpe), isNullable(c.nullability)) } + } + val nameWidth = cols.map(_._1.length).maxOption.getOrElse(0) + val typeWidth = cols.map(_._2.length).maxOption.getOrElse(0) + cols.map { case (name, tpe, nullable) => + TreeRow.ColumnRow(name, tpe, nullable, nameWidth, typeWidth) + } + } + + /** Renders one row of the tree given the SelectableList's reported selection state. */ + private def renderTreeRow(row: TreeRow, selected: Boolean, toggleExpand: String => Unit): Element = row match { + case TreeRow.SchemaHeader(label) => text(s" $label/", schemaStyle) + case TreeRow.Blank => empty() + case TreeRow.RelationRow(rel, isOpen) => + val nameStyle = if (selected) tableStyle.withBg(Color.BLUE) else tableStyle + val countStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val colCount = rel match { + case t: typr.db.Table => t.cols.toList.size + case v: typr.db.View => v.cols.toList.size + } + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(rel.name.name, nameStyle), + Span.styled(s" ($colCount cols)", countStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(rel.name.value))), + fill(1, widget(w)) + ) + case TreeRow.ColumnRow(name, tpe, nullable, nameWidth, typeWidth) => + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" ", colNameStyle), + Span.styled(name.padTo(nameWidth, ' '), colNameStyle), + Span.styled(" ", colNameStyle), + Span.styled(tpe.padTo(typeWidth, ' '), colTypeStyle), + Span.styled(if (nullable) " null" else " not null", colNullStyle) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + } + + /** Draft a FieldType from a column, write it to typr.yaml, navigate to TypeEditor for refinement. Match pattern is scoped to this `source:table.column` by default — narrow but unambiguous; the user + * can broaden it (drop the table, switch to a glob) in the editor. + */ + private def extractFieldType( + sourceName: String, + rel: typr.db.Relation, + col: TreeRow.ColumnRow, + app: AppApi, + router: jatatui.components.router.RouterApi + ): Unit = { + val existing = app.config.types.getOrElse(Map.empty) + val typeName = uniqueTypeName(Entity.pascalCase(col.name), existing.keySet) + val tableName = rel.name.name + val schemaName = rel.name.schema.filter(_.nonEmpty) + val ft = FieldType( + api = None, + db = Some( + DbMatch( + annotation = None, + column = Some(StringOrArrayString(col.name)), + comment = None, + db_type = None, + domain = None, + nullable = None, + primary_key = None, + references = None, + schema = schemaName.map(s => StringOrArrayString(s): StringOrArray), + source = Some(StringOrArrayString(sourceName)), + table = Some(StringOrArrayString(tableName)) + ) + ), + model = None, + underlying = Some(col.tpe), + validation = None + ) + val next = existing + (typeName -> (ft: BridgeType)) + val _ = app.updateConfig(c => c.copy(types = Some(next))) + router.push(RouterScreen.of(s"type · $typeName", TypeEditor.element(typeName))) + } + + private def uniqueTypeName(suggested: String, existing: Set[String]): String = + if (!existing.contains(suggested)) suggested + else LazyList.from(2).map(n => s"$suggested$n").find(n => !existing.contains(n)).get + + private def buildIndex(rels: List[typr.db.Relation]): List[SearchItem] = + rels.flatMap { rel => + val relLabel = rel.name.value + val cols = rel match { + case t: typr.db.Table => + t.cols.toList.map(c => SearchItem(s"$relLabel.${c.name.value}", rel.name, Some(c.name.value))) + case v: typr.db.View => + v.cols.toList.map { case (c, _) => SearchItem(s"$relLabel.${c.name.value}", rel.name, Some(c.name.value)) } + } + SearchItem(relLabel, rel.name, None) :: cols + } + + private def hitRow(item: SearchItem, selected: Boolean): Element = { + val style = + if (selected) Style.empty.withBg(Color.BLUE).withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.GRAY) + val prefix = if (selected) " ▶ " else " " + val icon = if (item.column.isDefined) "·" else "◇" + text(s"$prefix$icon ${item.label}", style) + } + + private def columnTypeLabel(tpe: typr.db.Type): String = tpe.toString + private def isNullable(n: typr.Nullability): Boolean = n match { + case typr.Nullability.Nullable => true + case typr.Nullability.NullableUnknown => true + case _ => false + } +} diff --git a/typr/src/scala/typr/cli/app/screens/SchemaPicker.scala b/typr/src/scala/typr/cli/app/screens/SchemaPicker.scala new file mode 100644 index 0000000000..73e5143094 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/SchemaPicker.scala @@ -0,0 +1,236 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import io.circe.Json +import jatatui.components.chrome.ScreenFrame +import typr.cli.app.components.Link +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.Borders +import jatatui.widgets.block.{Block, BorderType} +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.cli.app.{AppApi, LoadStatus} +import typr.cli.config.{ConfigParser, ParsedSource} + +/** Picks which source to open in a browser. Database / duckdb sources route to [[SchemaBrowser]]; openapi / jsonschema sources route to [[SpecBrowser]]. Sources whose kind we don't yet know how to + * browse (avro, grpc, …) are listed below with a note. + */ +object SchemaPicker { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val infoStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + + private sealed trait Routing + private object Routing { + case object DbBrowser extends Routing + case object SpecBrowser extends Routing + case object AvroBrowser extends Routing + case object ProtoBrowser extends Routing + case object Unsupported extends Routing + } + + val element: Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + + val sources = app.config.sources.getOrElse(Map.empty).toList.sortBy(_._1) + + val routed: List[(String, Json, Routing)] = sources.map { case (name, json) => + val r = ConfigParser.parseSource(json) match { + case Right(_: ParsedSource.Database | _: ParsedSource.DuckDb) => Routing.DbBrowser + case Right(_: ParsedSource.OpenApi | _: ParsedSource.JsonSchema) => Routing.SpecBrowser + case Right(_: ParsedSource.Avro) => Routing.AvroBrowser + case Right(_: ParsedSource.Grpc) => Routing.ProtoBrowser + case Left(_) => Routing.Unsupported + } + (name, json, r) + } + val browsable = routed.filter(_._3 != Routing.Unsupported) + val unsupported = routed.filter(_._3 == Routing.Unsupported) + + val cards: List[Element] = browsable.zipWithIndex.map { case ((name, json, routing), idx) => + val status = app.loadStatus.getOrElse(name, LoadStatus.NotLoaded) + val (label, target) = labelAndTarget(name, routing) + length( + 3, + sourceCard( + name, + json, + routing, + status, + autoFocus = idx == 0, + () => router.push(RouterScreen.of(label, target)) + ) + ) + } + + val title = column(length(1, empty()), length(1, text(" schema browser", titleStyle))) + + val notBrowsableNote = + if (unsupported.isEmpty) length(0, empty()) + else + length( + 2, + column( + length(1, empty()), + length( + 1, + text( + s" ${unsupported.size} source${plural(unsupported.size)} of unrecognized type — open as a file directly.", + infoStyle + ) + ) + ) + ) + + val body = + if (browsable.isEmpty) + column( + fill(1, empty()), + length(1, text(" no browsable sources configured.", infoStyle)), + length(1, text(" add a database, duckdb, or openapi source first.", hintStyle)), + fill(1, empty()), + length(1, text(" esc to return", hintStyle)) + ) + else + column( + (cards + ::: List( + notBrowsableNote, + fill(1, empty()), + length(1, text(" tab navigate · enter open · esc back", hintStyle)) + ))* + ) + + ScreenFrame.withTitle("menu", back, title, body) + } + + private def labelAndTarget(name: String, routing: Routing): (String, Element) = routing match { + case Routing.DbBrowser => (s"schema · $name", SchemaBrowser.element(name)) + case Routing.SpecBrowser => (s"spec · $name", SpecBrowser.element(name)) + case Routing.AvroBrowser => (s"avro · $name", AvroBrowser.element(name)) + case Routing.ProtoBrowser => (s"grpc · $name", ProtoBrowser.element(name)) + case Routing.Unsupported => (s"unsupported · $name", SchemaBrowser.element(name)) // dead branch + } + + private def sourceCard( + name: String, + json: Json, + routing: Routing, + status: LoadStatus, + autoFocus: Boolean, + onActivate: Runnable + ): Element = { + Link.focusable( + autoFocus, + onActivate, + { (focused: Boolean) => + val borderType = if (focused) BorderType.Thick else BorderType.Rounded + val borderStyle = Style.empty.withFg(if (focused) Color.CYAN else Color.DARK_GRAY) + + val (icon, accentColor) = routing match { + case Routing.DbBrowser => ("◈", Color.CYAN) + case Routing.SpecBrowser => ("◇", Color.MAGENTA) + case Routing.AvroBrowser => ("▦", Color.YELLOW) + case Routing.ProtoBrowser => ("▣", Color.GREEN) + case Routing.Unsupported => ("?", Color.DARK_GRAY) + } + + val (badge, badgeColor) = status match { + case LoadStatus.Loaded => ("✓ ready", Color.GREEN) + case LoadStatus.Loading => ("◕ loading…", Color.YELLOW) + case LoadStatus.Failed(_) => ("✗ failed", Color.RED) + case LoadStatus.NotLoaded => ("— not loaded", Color.DARK_GRAY) + } + + val titleLine = Line.from( + Span.styled(s" $icon ", Style.empty.withFg(if (focused) accentColor else Color.DARK_GRAY)), + Span.styled( + s"$name ", + if (focused) Style.empty.withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.GRAY).withAddModifier(Modifier.BOLD) + ), + Span.styled( + s"· ${kindOf(json)} ", + Style.empty.withFg(if (focused) accentColor else Color.DARK_GRAY) + ), + Span.styled(badge, Style.empty.withFg(badgeColor)), + Span.styled(" ", Style.empty) + ) + + val detailStyle = Style.empty.withFg(if (focused) Color.WHITE else Color.GRAY) + + val detail = status match { + case LoadStatus.Failed(err) => s" ${err.take(120)}" + case _ => s" ${detailLabel(json, routing)}" + } + + val w: Widget = (area, buffer) => { + val block = Block.empty + .withTitle(titleLine) + .withBorders(Borders.ALL) + .withBorderType(borderType) + .withBorderStyle(borderStyle) + Paragraph.of(detail).withBlock(block).withStyle(detailStyle).render(area, buffer) + } + widget(w) + } + ) + } + + private def kindOf(json: Json): String = { + val c = json.hcursor + c.get[String]("type").toOption.getOrElse { + if (c.get[String]("path").isRight) "duckdb" + else if (c.get[String]("spec").isRight) "openapi" + else if (c.get[String]("proto_path").isRight) "grpc" + else if (c.get[String]("host").isRight) "jdbc" + else "?" + } + } + + private def detailLabel(json: Json, routing: Routing): String = + (routing, ConfigParser.parseSource(json).toOption) match { + case (Routing.SpecBrowser, Some(ParsedSource.OpenApi(b))) => + b.spec.orElse(b.specs.flatMap(_.headOption)).getOrElse("(no spec path)") + case (Routing.SpecBrowser, Some(ParsedSource.JsonSchema(b))) => + b.spec.orElse(b.specs.flatMap(_.headOption)).getOrElse("(no spec path)") + case (Routing.AvroBrowser, Some(ParsedSource.Avro(b))) => + val list = b.schemas.toList.flatten ::: b.schema.toList + if (list.isEmpty) "(no schemas)" else list.mkString(", ") + case (Routing.ProtoBrowser, Some(ParsedSource.Grpc(b))) => + b.proto_path.orElse(b.proto_paths.flatMap(_.headOption)).getOrElse("(no proto path)") + case _ => connectionLabel(json) + } + + private def connectionLabel(json: Json): String = { + val c = json.hcursor + kindOf(json) match { + case "duckdb" => c.get[String]("path").getOrElse(":memory:") + case _ => + val host = c.get[String]("host").getOrElse("") + val port = c.get[Long]("port").map(_.toString).getOrElse("") + val db = c.get[String]("database").getOrElse("") + (host, port, db) match { + case ("", _, _) => "(no host)" + case (h, "", "") => h + case (h, p, "") => s"$h:$p" + case (h, "", d) => s"$h/$d" + case (h, p, d) => s"$h:$p/$d" + } + } + } + + private def plural(n: Int): String = if (n == 1) "" else "s" +} diff --git a/typr/src/scala/typr/cli/app/screens/SourceEditor.scala b/typr/src/scala/typr/cli/app/screens/SourceEditor.scala new file mode 100644 index 0000000000..bb915c3dc2 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/SourceEditor.scala @@ -0,0 +1,252 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.button.Button +import jatatui.components.chrome.ScreenFrame +import typr.cli.app.components.Link +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.components.textinput.{TextInputComponent, TextInputProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.react.Element +import jatatui.react.Components.* +import tui.crossterm.KeyCode +import typr.cli.app.{AppApi, ConnectionTest} +import typr.config.generated.{StringOrArrayArray, StringOrArrayString} + +/** Form-based editor for a single source. Each relevant attribute (host, port, …) is a [[TextInputComponent]] row; Tab cycles through them, Save persists via [[AppApi.updateConfig]] which writes + * typr.yaml back to disk. + * + * Field set comes from [[SourceForm.fieldsByKind]] — keyed on the source's inferred kind, with unknown fields passed through unchanged on save (so e.g. `selectors:` or `sql_scripts:` stay intact). + * + * Type switching is intentionally not exposed yet — changing a postgres source to a duckdb one is rare and risky; an explicit "convert" flow belongs in the wizard. + */ +object SourceEditor { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val okStyle: Style = Style.empty.withFg(Color.GREEN).withAddModifier(Modifier.BOLD) + + /** What the screen shows under the form: nothing, "saved", or an error. Local to the editor (cleared on every edit). + */ + sealed trait SaveResult + object SaveResult { + case object None extends SaveResult + case object Saved extends SaveResult + final case class Failed(error: String) extends SaveResult + } + + /** Connection-test progress + result, distinct from save state so they can be displayed simultaneously (e.g. "✓ saved · ✓ connected to PostgreSQL 16.2"). + */ + sealed trait TestStatus + object TestStatus { + case object Idle extends TestStatus + case object Running extends TestStatus + final case class Success(message: String) extends TestStatus + final case class Failure(error: String) extends TestStatus + } + + def element(name: String): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + + val initial = app.config.sources + .getOrElse(Map.empty) + .get(name) + .map(SourceForm.parse) + + initial match { + case None => notFound(name, back) + case Some(s) => editor(name, s, app, back) + } + } + + private def notFound(name: String, back: () => Unit): Element = { + val body = column( + fill(1, empty()), + length(1, text(s" source '$name' not found", errStyle)), + length(1, text(" press esc to return", hintStyle)), + fill(1, empty()) + ) + ScreenFrame.of("sources", back, body) + } + + private def editor(name: String, initial: SourceForm.State, app: AppApi, back: () => Unit): Element = + component { ctx => + val form = ctx.useState(() => initial) + val saved = ctx.useState(() => SaveResult.None: SaveResult) + val testStatus = ctx.useState(() => TestStatus.Idle: TestStatus) + val rerender = ctx.requestRerender() + + def save(): Unit = { + val newJson = form.get.toJson + val newSources = app.config.sources.getOrElse(Map.empty) + (name -> newJson) + app.updateConfig(c => c.copy(sources = Some(newSources))) match { + case Right(_) => saved.set(SaveResult.Saved) + case Left(e) => saved.set(SaveResult.Failed(Option(e.getMessage).getOrElse(e.toString))) + } + } + + def runTest(): Unit = { + testStatus.set(TestStatus.Running) + val json = form.get.toJson + val t = new Thread( + () => { + val result = ConnectionTest.run(json) match { + case Right(msg) => TestStatus.Success(msg) + case Left(err) => TestStatus.Failure(err) + } + testStatus.set(result) + rerender.run() + }, + s"conn-test-$name" + ) + t.setDaemon(true) + t.start() + } + + val fields = form.get.fields + + val rows: List[Element] = fields.zipWithIndex.map { case (field, idx) => + length( + 3, + fieldRow( + form.get.get(field.key), + field.key, + field.label, + idx == 0, + v => { + form.update(_.withField(field.key, v)) + saved.set(SaveResult.None) // dirty again + testStatus.set(TestStatus.Idle) // any old test result no longer applies + } + ) + ) + } + + val showTest = ConnectionTest.isTestable(form.get.toJson) && testStatus.get != TestStatus.Running + val testButtonLabel = testStatus.get match { + case TestStatus.Idle | _: TestStatus.Success | _: TestStatus.Failure => "test connection" + case TestStatus.Running => "testing…" + } + + val actionRow = length( + 3, + jatatui.react.Components.row( + length(14, Button.of("save", "save", true, () => save())), + length(2, empty()), + length(22, Button.of(testButtonLabel, "test", false, () => if (showTest) runTest())), + fill(1, empty()) + ) + ) + + val statusLine = saved.get match { + case SaveResult.None => text("", hintStyle) + case SaveResult.Saved => text(s" ✓ saved to ${app.configPath.getFileName}", okStyle) + case SaveResult.Failed(err) => text(s" ✗ save failed: ${err.take(120)}", errStyle) + } + + val testLine = testStatus.get match { + case TestStatus.Idle => text("", hintStyle) + case TestStatus.Running => text(" ◕ testing connection…", Style.empty.withFg(Color.CYAN)) + case TestStatus.Success(msg) => text(s" ✓ ${msg.take(160)}", okStyle) + case TestStatus.Failure(err) => text(s" ✗ ${err.take(160)}", errStyle) + } + + val title = column( + length(1, empty()), + length(1, text(s" source · $name · ${form.get.kind}", titleStyle)) + ) + + val consumers = outputsReferencing(app, name) + val router = RouterApi.useRouter(ctx) + val consumersRows = + if (consumers.isEmpty) Nil + else + List( + length(1, empty()), + length(1, text(s" used by ${consumers.size} output${if (consumers.size == 1) "" else "s"}:", hintStyle)) + ) ::: consumers.map(out => + length( + 1, + consumerLink(out, () => router.push(RouterScreen.of(s"output · $out", OutputEditor.element(out)))) + ) + ) + + val body = column( + (rows + ::: List( + length(1, empty()), + actionRow, + length(1, statusLine), + length(1, testLine) + ) + ::: consumersRows + ::: List( + fill(1, empty()), + length(1, text(" tab navigate · enter (on save) commit · esc back", hintStyle)) + ))* + ) + + ScreenFrame.withTitle("sources", back, title, body) + } + + /** Outputs whose `sources:` field references this source (or omits sources, which means "all sources"). Sorted alphabetically. Used to render clickable consumer links. + */ + private def outputsReferencing(app: AppApi, sourceName: String): List[String] = + app.config.outputs + .getOrElse(Map.empty) + .toList + .filter { case (_, out) => + out.sources match { + case Some(StringOrArrayString(s)) => s == sourceName + case Some(StringOrArrayArray(arr)) => arr.contains(sourceName) + case None => true + } + } + .map(_._1) + .sorted + + private val consumerStyle: Style = Style.empty.withFg(Color.YELLOW) + + private def consumerLink(outputName: String, onActivate: Runnable): Element = + Link.focusable( + false, + onActivate, + { (focused: Boolean) => + val style = + if (focused) Style.empty.withBg(Color.YELLOW).withFg(Color.BLACK).withAddModifier(Modifier.BOLD) + else consumerStyle + text(s" → $outputName", style) + } + ) + + /** Bordered text-input row with the label baked into the top border. Default jatatui styles are `Style.empty()` for the value, which renders in terminal-default colour and is easy to miss. We + * override with explicit white-on-default for the value, yellow+bold when focused, and a reversed cursor so the caret is unmistakable. + */ + private val valueStyle: Style = Style.empty.withFg(Color.WHITE) + private val focusedValueStyle: Style = Style.empty.withFg(Color.YELLOW).withAddModifier(Modifier.BOLD) + private val cursorStyle: Style = Style.empty.withAddModifier(Modifier.REVERSED) + + private def fieldRow( + value: String, + key: String, + label: String, + autoFocus: Boolean, + onChange: String => Unit + ): Element = + TextInputComponent.of( + TextInputProps + .of(value, v => onChange(v)) + .withTitle(label) + .withFocusId(s"src:$key") + .withAutoFocus(autoFocus) + .withStyle(valueStyle) + .withFocusedStyle(focusedValueStyle) + .withCursorStyle(cursorStyle) + ) +} diff --git a/typr/src/scala/typr/cli/app/screens/SourceForm.scala b/typr/src/scala/typr/cli/app/screens/SourceForm.scala new file mode 100644 index 0000000000..0ce9c03806 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/SourceForm.scala @@ -0,0 +1,87 @@ +package typr.cli.app.screens + +import io.circe.{Json, JsonObject} + +/** Plain data model for the source editor. Decouples the form's flat `Map[label, value]` representation from circe `Json`, and remembers the original JSON so fields we don't surface (selectors, + * sql_scripts, custom keys) pass through unchanged when we write the source back. + * + * Field set is keyed on the source kind. The kind is inferred from the JSON shape the same way [[SourceList]] does it — explicit `type` field wins, otherwise infer from `path` / `spec` / `host`. + * Type editing isn't supported yet; users can change values but not switch a postgres source to a duckdb one. + */ +object SourceForm { + + /** A single editable field: storage key + a human label for the form row. */ + final case class Field(key: String, label: String) + + /** Which fields are surfaced for which kind. Order is render order. */ + val fieldsByKind: Map[String, List[Field]] = Map( + "duckdb" -> List(Field("path", "path")), + "openapi" -> List(Field("spec", "spec path")), + "jsonschema" -> List(Field("spec", "spec path")), + "oracle" -> List( + Field("host", "host"), + Field("port", "port"), + Field("service", "service"), + Field("username", "username"), + Field("password", "password") + ), + "jdbc" -> List( + Field("host", "host"), + Field("port", "port"), + Field("database", "database"), + Field("username", "username"), + Field("password", "password") + ) + ) + + /** Current state of an open editor: which kind, current values, and the JSON we'd write back. */ + final case class State(kind: String, values: Map[String, String], original: Json) { + def fields: List[Field] = fieldsByKind.getOrElse(kind, Nil) + def get(key: String): String = values.getOrElse(key, "") + def withField(key: String, value: String): State = copy(values = values.updated(key, value)) + + /** Re-encode to JSON, preserving any keys we didn't expose in the form. Numeric ports go back as numbers when parseable, otherwise as the raw string the user typed (so a mis-typed port is still + * saved and visible — better than silently dropping). + */ + def toJson: Json = { + val obj0 = original.asObject.getOrElse(JsonObject.empty) + val merged = fields.foldLeft(obj0) { case (obj, Field(key, _)) => + val v = values.getOrElse(key, "").trim + if (v.isEmpty) obj.remove(key) + else if (key == "port") + v.toLongOption.fold(obj.add(key, Json.fromString(v)))(n => obj.add(key, Json.fromLong(n))) + else obj.add(key, Json.fromString(v)) + } + Json.fromJsonObject(merged) + } + } + + /** Parse a source JSON into a form state. Unknown kinds fall back to the jdbc field set (host/port/database/user/pass) — better than showing an empty form. + */ + def parse(json: Json): State = { + val kind = inferKind(json) match { + case k if fieldsByKind.contains(k) => k + case _ => "jdbc" + } + val c = json.hcursor + val values = fieldsByKind(kind).map { f => + val v = c + .get[String](f.key) + .toOption + .orElse(c.get[Long](f.key).toOption.map(_.toString)) + .getOrElse("") + f.key -> v + }.toMap + State(kind, values, json) + } + + private def inferKind(j: Json): String = { + val c = j.hcursor + c.get[String]("type").toOption.getOrElse { + if (c.get[String]("path").isRight) "duckdb" + else if (c.get[String]("spec").isRight) "openapi" + else if (c.get[String]("host").isRight) "jdbc" + else "?" + } + } +} diff --git a/typr/src/scala/typr/cli/app/screens/SourceList.scala b/typr/src/scala/typr/cli/app/screens/SourceList.scala new file mode 100644 index 0000000000..7f5007fcc2 --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/SourceList.scala @@ -0,0 +1,238 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import io.circe.Json +import jatatui.components.chrome.ScreenFrame +import typr.cli.app.components.Link +import jatatui.components.modal.ConfirmDialog +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.components.scrollable.Scrollable +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.Borders +import jatatui.widgets.block.{Block, BorderType} +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.cli.app.AppApi +import typr.config.generated.{StringOrArrayArray, StringOrArrayString} + +import scala.jdk.CollectionConverters.* + +/** List of configured sources. Tab cycles, Enter or click opens the source in [[SourceEditor]]; `d` on a focused card asks to delete it. Esc / b returns to the previous screen. + */ +object SourceList { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + + val element: Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + val pendingDelete = ctx.useState(() => Option.empty[String]) + val deleteError = ctx.useState(() => Option.empty[String]) + + // Only register Esc / b when no confirm modal is open; otherwise let the modal swallow Esc. + if (pendingDelete.get.isEmpty) { + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + } + + // Cascading delete: drop the source, and scrub references to it from each output's + // `sources` list. If an output ends up with no remaining sources, set its `sources` to + // None (meaning "all sources" — the same default a freshly created output uses). + def doDelete(name: String): Unit = { + app.updateConfig { c => + val nextSources = { + val remaining = c.sources.getOrElse(Map.empty) - name + if (remaining.isEmpty) None else Some(remaining) + } + val nextOutputs = c.outputs.map { outputs => + outputs.map { case (outName, out) => + val updated = out.sources match { + case Some(StringOrArrayString(s)) if s == name => out.copy(sources = None) + case Some(StringOrArrayArray(arr)) => + val filtered = arr.filterNot(_ == name) + val next = + if (filtered.isEmpty) None + else if (filtered.size == 1) Some(StringOrArrayString(filtered.head)) + else Some(StringOrArrayArray(filtered)) + out.copy(sources = next) + case _ => out + } + outName -> updated + } + } + c.copy(sources = nextSources, outputs = nextOutputs) + } match { + case Right(_) => + pendingDelete.set(None) + deleteError.set(None) + case Left(e) => + deleteError.set(Some(Option(e.getMessage).getOrElse(e.toString))) + pendingDelete.set(None) + } + } + + val sources = app.config.sources.getOrElse(Map.empty).toList.sortBy(_._1) + + val addCard = length( + 3, + addNewCard( + autoFocus = true, + onActivate = () => router.push(RouterScreen.of("add source", SourceWizard.element)) + ) + ) + val sourceCards: List[Element] = sources.map { case (name, json) => + length( + 3, + sourceCard( + name, + json, + () => pendingDelete.set(Some(name)), + () => router.push(RouterScreen.of(s"source · $name", SourceEditor.element(name))) + ) + ) + } + + val title = column(length(1, empty()), length(1, text(" sources", titleStyle))) + + val errorLine = + deleteError.get.fold(length(0, empty())) { msg => + length(1, text(s" ✗ ${msg.take(140)}", Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD))) + } + + val body = column( + fill(1, Scrollable.column((addCard :: sourceCards).asJava)), + errorLine, + length(1, text(" tab navigate · enter open · d delete · wheel to scroll · esc back", hintStyle)) + ) + + val frame = ScreenFrame.withTitle("menu", back, title, body) + + val confirm = ConfirmDialog.of( + pendingDelete.get.isDefined, + " delete source ", + pendingDelete.get.fold("")(nm => s"Delete '$nm'? This cannot be undone."), + "delete", + "cancel", + true, + () => pendingDelete.get.foreach(doDelete), + () => pendingDelete.set(None) + ) + + stack(frame, confirm) + } + + private def addNewCard(autoFocus: Boolean, onActivate: Runnable): Element = + Link.focusable( + autoFocus, + onActivate, + { (focused: Boolean) => + val borderType = if (focused) BorderType.Thick else BorderType.Rounded + val borderStyle = Style.empty.withFg(if (focused) Color.GREEN else Color.DARK_GRAY) + val labelStyle = + Style.empty.withFg(if (focused) Color.GREEN else Color.GRAY).withAddModifier(Modifier.BOLD) + + val w: Widget = (area, buffer) => { + val block = Block.empty + .withBorders(Borders.ALL) + .withBorderType(borderType) + .withBorderStyle(borderStyle) + Paragraph + .of(Line.from(Span.styled(" + add new source", labelStyle))) + .withBlock(block) + .render(area, buffer) + } + widget(w) + } + ) + + private def sourceCard(name: String, json: Json, onDelete: () => Unit, onActivate: Runnable): Element = + Link.focusable( + false, + onActivate, + { (focused: Boolean) => + component { c => + if (focused) c.onKey(new KeyCode.Char('d'), () => onDelete()) + + val borderType = if (focused) BorderType.Thick else BorderType.Rounded + val borderStyle = Style.empty.withFg(if (focused) Color.CYAN else Color.DARK_GRAY) + + val titleLine = Line.from( + Span.styled(s" ${sourceIcon(json)} ", borderStyle), + Span.styled( + s"$name ", + if (focused) Style.empty.withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.GRAY).withAddModifier(Modifier.BOLD) + ), + Span.styled( + s"· ${sourceKind(json)} ", + Style.empty.withFg(if (focused) Color.CYAN else Color.DARK_GRAY) + ) + ) + + val detailStyle = Style.empty.withFg(if (focused) Color.WHITE else Color.GRAY) + + val w: Widget = (area, buffer) => { + val block = Block.empty + .withTitle(titleLine) + .withBorders(Borders.ALL) + .withBorderType(borderType) + .withBorderStyle(borderStyle) + Paragraph + .of(s" ${sourceDetails(json)}") + .withBlock(block) + .withStyle(detailStyle) + .render(area, buffer) + } + widget(w) + } + } + ) + + private def sourceKind(json: Json): String = { + val c = json.hcursor + c.get[String]("type").toOption.getOrElse { + if (c.get[String]("path").isRight) "duckdb" + else if (c.get[String]("spec").isRight) "openapi" + else if (c.get[String]("host").isRight) "jdbc" + else "?" + } + } + + private def sourceIcon(json: Json): String = sourceKind(json) match { + case "duckdb" => "◆" + case "openapi" => "✦" + case "jdbc" => "◈" + case _ => "◇" + } + + private def sourceDetails(json: Json): String = { + val c = json.hcursor + sourceKind(json) match { + case "duckdb" => + c.get[String]("path").getOrElse(":memory:") + case "openapi" => + c.get[String]("spec") + .toOption + .orElse(c.downField("specs").downArray.as[String].toOption) + .getOrElse("") + case _ => + val host = c.get[String]("host").getOrElse("") + val port = c.get[Long]("port").map(_.toString).getOrElse("") + val db = c.get[String]("database").getOrElse("") + (host, port, db) match { + case ("", _, _) => "" + case (h, "", "") => h + case (h, p, "") => s"$h:$p" + case (h, "", d) => s"$h/$d" + case (h, p, d) => s"$h:$p/$d" + } + } + } +} diff --git a/typr/src/scala/typr/cli/app/screens/SourceWizard.scala b/typr/src/scala/typr/cli/app/screens/SourceWizard.scala new file mode 100644 index 0000000000..0ebc784d5e --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/SourceWizard.scala @@ -0,0 +1,163 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import io.circe.{Json, JsonObject} +import jatatui.components.button.Button +import jatatui.components.chrome.ScreenFrame +import jatatui.components.dropdown.{Dropdown, DropdownProps} +import jatatui.components.router.RouterApi +import jatatui.components.textinput.{TextInputComponent, TextInputProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.react.Components.* +import jatatui.react.Element +import tui.crossterm.KeyCode +import typr.cli.app.AppApi + +import scala.jdk.CollectionConverters.* + +/** New-source wizard. Name + kind dropdown + kind-specific fields, all on one screen so the form shape updates live when the kind changes. Save validates name uniqueness, writes through + * [[AppApi.updateConfig]] (which persists to typr.yaml), and pops back to the source list. + * + * Field set per kind is borrowed from [[SourceForm.fieldsByKind]] so the wizard and the editor agree on what's relevant. + */ +object SourceWizard { + + private val kinds: List[String] = List("postgres", "mariadb", "oracle", "sqlserver", "duckdb", "openapi") + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val valueStyle: Style = Style.empty.withFg(Color.WHITE) + private val focusedValueStyle: Style = Style.empty.withFg(Color.YELLOW).withAddModifier(Modifier.BOLD) + private val cursorStyle: Style = Style.empty.withAddModifier(Modifier.REVERSED) + + sealed trait Status + object Status { + case object Idle extends Status + final case class Failed(error: String) extends Status + } + + val element: Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + + val name = ctx.useState(() => "") + val kindIdx = ctx.useState(() => 0) + val values = ctx.useState(() => Map.empty[String, String]) + val status = ctx.useState(() => Status.Idle: Status) + + val currentKind: String = kinds(kindIdx.get) + val fieldDefs: List[SourceForm.Field] = SourceForm.fieldsByKind.getOrElse(formKindKey(currentKind), Nil) + + def save(): Unit = { + val trimmedName = name.get.trim + if (trimmedName.isEmpty) + status.set(Status.Failed("name required")) + else if (app.config.sources.getOrElse(Map.empty).contains(trimmedName)) + status.set(Status.Failed(s"source '$trimmedName' already exists")) + else { + val newJson = buildJson(currentKind, values.get, fieldDefs) + val newSources = app.config.sources.getOrElse(Map.empty) + (trimmedName -> newJson) + app.updateConfig(c => c.copy(sources = Some(newSources))) match { + case Right(_) => router.pop() + case Left(e) => status.set(Status.Failed(Option(e.getMessage).getOrElse(e.toString))) + } + } + } + + val nameRow = length( + 3, + TextInputComponent.of( + TextInputProps + .of(name.get, v => { name.set(v); status.set(Status.Idle) }) + .withTitle("name") + .withFocusId("wiz:name") + .withAutoFocus(true) + .withStyle(valueStyle) + .withFocusedStyle(focusedValueStyle) + .withCursorStyle(cursorStyle) + ) + ) + + val kindRow = length( + 3, + Dropdown.of( + DropdownProps + .of[String]( + "kind", + kinds.asJava, + (s: String) => s, + kindIdx.get, + (idx: Int) => kindIdx.set(idx) + ) + .withFocusId("wiz:kind") + .withStyle(valueStyle) + .withFocusedStyle(focusedValueStyle) + ) + ) + + val fieldRows: List[Element] = fieldDefs.map { f => + length( + 3, + TextInputComponent.of( + TextInputProps + .of(values.get.getOrElse(f.key, ""), v => values.update(_.updated(f.key, v))) + .withTitle(f.label) + .withFocusId(s"wiz:${f.key}") + .withAutoFocus(false) + .withStyle(valueStyle) + .withFocusedStyle(focusedValueStyle) + .withCursorStyle(cursorStyle) + ) + ) + } + + val saveButton = length(3, Button.of("save", "save", true, () => save())) + + val statusLine = status.get match { + case Status.Idle => text("", hintStyle) + case Status.Failed(err) => text(s" ✗ $err", errStyle) + } + + val title = column(length(1, empty()), length(1, text(" add source", titleStyle))) + + val body = column( + (nameRow + :: kindRow + :: fieldRows + ::: List( + length(1, empty()), + saveButton, + length(1, statusLine), + fill(1, empty()), + length(1, text(" tab navigate · enter (on save) commit · esc cancel", hintStyle)) + ))* + ) + + ScreenFrame.withTitle("sources", back, title, body) + } + + /** Map user-visible kind to the form's internal kind key (some kinds share field sets). */ + private def formKindKey(kind: String): String = kind match { + case "duckdb" => "duckdb" + case "openapi" | "jsonschema" => "openapi" + case "oracle" => "oracle" + case _ => "jdbc" + } + + private def buildJson(kind: String, values: Map[String, String], defs: List[SourceForm.Field]): Json = { + val obj0 = JsonObject("type" -> Json.fromString(kind)) + val merged = defs.foldLeft(obj0) { case (obj, SourceForm.Field(key, _)) => + val v = values.getOrElse(key, "").trim + if (v.isEmpty) obj + else if (key == "port") + v.toLongOption.fold(obj.add(key, Json.fromString(v)))(n => obj.add(key, Json.fromLong(n))) + else obj.add(key, Json.fromString(v)) + } + Json.fromJsonObject(merged) + } +} diff --git a/typr/src/scala/typr/cli/app/screens/SpecBrowser.scala b/typr/src/scala/typr/cli/app/screens/SpecBrowser.scala new file mode 100644 index 0000000000..597ffb952e --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/SpecBrowser.scala @@ -0,0 +1,656 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.chrome.ScreenFrame +import jatatui.components.picker.{Picker, PickerProps} +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.components.search.FuzzyMatch +import jatatui.components.selectablelist.{SelectableList, SelectableListProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.cli.app.{AppApi, Entity, LoadStatus, LoadedSource} +import typr.cli.app.components.CaretCell +import typr.config.generated.{BridgeType, FieldType, ModelMatch, StringOrArrayString} +import typr.openapi.* + +import scala.jdk.CollectionConverters.* + +/** Per-source OpenAPI / JSON-schema spec browser. Mirrors [[SchemaBrowser]] in shape: + * + * - On mount, look up [[AppApi.specCache]]; on miss, spawn a daemon to call [[SpecFetch.fetch]] and store the result. + * - Render a flat heterogeneous tree via [[jatatui.components.selectablelist.SelectableList]] — two sections, APIs and Models, each containing collapsible children (operations under an interface, + * properties under a model). + * - `/` opens a [[jatatui.components.picker.Picker]] with IntelliJ-style fuzzy ranking over interfaces, operations, models, sum types, and properties. + * - Up/Down navigates activatable rows, Left/Right collapses/expands or jumps in/out of a group, Enter toggles, Esc closes search or returns. + * + * HTTP methods are color-coded so the eye can scan a long operation list quickly. Models, sum types, and properties get their own visual treatment. + */ +object SpecBrowser { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val infoStyle: Style = Style.empty.withFg(Color.GRAY) + private val sectionStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val apiNameStyle: Style = Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD) + private val modelStyle: Style = Style.empty.withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + private val propNameStyle: Style = Style.empty.withFg(Color.CYAN) + private val propTypeStyle: Style = Style.empty.withFg(Color.YELLOW) + private val deprecatedStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.CROSSED_OUT) + + sealed trait TreeRow + object TreeRow { + final case class SectionHeader(label: String) extends TreeRow + final case class ApiInterfaceRow(api: ApiInterface, isOpen: Boolean) extends TreeRow + final case class ApiMethodRow(method: ApiMethod, indent: Int) extends TreeRow + final case class ModelRow(model: ModelClass, isOpen: Boolean) extends TreeRow + final case class SumTypeRow(sum: SumType, isOpen: Boolean) extends TreeRow + final case class PropertyRow(name: String, tpe: String, required: Boolean, nullable: Boolean, nameWidth: Int, typeWidth: Int) extends TreeRow + final case class EnumValueRow(value: String) extends TreeRow + final case class SubtypeRow(name: String) extends TreeRow + case object Blank extends TreeRow + } + + /** One entry in the fuzzy-search index. `anchorKey` identifies the parent tree node so the host can expand-and-jump after pick. + */ + private final case class SearchItem(label: String, kind: SearchKind, anchorKey: String) + private sealed trait SearchKind + private object SearchKind { + case object Api extends SearchKind + case object Method extends SearchKind + case object Model extends SearchKind + case object SumT extends SearchKind + case object Property extends SearchKind + } + + /** Renders the screen for source `name`. The Shell's startup loader populates [[AppApi.specCache]] in the background; this screen reads from it directly each render and renders whichever stage + * applies (loading / failed / loaded). No duplicate fetch — the load runs once per source. + */ + def element(name: String): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + + val title = column(length(1, empty()), length(1, text(s" spec · $name", titleStyle))) + val status = app.loadStatus.getOrElse(name, LoadStatus.NotLoaded) + val body = app.sourceCache.get(name) match { + case Some(LoadedSource.Spec(spec)) => loadedBody(name, spec, app, router) + case Some(_) => failedBody(s"source '$name' is not an openapi/jsonschema source") + case None => + status match { + case LoadStatus.Failed(err) => failedBody(err) + case LoadStatus.NotLoaded => notInConfigBody(name) + case _ => loadingBody() + } + } + ScreenFrame.withTitle("schemas", back, title, body) + } + + private def loadingBody(): Element = column( + length(1, text(" parsing spec…", Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD))), + length(1, empty()), + length(1, text(" reading file, resolving $refs, extracting models and operations.", infoStyle)), + fill(1, empty()) + ) + + private def failedBody(err: String): Element = column( + length(1, text(s" ✗ ${err.take(400)}", errStyle)), + length(1, empty()), + length(1, text(" esc to return", hintStyle)), + fill(1, empty()) + ) + + private def notInConfigBody(name: String): Element = column( + length(1, text(s" source '$name' not in config", errStyle)), + length(1, empty()), + length(1, text(" esc to return", hintStyle)), + fill(1, empty()) + ) + + private def loadedBody(sourceName: String, spec: ParsedSpec, app: AppApi, router: RouterApi): Element = + component { ctx => + val expanded = ctx.useState(() => Set.empty[String]) + val searchOpen = ctx.useState(() => false) + + ctx.onKey( + new KeyCode.Char('/'), + (e: jatatui.react.KeyEvent) => { + if (!searchOpen.get) { + searchOpen.set(true) + e.stopPropagation() + } + } + ) + + val rows: List[TreeRow] = treeRows(spec, expanded.get) + + // Quick lookup from anchorKey (set when a search hit lands on a parent) back to row idx so + // search-commit can position selection on the right tree node. + val anchorIdx: Map[String, Int] = + rows.zipWithIndex.collect { + case (TreeRow.ApiInterfaceRow(a, _), i) => apiKey(a) -> i + case (TreeRow.ApiMethodRow(m, _), i) => methodKey(m) -> i + case (TreeRow.ModelRow(m, _), i) => modelKey(m) -> i + case (TreeRow.SumTypeRow(s, _), i) => sumKey(s) -> i + }.toMap + + val selectedIdx = ctx.useState(() => + rows.zipWithIndex + .collectFirst { case (TreeRow.ApiInterfaceRow(_, _), i) => i } + .getOrElse( + rows.zipWithIndex.collectFirst { case (TreeRow.ModelRow(_, _), i) => i }.getOrElse(0) + ) + ) + + ctx.onKey( + new KeyCode.Right, + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case TreeRow.ApiInterfaceRow(api, false) => + expanded.update(_ + apiKey(api)); e.stopPropagation() + case TreeRow.ApiInterfaceRow(_, true) if hasChildAt(rows, sel + 1) => + selectedIdx.set(sel + 1); e.stopPropagation() + case TreeRow.ModelRow(model, false) => + expanded.update(_ + modelKey(model)); e.stopPropagation() + case TreeRow.ModelRow(_, true) if hasChildAt(rows, sel + 1) => + selectedIdx.set(sel + 1); e.stopPropagation() + case TreeRow.SumTypeRow(sum, false) => + expanded.update(_ + sumKey(sum)); e.stopPropagation() + case TreeRow.SumTypeRow(_, true) if hasChildAt(rows, sel + 1) => + selectedIdx.set(sel + 1); e.stopPropagation() + case _ => () + } + } + ) + ctx.onKey( + new KeyCode.Left, + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case TreeRow.ApiInterfaceRow(api, true) => + expanded.update(_ - apiKey(api)); e.stopPropagation() + case TreeRow.ModelRow(model, true) => + expanded.update(_ - modelKey(model)); e.stopPropagation() + case TreeRow.SumTypeRow(sum, true) => + expanded.update(_ - sumKey(sum)); e.stopPropagation() + case _: TreeRow.ApiMethodRow | _: TreeRow.PropertyRow | _: TreeRow.EnumValueRow | _: TreeRow.SubtypeRow => + val parent = (sel - 1 to 0 by -1).find(i => + rows(i).isInstanceOf[TreeRow.ApiInterfaceRow] + || rows(i).isInstanceOf[TreeRow.ModelRow] + || rows(i).isInstanceOf[TreeRow.SumTypeRow] + ) + parent.foreach(selectedIdx.set) + e.stopPropagation() + case _ => () + } + } + ) + + // 'e' on a PropertyRow: extract this property as a FieldType. Walks back to find the + // owning ModelRow so the ModelMatch can scope by schema (model name). + ctx.onKey( + new KeyCode.Char('e'), + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case prop: TreeRow.PropertyRow => + val parentModel = (sel - 1 to 0 by -1).iterator + .map(i => rows(i)) + .collectFirst { case TreeRow.ModelRow(m, _) => m } + parentModel.foreach(m => extractFieldType(sourceName, m, prop, app, router)) + e.stopPropagation() + case _ => () + } + } + ) + + // 'c' on a Model / SumType / ApiInterface: create a DomainType anchored on a real entity. + ctx.onKey( + new KeyCode.Char('c'), + (e: jatatui.react.KeyEvent) => { + val sel = selectedIdx.get + if (sel >= 0 && sel < rows.size) rows(sel) match { + case _: TreeRow.ModelRow | _: TreeRow.SumTypeRow | _: TreeRow.ApiInterfaceRow => + router.push(RouterScreen.of("create domain type", DomainFromEntity.element)) + e.stopPropagation() + case _ => () + } + } + ) + + val searchItems = buildIndex(spec) + val indexedRef = ctx.useRef(() => FuzzyMatch.index[SearchItem](searchItems.asJava, (it: SearchItem) => it.label)) + + val info = Line.from( + Span.styled(s" ${spec.info.title}", sectionStyle), + Span.styled(s" v${spec.info.version}", infoStyle), + Span.styled(s" · ${spec.apis.size} interface${plural(spec.apis.size)}", infoStyle), + Span.styled(s" · ${countMethods(spec)} operation${plural(countMethods(spec))}", infoStyle), + Span.styled(s" · ${spec.models.size} model${plural(spec.models.size)}", infoStyle), + Span.styled(s" · ${spec.sumTypes.size} sum-type${plural(spec.sumTypes.size)}", infoStyle), + Span.styled(s" · press / to search", hintStyle) + ) + + val infoLine: Widget = (area, buffer) => Paragraph.of(info).render(area, buffer) + + val tree = column( + length(1, widget(infoLine)), + length(1, empty()), + fill( + 1, + SelectableList.of( + SelectableListProps + .of[TreeRow]( + rows.asJava, + isActivatable, + (row, selected) => renderTreeRow(row, selected, key => expanded.update(t => toggle(t, key))), + selectedIdx.get, + idx => selectedIdx.set(idx) + ) + .withOnActivate { (row: TreeRow) => + row match { + case TreeRow.ApiInterfaceRow(api, _) => + expanded.update(t => toggle(t, apiKey(api))) + case TreeRow.ModelRow(model, _) => + expanded.update(t => toggle(t, modelKey(model))) + case TreeRow.SumTypeRow(sum, _) => + expanded.update(t => toggle(t, sumKey(sum))) + case _ => () + } + } + .withAutoFocus(true) + ) + ), + length(1, text(" ↑↓ navigate · ←→ collapse/expand · e extract field type · c create domain type · / search · esc back", hintStyle)) + ) + + if (!searchOpen.get) tree + else { + val filter: PickerProps.Filter[SearchItem] = (query: String) => + if (query.isEmpty) java.util.List.of() + else FuzzyMatch.rank(query, indexedRef.get).stream.limit(120).toList + + val rowRenderer: PickerProps.RowRenderer[SearchItem] = (item, selected) => hitRow(item, selected) + + stack( + tree, + Picker.of( + PickerProps + .of[SearchItem]( + "search", + filter, + rowRenderer, + (item: SearchItem) => { + // For deep hits (method / property / enum / subtype), open the parent group + // first, then jump selection to the anchor. + expanded.update(_ + item.anchorKey) + anchorIdx.get(item.anchorKey).foreach(selectedIdx.set) + searchOpen.set(false) + }, + () => searchOpen.set(false) + ) + ) + ) + } + } + + // ───────────────────────────────────── tree building ───────────────────────────────────────── + + private def treeRows(spec: ParsedSpec, expanded: Set[String]): List[TreeRow] = { + val out = List.newBuilder[TreeRow] + val apis = spec.apis.sortBy(_.name.toLowerCase) + if (apis.nonEmpty) { + out += TreeRow.SectionHeader(s"APIs (${apis.size})") + apis.foreach { api => + val key = apiKey(api) + val isOpen = expanded.contains(key) + out += TreeRow.ApiInterfaceRow(api, isOpen) + if (isOpen) api.methods.foreach(m => out += TreeRow.ApiMethodRow(m, indent = 2)) + } + out += TreeRow.Blank + } + + val sumTypes = spec.sumTypes.sortBy(_.name.toLowerCase) + if (sumTypes.nonEmpty) { + out += TreeRow.SectionHeader(s"Sum types (${sumTypes.size})") + sumTypes.foreach { sum => + val key = sumKey(sum) + val isOpen = expanded.contains(key) + out += TreeRow.SumTypeRow(sum, isOpen) + if (isOpen) sum.subtypeNames.foreach(n => out += TreeRow.SubtypeRow(n)) + } + out += TreeRow.Blank + } + + val models = spec.models.sortBy(_.name.toLowerCase) + if (models.nonEmpty) { + out += TreeRow.SectionHeader(s"Models (${models.size})") + models.foreach { model => + val key = modelKey(model) + val isOpen = expanded.contains(key) + out += TreeRow.ModelRow(model, isOpen) + if (isOpen) propertyRowsFor(model).foreach(out += _) + } + out += TreeRow.Blank + } + + out.result() + } + + private def propertyRowsFor(model: ModelClass): List[TreeRow] = model match { + case obj: ModelClass.ObjectType => + val props = obj.properties + val nameWidth = props.map(_.name.length).maxOption.getOrElse(0) + val typeWidth = props.map(p => renderTypeInfo(p.typeInfo).length).maxOption.getOrElse(0) + props.map { p => + TreeRow.PropertyRow( + p.name, + renderTypeInfo(p.typeInfo), + p.required, + p.nullable, + nameWidth, + typeWidth + ) + } + case e: ModelClass.EnumType => + e.values.map(v => TreeRow.EnumValueRow(v)) + case w: ModelClass.WrapperType => + List(TreeRow.PropertyRow("(value)", renderTypeInfo(w.underlying), required = true, nullable = false, 7, renderTypeInfo(w.underlying).length)) + case a: ModelClass.AliasType => + List(TreeRow.PropertyRow("(alias)", renderTypeInfo(a.underlying), required = true, nullable = false, 7, renderTypeInfo(a.underlying).length)) + } + + // ───────────────────────────────────── row rendering ───────────────────────────────────────── + + private def renderTreeRow(row: TreeRow, selected: Boolean, toggleExpand: String => Unit): Element = row match { + case TreeRow.SectionHeader(label) => + text(s" $label", sectionStyle) + + case TreeRow.Blank => empty() + + case TreeRow.ApiInterfaceRow(api, isOpen) => + val nameStyle = if (selected) apiNameStyle.withBg(Color.BLUE) else apiNameStyle + val countStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(api.name, nameStyle), + Span.styled(s" (${api.methods.size} ops)", countStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(apiKey(api)))), + fill(1, widget(w)) + ) + + case TreeRow.ApiMethodRow(method, _) => + val (mLabel, mStyle) = httpMethodLabel(method.httpMethod) + val pathStyle = + if (selected) Style.empty.withFg(Color.WHITE).withBg(Color.BLUE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.WHITE) + val opStyle = if (selected) Style.empty.withFg(Color.YELLOW) else Style.empty.withFg(Color.DARK_GRAY) + val rendered = + if (method.deprecated) + deprecatedStyle + else mStyle + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" ", Style.empty), + Span.styled(padLeft(mLabel, 6), rendered.withAddModifier(Modifier.BOLD)), + Span.styled(" ", Style.empty), + Span.styled(method.path, pathStyle), + Span.styled(" ", Style.empty), + Span.styled(method.name, opStyle) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + + case TreeRow.ModelRow(model, isOpen) => + val kind = modelKindLabel(model) + val nameStyle = if (selected) modelStyle.withBg(Color.BLUE) else modelStyle + val kindStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val countStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val count = model match { + case o: ModelClass.ObjectType => s"${o.properties.size} props" + case e: ModelClass.EnumType => s"${e.values.size} values" + case _: ModelClass.WrapperType => "wrapper" + case _: ModelClass.AliasType => "alias" + } + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(model.name, nameStyle), + Span.styled(s" [$kind]", kindStyle), + Span.styled(s" ($count)", countStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(modelKey(model)))), + fill(1, widget(w)) + ) + + case TreeRow.SumTypeRow(sum, isOpen) => + val nameStyle = if (selected) modelStyle.withBg(Color.BLUE) else modelStyle + val kindStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val countStyle = Style.empty.withFg(if (selected) Color.YELLOW else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(sum.name, nameStyle), + Span.styled(" [sum]", kindStyle), + Span.styled(s" (${sum.subtypeNames.size} variants on ${sum.discriminator.propertyName})", countStyle) + ) + Paragraph.of(line).render(area, buffer) + } + jatatui.react.Components.row( + length(CaretCell.WidthCols, CaretCell.of(isOpen, selected, () => toggleExpand(sumKey(sum)))), + fill(1, widget(w)) + ) + + case TreeRow.PropertyRow(name, tpe, required, nullable, nameWidth, typeWidth) => + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" ", Style.empty), + Span.styled(name.padTo(nameWidth, ' '), propNameStyle), + Span.styled(" ", Style.empty), + Span.styled(tpe.padTo(typeWidth, ' '), propTypeStyle), + Span.styled( + (if (required) " required" else " optional") + (if (nullable) " · nullable" else ""), + Style.empty.withFg(Color.DARK_GRAY) + ) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + + case TreeRow.EnumValueRow(value) => + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" · ", Style.empty.withFg(Color.DARK_GRAY)), + Span.styled(value, propTypeStyle) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + + case TreeRow.SubtypeRow(name) => + val w: Widget = (area, buffer) => { + val line = Line.from( + Span.styled(" ◇ ", Style.empty.withFg(Color.DARK_GRAY)), + Span.styled(name, propNameStyle) + ) + Paragraph.of(line).render(area, buffer) + } + widget(w) + } + + private def isActivatable(row: TreeRow): Boolean = row match { + case _: TreeRow.SectionHeader => false + case TreeRow.Blank => false + case _ => true + } + + private def hasChildAt(rows: List[TreeRow], idx: Int): Boolean = + idx >= 0 && idx < rows.size && (rows(idx) match { + case _: TreeRow.ApiMethodRow | _: TreeRow.PropertyRow | _: TreeRow.EnumValueRow | _: TreeRow.SubtypeRow => true + case _ => false + }) + + // ───────────────────────────────────── search index ───────────────────────────────────────── + + private def buildIndex(spec: ParsedSpec): List[SearchItem] = { + val b = List.newBuilder[SearchItem] + spec.apis.foreach { api => + b += SearchItem(s"API: ${api.name}", SearchKind.Api, apiKey(api)) + api.methods.foreach { m => + b += SearchItem(s"${httpMethodText(m.httpMethod)} ${m.path} ${m.name}", SearchKind.Method, apiKey(api)) + } + } + spec.sumTypes.foreach { s => + b += SearchItem(s"sum: ${s.name}", SearchKind.SumT, sumKey(s)) + s.subtypeNames.foreach(sub => b += SearchItem(s"${s.name} → $sub", SearchKind.SumT, sumKey(s))) + } + spec.models.foreach { m => + b += SearchItem(s"${modelKindShort(m)}: ${m.name}", SearchKind.Model, modelKey(m)) + m match { + case obj: ModelClass.ObjectType => + obj.properties.foreach { p => + b += SearchItem(s"${m.name}.${p.name} : ${renderTypeInfo(p.typeInfo)}", SearchKind.Property, modelKey(m)) + } + case e: ModelClass.EnumType => + e.values.foreach(v => b += SearchItem(s"${m.name}.$v", SearchKind.Property, modelKey(m))) + case _ => () + } + } + b.result() + } + + private def hitRow(item: SearchItem, selected: Boolean): Element = { + val (icon, color) = item.kind match { + case SearchKind.Api => ("◈", Color.CYAN) + case SearchKind.Method => ("→", Color.WHITE) + case SearchKind.Model => ("▦", Color.MAGENTA) + case SearchKind.SumT => ("∪", Color.MAGENTA) + case SearchKind.Property => ("·", Color.YELLOW) + } + val style = + if (selected) Style.empty.withBg(Color.BLUE).withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(color) + val prefix = if (selected) " ▶ " else " " + text(s"$prefix$icon ${item.label}", style) + } + + // ───────────────────────────────────── keys + labels ───────────────────────────────────────── + + private def apiKey(a: ApiInterface): String = s"api:${a.name}" + private def methodKey(m: ApiMethod): String = s"method:${m.name}:${m.path}" + private def modelKey(m: ModelClass): String = s"model:${m.name}" + private def sumKey(s: SumType): String = s"sum:${s.name}" + + private def httpMethodLabel(m: HttpMethod): (String, Style) = m match { + case HttpMethod.Get => ("GET", Style.empty.withFg(Color.GREEN)) + case HttpMethod.Post => ("POST", Style.empty.withFg(Color.YELLOW)) + case HttpMethod.Put => ("PUT", Style.empty.withFg(Color.BLUE)) + case HttpMethod.Delete => ("DELETE", Style.empty.withFg(Color.RED)) + case HttpMethod.Patch => ("PATCH", Style.empty.withFg(Color.MAGENTA)) + case HttpMethod.Head => ("HEAD", Style.empty.withFg(Color.CYAN)) + case HttpMethod.Options => ("OPTIONS", Style.empty.withFg(Color.CYAN)) + } + + private def httpMethodText(m: HttpMethod): String = httpMethodLabel(m)._1 + + private def modelKindLabel(m: ModelClass): String = m match { + case _: ModelClass.ObjectType => "object" + case _: ModelClass.EnumType => "enum" + case _: ModelClass.WrapperType => "wrapper" + case _: ModelClass.AliasType => "alias" + } + + private def modelKindShort(m: ModelClass): String = m match { + case _: ModelClass.ObjectType => "obj" + case _: ModelClass.EnumType => "enum" + case _: ModelClass.WrapperType => "wrap" + case _: ModelClass.AliasType => "alias" + } + + private def renderTypeInfo(t: TypeInfo): String = t match { + case TypeInfo.Primitive(p) => primitiveLabel(p) + case TypeInfo.ListOf(inner) => s"List[${renderTypeInfo(inner)}]" + case TypeInfo.Optional(inner) => s"Optional[${renderTypeInfo(inner)}]" + case TypeInfo.MapOf(k, v) => s"Map[${renderTypeInfo(k)}, ${renderTypeInfo(v)}]" + case TypeInfo.Ref(name) => name + case TypeInfo.Any => "Any" + case TypeInfo.InlineEnum(vs) => s"enum(${vs.take(3).mkString("|")}${if (vs.size > 3) "…" else ""})" + } + + private def primitiveLabel(p: PrimitiveType): String = p match { + case PrimitiveType.String => "String" + case PrimitiveType.Int32 => "Int" + case PrimitiveType.Int64 => "Long" + case PrimitiveType.Float => "Float" + case PrimitiveType.Double => "Double" + case PrimitiveType.Boolean => "Boolean" + case PrimitiveType.Date => "Date" + case PrimitiveType.DateTime => "DateTime" + case PrimitiveType.Time => "Time" + case PrimitiveType.UUID => "UUID" + case PrimitiveType.URI => "URI" + case PrimitiveType.Email => "Email" + case PrimitiveType.Binary => "Binary" + case PrimitiveType.Byte => "Byte" + case PrimitiveType.BigDecimal => "BigDecimal" + } + + private def toggle(s: Set[String], k: String): Set[String] = if (s.contains(k)) s - k else s + k + private def plural(n: Int): String = if (n == 1) "" else "s" + private def countMethods(spec: ParsedSpec): Int = spec.apis.map(_.methods.size).sum + private def padLeft(s: String, n: Int): String = if (s.length >= n) s else " " * (n - s.length) + s + + /** Draft a FieldType from a model property — scoped to this source + model name via ModelMatch — write it to typr.yaml, navigate to TypeEditor for refinement. + */ + private def extractFieldType( + sourceName: String, + model: ModelClass, + prop: TreeRow.PropertyRow, + app: AppApi, + router: RouterApi + ): Unit = { + val existing = app.config.types.getOrElse(Map.empty) + val typeName = uniqueTypeName(Entity.pascalCase(prop.name), existing.keySet) + val ft = FieldType( + api = None, + db = None, + model = Some( + ModelMatch( + `extension` = None, + format = None, + json_path = None, + name = Some(StringOrArrayString(prop.name)), + required = None, + schema = Some(StringOrArrayString(model.name)), + schema_type = None, + source = Some(StringOrArrayString(sourceName)) + ) + ), + underlying = Some(prop.tpe), + validation = None + ) + val next = existing + (typeName -> (ft: BridgeType)) + val _ = app.updateConfig(c => c.copy(types = Some(next))) + router.push(RouterScreen.of(s"type · $typeName", TypeEditor.element(typeName))) + } + + private def uniqueTypeName(suggested: String, existing: Set[String]): String = + if (!existing.contains(suggested)) suggested + else LazyList.from(2).map(n => s"$suggested$n").find(n => !existing.contains(n)).get +} diff --git a/typr/src/scala/typr/cli/app/screens/Splash.scala b/typr/src/scala/typr/cli/app/screens/Splash.scala new file mode 100644 index 0000000000..de72e115ee --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/Splash.scala @@ -0,0 +1,75 @@ +package typr.cli.app.screens + +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.react.Components.* +import jatatui.react.Element +import tui.crossterm.KeyCode +import typr.cli.beta.{BetaGate, BetaTerms} + +/** Animated logo + auto-dismiss. Always reached after [[BetaNotice]] (initial mode) has run, so the 2-second auto-dismiss only kicks in for accepted users — first-run users see the gate modal until + * they accept, then the splash, then the main menu. + * + * Bottom chip shows the closed-beta state. During the 14-day warning window it goes yellow and counts down. `t` opens [[BetaNotice]] in read-only mode for users who want to reread. + */ +object Splash { + + private val logo: List[String] = List( + " ████████╗██╗ ██╗██████╗ ██████╗ ", + " ╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔══██╗ ", + " ██║ ╚████╔╝ ██████╔╝██████╔╝ ", + " ██║ ╚██╔╝ ██╔═══╝ ██╔══██╗ ", + " ██║ ██║ ██║ ██║ ██║ ", + " ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ " + ) + + private val tagline = "Seal Your System's Boundaries" + private val hint = "press any key to continue" + + private val logoStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val taglineStyle: Style = Style.empty.withFg(Color.WHITE) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val warningStyle: Style = Style.empty.withFg(Color.YELLOW).withAddModifier(Modifier.BOLD) + + private val AutoDismissMs: Long = 2000L + + /** `binaryVersion` is threaded through so the `t`-key BetaNotice pop has a value to pass into [[BetaGate.markAccepted]] if the user happens to accept from there (read-only mode doesn't re-accept, + * but the screen factory still needs the param). Defaulting at this layer is fine — the value is informational, not load-bearing. + */ + def element(binaryVersion: String = "unknown"): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val advance = () => router.replace(RouterScreen.of("main menu", MainMenu.element)) + + ctx.useTimeout(AutoDismissMs, () => advance()) + ctx.onGlobalKey(ANY_KEY, () => advance()) + ctx.onClick(() => advance()) + + // 't' opens the terms screen in read-only mode (push so esc returns here). + ctx.onGlobalKey(new KeyCode.Char('t'), () => router.push(RouterScreen.of("terms", BetaNotice.element(BetaNotice.Mode.ReadOnly, binaryVersion)))) + + val (chipText, chipStyle) = + if (BetaGate.isInWarningWindow()) { + val d = BetaGate.daysUntilExpiry() + val units = if (d == 1) "day" else "days" + (s"closed beta · expires in $d $units — typr.dev/get · press t for terms", warningStyle) + } else + (s"closed beta · build expires ${BetaTerms.expiresOn} · press t for terms", hintStyle) + + val logoLines: Array[Element] = logo.map(l => length(1, text(l, logoStyle))).toArray + val body = column( + (length(2, empty()) + :: logoLines.toList + ::: List( + length(1, empty()), + length(1, text(tagline, taglineStyle)), + length(1, empty()), + length(1, text(hint, hintStyle)), + fill(1, empty()), + length(1, text(s" $chipText", chipStyle)) + ))* + ) + + row(fill(1, empty()), length(80, body), fill(1, empty())) + } +} diff --git a/typr/src/scala/typr/cli/app/screens/TypeEditor.scala b/typr/src/scala/typr/cli/app/screens/TypeEditor.scala new file mode 100644 index 0000000000..2ea9c151ff --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/TypeEditor.scala @@ -0,0 +1,711 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.button.Button +import jatatui.components.chrome.ScreenFrame +import jatatui.components.router.RouterApi +import jatatui.components.scrollable.Scrollable +import jatatui.components.textinput.{TextInputComponent, TextInputProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.react.Components.* +import jatatui.react.Element +import tui.crossterm.KeyCode +import typr.cli.app.{AppApi, Entity, EntityCatalog, LoadedSource, TypePreview} +import typr.config.generated.{BridgeType, DomainType, FieldType} +import jatatui.widgets.paragraph.Paragraph + +import scala.jdk.CollectionConverters.* + +/** Form-based editor for a single bridge type. Two variants in one screen since [[FieldType]] and [[DomainType]] have entirely different shapes — render different fields per kind, share the common + * save plumbing. + * + * First cut is intentionally narrow: FieldType edits `underlying` only; DomainType edits `primary` and `description`. The deep nested bits (DbMatch / ApiMatch / ModelMatch / ValidationRules on field + * types; `fields` / `alignedSources` on domain types) get their own editors later — they're each a screen's worth of UI. + */ +object TypeEditor { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val okStyle: Style = Style.empty.withFg(Color.GREEN).withAddModifier(Modifier.BOLD) + private val valueStyle: Style = Style.empty.withFg(Color.WHITE) + private val focusedValueStyle: Style = Style.empty.withFg(Color.YELLOW).withAddModifier(Modifier.BOLD) + private val cursorStyle: Style = Style.empty.withAddModifier(Modifier.REVERSED) + + sealed trait SaveResult + object SaveResult { + case object None extends SaveResult + case object Saved extends SaveResult + final case class Failed(error: String) extends SaveResult + } + + def element(name: String): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + + app.config.types.getOrElse(Map.empty).get(name) match { + case Some(ft: FieldType) => fieldEditor(name, ft, app, back) + case Some(dt: DomainType) => domainEditor(name, dt, app, back) + case _ => notFound(name, back) + } + } + + private def notFound(name: String, back: () => Unit): Element = { + val body = column( + fill(1, empty()), + length(1, text(s" type '$name' not found", errStyle)), + length(1, text(" press esc to return", hintStyle)), + fill(1, empty()) + ) + ScreenFrame.of("types", back, body) + } + + // ---- FieldType ---- + + private val sectionStyle: Style = + Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD) + private val sectionMutedStyle: Style = + Style.empty.withFg(Color.DARK_GRAY).withAddModifier(Modifier.BOLD) + + private def fieldEditor(name: String, ft: FieldType, app: AppApi, back: () => Unit): Element = + component { ctx => + val form = ctx.useState(() => FieldTypeForm.of(ft)) + val saved = ctx.useState(() => SaveResult.None: SaveResult) + + def save(): Unit = { + val newType = FieldTypeForm.toFieldType(form.get) + val newTypes = app.config.types.getOrElse(Map.empty) + (name -> (newType: BridgeType)) + app.updateConfig(c => c.copy(types = Some(newTypes))) match { + case Right(_) => saved.set(SaveResult.Saved) + case Left(e) => saved.set(SaveResult.Failed(Option(e.getMessage).getOrElse(e.toString))) + } + } + + def update(f: FieldTypeForm => FieldTypeForm): Unit = { + form.update(t => f(t)) + saved.set(SaveResult.None) + } + + val underlyingRow = length( + 3, + fieldRow( + label = "underlying", + focusId = "ft:underlying", + value = form.get.underlying, + autoFocus = true, + onChange = v => update(_.copy(underlying = v)) + ) + ) + + // Each match section is collapsible. Header is a focusable toggle row. + val apiRows = matchSection( + label = "api match", + focusId = "ft:api-toggle", + isOpen = form.get.apiOpen, + toggle = () => update(s => s.copy(apiOpen = !s.apiOpen)), + children = List( + textField("name", "ft:api-name", form.get.apiName, v => update(_.copy(apiName = v))), + textField("path", "ft:api-path", form.get.apiPath, v => update(_.copy(apiPath = v))), + textField("http_method", "ft:api-method", form.get.apiHttpMethod, v => update(_.copy(apiHttpMethod = v))), + textField("location", "ft:api-location", form.get.apiLocation, v => update(_.copy(apiLocation = v))), + textField("operation_id", "ft:api-opid", form.get.apiOperationId, v => update(_.copy(apiOperationId = v))), + textField("source", "ft:api-source", form.get.apiSource, v => update(_.copy(apiSource = v))), + textField("required", "ft:api-required", form.get.apiRequired, v => update(_.copy(apiRequired = v))), + textField("extension", "ft:api-extension", form.get.apiExtension, v => update(_.copy(apiExtension = v))) + ) + ) + + val dbRows = matchSection( + label = "db match", + focusId = "ft:db-toggle", + isOpen = form.get.dbOpen, + toggle = () => update(s => s.copy(dbOpen = !s.dbOpen)), + children = List( + textField("column", "ft:db-column", form.get.dbColumn, v => update(_.copy(dbColumn = v))), + textField("table", "ft:db-table", form.get.dbTable, v => update(_.copy(dbTable = v))), + textField("schema", "ft:db-schema", form.get.dbSchema, v => update(_.copy(dbSchema = v))), + textField("db_type", "ft:db-type", form.get.dbType, v => update(_.copy(dbType = v))), + textField("source", "ft:db-source", form.get.dbSource, v => update(_.copy(dbSource = v))), + textField("domain", "ft:db-domain", form.get.dbDomain, v => update(_.copy(dbDomain = v))), + textField("comment", "ft:db-comment", form.get.dbComment, v => update(_.copy(dbComment = v))), + textField("references", "ft:db-references", form.get.dbReferences, v => update(_.copy(dbReferences = v))), + textField("annotation", "ft:db-annotation", form.get.dbAnnotation, v => update(_.copy(dbAnnotation = v))), + textField("nullable", "ft:db-nullable", form.get.dbNullable, v => update(_.copy(dbNullable = v))), + textField("primary_key", "ft:db-pk", form.get.dbPrimaryKey, v => update(_.copy(dbPrimaryKey = v))) + ) + ) + + val modelRows = matchSection( + label = "model match", + focusId = "ft:model-toggle", + isOpen = form.get.modelOpen, + toggle = () => update(s => s.copy(modelOpen = !s.modelOpen)), + children = List( + textField("name", "ft:model-name", form.get.modelName, v => update(_.copy(modelName = v))), + textField("schema", "ft:model-schema", form.get.modelSchema, v => update(_.copy(modelSchema = v))), + textField("schema_type", "ft:model-schema-type", form.get.modelSchemaType, v => update(_.copy(modelSchemaType = v))), + textField("format", "ft:model-format", form.get.modelFormat, v => update(_.copy(modelFormat = v))), + textField("json_path", "ft:model-jsonpath", form.get.modelJsonPath, v => update(_.copy(modelJsonPath = v))), + textField("source", "ft:model-source", form.get.modelSource, v => update(_.copy(modelSource = v))), + textField("required", "ft:model-required", form.get.modelRequired, v => update(_.copy(modelRequired = v))), + textField("extension", "ft:model-extension", form.get.modelExtension, v => update(_.copy(modelExtension = v))) + ) + ) + + val validationRows = matchSection( + label = "validation", + focusId = "ft:val-toggle", + isOpen = form.get.validationOpen, + toggle = () => update(s => s.copy(validationOpen = !s.validationOpen)), + children = List( + textField("pattern", "ft:val-pattern", form.get.valPattern, v => update(_.copy(valPattern = v))), + textField("min", "ft:val-min", form.get.valMin, v => update(_.copy(valMin = v))), + textField("max", "ft:val-max", form.get.valMax, v => update(_.copy(valMax = v))), + textField("exclusive_min", "ft:val-emin", form.get.valExclusiveMin, v => update(_.copy(valExclusiveMin = v))), + textField("exclusive_max", "ft:val-emax", form.get.valExclusiveMax, v => update(_.copy(valExclusiveMax = v))), + textField("min_length", "ft:val-minlen", form.get.valMinLength, v => update(_.copy(valMinLength = v))), + textField("max_length", "ft:val-maxlen", form.get.valMaxLength, v => update(_.copy(valMaxLength = v))), + textField("allowed_values", "ft:val-allowed", form.get.valAllowedValues, v => update(_.copy(valAllowedValues = v))) + ) + ) + + // Preview-matches results live in a separate state slot. Recomputed synchronously when + // the user clicks the button — no I/O, just walks cached MetaDb structures. + val previewResults = ctx.useState(() => Option.empty[List[TypePreview.Result]]) + + def runPreview(): Unit = { + val cachedDbs = app.sourceCache.toList.sortBy(_._1).collect { case (sourceName, LoadedSource.Db(metaDb)) => + sourceName -> metaDb + } + val current = FieldTypeForm.toFieldType(form.get) + val results = cachedDbs.map { case (sourceName, metaDb) => + TypePreview.runDb(name, current, sourceName, metaDb) + } + previewResults.set(Some(results)) + } + + val saveButton = length(3, Button.of("save", "save", true, () => save())) + + val previewButton = length(3, Button.of("preview matches", "preview", false, () => runPreview())) + + val actionRow = length( + 3, + jatatui.react.Components.row( + length(14, saveButton), + length(2, empty()), + length(24, previewButton), + fill(1, empty()) + ) + ) + + val statusLine = saved.get match { + case SaveResult.None => text("", hintStyle) + case SaveResult.Saved => text(s" ✓ saved to ${app.configPath.getFileName}", okStyle) + case SaveResult.Failed(err) => text(s" ✗ save failed: ${err.take(120)}", errStyle) + } + + val cachedDbCount = app.sourceCache.count { case (_, v) => v.isInstanceOf[LoadedSource.Db] } + val previewRows: List[Element] = renderPreviewResults(previewResults.get, cachedDbCount) + + val title = column( + length(1, empty()), + length(1, text(s" type · $name · field", titleStyle)) + ) + + val formRows: List[Element] = + underlyingRow :: + length(1, empty()) :: + apiRows ::: + dbRows ::: + modelRows ::: + validationRows ::: + List(length(1, empty()), actionRow, length(1, statusLine)) ::: + previewRows + + val body = column( + fill(1, Scrollable.column(formRows.asJava)), + length(1, text(" tab navigate · enter (on toggles) collapse/expand · wheel to scroll · esc back", hintStyle)) + ) + + ScreenFrame.withTitle("types", back, title, body) + } + + /** Render the per-source preview results. Each source gets a header + up to 8 matched columns; more than 8 collapses to "(+N more)". Empty result for a source just notes "no matches" so the user + * can tell the difference between "no cached sources" and "cached but nothing matched". + */ + private def renderPreviewResults(results: Option[List[TypePreview.Result]], cachedCount: Int): List[Element] = { + val previewTitleStyle = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + val previewSourceStyle = Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD) + val previewMatchStyle = Style.empty.withFg(Color.WHITE) + val previewMutedStyle = Style.empty.withFg(Color.DARK_GRAY) + + results match { + case None if cachedCount == 0 => + List( + length(1, empty()), + length(1, text(" preview matches:", previewTitleStyle)), + length(1, text(" no sources loaded yet — open a source in the schema browser first.", previewMutedStyle)) + ) + case None => + Nil + case Some(perSource) => + val header = List( + length(1, empty()), + length(1, text(s" preview matches ($cachedCount cached source${if (cachedCount == 1) "" else "s"})", previewTitleStyle)) + ) + if (perSource.isEmpty) header ::: List(length(1, text(" no cached sources to test against.", previewMutedStyle))) + else + header ::: perSource.flatMap { res => + val matchCount = res.matches.size + val sourceHeader = + length(1, text(s" ${res.sourceName} · $matchCount match${if (matchCount == 1) "" else "es"}", previewSourceStyle)) + val matchRows: List[Element] = + if (res.matches.isEmpty) + List(length(1, text(" (none)", previewMutedStyle))) + else { + val visible = res.matches.take(8) + val rows = visible.map { m => + length(1, text(s" · ${m.qualifiedColumn} ${m.dbType}", previewMatchStyle)) + } + val overflow = matchCount - visible.size + if (overflow > 0) rows :+ length(1, text(s" (+$overflow more)", previewMutedStyle)) + else rows + } + sourceHeader :: matchRows + } + } + } + + /** Per-field alignment status across each aligned source. Rendered live as the user edits fields / aligned sources, so they see drift instantly without leaving the editor. + * + * ✓ domain field maps to an entity field with the same (normalised) name ⚠ domain field has no counterpart in the aligned entity (drift you should fix) · aligned entity has a field with no + * counterpart in the domain (extra; the mapper generator will pick a default) + * + * The matrix only renders when there's something to compare against — at least one aligned source and at least one field on the domain side. + */ + private def renderAlignmentMatrix( + form: DomainTypeForm, + cache: collection.Map[String, LoadedSource] + ): List[Element] = { + val domainFields = form.fields.filter(_.name.trim.nonEmpty).map(_.name.trim).toList + val alignedKeys = form.aligned.filter(_.key.trim.nonEmpty).map(_.key.trim).toList + + if (domainFields.isEmpty || alignedKeys.isEmpty) return Nil + + // Build per-source entity lookup. For an aligned-source key "boundary:entity", find the + // matching entity by its primaryKey (same shape). + val allEntities = EntityCatalog.fromCache(cache) + val byKey: Map[String, Entity] = allEntities.iterator.map(e => e.primaryKey -> e).toMap + + val resolved: List[(String, Option[Entity])] = alignedKeys.map(k => k -> byKey.get(k)) + + // If none of the aligned keys resolve, the user is probably still typing — skip the matrix + // so a stale ✗-everywhere block doesn't shout at them mid-edit. + if (resolved.forall(_._2.isEmpty)) return Nil + + val titleStyle = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + val sourceStyle = Style.empty.withFg(Color.CYAN).withAddModifier(Modifier.BOLD) + val fieldStyle = Style.empty.withFg(Color.WHITE) + val matchStyle = Style.empty.withFg(Color.GREEN) + val missingStyle = Style.empty.withFg(Color.YELLOW) + val extraStyle = Style.empty.withFg(Color.DARK_GRAY) + val mutedStyle = Style.empty.withFg(Color.DARK_GRAY) + + val maxFieldWidth = domainFields.map(_.length).max + val perSourceWidth = (resolved.map(_._1.length).maxOption.getOrElse(8) + 6).max(20) + + // Header + val headerLine = Line.from( + Span.styled(" ", Style.empty), + Span.styled("field".padTo(maxFieldWidth + 4, ' '), titleStyle), + Span.styled(resolved.map { case (k, _) => k.padTo(perSourceWidth, ' ') }.mkString, sourceStyle) + ) + val headerWidget: jatatui.core.widgets.Widget = (area, buffer) => Paragraph.of(headerLine).render(area, buffer) + + // One row per domain field + val fieldRows: List[Element] = domainFields.map { fieldName => + val normField = Entity.normalise(fieldName) + val cells: List[Span] = resolved.map { case (_, entityOpt) => + entityOpt match { + case None => + Span.styled("(source not loaded)".padTo(perSourceWidth, ' '), mutedStyle) + case Some(entity) => + entity.fields.find(f => Entity.normalise(f.name) == normField) match { + case Some(matched) => + Span.styled(s"✓ ${matched.name}".padTo(perSourceWidth, ' '), matchStyle) + case None => + Span.styled("⚠ (missing)".padTo(perSourceWidth, ' '), missingStyle) + } + } + } + val w: jatatui.core.widgets.Widget = (area, buffer) => { + val line = Line.from( + (Span.styled(" ", Style.empty) + +: Span.styled(fieldName.padTo(maxFieldWidth + 4, ' '), fieldStyle) + +: cells)* + ) + Paragraph.of(line).render(area, buffer) + } + length(1, widget(w)) + } + + // Extras: fields in an aligned entity that don't appear in the domain + val domainNormSet = domainFields.iterator.map(Entity.normalise).toSet + val extrasBySource: List[(String, List[String])] = resolved + .collect { case (key, Some(entity)) => + val extras = entity.fields.filter(f => !domainNormSet.contains(Entity.normalise(f.name))).map(_.name) + key -> extras + } + .filter(_._2.nonEmpty) + + val extraRows: List[Element] = + if (extrasBySource.isEmpty) Nil + else + length(1, empty()) :: + length(1, text(" extras (fields in aligned sources not yet in domain):", titleStyle)) :: + extrasBySource.flatMap { case (key, extras) => + val visible = extras.take(6) + val overflow = extras.size - visible.size + val header = length(1, text(s" ${key}", sourceStyle)) + val rows = visible.map(n => length(1, text(s" · $n", extraStyle))) + val tail = + if (overflow > 0) List(length(1, text(s" (+$overflow more)", mutedStyle))) + else Nil + header :: rows ::: tail + } + + length(1, empty()) :: + length(1, text(" alignment matrix", titleStyle)) :: + length(1, widget(headerWidget)) :: + fieldRows ::: extraRows + } + + /** Renders a collapsible section header followed by its child rows when expanded. The header row is itself a focusable+activatable toggle so the user can Tab to it and press Enter to + * collapse/expand without reaching for the mouse. + */ + private def matchSection( + label: String, + focusId: String, + isOpen: Boolean, + toggle: () => Unit, + children: List[Element] + ): List[Element] = { + val header = length(1, sectionToggle(label, focusId, isOpen, toggle)) + if (isOpen) header :: children.map(c => length(3, c)) ::: List(length(1, empty())) + else List(header) + } + + private def sectionToggle(label: String, focusId: String, isOpen: Boolean, toggle: () => Unit): Element = + component { ctx => + val focused = ctx.useFocus(java.util.Optional.of(focusId), false) + if (focused) ctx.onKey(new KeyCode.Enter, () => toggle()) + ctx.onClick(() => toggle()) + val caret = if (isOpen) "▾" else "▸" + val st = if (focused) sectionStyle else sectionMutedStyle + text(s" $caret $label", st) + } + + private def textField( + label: String, + focusId: String, + value: String, + onChange: String => Unit + ): Element = fieldRow(label, focusId, value, autoFocus = false, onChange) + + // ---- DomainType ---- + + private def domainEditor(name: String, dt: DomainType, app: AppApi, back: () => Unit): Element = + component { ctx => + val form = ctx.useState(() => DomainTypeForm.of(dt)) + val saved = ctx.useState(() => SaveResult.None: SaveResult) + + def save(): Unit = { + val newType = DomainTypeForm.toDomainType(form.get) + val newTypes = app.config.types.getOrElse(Map.empty) + (name -> (newType: BridgeType)) + app.updateConfig(c => c.copy(types = Some(newTypes))) match { + case Right(_) => saved.set(SaveResult.Saved) + case Left(e) => saved.set(SaveResult.Failed(Option(e.getMessage).getOrElse(e.toString))) + } + } + + def update(f: DomainTypeForm => DomainTypeForm): Unit = { + form.update(t => f(t)) + saved.set(SaveResult.None) + } + + val primaryRow = length( + 3, + fieldRow( + label = "primary", + focusId = "dt:primary", + value = form.get.primary, + autoFocus = true, + onChange = v => update(_.copy(primary = v)) + ) + ) + val descRow = length( + 3, + fieldRow( + label = "description", + focusId = "dt:description", + value = form.get.description, + autoFocus = false, + onChange = v => update(_.copy(description = v)) + ) + ) + + val fieldRows = fieldsSection(form.get, update) + val alignedRows = alignedSection(form.get, update) + val generateRows = generateSection(form.get, update) + + val saveButton = length(3, Button.of("save", "save", true, () => save())) + + val statusLine = saved.get match { + case SaveResult.None => text("", hintStyle) + case SaveResult.Saved => text(s" ✓ saved to ${app.configPath.getFileName}", okStyle) + case SaveResult.Failed(err) => text(s" ✗ save failed: ${err.take(120)}", errStyle) + } + + val title = column( + length(1, empty()), + length(1, text(s" type · $name · domain", titleStyle)) + ) + + val matrixRows = renderAlignmentMatrix(form.get, app.sourceCache) + + val formRows: List[Element] = + primaryRow :: + descRow :: + length(1, empty()) :: + fieldRows ::: + alignedRows ::: + generateRows ::: + matrixRows ::: + List(length(1, empty()), saveButton, length(1, statusLine)) + + val body = column( + fill(1, Scrollable.column(formRows.asJava)), + length(1, text(" tab navigate · enter (on +/×/toggle) act · wheel to scroll · esc back", hintStyle)) + ) + + ScreenFrame.withTitle("types", back, title, body) + } + + // One row per `fields:` entry. Each row has [name input | type input | delete button]. A + // trailing "+ add field" row at the bottom appends a blank entry. + private def fieldsSection(form: DomainTypeForm, update: (DomainTypeForm => DomainTypeForm) => Unit): List[Element] = { + val header = length( + 1, + sectionToggle( + s"fields (${form.fields.size})", + "dt:fields-toggle", + form.fieldsOpen, + () => update(s => s.copy(fieldsOpen = !s.fieldsOpen)) + ) + ) + if (!form.fieldsOpen) List(header) + else { + val rows = form.fields.zipWithIndex.toList.map { case (row, idx) => + length( + 3, + jatatui.react.Components.row( + fill( + 2, + fieldRow( + label = "name", + focusId = s"dt:field-name-$idx", + value = row.name, + autoFocus = false, + onChange = v => update(_.copy(fields = form.fields.updated(idx, row.copy(name = v)))) + ) + ), + length(2, empty()), + fill( + 3, + fieldRow( + label = "type", + focusId = s"dt:field-type-$idx", + value = row.tpe, + autoFocus = false, + onChange = v => update(_.copy(fields = form.fields.updated(idx, row.copy(tpe = v)))) + ) + ), + length(2, empty()), + length( + 7, + Button.of( + "×", + s"dt:field-del-$idx", + false, + () => update(_.copy(fields = form.fields.patch(idx, Nil, 1))) + ) + ) + ) + ) + } + val addRow = length( + 3, + Button.of( + "+ add field", + "dt:field-add", + false, + () => update(_.copy(fields = form.fields :+ DomainTypeForm.newField)) + ) + ) + (header :: rows) ::: List(addRow, length(1, empty())) + } + } + + // alignedSources: similar pattern. Row holds key, entity, mode, direction, readonly. + private def alignedSection(form: DomainTypeForm, update: (DomainTypeForm => DomainTypeForm) => Unit): List[Element] = { + val header = length( + 1, + sectionToggle( + s"aligned sources (${form.aligned.size})", + "dt:aligned-toggle", + form.alignedOpen, + () => update(s => s.copy(alignedOpen = !s.alignedOpen)) + ) + ) + if (!form.alignedOpen) List(header) + else { + val rows = form.aligned.zipWithIndex.toList.flatMap { case (row, idx) => + List( + // Line 1: key entity delete + length( + 3, + jatatui.react.Components.row( + fill( + 3, + fieldRow( + label = "key (boundary:entity)", + focusId = s"dt:al-key-$idx", + value = row.key, + autoFocus = false, + onChange = v => update(_.copy(aligned = form.aligned.updated(idx, row.copy(key = v)))) + ) + ), + length(2, empty()), + fill( + 3, + fieldRow( + label = "entity", + focusId = s"dt:al-entity-$idx", + value = row.entity, + autoFocus = false, + onChange = v => update(_.copy(aligned = form.aligned.updated(idx, row.copy(entity = v)))) + ) + ), + length(2, empty()), + length( + 7, + Button.of( + "×", + s"dt:al-del-$idx", + false, + () => update(_.copy(aligned = form.aligned.patch(idx, Nil, 1))) + ) + ) + ) + ), + // Line 2: mode direction readonly + length( + 3, + jatatui.react.Components.row( + fill( + 1, + fieldRow( + label = "mode (exact|superset|subset)", + focusId = s"dt:al-mode-$idx", + value = row.mode, + autoFocus = false, + onChange = v => update(_.copy(aligned = form.aligned.updated(idx, row.copy(mode = v)))) + ) + ), + length(2, empty()), + fill( + 1, + fieldRow( + label = "direction (in|out|in-out)", + focusId = s"dt:al-dir-$idx", + value = row.direction, + autoFocus = false, + onChange = v => update(_.copy(aligned = form.aligned.updated(idx, row.copy(direction = v)))) + ) + ), + length(2, empty()), + fill( + 1, + fieldRow( + label = "readonly (yes|no)", + focusId = s"dt:al-ro-$idx", + value = row.readonly, + autoFocus = false, + onChange = v => update(_.copy(aligned = form.aligned.updated(idx, row.copy(readonly = v)))) + ) + ) + ) + ), + length(1, empty()) + ) + } + val addRow = length( + 3, + Button.of( + "+ add aligned source", + "dt:al-add", + false, + () => update(_.copy(aligned = form.aligned :+ DomainTypeForm.newAligned)) + ) + ) + (header :: rows) ::: List(addRow, length(1, empty())) + } + } + + // generate options: simple 3-state yes/no/empty per flag. + private def generateSection(form: DomainTypeForm, update: (DomainTypeForm => DomainTypeForm) => Unit): List[Element] = { + val header = length( + 1, + sectionToggle( + "generate options", + "dt:gen-toggle", + form.generateOpen, + () => update(s => s.copy(generateOpen = !s.generateOpen)) + ) + ) + if (!form.generateOpen) List(header) + else + List( + header, + length(3, fieldRow("builder (yes|no)", "dt:gen-builder", form.genBuilder, false, v => update(_.copy(genBuilder = v)))), + length(3, fieldRow("copy (yes|no)", "dt:gen-copy", form.genCopy, false, v => update(_.copy(genCopy = v)))), + length(3, fieldRow("domain type (yes|no)", "dt:gen-dt", form.genDomainType, false, v => update(_.copy(genDomainType = v)))), + length(3, fieldRow("interface (yes|no)", "dt:gen-iface", form.genInterface, false, v => update(_.copy(genInterface = v)))), + length(3, fieldRow("mappers (yes|no)", "dt:gen-map", form.genMappers, false, v => update(_.copy(genMappers = v)))), + length(1, empty()) + ) + } + + private def fieldRow( + label: String, + focusId: String, + value: String, + autoFocus: Boolean, + onChange: String => Unit + ): Element = + TextInputComponent.of( + TextInputProps + .of(value, v => onChange(v)) + .withTitle(label) + .withFocusId(focusId) + .withAutoFocus(autoFocus) + .withStyle(valueStyle) + .withFocusedStyle(focusedValueStyle) + .withCursorStyle(cursorStyle) + ) +} diff --git a/typr/src/scala/typr/cli/app/screens/TypeList.scala b/typr/src/scala/typr/cli/app/screens/TypeList.scala new file mode 100644 index 0000000000..3c3ea2d57f --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/TypeList.scala @@ -0,0 +1,262 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.chrome.ScreenFrame +import typr.cli.app.components.Link +import jatatui.components.modal.ConfirmDialog +import jatatui.components.router.{RouterApi, Screen as RouterScreen} +import jatatui.components.scrollable.Scrollable +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.core.text.{Line, Span} +import jatatui.core.widgets.Widget +import jatatui.react.Components.* +import jatatui.react.Element +import jatatui.widgets.Borders +import jatatui.widgets.block.{Block, BorderType} +import jatatui.widgets.paragraph.Paragraph +import tui.crossterm.KeyCode +import typr.cli.app.AppApi +import typr.config.generated.{BridgeType, DomainType, FieldType} + +import scala.jdk.CollectionConverters.* + +object TypeList { + + enum Kind { + case Field, Domain + } + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + + def element(kind: Kind): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + val pendingDelete = ctx.useState(() => Option.empty[String]) + val deleteError = ctx.useState(() => Option.empty[String]) + + if (pendingDelete.get.isEmpty) { + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + ctx.onGlobalKey(new KeyCode.Char('b'), () => back()) + } + + def doDelete(name: String): Unit = { + val remaining = app.config.types.getOrElse(Map.empty) - name + val nextMap = if (remaining.isEmpty) None else Some(remaining) + app.updateConfig(c => c.copy(types = nextMap)) match { + case Right(_) => + pendingDelete.set(None) + deleteError.set(None) + case Left(e) => + deleteError.set(Some(Option(e.getMessage).getOrElse(e.toString))) + pendingDelete.set(None) + } + } + + val types = app.config.types + .getOrElse(Map.empty) + .toList + .sortBy(_._1) + .filter { case (_, t) => matchesKind(t, kind) } + + val addCard = length( + 3, + addNewCard( + kind, + autoFocus = true, + onActivate = { + val (linkLabel, _) = addNewLabels(kind) + () => router.push(RouterScreen.of(linkLabel, TypeWizard.element(kind))) + } + ) + ) + // Domain types get a second "create from real entity" card right under the blank-form + // option, since picking a real entity is the easier (and recommended) path. + val fromEntityCard: List[Element] = kind match { + case Kind.Domain => List(length(3, fromEntityShortcut(() => router.push(RouterScreen.of("create from entity", DomainFromEntity.element))))) + case Kind.Field => Nil + } + val typeCards: List[Element] = types.map { case (name, t) => + length( + 3, + typeCard( + name, + t, + () => pendingDelete.set(Some(name)), + () => router.push(RouterScreen.of(s"type · $name", TypeEditor.element(name))) + ) + ) + } + + val title = column(length(1, empty()), length(1, text(s" ${heading(kind)}", titleStyle))) + + val errorLine = + deleteError.get.fold(length(0, empty())) { msg => + length(1, text(s" ✗ ${msg.take(140)}", Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD))) + } + + val body = column( + fill(1, Scrollable.column((addCard :: (fromEntityCard ::: typeCards)).asJava)), + errorLine, + length(1, text(" tab navigate · enter open · d delete · wheel to scroll · esc back", hintStyle)) + ) + + val frame = ScreenFrame.withTitle("menu", back, title, body) + + val confirm = ConfirmDialog.of( + pendingDelete.get.isDefined, + s" delete ${kindNoun(kind)} ", + pendingDelete.get.fold("")(nm => s"Delete '$nm'? This cannot be undone."), + "delete", + "cancel", + true, + () => pendingDelete.get.foreach(doDelete), + () => pendingDelete.set(None) + ) + + stack(frame, confirm) + } + + private def kindNoun(kind: Kind): String = kind match { + case Kind.Field => "field type" + case Kind.Domain => "domain type" + } + + private def heading(kind: Kind): String = kind match { + case Kind.Field => "field types" + case Kind.Domain => "domain types" + } + + private def matchesKind(t: BridgeType, kind: Kind): Boolean = (t, kind) match { + case (_: FieldType, Kind.Field) => true + case (_: DomainType, Kind.Domain) => true + case _ => false + } + + /** "Create from a real entity" shortcut on the domain-types screen. Tabs into the entity picker, where the user sees actual tables / models / records / messages from loaded sources instead of + * having to invent a `primary` string by hand. + */ + private def fromEntityShortcut(onActivate: Runnable): Element = + Link.focusable( + false, + onActivate, + { (focused: Boolean) => + val borderType = if (focused) BorderType.Thick else BorderType.Rounded + val borderStyle = Style.empty.withFg(if (focused) Color.CYAN else Color.DARK_GRAY) + val labelStyle = + Style.empty.withFg(if (focused) Color.CYAN else Color.GRAY).withAddModifier(Modifier.BOLD) + val hintTxt = Style.empty.withFg(if (focused) Color.WHITE else Color.DARK_GRAY) + val w: Widget = (area, buffer) => { + val block = Block.empty + .withBorders(Borders.ALL) + .withBorderType(borderType) + .withBorderStyle(borderStyle) + Paragraph + .of( + Line.from( + Span.styled(" ✦ create from a real entity ", labelStyle), + Span.styled(s" — pick a table / model / record, get fields + alignment suggestions", hintTxt) + ) + ) + .withBlock(block) + .render(area, buffer) + } + widget(w) + } + ) + + private def addNewLabels(kind: Kind): (String, String) = kind match { + case Kind.Field => ("add field type", "+ add new field type") + case Kind.Domain => ("add domain type", "+ add new domain type") + } + + private def addNewCard(kind: Kind, autoFocus: Boolean, onActivate: Runnable): Element = { + val (_, cardLabel) = addNewLabels(kind) + Link.focusable( + autoFocus, + onActivate, + { (focused: Boolean) => + val borderType = if (focused) BorderType.Thick else BorderType.Rounded + val borderStyle = Style.empty.withFg(if (focused) Color.GREEN else Color.DARK_GRAY) + val labelStyle = + Style.empty.withFg(if (focused) Color.GREEN else Color.GRAY).withAddModifier(Modifier.BOLD) + + val w: Widget = (area, buffer) => { + val block = Block.empty + .withBorders(Borders.ALL) + .withBorderType(borderType) + .withBorderStyle(borderStyle) + Paragraph + .of(Line.from(Span.styled(s" $cardLabel", labelStyle))) + .withBlock(block) + .render(area, buffer) + } + widget(w) + } + ) + } + + private def typeCard(name: String, t: BridgeType, onDelete: () => Unit, onActivate: Runnable): Element = + Link.focusable( + false, + onActivate, + { (focused: Boolean) => + component { c => + if (focused) c.onKey(new KeyCode.Char('d'), () => onDelete()) + + val borderType = if (focused) BorderType.Thick else BorderType.Rounded + val borderStyle = Style.empty.withFg(if (focused) Color.CYAN else Color.DARK_GRAY) + + val (icon, kindLabel, detail) = t match { + case ft: FieldType => ("◬", "field", fieldTypeDetail(ft)) + case dt: DomainType => ("✦", "domain", domainTypeDetail(dt)) + } + + val titleLine = Line.from( + Span.styled(s" $icon ", borderStyle), + Span.styled( + s"$name ", + if (focused) Style.empty.withFg(Color.WHITE).withAddModifier(Modifier.BOLD) + else Style.empty.withFg(Color.GRAY).withAddModifier(Modifier.BOLD) + ), + Span.styled( + s"· $kindLabel ", + Style.empty.withFg(if (focused) Color.CYAN else Color.DARK_GRAY) + ) + ) + + val detailStyle = Style.empty.withFg(if (focused) Color.WHITE else Color.GRAY) + + val w: Widget = (area, buffer) => { + val block = Block.empty + .withTitle(titleLine) + .withBorders(Borders.ALL) + .withBorderType(borderType) + .withBorderStyle(borderStyle) + Paragraph.of(s" $detail").withBlock(block).withStyle(detailStyle).render(area, buffer) + } + widget(w) + } + } + ) + + private def fieldTypeDetail(t: FieldType): String = { + val underlying = t.underlying.getOrElse("—") + val matchers = List( + t.api.map(_ => "api"), + t.db.map(_ => "db"), + t.model.map(_ => "model") + ).flatten + val matchersStr = if (matchers.isEmpty) "no patterns" else matchers.mkString(" · ") + s"$underlying · $matchersStr" + } + + private def domainTypeDetail(t: DomainType): String = { + val primary = t.primary.getOrElse("—") + val fields = t.fields.size + val aligned = t.alignedSources.map(_.size).getOrElse(0) + s"primary = $primary · $fields fields · $aligned aligned source${if (aligned == 1) "" else "s"}" + } +} diff --git a/typr/src/scala/typr/cli/app/screens/TypeWizard.scala b/typr/src/scala/typr/cli/app/screens/TypeWizard.scala new file mode 100644 index 0000000000..1ceec6488e --- /dev/null +++ b/typr/src/scala/typr/cli/app/screens/TypeWizard.scala @@ -0,0 +1,147 @@ +package typr.cli.app.screens +import typr.cli.app.components.Run.given + +import jatatui.components.button.Button +import jatatui.components.chrome.ScreenFrame +import jatatui.components.router.RouterApi +import jatatui.components.textinput.{TextInputComponent, TextInputProps} +import jatatui.core.style.{Color, Modifier, Style} +import jatatui.react.Components.* +import jatatui.react.Element +import tui.crossterm.KeyCode +import typr.cli.app.AppApi +import typr.config.generated.{BridgeType, DomainType, FieldType} + +/** New-type wizard. The kind (`Field` / `Domain`) is fixed by which list the wizard was launched from, so no kind-picker — just the name + the minimum fields needed to make a valid type. Save + * persists via [[AppApi.updateConfig]] and pops back to the list. + * + * Matches the editor's first-cut depth: FieldType captures `underlying` only; DomainType captures `primary` + `description`. Deep editing of nested matchers / fields map belongs in the next pass on + * the editor. + */ +object TypeWizard { + + private val titleStyle: Style = Style.empty.withFg(Color.MAGENTA).withAddModifier(Modifier.BOLD) + private val hintStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val errStyle: Style = Style.empty.withFg(Color.RED).withAddModifier(Modifier.BOLD) + private val infoStyle: Style = Style.empty.withFg(Color.DARK_GRAY) + private val valueStyle: Style = Style.empty.withFg(Color.WHITE) + private val focusedValueStyle: Style = Style.empty.withFg(Color.YELLOW).withAddModifier(Modifier.BOLD) + private val cursorStyle: Style = Style.empty.withAddModifier(Modifier.REVERSED) + + sealed trait Status + object Status { + case object Idle extends Status + final case class Failed(error: String) extends Status + } + + def element(kind: TypeList.Kind): Element = + component { ctx => + val router = RouterApi.useRouter(ctx) + val app = AppApi.use(ctx) + val back = () => router.pop() + + ctx.onGlobalKey(new KeyCode.Esc, () => back()) + + val name = ctx.useState(() => "") + val underlying = ctx.useState(() => "") // FieldType + val primary = ctx.useState(() => "") // DomainType + val descr = ctx.useState(() => "") // DomainType + val status = ctx.useState(() => Status.Idle: Status) + + def save(): Unit = { + val n = name.get.trim + if (n.isEmpty) + status.set(Status.Failed("name required")) + else if (app.config.types.getOrElse(Map.empty).contains(n)) + status.set(Status.Failed(s"type '$n' already exists")) + else { + val newType: BridgeType = kind match { + case TypeList.Kind.Field => + FieldType( + api = None, + db = None, + model = None, + underlying = Option(underlying.get.trim).filter(_.nonEmpty), + validation = None + ) + case TypeList.Kind.Domain => + DomainType( + alignedSources = None, + description = Option(descr.get.trim).filter(_.nonEmpty), + fields = Map.empty, + generate = None, + primary = Option(primary.get.trim).filter(_.nonEmpty), + projections = None + ) + } + val newTypes = app.config.types.getOrElse(Map.empty) + (n -> newType) + app.updateConfig(c => c.copy(types = Some(newTypes))) match { + case Right(_) => router.pop() + case Left(e) => status.set(Status.Failed(Option(e.getMessage).getOrElse(e.toString))) + } + } + } + + def textRow(value: String, label: String, focusId: String, autoFocus: Boolean, onChange: String => Unit) = + length( + 3, + TextInputComponent.of( + TextInputProps + .of(value, v => { onChange(v); status.set(Status.Idle) }) + .withTitle(label) + .withFocusId(focusId) + .withAutoFocus(autoFocus) + .withStyle(valueStyle) + .withFocusedStyle(focusedValueStyle) + .withCursorStyle(cursorStyle) + ) + ) + + val kindRows: List[Element] = kind match { + case TypeList.Kind.Field => + List(textRow(underlying.get, "underlying", "wiz:underlying", autoFocus = false, underlying.set)) + case TypeList.Kind.Domain => + List( + textRow(primary.get, "primary", "wiz:primary", autoFocus = false, primary.set), + textRow(descr.get, "description", "wiz:description", autoFocus = false, descr.set) + ) + } + + val saveButton = length(3, Button.of("save", "save", true, () => save())) + + val statusLine = status.get match { + case Status.Idle => text("", hintStyle) + case Status.Failed(err) => text(s" ✗ $err", errStyle) + } + + val heading = kind match { + case TypeList.Kind.Field => " add field type" + case TypeList.Kind.Domain => " add domain type" + } + + val note = kind match { + case TypeList.Kind.Field => + " match patterns (api / db / model) + validation come from the editor after save." + case TypeList.Kind.Domain => + " fields + aligned sources come from the editor after save." + } + + val title = column(length(1, empty()), length(1, text(heading, titleStyle))) + + val body = column( + (textRow(name.get, "name", "wiz:name", autoFocus = true, name.set) + :: kindRows + ::: List( + length(1, empty()), + saveButton, + length(1, statusLine), + length(1, empty()), + length(1, text(note, infoStyle)), + fill(1, empty()), + length(1, text(" tab navigate · enter (on save) commit · esc cancel", hintStyle)) + ))* + ) + + ScreenFrame.withTitle("types", back, title, body) + } +} diff --git a/typr/src/scala/typr/cli/beta/BetaGate.scala b/typr/src/scala/typr/cli/beta/BetaGate.scala new file mode 100644 index 0000000000..69e744c0f2 --- /dev/null +++ b/typr/src/scala/typr/cli/beta/BetaGate.scala @@ -0,0 +1,138 @@ +package typr.cli.beta + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Path, Paths, StandardOpenOption} +import java.time.format.DateTimeFormatter +import java.time.{Instant, LocalDate} + +/** What we persist about a single acceptance. Plain text on disk, human-inspectable; if a key fails to parse the whole file is treated as absent (re-prompt). + */ +final case class Acceptance(acceptedAt: Instant, binaryVersion: String, termsVersion: Int) + +/** Read / write the beta-acceptance file, and answer the time-based queries every gate site needs. All paths are XDG-aware: `$XDG_CONFIG_HOME/typr/beta-accepted.txt`, falling back to + * `~/.config/typr/beta-accepted.txt` on Linux/Mac, `%APPDATA%/typr/beta-accepted.txt` on Windows. No locking — the file is tiny, writes are last-wins, and the user only edits it indirectly via + * `--accept` or the TUI modal. + */ +object BetaGate { + + // ─────────────────────────────────── file location ─────────────────────────────────── + + /** Resolved acceptance-file path. Created on demand by [[markAccepted]]. */ + def configPath: Path = configDir.resolve("beta-accepted.txt") + + private def configDir: Path = { + val xdg = Option(System.getenv("XDG_CONFIG_HOME")).filter(_.nonEmpty) + val appdata = Option(System.getenv("APPDATA")).filter(_.nonEmpty) + val home = System.getProperty("user.home") + val base = + xdg + .map(Paths.get(_)) + .orElse(appdata.map(Paths.get(_))) + .getOrElse(Paths.get(home, ".config")) + base.resolve("typr") + } + + // ─────────────────────────────────── read / write ─────────────────────────────────── + + /** Parse the acceptance file if present and well-formed. A missing file, an I/O error, or any unparseable key returns `None` — the caller treats that as "not accepted, prompt again". + */ + def read(): Option[Acceptance] = + try { + val p = configPath + if (!Files.exists(p)) None + else { + val kvs = Files + .readAllLines(p, StandardCharsets.UTF_8) + .toArray(Array.empty[String]) + .iterator + .map(_.trim) + .filterNot(_.isEmpty) + .filterNot(_.startsWith("#")) + .flatMap { line => + line.split(":", 2) match { + case Array(k, v) => Some(k.trim -> v.trim) + case _ => None + } + } + .toMap + for { + rawAt <- kvs.get("accepted-at") + at <- tryParseInstant(rawAt) + ver <- kvs.get("binary-version") + rawTv <- kvs.get("terms-version") + tv <- rawTv.toIntOption + } yield Acceptance(at, ver, tv) + } + } catch case _: Throwable => None + + /** Write the acceptance file, creating any missing parent dirs. Best-effort — failures throw (caller can surface a stderr message). The file's contents are stable across re-acceptances apart from + * `accepted-at`, so we always overwrite rather than append. + */ + def markAccepted(binaryVersion: String): Unit = { + val now = Instant.now() + val body = + s"""accepted-at: ${DateTimeFormatter.ISO_INSTANT.format(now)} + |binary-version: $binaryVersion + |terms-version: ${BetaTerms.termsVersion} + |""".stripMargin + Files.createDirectories(configDir) + Files.write( + configPath, + body.getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE + ) + () + } + + // ─────────────────────────────────── queries ─────────────────────────────────── + + /** Whether the stored acceptance covers the current terms version. */ + def isCurrent: Boolean = read().exists(_.termsVersion >= BetaTerms.termsVersion) + + /** Days until expiry. Negative once expired (e.g. `-3` three days after `expiresOn`). */ + def daysUntilExpiry(today: LocalDate = LocalDate.now()): Long = + today.until(BetaTerms.expiresOn, java.time.temporal.ChronoUnit.DAYS) + + def isExpired(today: LocalDate = LocalDate.now()): Boolean = daysUntilExpiry(today) < 0 + + def isInWarningWindow(today: LocalDate = LocalDate.now()): Boolean = { + val d = daysUntilExpiry(today) + d >= 0 && d <= BetaTerms.warningWindowDays + } + + // ─────────────────────────────────── messages ─────────────────────────────────── + + /** The stderr block printed when an expired binary is invoked. Mirrors the TUI expired screen so users see the same text wherever they bump into the wall. + */ + val expiredMessage: String = + s"""This Typr build expired on ${BetaTerms.expiresOn}. + | + |Update to keep using Typr: + | download the latest from https://typr.dev/get + | + |Closed-beta builds are time-limited so we can keep everyone on a + |recent version while the API stabilizes. Apologies for the friction. + |""".stripMargin + + /** One-line CLI warning when within the warning window. Goes through the structured logger so it lands in CI logs and gives a paper trail. + */ + def warningLine(today: LocalDate = LocalDate.now()): String = { + val d = daysUntilExpiry(today) + val units = if (d == 1) "day" else "days" + s"closed beta · this build expires in $d $units — download a fresh one at https://typr.dev/get" + } + + /** Stderr refusal printed for a CLI command run before acceptance. */ + val notAcceptedMessage: String = + s"""Typr is in closed beta. By running it you accept the terms. + |Re-run with --accept to confirm, or run `typr terms` to read them. + |""".stripMargin + + // ─────────────────────────────────── helpers ─────────────────────────────────── + + private def tryParseInstant(s: String): Option[Instant] = + try Some(Instant.parse(s)) + catch case _: Throwable => None +} diff --git a/typr/src/scala/typr/cli/beta/BetaTerms.scala b/typr/src/scala/typr/cli/beta/BetaTerms.scala new file mode 100644 index 0000000000..2d0361d38d --- /dev/null +++ b/typr/src/scala/typr/cli/beta/BetaTerms.scala @@ -0,0 +1,52 @@ +package typr.cli.beta + +import java.time.LocalDate + +/** The hard-coded terms of the current closed-beta release. + * + * `termsVersion` is the lever that re-prompts existing users when material text changes (cosmetic edits should leave the version alone). On each run we compare this number against the one in the + * user's [[Acceptance]] file — anything stored under a lower version triggers a fresh prompt. + * + * `expiresOn` is the literal date the binary refuses to run after. There is no auto-update — users grab a fresh build from typr.dev. Keep `warningDays` in step with how often we publish: shorter + * than the release cadence and users get caught surprised; much longer and they tune out the warning. + */ +object BetaTerms { + + /** Bump when the text of [[displayText]] changes substantively — adds a new bullet, alters pricing/licensing assertions, etc. Existing acceptances at a lower version re-prompt. + */ + val termsVersion: Int = 1 + + /** This particular build refuses to run after this date (inclusive — `LocalDate.now()` > `expiresOn` is the expired check). Users update by downloading a fresh binary. + */ + val expiresOn: LocalDate = LocalDate.of(2026, 7, 1) + + /** How many days before expiry we start warning loudly in CLI logs and the TUI splash chip. */ + val warningWindowDays: Int = 14 + + /** Plain-text terms — rendered as-is in the TUI BetaNotice screen and on `typr terms`. Wrap lines at ~70 cols for readability inside the modal box. + */ + val displayText: String = + """Typr Closed Beta — Terms + | + | • Typr is not open source. The codegen and runtime are + | proprietary; the code Typr emits is yours to ship. + | + | • Support for commercial databases (Oracle, SQL Server, DB2) + | will not remain free in the released product. Open-source + | databases — Postgres, MariaDB/MySQL, DuckDB — are expected + | to stay free for reasonable use. + | + | • There will be a limit on how many boundaries can take part + | in domain-type alignment per generate run. Small projects + | will be unaffected; large multi-source generations will + | eventually require a paid tier. + | + | • This particular build expires on 2026-07-01. We're moving + | fast — keep your install fresh by re-downloading from + | typr.dev when prompted. + |""".stripMargin + + /** A one-line summary for CLI output where the full text would be too much. */ + val shortLine: String = + "Typr is in closed beta. Not open source; commercial DB support won't stay free; this build expires 2026-07-01." +}