Add structured coroutines and explicit error handling

This commit is contained in:
pavel 2026-08-27 16:26:40 +02:00
commit fe62e81152
31 changed files with 1701 additions and 325 deletions

113
README.md
View file

@ -12,7 +12,7 @@ This is the practical boundary of the prototype:
- `fun` declarations - `fun` declarations
- `val` and `var` - `val` and `var`
- `Int`, `String`, `Boolean`, `Unit` - `Int`, `Long`, `String`, `Boolean`, `Unit`
- function types like `(String) -> Unit` - function types like `(String) -> Unit`
- `if`, `else`, `while`, and `for (item in items)` - `if`, `else`, `while`, and `for (item in items)`
- function calls - function calls
@ -23,8 +23,11 @@ This is the practical boundary of the prototype:
- `println(...)` - `println(...)`
- arithmetic, comparison, and boolean operators - arithmetic, comparison, and boolean operators
- decimal literals, `defer`, and Go address-of expressions such as `&value` - decimal literals, `defer`, and Go address-of expressions such as `&value`
- explicit nullable types (`Type?`), safe access (`?.`), and non-null assertions (`!!`)
- Gotlin classes are reference types by default; `*` is only needed for external Go pointer types
- named external Go struct construction, for example `http.Client(timeout = 3 * time.second)` - named external Go struct construction, for example `http.Client(timeout = 3 * time.second)`
- top-level embedded resources such as `@embed("assets/*") val assets: embed.FS` - top-level embedded resources such as `@embed("assets/*") val assets: embed.FS`
- structured coroutines with `suspend fun`, `runBlocking`, `coroutineScope`, `launch`, `async`, `await`, `delay`, `withTimeout`, and `isActive`
## Example ## Example
@ -118,6 +121,58 @@ fun describe(result: PaymentResult): String {
Enum matches must contain each variant exactly once. Variant payload arity is Enum matches must contain each variant exactly once. Variant payload arity is
checked during Gotlin compilation. checked during Gotlin compilation.
## Null safety
Types are non-nullable by default. Add `?` explicitly when `null` is valid:
```kotlin
fun email(user: User?): String? {
return user?.email
}
```
The compiler rejects `null` in non-nullable arguments, fields, local variables,
and return values. Nullable receivers cannot be dereferenced directly. Gotlin
smart-casts values after `value != null` branches and guard clauses such as
`if (value == null) { return }`. Use `!!` only when an invariant cannot be
expressed through control flow:
```kotlin
val required: User = optionalUser!!
```
## Error handling
Gotlin adapts Go `(T, error)` returns into Rust-style `Result<T, Error>` flows.
An explicit `Result` return or variable type converts the Go return directly:
```kotlin
fun parse(value: String): Result<Int, Error> {
return strconv.atoi(value)
}
fun ping(db: *sql.DB): Result<Unit, Error> {
return db.ping()
}
```
Use `?` to propagate a Go error or chain a Gotlin function returning `Result`.
Explicit panic and fallback operations are available when appropriate:
```kotlin
val required = parse("42").unwrap()
val fallback = parse("invalid").unwrapOr(0)
```
Gotlin never inserts implicit panic wrappers. A Go call returning `(T, error)`
must have an explicit `Result` context or use `?`, explicit `value, error`
destructuring, or `.unwrap()`.
Gotlin-defined `class` and `data class` values are references automatically,
including nested generic types such as `List<User>`. Explicit pointer syntax is
still supported for compatibility. Go interop remains explicit, for example
`*http.Request` and `*pgxpool.Pool`.
Enums whose variants carry no payload are represented as string-backed values. Enums whose variants carry no payload are represented as string-backed values.
The exact variant identifier is used for JSON and PostgreSQL text values: The exact variant identifier is used for JSON and PostgreSQL text values:
@ -148,11 +203,11 @@ When the surrounding expression provides a target type, the type argument is
optional: optional:
```kotlin ```kotlin
fun response(account: *AccountEntity): *AccountResponse { fun response(account: AccountEntity): AccountResponse {
return account.mapTo() return account.mapTo()
} }
val response: *AccountResponse = account.mapTo() val response: AccountResponse = account.mapTo()
val envelope = Envelope(account.mapTo()) val envelope = Envelope(account.mapTo())
``` ```
@ -192,6 +247,28 @@ Run directly:
go run ./cmd/gotlinc run ./examples/hello.gt go run ./cmd/gotlinc run ./examples/hello.gt
``` ```
## Structured coroutines
Gotlin coroutines use Go goroutines underneath, but expose only structured
scopes. A scope waits for its children, propagates child failures, and cancels
sibling coroutine contexts. The removed `worker` and bare `go` forms are not
valid Gotlin syntax.
```kotlin
suspend fun load(): Int {
delay(10)
return 42
}
fun main() {
runBlocking {
val value = async<Int> { return load() }
launch { println("loading") }
println(value.await())
}
}
```
## Type-checked SQL queries ## Type-checked SQL queries
Gotlin recognizes a PostgreSQL SQL DSL at compile time. SQL row mappings must be data classes annotated with `@table`. Fields map from lower-camel Gotlin names to `snake_case` columns by default and can override the SQL name with `@column`. Conflict keys use `@id`; database-generated or defaulted fields use `@generated`. Gotlin recognizes a PostgreSQL SQL DSL at compile time. SQL row mappings must be data classes annotated with `@table`. Fields map from lower-camel Gotlin names to `snake_case` columns by default and can override the SQL name with `@column`. Conflict keys use `@id`; database-generated or defaulted fields use `@generated`.
@ -262,17 +339,20 @@ Projection targets are local data classes. The constructor must contain one dire
```kotlin ```kotlin
data class AccountSummary(var id: String, var balance: Double) data class AccountSummary(var id: String, var balance: Double)
fun summaries(pool: *pgxpool.Pool, ctx: context.Context): List<*AccountSummary> { fun summaries(pool: *pgxpool.Pool, ctx: context.Context): List<AccountSummary> {
return sql.from<AccountRow>() return sql.from<AccountRow>()
.select { row -> AccountSummary(row.id, row.balance) } .select { row -> AccountSummary(row.id, row.balance) }
.orderBy { it.balance } .orderBy { it.balance }
.fetch(pool, ctx) .fetch(pool, ctx)
.unwrap()
} }
``` ```
The compiler emits `SELECT id, balance`, scans in projection declaration order, and makes `fetch`, `single`, and `iterator` target `AccountSummary` rather than `AccountRow`. The row parameter in later `where` and ordering methods still represents `AccountRow`. The compiler emits `SELECT id, balance`, scans in projection declaration order, and makes `fetch`, `single`, and `iterator` target `AccountSummary` rather than `AccountRow`. The row parameter in later `where` and ordering methods still represents `AccountRow`.
Select chains can execute directly against a pgx/v5 pool. `fetch(pool, ctx)` returns `List<*AccountRow>` and closes the pgx rows after reading and scanning every result in data-class field declaration order: Select chains can execute directly against a pgx/v5 pool. `fetch(pool, ctx)`
returns `Result<List<AccountRow>, Error>` and closes pgx rows after reading and
scanning every result in data-class field declaration order:
```kotlin ```kotlin
import context import context
@ -282,33 +362,38 @@ fun accountsFor(
pool: *pgxpool.Pool, pool: *pgxpool.Pool,
ctx: context.Context, ctx: context.Context,
customerId: String customerId: String
): List<*AccountRow> { ): List<AccountRow> {
return sql.from<AccountRow>() return sql.from<AccountRow>()
.where { it.customerId == customerId } .where { it.customerId == customerId }
.orderBy { it.accountType } .orderBy { it.accountType }
.fetch(pool, ctx) .fetch(pool, ctx)
.unwrap()
} }
``` ```
`single(pool, ctx)` returns `*AccountRow`. It closes the rows and panics through Gotlin's auto-throw path unless the query produces exactly one row: `single(pool, ctx)` returns `Result<AccountRow, Error>` and closes the rows. Use
`?` to propagate zero/multiple-row errors or call `.unwrap()` explicitly:
```kotlin ```kotlin
fun account(pool: *pgxpool.Pool, ctx: context.Context, id: String): *AccountRow { fun account(pool: *pgxpool.Pool, ctx: context.Context, id: String): AccountRow {
return sql.from<AccountRow>() return sql.from<AccountRow>()
.where { it.id == id } .where { it.id == id }
.single(pool, ctx) .single(pool, ctx)
.unwrap()
} }
``` ```
`iterator(pool, ctx)` streams pgx rows. Call `next()` before each `value()`, always arrange an explicit `close()` (normally with `defer`), and call `err()` after iteration. Assigning the error result invokes the existing auto-throw convention: `iterator(pool, ctx)` returns a `Result` around a streaming pgx iterator. Call
`next()` before each `value()`, arrange an explicit `close()`, and inspect
`err()` after iteration:
```kotlin ```kotlin
fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) { fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
val rows = sql.from<AccountRow>().iterator(pool, ctx) val rows = sql.from<AccountRow>().iterator(pool, ctx).unwrap()
defer rows.close() defer rows.close()
while (rows.next()) { while (rows.next()) {
val account: *AccountRow = rows.value() val account: AccountRow = rows.value()
println(account.customerId) println(account.customerId)
} }
@ -316,7 +401,11 @@ fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
} }
``` ```
Query and scan failures panic through `gotlinAutoThrow`. `fetch` and `single` close rows internally; an iterator leaves lifecycle control with the caller, and `value()` panics unless the immediately preceding `next()` succeeded. None of the execution terminals require an intermediate `build()`, `query.sql`, or `query.args` access. `build()` remains available when a query value is needed for manual execution. Query and scan failures are returned as `Result` errors. `fetch` and `single`
close rows internally; an iterator leaves lifecycle control with the caller,
and `value()` panics only when called without a successful `next()`. None of the
execution terminals require an intermediate `build()`, `query.sql`, or
`query.args` access. `build()` remains available for manual execution.
### Inserts and returning ### Inserts and returning

View file

