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..950a748e95 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 @@ -893,9 +898,18 @@ 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: template-scala-3 + extends: + - template-scala-3 + - template-publishable platform: name: jvm mainClass: typr.cli.Main @@ -924,7 +938,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 +950,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 +1009,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 +1032,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/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." +} 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}