@ -998,6 +998,10 @@ func functionSemanticDiagnostics(text string, fn lang.FunctionDecl, functions ma
} }
case lang.SelectorExpr: case lang.SelectorExpr:
walkExpr(e.Receiver, scope) walkExpr(e.Receiver, scope)
case lang.SafeSelectorExpr:
walkExpr(e.Receiver, scope)
case lang.NonNullExpr:
walkExpr(e.Value, scope)
case lang.IndexExpr: case lang.IndexExpr:
walkExpr(e.Receiver, scope) walkExpr(e.Receiver, scope)
walkExpr(e.Index, scope) walkExpr(e.Index, scope)
@ -2192,33 +2196,42 @@ func builtinHoverDetail(name string) (string, bool) {
} }
var builtinDetails = map[string]string{ var builtinDetails = map[string]string{
"println": "fun println(value: Any): Unit", "println": "fun println(value: Any): Unit",
"runCatching": "fun runCatching(block: () -> Unit): Result", "runCatching": "fun runCatching(block: () -> Unit): Result",
"Channel": "fun Channel<T>(capacity: Int = 0): Channel<T>", "Channel": "fun Channel<T>(capacity: Int = 0): Channel<T>",
"after": "fun after(ms: Int): Channel<time.Time>", "after": "fun after(ms: Int): Channel<time.Time>",
"every": "fun every(ms: Int): Channel<time.Time>", "every": "fun every(ms: Int): Channel<time.Time>",
"listOf": "fun listOf<T>(values: T...): List<T>", "listOf": "fun listOf<T>(values: T...): List<T>",
"mutableListOf": "fun mutableListOf<T>(values: T...): MutableList<T>", "mutableListOf": "fun mutableListOf<T>(values: T...): MutableList<T>",
"mapOf": "fun mapOf<K, V>(pairs: Any...): Map<K, V>", "mapOf": "fun mapOf<K, V>(pairs: Any...): Map<K, V>",
"mutableMapOf": "fun mutableMapOf<K, V>(pairs: Any...): MutableMap<K, V>", "mutableMapOf": "fun mutableMapOf<K, V>(pairs: Any...): MutableMap<K, V>",
"ByteSlice": "fun ByteSlice(value: String): ByteSlice", "ByteSlice": "fun ByteSlice(value: String | Int): ByteSlice",
"append": "fun append<T>(values: List<T>, value: T): List<T>", "append": "fun append<T>(values: List<T>, value: T): List<T>",
"len": "fun len(value: Any): Int", "keys": "fun keys<K, V>(values: Map<K, V>): List<K>",
"cap": "fun cap(value: Any): Int", "goAssert": "fun goAssert<T>(value: Any): T",
"make": "fun make<T>(size: Int): T", "len": "fun len(value: Any): Int",
"new": "fun new<T>(): *T", "cap": "fun cap(value: Any): Int",
"copy": "fun copy(target: Any, source: Any): Int", "make": "fun make<T>(size: Int): T",
"delete": "fun delete(map: Any, key: Any): Unit", "new": "fun new<T>(): *T",
"close": "fun close(channel: Any): Unit", "copy": "fun copy(target: Any, source: Any): Int",
"panic": "fun panic(value: Any): Unit", "delete": "fun delete(map: Any, key: Any): Unit",
"recover": "fun recover(): Any", "close": "fun close(channel: Any): Unit",
"string": "fun string(value: Any): String", "panic": "fun panic(value: Any): Unit",
"int": "fun int(value: Any): Int", "recover": "fun recover(): Any",
"float64": "fun float64(value: Any): Double", "string": "fun string(value: Any): String",
"bool": "fun bool(value: Any): Boolean", "int": "fun int(value: Any): Int",
"sql": "typed PostgreSQL query DSL", "float64": "fun float64(value: Any): Double",
"set": "fun set(target: Any, value: Any): Unit", "bool": "fun bool(value: Any): Boolean",
"now": "fun now(): time.Time", "sql": "typed PostgreSQL query DSL",
"set": "fun set(target: Any, value: Any): Unit",
"now": "fun now(): time.Time",
"runBlocking": "fun runBlocking(block: suspend () -> Unit): Unit",
"coroutineScope": "suspend fun coroutineScope(block: suspend () -> Unit): Unit",
"launch": "suspend fun launch(block: suspend () -> Unit): Unit",
"async": "suspend fun async<T>(block: suspend () -> T): Deferred<T>",
"delay": "suspend fun delay(ms: Int): Unit",
"withTimeout": "suspend fun withTimeout(ms: Int, block: suspend () -> Unit): Unit",
"isActive": "suspend fun isActive(): Boolean",
} }
func contains(values []string, needle string) bool { func contains(values []string, needle string) bool {

View file

@ -92,7 +92,7 @@ fun main() {
t.Fatalf("unexpected builtin diagnostic: %+v", diagnostic) t.Fatalf("unexpected builtin diagnostic: %+v", diagnostic)
} }
} }
for _, name := range []string{"ByteSlice", "append", "len", "sql", "set", "now"} { for _, name := range []string{"ByteSlice", "append", "keys", "goAssert", "len", "sql", "set", "now"} {
if !isBuiltin(name) { if !isBuiltin(name) {
t.Fatalf("%s is not registered as builtin", name) t.Fatalf("%s is not registered as builtin", name)
} }
@ -694,7 +694,7 @@ fun runWorker(name: String) {
} }
fun main() { fun main() {
go runWorker("alice") runBlocking { launch { runWorker("alice") } }
} }
`) `)
@ -717,9 +717,11 @@ fun writer(ch: Channel<Int>) {
fun main() { fun main() {
val ch = Channel<Int>() val ch = Channel<Int>()
go writer(ch) runBlocking {
select { launch { writer(ch) }
ch -> println(it) select {
ch -> println(it)
}
} }
val v = ch.read() val v = ch.read()
println(v) println(v)
@ -778,22 +780,11 @@ fun main() {
`) `)
state := buildDocumentState(text) state := buildDocumentState(text)
if state.program == nil { if state.program != nil {
t.Fatal("expected parsed program") t.Fatal("worker syntax should no longer parse")
} }
if len(state.diagnostics) != 0 { if len(state.diagnostics) == 0 {
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics) t.Fatal("expected removed worker syntax diagnostic")
}
var counterDetail string
for _, sym := range state.symbols {
if sym.Kind == symbolKindVariable && sym.Name == "counter" {
counterDetail = sym.Detail
break
}
}
if counterDetail != "val counter: Counter" {
t.Fatalf("unexpected counter detail: %q", counterDetail)
} }
} }

View file

@ -36,18 +36,6 @@ class EpicControllerImpl(val db: *bun.DB) {
} }
} }
worker Counter {
var counter = 0
fun getCount(): Int {
return counter
}
fun increment() {
counter += 1
println(counter)
}
}
fun main() { fun main() {
val postgresDsn = "postgresql://postgres:postgres@localhost/postgres?sslmode=disable" val postgresDsn = "postgresql://postgres:postgres@localhost/postgres?sslmode=disable"
val sqlDb = sql.OpenDB( val sqlDb = sql.OpenDB(
@ -56,21 +44,10 @@ fun main() {
) )
) )
val db = bun.NewDB(sqlDb, pgdialect.New()) val db = bun.NewDB(sqlDb, pgdialect.New())
val counter = Counter()
go {
while(true) {
select {
every(1000) -> counter.increment()
}
}
}
val epicController = EpicControllerImpl(db) val epicController = EpicControllerImpl(db)
fmt.Println("serving http://localhost:8080") fmt.Println("serving http://localhost:8080")
http.HandleFunc("/", epicController.hello) http.HandleFunc("/", epicController.hello)
http.HandleFunc("/bun", epicController.bunHealth) http.HandleFunc("/bun", epicController.bunHealth)
http.HandleFunc("/bun/users", epicController.bunUsers) http.HandleFunc("/bun/users", epicController.bunUsers)
http.HandleFunc("/counter") { w, r ->
fmt.Fprintln(w, "bun users total:", counter.getCount())
}
http.ListenAndServe(":8080", http.DefaultServeMux) http.ListenAndServe(":8080", http.DefaultServeMux)
} }

View file

@ -16,13 +16,13 @@ enum ResponseState {
data class AccountEntity( data class AccountEntity(
var id: String, var id: String,
var address: *AddressEntity, var address: AddressEntity,
var labels: List<String>, var labels: List<String>,
var state: EntityState var state: EntityState
) )
data class AccountResponse( data class AccountResponse(
var address: *AddressResponse, var address: AddressResponse,
var id: String, var id: String,
var labels: List<String>, var labels: List<String>,
var state: ResponseState var state: ResponseState

18
examples/nullability.gt Normal file
View file

@ -0,0 +1,18 @@
package main
data class User(var email: String)
fun emailOrMissing(user: User?): String {
if (user == null) { return "missing" }
return user.email
}
fun optionalEmail(user: User?): String? {
return user?.email
}
fun main() {
val user: User? = User("user@example.com")
println(emailOrMissing(user))
println(user!!.email)
}

18
examples/results.gt Normal file
View file

@ -0,0 +1,18 @@
package main
import strconv
import os
fun parse(value: String): Result<Int, Error> {
return strconv.atoi(value)
}
fun changeDirectory(path: String): Result<Unit, Error> {
return os.chdir(path)
}
fun main() {
println(parse("42").unwrap())
println(parse("invalid").unwrapOr(0))
changeDirectory(".").unwrap()
}

View file

@ -13,25 +13,6 @@ class PrefixGreeter(val prefix: String): Greeter {
} }
} }
worker Counter {
var count = 0
fun increment() {
count += 1
}
fun value(): Int {
return count
}
}
fun risky(input: String): String {
if (input == "boom") {
throw "boom requested"
}
return input
}
fun main() { fun main() {
val greeter: Greeter = PrefixGreeter("hello") val greeter: Greeter = PrefixGreeter("hello")
println(greeter.greet("gotlin")) println(greeter.greet("gotlin"))
@ -44,35 +25,25 @@ fun main() {
val upper = strings.ToUpper("gotlin") val upper = strings.ToUpper("gotlin")
fmt.Println("interop:", upper) fmt.Println("interop:", upper)
val result = runCatching({ risky("boom") }) val maybe: String? = null
if (result.isSuccess()) {
println("runCatching: success")
} else {
println("runCatching:")
println(result.exceptionOrNull())
}
val maybe: any = null
if (maybe == null) { if (maybe == null) {
println("null check works") println("null check works")
} }
val counter = Counter() runBlocking {
counter.increment() val ready = Channel<String>()
counter.increment() launch {
fmt.Println("worker value:", counter.value()) delay(120)
ready.send("timer fired")
val ready = Channel<String>() }
go { select {
select { ready -> println("channel says: " + it)
after(120) -> ready.send("timer fired")
} }
}
select {
ready -> println("channel says: " + it)
}
select { val answer = async<Int> {
every(50) -> println("one periodic tick") delay(50)
return 42
}
fmt.Println("async value:", answer.await())
} }
} }

View file

@ -71,6 +71,7 @@ type FunctionSignature struct {
Name string Name string
Params []Param Params []Param
ReturnType string ReturnType string
Suspend bool
} }
type FunctionDecl struct { type FunctionDecl struct {
@ -78,6 +79,7 @@ type FunctionDecl struct {
Params []Param Params []Param
ReturnType string ReturnType string
Body []Stmt Body []Stmt
Suspend bool
} }
type Param struct { type Param struct {
@ -290,6 +292,21 @@ type SelectorExpr struct {
func (SelectorExpr) exprNode() {} func (SelectorExpr) exprNode() {}
type SafeSelectorExpr struct {
Receiver Expr
Name string
}
func (SafeSelectorExpr) exprNode() {}
type NonNullExpr struct{ Value Expr }
func (NonNullExpr) exprNode() {}
type TryExpr struct{ Value Expr }
func (TryExpr) exprNode() {}
type IndexExpr struct { type IndexExpr struct {
Receiver Expr Receiver Expr
Index Expr Index Expr

View file

@ -70,7 +70,7 @@ fun main() {
for _, want := range []string{ for _, want := range []string{
`"strings"`, `"strings"`,
`rand "math/rand"`, `rand "math/rand"`,
`upper := gotlinAutoThrow(strings.ToUpper("go"))`, `upper := strings.ToUpper("go")`,
`fmt.Println(rand.Intn(3))`, `fmt.Println(rand.Intn(3))`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {
@ -294,7 +294,7 @@ fun main() {
`func (self *Greeter) greet() {`, `func (self *Greeter) greet() {`,
`fmt.Println("hello, " + self.name)`, `fmt.Println("hello, " + self.name)`,
`fmt.Println(self.name)`, `fmt.Println(self.name)`,
`greeter := gotlinAutoThrow(NewGreeter("world"))`, `greeter := NewGreeter("world")`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code) t.Fatalf("generated Go missing %q:\n%s", want, code)
@ -388,7 +388,7 @@ fun main() {
`type EpicControllerImpl struct {`, `type EpicControllerImpl struct {`,
`func NewEpicControllerImpl() *EpicControllerImpl`, `func NewEpicControllerImpl() *EpicControllerImpl`,
`func (self *EpicControllerImpl) hello(w http.ResponseWriter, r *http.Request) {`, `func (self *EpicControllerImpl) hello(w http.ResponseWriter, r *http.Request) {`,
`var epicController EpicController = gotlinAutoThrow(NewEpicControllerImpl())`, `var epicController EpicController = NewEpicControllerImpl()`,
`http.HandleFunc("/", epicController.hello)`, `http.HandleFunc("/", epicController.hello)`,
`http.ListenAndServe(":8080", http.DefaultServeMux)`, `http.ListenAndServe(":8080", http.DefaultServeMux)`,
} { } {
@ -546,7 +546,7 @@ fun main() {
`type gotlinResult struct {`, `type gotlinResult struct {`,
`func gotlinRunCatching(fn func()) (result gotlinResult) {`, `func gotlinRunCatching(fn func()) (result gotlinResult) {`,
`panic("boom")`, `panic("boom")`,
`result := gotlinAutoThrow(gotlinRunCatching(func() {`, `result := gotlinRunCatching(func() {`,
`if recovered := recover(); recovered != nil {`, `if recovered := recover(); recovered != nil {`,
`e := recovered`, `e := recovered`,
} { } {
@ -556,7 +556,7 @@ fun main() {
} }
} }
func TestGenerateGoAutoThrowForGoErrorResults(t *testing.T) { func TestGenerateGoExplicitErrorsForGoErrorResults(t *testing.T) {
src := ` src := `
package demo package demo
@ -569,6 +569,7 @@ fun main() {
throw err throw err
} }
} }
` `
prog, err := Parse(src) prog, err := Parse(src)
@ -583,9 +584,8 @@ fun main() {
code := string(out) code := string(out)
for _, want := range []string{ for _, want := range []string{
`func gotlinAutoThrow[T any](value T, rest ...any) T {`, `db := sql.Open("postgres", "postgres://localhost/postgres?sslmode=disable")`,
`db := gotlinAutoThrow(sql.Open("postgres", "postgres://localhost/postgres?sslmode=disable"))`, `err := db.Ping()`,
`err := gotlinAutoThrow(db.Ping())`,
`if err != nil {`, `if err != nil {`,
`panic(err)`, `panic(err)`,
} { } {
@ -595,7 +595,32 @@ fun main() {
} }
} }
func TestGenerateGoAutoThrowForErrorExprStmt(t *testing.T) { func TestGenerateGoMigrationInteropHelpers(t *testing.T) {
prog, err := Parse(`package demo
data class Versioned(var version: Long)
fun names(values: Map<String, Int>): List<String> { return keys(values) }
fun buffer(): ByteSlice { return ByteSlice(32) }
fun optionalError(): Error? { return null }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"Version int64",
"for key := range values",
"return make([]byte, 32)",
"func optionalError() error",
} {
if !strings.Contains(string(out), want) {
t.Fatalf("generated Go missing %q:\n%s", want, out)
}
}
}
func TestGenerateGoExplicitErrorsForErrorExprStmt(t *testing.T) {
src := ` src := `
package demo package demo
@ -619,7 +644,7 @@ fun main() {
code := string(out) code := string(out)
for _, want := range []string{ for _, want := range []string{
`db := gotlinAutoThrow(sql.Open("postgres", "postgres://localhost/postgres?sslmode=disable"))`, `db := sql.Open("postgres", "postgres://localhost/postgres?sslmode=disable")`,
`db.Ping()`, `db.Ping()`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {
@ -654,7 +679,7 @@ fun main() {
for _, want := range []string{ for _, want := range []string{
`type User struct {`, `type User struct {`,
`func NewUser(Name string) *User`, `func NewUser(Name string) *User`,
`user := gotlinAutoThrow(NewUser("alice"))`, `user := NewUser("alice")`,
`fmt.Println(user.Name)`, `fmt.Println(user.Name)`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {
@ -723,8 +748,8 @@ fun main() {
code := string(out) code := string(out)
for _, want := range []string{ for _, want := range []string{
`var names []string = gotlinAutoThrow([]string{"alice", "bob"})`, `var names []string = []string{"alice", "bob"}`,
`var ages map[string]int = gotlinAutoThrow(map[string]int{"alice": 30, "bob": 25})`, `var ages map[string]int = map[string]int{"alice": 30, "bob": 25}`,
`fmt.Println(names)`, `fmt.Println(names)`,
`fmt.Println(ages)`, `fmt.Println(ages)`,
} { } {
@ -758,8 +783,8 @@ fun main() {
code := string(out) code := string(out)
for _, want := range []string{ for _, want := range []string{
`test := gotlinAutoThrow([]int{})`, `test := []int{}`,
`labels := gotlinAutoThrow(map[string]int{})`, `labels := map[string]int{}`,
`fmt.Println(test)`, `fmt.Println(test)`,
`fmt.Println(labels)`, `fmt.Println(labels)`,
} { } {
@ -791,7 +816,7 @@ fun main() {
code := string(out) code := string(out)
for _, want := range []string{ for _, want := range []string{
`_ = gotlinAutoThrow([]int{1, 7})`, `_ = []int{1, 7}`,
`fmt.Println("ok")`, `fmt.Println("ok")`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {
@ -814,7 +839,7 @@ fun runWorker(name: String) {
} }
fun main() { fun main() {
go runWorker("alice") runBlocking { launch { runWorker("alice") } }
} }
` `
@ -832,9 +857,9 @@ fun main() {
for _, want := range []string{ for _, want := range []string{
`func runWorker(name string)`, `func runWorker(name string)`,
`fmt.Println(name)`, `fmt.Println(name)`,
`go func() {`, `gotlinScope.Launch`,
`runWorker("alice")`, `runWorker("alice")`,
`println("async panic:", recovered)`, `gotlinRunBlocking`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code) t.Fatalf("generated Go missing %q:\n%s", want, code)
@ -854,16 +879,9 @@ worker Counter {
} }
` `
prog, err := Parse(src) _, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
_, err = GenerateGo(prog)
if err == nil { if err == nil {
t.Fatal("expected worker self-call generation error") t.Fatal("expected removed worker syntax error")
}
if !strings.Contains(err.Error(), "worker self-calls are forbidden") {
t.Fatalf("unexpected error: %v", err)
} }
} }
@ -1054,25 +1072,9 @@ worker Counter {
} }
} }
` `
prog, err := Parse(src) _, err := Parse(src)
if err != nil { if err == nil {
t.Fatalf("parse failed: %v", err) t.Fatal("expected removed worker syntax error")
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("go generation failed: %v", err)
}
code := string(out)
for _, want := range []string{
`panicCh := make(chan any, 1)`,
`if recovered := recover(); recovered != nil {`,
`panicCh <- recovered`,
`case recovered := <-panicCh:`,
`panic(recovered)`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
} }
} }
@ -1089,7 +1091,7 @@ fun main() {
if err == nil { if err == nil {
t.Fatal("expected parse error") t.Fatal("expected parse error")
} }
if !strings.Contains(err.Error(), "'go' expects a function call expression") { if !strings.Contains(err.Error(), "bare go is removed") {
t.Fatalf("unexpected parse error: %v", err) t.Fatalf("unexpected parse error: %v", err)
} }
} }
@ -1104,9 +1106,11 @@ fun writer(ch: Channel<Int>) {
fun main() { fun main() {
val ch = Channel<Int>() val ch = Channel<Int>()
go writer(ch) runBlocking {
select { launch { writer(ch) }
ch -> println(it) select {
ch -> println(it)
}
} }
val v = ch.read() val v = ch.read()
println(v) println(v)
@ -1127,13 +1131,13 @@ fun main() {
for _, want := range []string{ for _, want := range []string{
`func writer(ch chan int)`, `func writer(ch chan int)`,
`ch <- 7`, `ch <- 7`,
`ch := gotlinAutoThrow(make(chan int))`, `ch := make(chan int)`,
`go func() {`, `gotlinScope.Launch`,
`writer(ch)`, `writer(ch)`,
`select {`, `select {`,
`case it := <-ch:`, `case it := <-ch:`,
`fmt.Println(it)`, `fmt.Println(it)`,
`v := gotlinAutoThrow(<-ch)`, `v := <-ch`,
`fmt.Println(v)`, `fmt.Println(v)`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {
@ -1326,7 +1330,7 @@ fun main() {
} }
} }
func TestGenerateGoAutoThrowForBunStyleCalls(t *testing.T) { func TestGenerateGoExplicitErrorsForBunStyleCalls(t *testing.T) {
src := ` src := `
package demo package demo
@ -1356,7 +1360,7 @@ class Repo(val db: *bun.DB) {
code := string(out) code := string(out)
for _, want := range []string{ for _, want := range []string{
`self.db.NewSelect().ColumnExpr("1").Scan(ctx)`, `self.db.NewSelect().ColumnExpr("1").Scan(ctx)`,
`total := gotlinAutoThrow(self.db.NewSelect().ColumnExpr("1").Count(ctx))`, `total := self.db.NewSelect().ColumnExpr("1").Count(ctx)`,
`fmt.Println(total)`, `fmt.Println(total)`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {

124
internal/lang/coroutines.go Normal file
View file

@ -0,0 +1,124 @@
package lang
import (
"fmt"
"strings"
)
var coroutineBuiltins = map[string]bool{"runBlocking": true, "coroutineScope": true, "launch": true, "async": true, "delay": true, "withTimeout": true, "isActive": true}
func programUsesCoroutines(program *Program) bool {
for _, fn := range program.Functions {
if fn.Suspend || statementsUseCoroutines(fn.Body) {
return true
}
}
for _, class := range program.Classes {
for _, fn := range class.Methods {
if fn.Suspend || statementsUseCoroutines(fn.Body) {
return true
}
}
}
return false
}
func statementsUseCoroutines(stmts []Stmt) bool {
for _, stmt := range stmts {
if statementUsesCoroutines(stmt) {
return true
}
}
return false
}
func statementUsesCoroutines(stmt Stmt) bool {
switch s := stmt.(type) {
case VarDecl:
return expressionUsesCoroutines(s.Value)
case MultiVarDecl:
return expressionUsesCoroutines(s.Value)
case AssignStmt:
return expressionUsesCoroutines(s.Value)
case ExprStmt:
return expressionUsesCoroutines(s.Value)
case ReturnStmt:
return s.Value != nil && expressionUsesCoroutines(s.Value)
case IfStmt:
return expressionUsesCoroutines(s.Cond) || statementsUseCoroutines(s.Then) || statementsUseCoroutines(s.Else)
case WhileStmt:
return expressionUsesCoroutines(s.Cond) || statementsUseCoroutines(s.Body)
case ForEachStmt:
return expressionUsesCoroutines(s.Source) || statementsUseCoroutines(s.Body)
case TryCatchStmt:
return statementsUseCoroutines(s.TryBody) || statementsUseCoroutines(s.CatchBody)
}
return false
}
func expressionUsesCoroutines(expr Expr) bool {
switch e := expr.(type) {
case CallExpr:
if id, ok := e.Callee.(IdentExpr); ok && coroutineBuiltins[id.Name] {
return true
}
for _, a := range e.Args {
if expressionUsesCoroutines(a) {
return true
}
}
case LambdaExpr:
return statementsUseCoroutines(e.Body)
case SelectorExpr:
return expressionUsesCoroutines(e.Receiver)
case BinaryExpr:
return expressionUsesCoroutines(e.Left) || expressionUsesCoroutines(e.Right)
case UnaryExpr:
return expressionUsesCoroutines(e.Value)
}
return false
}
func (g *goGenerator) emitCoroutineSupport() {
g.line("type GotlinCoroutineScope struct { ctx context.Context; cancel context.CancelFunc; workers sync.WaitGroup; mutex sync.Mutex; failure any }")
g.line("func gotlinNewCoroutineScope(parent context.Context) *GotlinCoroutineScope { ctx, cancel := context.WithCancel(parent); return &GotlinCoroutineScope{ctx: ctx, cancel: cancel} }")
g.line("func (scope *GotlinCoroutineScope) fail(value any) { scope.mutex.Lock(); if scope.failure == nil { scope.failure = value; scope.cancel() }; scope.mutex.Unlock() }")
g.line("func (scope *GotlinCoroutineScope) Launch(block func(*GotlinCoroutineScope)) { scope.workers.Add(1); go func(){ defer scope.workers.Done(); child:=gotlinNewCoroutineScope(scope.ctx); defer child.cancel(); defer func(){if value:=recover();value!=nil{scope.fail(value)}}(); block(child); child.wait() }() }")
g.line("func (scope *GotlinCoroutineScope) wait() { scope.workers.Wait(); scope.mutex.Lock(); failure:=scope.failure; scope.mutex.Unlock(); if failure!=nil{panic(failure)} }")
g.line("func (scope *GotlinCoroutineScope) Scope(block func(*GotlinCoroutineScope)) { child:=gotlinNewCoroutineScope(scope.ctx); defer child.cancel(); defer func(){if value:=recover();value!=nil{child.cancel();child.workers.Wait();panic(value)}}(); block(child); child.wait() }")
g.line("func (scope *GotlinCoroutineScope) Delay(ms int) { timer:=time.NewTimer(time.Duration(ms)*time.Millisecond); defer timer.Stop(); select { case <-timer.C: case <-scope.ctx.Done(): panic(scope.ctx.Err()) } }")
g.line("func (scope *GotlinCoroutineScope) IsActive() bool { return scope.ctx.Err()==nil }")
g.line("func (scope *GotlinCoroutineScope) WithTimeout(ms int, block func(*GotlinCoroutineScope)) { ctx,cancel:=context.WithTimeout(scope.ctx,time.Duration(ms)*time.Millisecond); defer cancel(); child:=gotlinNewCoroutineScope(ctx); defer child.cancel(); block(child); child.wait() }")
g.line("func gotlinRunBlocking(block func(*GotlinCoroutineScope)) { scope:=gotlinNewCoroutineScope(context.Background()); defer scope.cancel(); defer func(){if value:=recover();value!=nil{scope.cancel();scope.workers.Wait();panic(value)}}(); block(scope); scope.wait() }")
g.line("type GotlinDeferred[T any] struct { done chan struct{}; value T; failure any }")
g.line("func gotlinAsync[T any](scope *GotlinCoroutineScope, block func(*GotlinCoroutineScope) T) *GotlinDeferred[T] { deferred:=&GotlinDeferred[T]{done:make(chan struct{})}; scope.Launch(func(child *GotlinCoroutineScope){defer close(deferred.done);defer func(){if value:=recover();value!=nil{deferred.failure=value;scope.fail(value)}}();deferred.value=block(child)}); return deferred }")
g.line("func (deferred *GotlinDeferred[T]) await() T { <-deferred.done; if deferred.failure!=nil{panic(deferred.failure)}; return deferred.value }")
}
func (g *goGenerator) coroutineLambda(lambda LambdaExpr, returnType string) (string, error) {
var b strings.Builder
b.WriteString("func(gotlinScope *GotlinCoroutineScope)")
if mapped := mapGoType(returnType); mapped != "" {
b.WriteString(" ")
b.WriteString(mapped)
}
b.WriteString(" {\n")
sub := goGenerator{indentLevel: 1, needsFmt: g.needsFmt, needsTime: g.needsTime, needsCoroutines: true, functions: g.functions, classes: g.classes, workers: g.workers, enums: g.enums, imports: g.imports, currentFunc: FunctionDecl{ReturnType: returnType}, currentClass: g.currentClass, currentWorker: g.currentWorker, currentCoroutineScope: "gotlinScope", mappings: g.mappings}
sub.scopes = g.cloneScopes()
sub.typeScopes = g.cloneTypeScopes()
sub.pushScope()
if err := sub.block(lambda.Body); err != nil {
return "", err
}
b.Write(sub.buf.Bytes())
b.WriteString("}")
return b.String(), nil
}
func coroutineLambdaArg(call CallExpr, name string) (LambdaExpr, error) {
if len(call.Args) != 1 {
return LambdaExpr{}, fmt.Errorf("%s expects one lambda", name)
}
lambda, ok := call.Args[0].(LambdaExpr)
if !ok {
return LambdaExpr{}, fmt.Errorf("%s expects a lambda", name)
}
return lambda, nil
}

View file

@ -0,0 +1,69 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateStructuredCoroutines(t *testing.T) {
prog, err := Parse(`package demo
suspend fun load(): Int { delay(1); return 42 }
fun main() {
runBlocking {
coroutineScope {
launch { delay(1) }
val result = async<Int> { return load() }
println(result.await())
}
}
}`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"func load(gotlinScope *GotlinCoroutineScope) int", "gotlinScope.Delay(1)", "gotlinScope.Launch", "gotlinAsync[int]", "load(gotlinScope)", ".await()", "gotlinRunBlocking"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestSuspendFunctionRequiresScope(t *testing.T) {
prog, err := Parse(`package demo
suspend fun load(): Int { return 1 }
fun main() { println(load()) }`)
if err != nil {
t.Fatal(err)
}
_, err = GenerateGo(prog)
if err == nil || !strings.Contains(err.Error(), "requires a coroutine scope") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWorkerAndBareGoAreRemoved(t *testing.T) {
for _, source := range []string{`package demo worker Counter { var count = 0 }`, `package demo fun main() { go println("x") }`} {
if _, err := Parse(source); err == nil {
t.Fatalf("deprecated concurrency syntax parsed: %s", source)
}
}
}
func TestRunBlockingCancelsAndJoinsChildrenOnPanic(t *testing.T) {
prog, err := Parse(`package demo fun main() { runBlocking { launch { delay(1) }; panic("failed") } }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"scope.cancel()", "scope.workers.Wait()", "panic(value)", "gotlinRunBlocking"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}

View file

@ -53,7 +53,7 @@ import json encoding.json
data class Request(var email: String, private val traceId: String) data class Request(var email: String, private val traceId: String)
fun email(body: ByteSlice): String { fun email(body: ByteSlice): String {
val request = json.decode<Request>(body) val request = json.decode<Request>(body).unwrap()
return request.email return request.email
} }
`) `)

View file

@ -49,8 +49,8 @@ fun use(result: Result) { match (result) { Result::Ok -> { println("ok") } } }`)
func TestRejectWrongVariantPayloadCount(t *testing.T) { func TestRejectWrongVariantPayloadCount(t *testing.T) {
prog, err := Parse(`package demo prog, err := Parse(`package demo
enum Result { Ok(String) } enum Outcome { Ok(String) }
fun main() { val result = Result::Ok() }`) fun main() { val result = Outcome::Ok() }`)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ package demo
import json encoding.json import json encoding.json
fun decode(body: ByteSlice): List<Account> { fun decode(body: ByteSlice): List<Account> {
val accounts = json.decode<List<Account>>(body) val accounts = json.decode<List<Account>>(body).unwrap()
return accounts return accounts
} }
`) `)
@ -23,7 +23,7 @@ fun decode(body: ByteSlice): List<Account> {
if err != nil { if err != nil {
t.Fatalf("generation failed: %v", err) t.Fatalf("generation failed: %v", err)
} }
for _, want := range []string{"accounts := gotlinAutoThrow(gotlinJSONDecode[[]Account](body))", "json.Unmarshal(body, &value)"} { for _, want := range []string{"accounts := gotlinResultUnwrap(gotlinJSONDecode[[]Account](body))", "json.Unmarshal(body, &value)"} {
if !strings.Contains(string(out), want) { if !strings.Contains(string(out), want) {
t.Fatalf("generated Go missing %q:\n%s", want, out) t.Fatalf("generated Go missing %q:\n%s", want, out)
} }

View file

@ -122,6 +122,9 @@ func (l *lexer) next() (token, error) {
case '%': case '%':
return token{kind: tokenPercent, lexeme: "%", pos: start}, nil return token{kind: tokenPercent, lexeme: "%", pos: start}, nil
case '!': case '!':
if l.match('!') {
return token{kind: tokenDoubleBang, lexeme: "!!", pos: start}, nil
}
if l.match('=') { if l.match('=') {
return token{kind: tokenNeq, lexeme: "!=", pos: start}, nil return token{kind: tokenNeq, lexeme: "!=", pos: start}, nil
} }
@ -149,6 +152,9 @@ func (l *lexer) next() (token, error) {
case '@': case '@':
return token{kind: tokenAt, lexeme: "@", pos: start}, nil return token{kind: tokenAt, lexeme: "@", pos: start}, nil
case '?': case '?':
if l.match('.') {
return token{kind: tokenSafeDot, lexeme: "?.", pos: start}, nil
}
return token{kind: tokenQuestion, lexeme: "?", pos: start}, nil return token{kind: tokenQuestion, lexeme: "?", pos: start}, nil
case '|': case '|':
if l.match('|') { if l.match('|') {

View file

@ -0,0 +1,67 @@
package lang
import (
"strings"
"testing"
)
func TestSafeAccessAndNonNullAssertion(t *testing.T) {
prog, err := Parse(`package demo
data class User(var email: String)
fun safe(user: *User?): String? { return user?.email }
fun required(user: *User?): String { return user!!.email }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"if value == nil {", "return &result", "non-null assertion failed"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestRejectNullableDereference(t *testing.T) {
prog, err := Parse(`package demo
fun unsafe(user: *User?): String { return user.email }`)
if err != nil {
t.Fatal(err)
}
_, err = GenerateGo(prog)
if err == nil || !strings.Contains(err.Error(), "requires ?. or !!") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRejectNullForNonNullableTypes(t *testing.T) {
for _, source := range []string{
`package demo data class User(var email: String) fun main() { val user: *User = null }`,
`package demo data class User(var email: String) fun use(user: *User) {} fun main() { use(null) }`,
`package demo fun name(): String { return null }`,
`package demo fun main() { val value = null }`,
} {
prog, err := Parse(source)
if err != nil {
t.Fatal(err)
}
if _, err = GenerateGo(prog); err == nil {
t.Fatalf("expected nullability error for %s", source)
}
}
}
func TestNullableSmartCasts(t *testing.T) {
prog, err := Parse(`package demo
data class User(var email: String)
fun guarded(user: *User?): String { if (user == null) { return "missing" }; return user.email }
fun branched(user: *User?): String { if (user != null) { return user.email } else { return "missing" } }`)
if err != nil {
t.Fatal(err)
}
if _, err = GenerateGo(prog); err != nil {
t.Fatal(err)
}
}

View file

@ -137,7 +137,7 @@ func (p *parser) parseProgram() (*Program, error) {
return nil, err return nil, err
} }
prog.Workers = append(prog.Workers, decl) prog.Workers = append(prog.Workers, decl)
case p.check(tokenFun): case p.check(tokenFun) || p.check(tokenSuspend):
fn, err := p.parseFunction() fn, err := p.parseFunction()
if err != nil { if err != nil {
return nil, err return nil, err
@ -320,7 +320,7 @@ func (p *parser) parseClass() (ClassDecl, error) {
var methods []FunctionDecl var methods []FunctionDecl
for !p.check(tokenRBrace) && !p.check(tokenEOF) { for !p.check(tokenRBrace) && !p.check(tokenEOF) {
p.match(tokenOverride) p.match(tokenOverride)
if !p.check(tokenFun) { if !p.check(tokenFun) && !p.check(tokenSuspend) {
tok := p.peek() tok := p.peek()
return ClassDecl{}, fmt.Errorf("expected class member at %d, found %q", tok.pos, tok.lexeme) return ClassDecl{}, fmt.Errorf("expected class member at %d, found %q", tok.pos, tok.lexeme)
} }
@ -538,10 +538,12 @@ func (p *parser) parseFunction() (FunctionDecl, error) {
Params: signature.Params, Params: signature.Params,
ReturnType: signature.ReturnType, ReturnType: signature.ReturnType,
Body: body, Body: body,
Suspend: signature.Suspend,
}, nil }, nil
} }
func (p *parser) parseFunctionSignature() (FunctionSignature, error) { func (p *parser) parseFunctionSignature() (FunctionSignature, error) {
suspend := p.match(tokenSuspend)
if _, err := p.expect(tokenFun, "expected 'fun'"); err != nil { if _, err := p.expect(tokenFun, "expected 'fun'"); err != nil {
return FunctionSignature{}, err return FunctionSignature{}, err
} }
@ -573,6 +575,7 @@ func (p *parser) parseFunctionSignature() (FunctionSignature, error) {
Name: name.lexeme, Name: name.lexeme,
Params: params, Params: params,
ReturnType: returnType, ReturnType: returnType,
Suspend: suspend,
}, nil }, nil
} }
@ -620,6 +623,9 @@ func (p *parser) parseBlock() ([]Stmt, error) {
} }
func (p *parser) parseStmt() (Stmt, error) { func (p *parser) parseStmt() (Stmt, error) {
if p.check(tokenIdent) && p.peek().lexeme == "go" {
return nil, fmt.Errorf("bare go is removed; use launch inside a coroutine scope")
}
switch { switch {
case p.match(tokenVal): case p.match(tokenVal):
return p.parseVarDecl(false) return p.parseVarDecl(false)
@ -1105,6 +1111,16 @@ func (p *parser) parsePostfix(expr Expr) (Expr, error) {
return nil, fmt.Errorf("expected selector name at %d, found %q", name.pos, name.lexeme) return nil, fmt.Errorf("expected selector name at %d, found %q", name.pos, name.lexeme)
} }
expr = SelectorExpr{Receiver: expr, Name: name.lexeme} expr = SelectorExpr{Receiver: expr, Name: name.lexeme}
case p.match(tokenSafeDot):
name := p.advance()
if !selectorName(name.lexeme) {
return nil, fmt.Errorf("expected selector name at %d, found %q", name.pos, name.lexeme)
}
expr = SafeSelectorExpr{Receiver: expr, Name: name.lexeme}
case p.match(tokenDoubleBang):
expr = NonNullExpr{Value: expr}
case p.match(tokenQuestion):
expr = TryExpr{Value: expr}
case p.match(tokenLBracket): case p.match(tokenLBracket):
index, err := p.parseExpr(0) index, err := p.parseExpr(0)
if err != nil { if err != nil {
@ -1122,6 +1138,10 @@ func (p *parser) parsePostfix(expr Expr) (Expr, error) {
if !hasTypeArgs { if !hasTypeArgs {
return expr, nil return expr, nil
} }
if p.check(tokenLBrace) {
expr = CallExpr{Callee: expr, TypeArgs: typeArgs}
continue
}
if _, err := p.expect(tokenLParen, "expected '(' after generic type arguments"); err != nil { if _, err := p.expect(tokenLParen, "expected '(' after generic type arguments"); err != nil {
return nil, err return nil, err
} }
@ -1149,6 +1169,8 @@ func (p *parser) parsePostfix(expr Expr) (Expr, error) {
call = current call = current
case SelectorExpr: case SelectorExpr:
call = CallExpr{Callee: current} call = CallExpr{Callee: current}
case IdentExpr:
call = CallExpr{Callee: current}
default: default:
return expr, nil return expr, nil
} }
@ -1236,7 +1258,7 @@ func (p *parser) tryParseCallTypeArgs() ([]string, bool, error) {
p.pos = saved p.pos = saved
return nil, false, nil return nil, false, nil
} }
if !p.check(tokenLParen) { if !p.check(tokenLParen) && !p.check(tokenLBrace) {
p.pos = saved p.pos = saved
return nil, false, nil return nil, false, nil
} }

192
internal/lang/references.go Normal file
View file

@ -0,0 +1,192 @@
package lang
import "strings"
func normalizeClassReferences(program *Program) {
classes := map[string]bool{}
for _, class := range program.Classes {
classes[class.Name] = true
}
normalize := func(value string) string { return normalizeReferenceType(value, classes) }
for i := range program.Interfaces {
for j := range program.Interfaces[i].Methods {
normalizeSignature(&program.Interfaces[i].Methods[j], normalize)
}
}
for i := range program.Enums {
for j := range program.Enums[i].Variants {
for k := range program.Enums[i].Variants[j].PayloadTypes {
program.Enums[i].Variants[j].PayloadTypes[k] = normalize(program.Enums[i].Variants[j].PayloadTypes[k])
}
}
}
for i := range program.Classes {
for j := range program.Classes[i].Fields {
program.Classes[i].Fields[j].Type = normalize(program.Classes[i].Fields[j].Type)
}
for j := range program.Classes[i].Methods {
normalizeFunction(&program.Classes[i].Methods[j], normalize)
}
}
for i := range program.Workers {
for j := range program.Workers[i].Fields {
program.Workers[i].Fields[j].Type = normalize(program.Workers[i].Fields[j].Type)
normalizeExprTypes(program.Workers[i].Fields[j].Value, normalize)
}
for j := range program.Workers[i].Methods {
normalizeFunction(&program.Workers[i].Methods[j], normalize)
}
}
for i := range program.Functions {
normalizeFunction(&program.Functions[i], normalize)
}
}
func normalizeSignature(signature *FunctionSignature, normalize func(string) string) {
for i := range signature.Params {
signature.Params[i].Type = normalize(signature.Params[i].Type)
}
signature.ReturnType = normalize(signature.ReturnType)
}
func normalizeFunction(function *FunctionDecl, normalize func(string) string) {
for i := range function.Params {
function.Params[i].Type = normalize(function.Params[i].Type)
}
function.ReturnType = normalize(function.ReturnType)
normalizeStmtTypes(function.Body, normalize)
}
func normalizeStmtTypes(statements []Stmt, normalize func(string) string) {
for index, statement := range statements {
switch value := statement.(type) {
case VarDecl:
value.Type = normalize(value.Type)
normalizeExprTypes(value.Value, normalize)
statements[index] = value
case MultiVarDecl:
normalizeExprTypes(value.Value, normalize)
case AssignStmt:
normalizeExprTypes(value.Value, normalize)
case AddAssignStmt:
normalizeExprTypes(value.Value, normalize)
case MultiAssignStmt:
normalizeExprTypes(value.Value, normalize)
case ReturnStmt:
if value.Value != nil {
normalizeExprTypes(value.Value, normalize)
}
case ThrowStmt:
normalizeExprTypes(value.Value, normalize)
case GoStmt:
normalizeExprTypes(value.Value, normalize)
case DeferStmt:
normalizeExprTypes(value.Value, normalize)
case ExprStmt:
normalizeExprTypes(value.Value, normalize)
case IfStmt:
normalizeExprTypes(value.Cond, normalize)
normalizeStmtTypes(value.Then, normalize)
normalizeStmtTypes(value.Else, normalize)
case WhileStmt:
normalizeExprTypes(value.Cond, normalize)
normalizeStmtTypes(value.Body, normalize)
case ForEachStmt:
normalizeExprTypes(value.Source, normalize)
normalizeStmtTypes(value.Body, normalize)
case SelectStmt:
for _, c := range value.Cases {
normalizeExprTypes(c.Source, normalize)
normalizeStmtTypes(c.Body, normalize)
}
case MatchStmt:
normalizeExprTypes(value.Value, normalize)
for _, c := range value.Cases {
normalizeStmtTypes(c.Body, normalize)
}
case TryCatchStmt:
value.CatchType = normalize(value.CatchType)
normalizeStmtTypes(value.TryBody, normalize)
normalizeStmtTypes(value.CatchBody, normalize)
statements[index] = value
}
}
}
func normalizeExprTypes(expression Expr, normalize func(string) string) {
switch value := expression.(type) {
case UnaryExpr:
normalizeExprTypes(value.Value, normalize)
case NonNullExpr:
normalizeExprTypes(value.Value, normalize)
case BinaryExpr:
normalizeExprTypes(value.Left, normalize)
normalizeExprTypes(value.Right, normalize)
case SelectorExpr:
normalizeExprTypes(value.Receiver, normalize)
case SafeSelectorExpr:
normalizeExprTypes(value.Receiver, normalize)
case IndexExpr:
normalizeExprTypes(value.Receiver, normalize)
normalizeExprTypes(value.Index, normalize)
case EnumVariantExpr:
for _, item := range value.Values {
normalizeExprTypes(item, normalize)
}
case LambdaExpr:
for i := range value.Params {
value.Params[i].Type = normalize(value.Params[i].Type)
}
normalizeStmtTypes(value.Body, normalize)
case CallExpr:
skipTypeArgs := false
if selector, ok := value.Callee.(SelectorExpr); ok {
if root, ok := selector.Receiver.(IdentExpr); ok && root.Name == "sql" {
skipTypeArgs = true
}
}
if !skipTypeArgs {
for i := range value.TypeArgs {
value.TypeArgs[i] = normalize(value.TypeArgs[i])
}
}
normalizeExprTypes(value.Callee, normalize)
for _, item := range value.Args {
normalizeExprTypes(item, normalize)
}
for _, item := range value.NamedArgs {
normalizeExprTypes(item.Value, normalize)
}
}
}
func normalizeReferenceType(value string, classes map[string]bool) string {
if value == "" {
return value
}
nullable := strings.HasSuffix(value, "?")
if nullable {
value = strings.TrimSuffix(value, "?")
}
explicitPointer := strings.HasPrefix(value, "*")
if explicitPointer {
value = strings.TrimPrefix(value, "*")
}
if params, result, ok := parseFunctionType(value); ok {
for i := range params {
params[i] = normalizeReferenceType(params[i], classes)
}
value = "(" + strings.Join(params, ", ") + ") -> " + normalizeReferenceType(result, classes)
} else if base, args, ok := parseGenericType(value); ok {
for i := range args {
args[i] = normalizeReferenceType(args[i], classes)
}
value = base + "<" + strings.Join(args, ", ") + ">"
} else if classes[value] || explicitPointer {
value = "*" + value
}
if nullable {
value += "?"
}
return value
}

View file

@ -0,0 +1,43 @@
package lang
import (
"strings"
"testing"
)
func TestGotlinClassesAreReferenceTypesByDefault(t *testing.T) {
prog, err := Parse(`package demo
class Repository
class Service(val repository: Repository)
fun create(): Service { return Service(Repository()) }
fun many(): List<Repository> { return listOf(Repository()) }
fun optional(): Repository? { return null }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"repository *Repository", "func create() *Service", "func many() []*Repository", "func optional() *Repository"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestExplicitClassPointerRemainsCompatible(t *testing.T) {
prog, err := Parse(`package demo
class Repository
fun use(repository: *Repository): *Repository { return repository }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "**Repository") || !strings.Contains(string(out), "repository *Repository") {
t.Fatalf("unexpected explicit pointer output:\n%s", out)
}
}

View file

@ -0,0 +1,142 @@
package lang
import (
"strings"
"testing"
)
func TestResultQuestionPropagatesGoError(t *testing.T) {
prog, err := Parse(`package demo
import strconv
fun parse(value: String): Result<Int, Error> {
val parsed = strconv.atoi(value)?
return Result::Ok(parsed)
}`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"parsed, gotlinError1 := strconv.Atoi(value)", "if gotlinError1 != nil {", "GotlinResult[int]{Err: gotlinError1}", "GotlinResult[int]{Value: parsed}"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestGoValueAndErrorReturnConvertsToResult(t *testing.T) {
prog, err := Parse(`package demo
import strconv
fun parse(value: String): Result<Int, Error> { return strconv.atoi(value) }
fun assign(value: String) { val result: Result<Int, Error> = strconv.atoi(value); println(result.unwrapOr(0)) }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"value, err := strconv.Atoi(value)",
"GotlinResult[int]{Value: value, Err: err}",
"var result GotlinResult[int] = func() GotlinResult[int]",
} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestGoErrorOnlyReturnConvertsToUnitResult(t *testing.T) {
prog, err := Parse(`package demo
import os
fun changeDirectory(path: String): Result<Unit, Error> { return os.chdir(path) }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"func changeDirectory(path string) GotlinResult[struct{}]",
"err := os.Chdir(path)",
"GotlinResult[struct{}]{Err: err}",
} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestResultQuestionPropagatesGotlinResult(t *testing.T) {
prog, err := Parse(`package demo
import errors
fun inner(ok: Boolean): Result<String, Error> { if (!ok) { return Result::Err(errors.new("failed")) }; return Result::Ok("ok") }
fun outer(): Result<String, Error> { val value = inner(true)?; return Result::Ok(value) }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"gotlinResult1 := inner(true)", "gotlinResult1.Err", "value := gotlinResult1.Value"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestResultUnwrapAndUnwrapOr(t *testing.T) {
prog, err := Parse(`package demo
fun value(result: Result<String, Error>): String { return result.unwrapOr("fallback") }
fun required(result: Result<String, Error>): String { return result.unwrap() }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"gotlinResultUnwrapOr(result, \"fallback\")", "gotlinResultUnwrap(result)"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestQuestionRequiresResultFunction(t *testing.T) {
prog, err := Parse(`package demo
import strconv
fun parse(value: String): Int { val parsed = strconv.atoi(value)?; return parsed }`)
if err != nil {
t.Fatal(err)
}
_, err = GenerateGo(prog)
if err == nil || !strings.Contains(err.Error(), "returning Result") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestQuestionUseKeepsReferencedVariableLive(t *testing.T) {
prog, err := Parse(`package demo
import strconv
fun parse(value: String): Result<Int, Error> {
val input = value
val parsed = strconv.atoi(input)?
return Result::Ok(parsed)
}`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "input := value") || strings.Contains(string(out), "_ = value") {
t.Fatalf("variable used by ? expression was removed:\n%s", out)
}
}

View file

@ -208,6 +208,12 @@ func (c *mutabilityChecker) checkExpr(expr Expr) error {
} }
case SelectorExpr: case SelectorExpr:
return c.checkExpr(e.Receiver) return c.checkExpr(e.Receiver)
case SafeSelectorExpr:
return c.checkExpr(e.Receiver)
case NonNullExpr:
return c.checkExpr(e.Value)
case TryExpr:
return c.checkExpr(e.Value)
case IndexExpr: case IndexExpr:
if err := c.checkExpr(e.Receiver); err != nil { if err := c.checkExpr(e.Receiver); err != nil {
return err return err

View file

@ -47,6 +47,11 @@ func splitSQLChain(expr Expr) (CallExpr, string, []sqlCallStep, bool) {
} }
func (g *goGenerator) lowerSQLQuery(expr Expr) (string, bool, error) { func (g *goGenerator) lowerSQLQuery(expr Expr) (string, bool, error) {
if call, ok := expr.(CallExpr); ok {
if selector, ok := call.Callee.(SelectorExpr); ok && (selector.Name == "unwrap" || selector.Name == "unwrapOr") {
return "", false, nil
}
}
root, operation, steps, ok := splitSQLChain(expr) root, operation, steps, ok := splitSQLChain(expr)
if !ok { if !ok {
return "", false, nil return "", false, nil
@ -166,11 +171,11 @@ func sqlChainResultType(expr Expr) (string, bool) {
} }
switch terminal { switch terminal {
case "fetch": case "fetch":
return "List<*" + resultType + ">", true return "Result<List<*" + resultType + ">, Error>", true
case "single": case "single":
return "*" + resultType, true return "Result<*" + resultType + ", Error>", true
case "iterator": case "iterator":
return "GotlinSQLIterator<" + resultType + ">", true return "Result<GotlinSQLIterator<" + resultType + ">, Error>", true
default: default:
return "", false return "", false
} }

View file

@ -68,18 +68,18 @@ fun events(pool: *pgxpool.Pool, ctx: context.Context): List<*EventProjection> {
return sql.from<EventRow>() return sql.from<EventRow>()
.select { row -> EventProjection(row.id, row.payload) } .select { row -> EventProjection(row.id, row.payload) }
.orderBy { it.createdAt } .orderBy { it.createdAt }
.fetch(pool, ctx) .fetch(pool, ctx).unwrap()
} }
fun eventStream(pool: *pgxpool.Pool, ctx: context.Context): GotlinSQLIterator<EventProjection> { fun eventStream(pool: *pgxpool.Pool, ctx: context.Context): GotlinSQLIterator<EventProjection> {
return sql.from<EventRow>() return sql.from<EventRow>()
.select { row -> EventProjection(row.id, row.payload) } .select { row -> EventProjection(row.id, row.payload) }
.iterator(pool, ctx) .iterator(pool, ctx).unwrap()
} }
`) `)
for _, want := range []string{ for _, want := range []string{
`return gotlinSQLFetch[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events ORDER BY created_at", Args: []any{}}, gotlinSQLScanEventProjection)`, `return gotlinResultUnwrap(gotlinSQLFetch[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events ORDER BY created_at", Args: []any{}}, gotlinSQLScanEventProjection))`,
`return gotlinSQLIterate[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events", Args: []any{}}, gotlinSQLScanEventProjection)`, `return gotlinResultUnwrap(gotlinSQLIterate[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events", Args: []any{}}, gotlinSQLScanEventProjection))`,
`func gotlinSQLScanEventProjection(row gotlinSQLRow) (*EventProjection, error)`, `func gotlinSQLScanEventProjection(row gotlinSQLRow) (*EventProjection, error)`,
`err := row.Scan(&value.Id, &value.Payload)`, `err := row.Scan(&value.Id, &value.Payload)`,
} { } {
@ -97,7 +97,7 @@ import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun create(row: EventRow, pool: *pgxpool.Pool, ctx: context.Context): *EventProjection { fun create(row: EventRow, pool: *pgxpool.Pool, ctx: context.Context): *EventProjection {
return sql.insert<EventRow>(row) return sql.insert<EventRow>(row)
.returning { value -> EventProjection(value.id, value.payload) } .returning { value -> EventProjection(value.id, value.payload) }
.single(pool, ctx) .single(pool, ctx).unwrap()
} }
`) `)
for _, want := range []string{ for _, want := range []string{
@ -135,7 +135,7 @@ fun claim(id: String, payload: String, pool: *pgxpool.Pool, ctx: context.Context
} }
.where { it.id == id && it.publishedAt == null } .where { it.id == id && it.publishedAt == null }
.returning { row -> EventProjection(row.id, row.payload) } .returning { row -> EventProjection(row.id, row.payload) }
.single(pool, ctx) .single(pool, ctx).unwrap()
} }
`) `)
for _, want := range []string{ for _, want := range []string{
@ -158,7 +158,7 @@ fun remove(id: String, pool: *pgxpool.Pool, ctx: context.Context): *EventRow {
return sql.delete<EventRow>() return sql.delete<EventRow>()
.where { it.id == id } .where { it.id == id }
.returning { it } .returning { it }
.single(pool, ctx) .single(pool, ctx).unwrap()
} }
`) `)
for _, want := range []string{ for _, want := range []string{
@ -245,7 +245,7 @@ func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
}, },
{ {
name: "write execution without returning", name: "write execution without returning",
src: eventSQLSource + `fun query(pool: Any, ctx: Any): *EventRow { return sql.delete<EventRow>().single(pool, ctx) }`, src: eventSQLSource + `fun query(pool: Any, ctx: Any): *EventRow { return sql.delete<EventRow>().single(pool, ctx).unwrap() }`,
want: "requires returning()", want: "requires returning()",
}, },
{ {

View file

@ -97,15 +97,15 @@ import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun accounts(pool: *pgxpool.Pool, ctx: context.Context, customerId: String): List<*AccountRow> { fun accounts(pool: *pgxpool.Pool, ctx: context.Context, customerId: String): List<*AccountRow> {
return sql.from<AccountRow>() return sql.from<AccountRow>()
.where { it.customerId == customerId } .where { it.customerId == customerId }
.fetch(pool, ctx) .fetch(pool, ctx).unwrap()
} }
`) `)
for _, want := range []string{ for _, want := range []string{
`"github.com/jackc/pgx/v5"`, `"github.com/jackc/pgx/v5"`,
`func accounts(pool *pgxpool.Pool, ctx context.Context, customerId string) []*AccountRow`, `func accounts(pool *pgxpool.Pool, ctx context.Context, customerId string) []*AccountRow`,
`return gotlinSQLFetch[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts WHERE customer_id = $1", Args: []any{customerId}}, gotlinSQLScanAccountRow)`, `return gotlinResultUnwrap(gotlinSQLFetch[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts WHERE customer_id = $1", Args: []any{customerId}}, gotlinSQLScanAccountRow))`,
`defer rows.Close()`, `defer rows.Close()`,
`values = append(values, gotlinAutoThrow(scan(rows)))`, `values = append(values, value)`,
`err := row.Scan(&value.Id, &value.CustomerId, &value.AccountType, &value.Balance)`, `err := row.Scan(&value.Id, &value.CustomerId, &value.AccountType, &value.Balance)`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {
@ -120,15 +120,15 @@ import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool" import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun balance(pool: *pgxpool.Pool, ctx: context.Context, id: String): Double { fun balance(pool: *pgxpool.Pool, ctx: context.Context, id: String): Double {
val account = sql.from<AccountRow>().where { it.id == id }.single(pool, ctx) val account = sql.from<AccountRow>().where { it.id == id }.single(pool, ctx).unwrap()
return account.balance return account.balance
} }
`) `)
for _, want := range []string{ for _, want := range []string{
`account := gotlinSQLSingle[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts WHERE id = $1", Args: []any{id}}, gotlinSQLScanAccountRow)`, `account := gotlinResultUnwrap(gotlinSQLSingle[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts WHERE id = $1", Args: []any{id}}, gotlinSQLScanAccountRow))`,
`return account.Balance`, `return account.Balance`,
`gotlinAutoThrow(gotlinSQLError("SQL single() expected exactly one row, got zero"))`, `GotlinResult[*T]{Err: gotlinSQLError("SQL single() expected exactly one row, got zero")}`,
`gotlinAutoThrow(gotlinSQLError("SQL single() expected exactly one row, got more than one"))`, `GotlinResult[*T]{Err: gotlinSQLError("SQL single() expected exactly one row, got more than one")}`,
} { } {
if !strings.Contains(code, want) { if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code) t.Fatalf("generated Go missing %q:\n%s", want, code)
@ -142,7 +142,7 @@ import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool" import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) { fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
val rows = sql.from<AccountRow>().iterator(pool, ctx) val rows = sql.from<AccountRow>().iterator(pool, ctx).unwrap()
defer rows.close() defer rows.close()
while (rows.next()) { while (rows.next()) {
val account = rows.value() val account = rows.value()
@ -152,12 +152,12 @@ fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
} }
`) `)
for _, want := range []string{ for _, want := range []string{
`rows := gotlinSQLIterate[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts", Args: []any{}}, gotlinSQLScanAccountRow)`, `rows := gotlinResultUnwrap(gotlinSQLIterate[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts", Args: []any{}}, gotlinSQLScanAccountRow))`,
`defer rows.close()`, `defer rows.close()`,
`for rows.next()`, `for rows.next()`,
`account := gotlinAutoThrow(rows.value())`, `account := rows.value()`,
`fmt.Println(account.CustomerId)`, `fmt.Println(account.CustomerId)`,
`_ = gotlinAutoThrow(rows.err())`, `_ = rows.err()`,
`type GotlinSQLIterator[T any] struct`, `type GotlinSQLIterator[T any] struct`,
`func (iterator *GotlinSQLIterator[T]) next() bool`, `func (iterator *GotlinSQLIterator[T]) next() bool`,
`func (iterator *GotlinSQLIterator[T]) value() *T`, `func (iterator *GotlinSQLIterator[T]) value() *T`,
@ -304,17 +304,17 @@ fun query(row: OtherRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).on
}, },
{ {
name: "fetch missing arguments", name: "fetch missing arguments",
src: accountRowSource + `fun query(): List<*AccountRow> { return sql.from<AccountRow>().fetch() }`, src: accountRowSource + `fun query(): List<*AccountRow> { return sql.from<AccountRow>().fetch().unwrap() }`,
want: "fetch() expects exactly pool and ctx positional arguments", want: "fetch() expects exactly pool and ctx positional arguments",
}, },
{ {
name: "single extra argument", name: "single extra argument",
src: accountRowSource + `fun query(pool: Any, ctx: Any): *AccountRow { return sql.from<AccountRow>().single(pool, ctx, ctx) }`, src: accountRowSource + `fun query(pool: Any, ctx: Any): *AccountRow { return sql.from<AccountRow>().single(pool, ctx, ctx).unwrap() }`,
want: "single() expects exactly pool and ctx positional arguments", want: "single() expects exactly pool and ctx positional arguments",
}, },
{ {
name: "iterator named arguments", name: "iterator named arguments",
src: accountRowSource + `fun query(pool: Any, ctx: Any): GotlinSQLIterator<AccountRow> { return sql.from<AccountRow>().iterator(pool = pool, ctx = ctx) }`, src: accountRowSource + `fun query(pool: Any, ctx: Any): GotlinSQLIterator<AccountRow> { return sql.from<AccountRow>().iterator(pool = pool, ctx = ctx).unwrap() }`,
want: "iterator() expects exactly pool and ctx positional arguments", want: "iterator() expects exactly pool and ctx positional arguments",
}, },
{ {
@ -324,17 +324,17 @@ fun query(row: OtherRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).on
}, },
{ {
name: "fetch invalid pool type", name: "fetch invalid pool type",
src: accountRowSource + `fun query(pool: String, ctx: Any): List<*AccountRow> { return sql.from<AccountRow>().fetch(pool, ctx) }`, src: accountRowSource + `fun query(pool: String, ctx: Any): List<*AccountRow> { return sql.from<AccountRow>().fetch(pool, ctx).unwrap() }`,
want: "pool argument has non-query type String", want: "pool argument has non-query type String",
}, },
{ {
name: "single invalid context type", name: "single invalid context type",
src: accountRowSource + `fun query(pool: Any, ctx: Int): *AccountRow { return sql.from<AccountRow>().single(pool, ctx) }`, src: accountRowSource + `fun query(pool: Any, ctx: Int): *AccountRow { return sql.from<AccountRow>().single(pool, ctx).unwrap() }`,
want: "ctx argument has non-context type Int", want: "ctx argument has non-context type Int",
}, },
{ {
name: "insert execution terminal", name: "insert execution terminal",
src: accountRowSource + `fun query(row: AccountRow, pool: Any, ctx: Any): List<*AccountRow> { return sql.insert<AccountRow>(row).fetch(pool, ctx) }`, src: accountRowSource + `fun query(row: AccountRow, pool: Any, ctx: Any): List<*AccountRow> { return sql.insert<AccountRow>(row).fetch(pool, ctx).unwrap() }`,
want: "fetch() is only supported for sql.from", want: "fetch() is only supported for sql.from",
}, },
} }

View file

@ -20,6 +20,7 @@ const (
tokenEnum tokenKind = "ENUM" tokenEnum tokenKind = "ENUM"
tokenMatch tokenKind = "MATCH" tokenMatch tokenKind = "MATCH"
tokenFun tokenKind = "FUN" tokenFun tokenKind = "FUN"
tokenSuspend tokenKind = "SUSPEND"
tokenOverride tokenKind = "OVERRIDE" tokenOverride tokenKind = "OVERRIDE"
tokenPrivate tokenKind = "PRIVATE" tokenPrivate tokenKind = "PRIVATE"
tokenVal tokenKind = "VAL" tokenVal tokenKind = "VAL"
@ -65,17 +66,19 @@ const (
tokenAmp tokenKind = "&" tokenAmp tokenKind = "&"
tokenAt tokenKind = "@" tokenAt tokenKind = "@"
tokenQuestion tokenKind = "?" tokenQuestion tokenKind = "?"
tokenSafeDot tokenKind = "?."
tokenDoubleBang tokenKind = "!!"
tokenOr tokenKind = "||" tokenOr tokenKind = "||"
tokenArrow tokenKind = "->" tokenArrow tokenKind = "->"
) )
var keywords = map[string]tokenKind{ var keywords = map[string]tokenKind{
"fun": tokenFun, "fun": tokenFun,
"suspend": tokenSuspend,
"import": tokenImport, "import": tokenImport,
"package": tokenPackage, "package": tokenPackage,
"class": tokenClass, "class": tokenClass,
"data": tokenData, "data": tokenData,
"worker": tokenWorker,
"interface": tokenInterface, "interface": tokenInterface,
"enum": tokenEnum, "enum": tokenEnum,
"match": tokenMatch, "match": tokenMatch,
@ -90,7 +93,6 @@ var keywords = map[string]tokenKind{
"in": tokenIn, "in": tokenIn,
"select": tokenSelect, "select": tokenSelect,
"return": tokenReturn, "return": tokenReturn,
"go": tokenGo,
"defer": tokenDefer, "defer": tokenDefer,
"try": tokenTry, "try": tokenTry,
"catch": tokenCatch, "catch": tokenCatch,

View file

@ -8,7 +8,7 @@ VS Code language support for Gotlin (`.gt`) files.
- Dedicated highlighting for the typed `sql` DSL and its query, mutation, and execution methods - Dedicated highlighting for the typed `sql` DSL and its query, mutation, and execution methods
- Go import highlighting for bare dotted paths, aliases, and quoted module paths - Go import highlighting for bare dotted paths, aliases, and quoted module paths
- Bracket matching, indentation, folding markers, comments, and editor pairs - Bracket matching, indentation, folding markers, comments, and editor pairs
- Snippets for data classes, SQL table rows and operations, workers, embeds, concurrency, defer, and foreach loops - Snippets for data classes, SQL table rows and operations, structured coroutines, embeds, defer, and foreach loops
- `gotlin-lsp` integration, including its optional `gopls` bridge for Go-imported symbols - `gotlin-lsp` integration, including its optional `gopls` bridge for Go-imported symbols
## Development ## Development

View file

@ -42,12 +42,13 @@ assert(language.folding?.markers?.start && language.indentationRules?.increaseIn
const grammarSource = JSON.stringify(grammar); const grammarSource = JSON.stringify(grammar);
const expectedTokens = [ const expectedTokens = [
"data", "class", "worker", "private", "override", "val", "var", "if", "else", "data", "class", "suspend", "private", "override", "val", "var", "if", "else",
"while", "for", "in", "select", "return", "go", "defer", "try", "catch", "while", "for", "in", "select", "return", "defer", "try", "catch",
"throw", "enum", "match", "package", "import", "jsonNaming", "embed", "table", "column", "id", "throw", "enum", "match", "package", "import", "jsonNaming", "embed", "table", "column", "id",
"generated", "Int", "String", "Boolean", "Unit", "Double", "Float", "Any", "generated", "Int", "Long", "String", "Boolean", "Unit", "Double", "Float", "Any",
"ByteSlice", "List", "MutableList", "Map", "MutableMap", "Channel", "GotlinSQLQuery", "ByteSlice", "Error", "Result", "List", "MutableList", "Map", "MutableMap", "Channel", "GotlinSQLQuery",
"GotlinSQLIterator", "from", "where", "orderBy", "orderByDescending", "limit", "GotlinSQLIterator", "from", "where", "orderBy", "orderByDescending", "limit",
"runBlocking", "coroutineScope", "launch", "async", "await", "delay", "withTimeout", "isActive",
"forUpdate", "skipLocked", "insert", "update", "delete", "onConflict", "doNothing", "forUpdate", "skipLocked", "insert", "update", "delete", "onConflict", "doNothing",
"doUpdate", "returning", "build", "fetch", "single", "iterator", "set", "now", "mapTo" "doUpdate", "returning", "build", "fetch", "single", "iterator", "set", "now", "mapTo"
]; ];
@ -81,7 +82,7 @@ for (const declaration of [
const prefixes = new Set(Object.values(snippets).map((snippet) => snippet.prefix)); const prefixes = new Set(Object.values(snippets).map((snippet) => snippet.prefix));
for (const prefix of [ for (const prefix of [
"dataclass", "tablerow", "embed", "worker", "enum", "match", "mapto", "go", "defer", "foreach", "sqlfetch", "dataclass", "tablerow", "embed", "coroutinescope", "launch", "async", "enum", "match", "mapto", "safe", "nonnull", "resultfun", "resultmatch", "defer", "foreach", "sqlfetch",
"sqlsingle", "sqliterator", "sqlinsertnothing", "sqlinsertupdate", "sqlupdatereturning" "sqlsingle", "sqliterator", "sqlinsertnothing", "sqlinsertupdate", "sqlupdatereturning"
]) { ]) {
assert(prefixes.has(prefix), `snippets are missing prefix ${prefix}`); assert(prefixes.has(prefix), `snippets are missing prefix ${prefix}`);

View file

@ -52,19 +52,6 @@
"body": ["@embed(\"${1:path/to/file}\") val ${2:name}: ${3:ByteSlice}"], "body": ["@embed(\"${1:path/to/file}\") val ${2:name}: ${3:ByteSlice}"],
"description": "Embed a file as a top-level value" "description": "Embed a file as a top-level value"
}, },
"Worker": {
"prefix": "worker",
"body": [
"worker ${1:Name} {",
" var ${2:state}: ${3:Int} = ${4:0}",
"",
" fun ${5:run}() {",
" $0",
" }",
"}"
],
"description": "Stateful Gotlin worker"
},
"Rust-style Enum": { "Rust-style Enum": {
"prefix": "enum", "prefix": "enum",
"body": [ "body": [
@ -98,14 +85,50 @@
"body": ["val ${1:target} = ${2:source}.mapTo<${3:Target}>()"], "body": ["val ${1:target} = ${2:source}.mapTo<${3:Target}>()"],
"description": "Recursively map compatible classes or enums" "description": "Recursively map compatible classes or enums"
}, },
"Go Block": { "Nullable Safe Access": {
"prefix": "go", "prefix": "safe",
"body": ["${1:value}?.${2:field}"],
"description": "Safely access a nullable value"
},
"Non-null Assertion": {
"prefix": "nonnull",
"body": ["${1:value}!!"],
"description": "Assert that a nullable value is non-null"
},
"Result Function": {
"prefix": "resultfun",
"body": [ "body": [
"go {", "fun ${1:name}(${2}): Result<${3:Value}, Error> {",
" $0", " val ${4:value} = ${5:operation}()?",
" return Result::Ok(${4:value})",
"}" "}"
], ],
"description": "Run a block concurrently" "description": "Function with Rust-style Result propagation"
},
"Result Match": {
"prefix": "resultmatch",
"body": [
"match (${1:result}) {",
" Result::Ok(${2:value}) -> { $3 }",
" Result::Err(${4:error}) -> { $0 }",
"}"
],
"description": "Match a Result value"
},
"Coroutine Scope": {
"prefix": "coroutinescope",
"body": ["coroutineScope {", " $0", "}"],
"description": "Structured child coroutine scope"
},
"Launch Coroutine": {
"prefix": "launch",
"body": ["launch {", " $0", "}"],
"description": "Launch a structured child coroutine"
},
"Async Coroutine": {
"prefix": "async",
"body": ["val ${1:result} = async<${2:Type}> {", " $0", "}"],
"description": "Start a typed deferred coroutine"
}, },
"Defer Call": { "Defer Call": {
"prefix": "defer", "prefix": "defer",
@ -124,7 +147,7 @@
"Typed SQL Fetch": { "Typed SQL Fetch": {
"prefix": "sqlfetch", "prefix": "sqlfetch",
"body": [ "body": [
"val ${1:rows}: List<*${2:Row}> = sql.from<${2:Row}>()", "val ${1:rows}: List<${2:Row}> = sql.from<${2:Row}>()",
" .where { ${3:it.id == id} }", " .where { ${3:it.id == id} }",
" .fetch(${4:pool}, ${5:ctx})" " .fetch(${4:pool}, ${5:ctx})"
], ],
@ -133,7 +156,7 @@
"Typed SQL Single": { "Typed SQL Single": {
"prefix": "sqlsingle", "prefix": "sqlsingle",
"body": [ "body": [
"val ${1:row}: *${2:Row} = sql.from<${2:Row}>()", "val ${1:row}: ${2:Row} = sql.from<${2:Row}>()",
" .where { ${3:it.id == id} }", " .where { ${3:it.id == id} }",
" .single(${4:pool}, ${5:ctx})" " .single(${4:pool}, ${5:ctx})"
], ],

View file

@ -142,14 +142,6 @@
"2": { "name": "entity.name.type.interface.gotlin" } "2": { "name": "entity.name.type.interface.gotlin" }
} }
}, },
{
"name": "meta.worker.gotlin",
"match": "\\b(worker)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.worker.gotlin" },
"2": { "name": "entity.name.type.worker.gotlin" }
}
},
{ {
"name": "meta.enum.gotlin", "name": "meta.enum.gotlin",
"match": "\\b(enum)\\s+([A-Za-z_][A-Za-z0-9_]*)", "match": "\\b(enum)\\s+([A-Za-z_][A-Za-z0-9_]*)",
@ -262,7 +254,7 @@
"patterns": [ "patterns": [
{ {
"name": "keyword.control.declaration.gotlin", "name": "keyword.control.declaration.gotlin",
"match": "\\b(package|import|data|class|interface|worker|enum|fun)\\b" "match": "\\b(package|import|data|class|interface|enum|suspend|fun)\\b"
}, },
{ {
"name": "storage.modifier.gotlin", "name": "storage.modifier.gotlin",
@ -282,7 +274,7 @@
}, },
{ {
"name": "keyword.control.concurrency.gotlin", "name": "keyword.control.concurrency.gotlin",
"match": "\\b(select|go|defer)\\b" "match": "\\b(select|defer|runBlocking|coroutineScope|launch|async|await|delay|withTimeout|isActive)\\b"
}, },
{ {
"name": "keyword.control.exception.gotlin", "name": "keyword.control.exception.gotlin",
@ -298,7 +290,7 @@
"patterns": [ "patterns": [
{ {
"name": "support.type.builtin.gotlin", "name": "support.type.builtin.gotlin",
"match": "\\b(Int|String|Boolean|Unit|Double|Float|Any|ByteSlice|List|MutableList|Map|MutableMap|Channel|GotlinSQLQuery|GotlinSQLIterator)\\b" "match": "\\b(Int|Long|String|Boolean|Unit|Double|Float|Any|Error|Result|ByteSlice|List|MutableList|Map|MutableMap|Channel|GotlinSQLQuery|GotlinSQLIterator)\\b"
}, },
{ {
"name": "support.type.qualified.gotlin", "name": "support.type.qualified.gotlin",
@ -324,6 +316,14 @@
}, },
"typeOperators": { "typeOperators": {
"patterns": [ "patterns": [
{
"name": "keyword.operator.nullsafe.gotlin",
"match": "\\?\\.|!!"
},
{
"name": "keyword.operator.error-propagation.gotlin",
"match": "(?<=[a-z0-9_)\\]])\\?(?!\\.)"
},
{ {
"name": "keyword.operator.type.nullable.gotlin", "name": "keyword.operator.type.nullable.gotlin",
"match": "(?<=[A-Za-z0-9_>])\\?" "match": "(?<=[A-Za-z0-9_>])\\?"