Expand Gotlin language and tooling

This commit is contained in:
pavel 2026-08-27 01:46:57 +02:00
commit de1262b4cf
41 changed files with 6059 additions and 379 deletions

3
.gitignore vendored
View file

@ -2,3 +2,6 @@ node_modules
.gocache
bin
out
/gotlinc
/gotlin-lsp
*.vsix

312
README.md
View file

@ -14,13 +14,17 @@ This is the practical boundary of the prototype:
- `val` and `var`
- `Int`, `String`, `Boolean`, `Unit`
- function types like `(String) -> Unit`
- `if`, `else`, `while`
- `if`, `else`, `while`, and `for (item in items)`
- function calls
- lambdas like `{ x: Int -> println(x) }` and `{ println(it) }`
- `class` with primary-constructor fields and methods
- `interface` with method signatures
- Rust-style algebraic `enum` declarations with payload variants and exhaustive `match`
- `println(...)`
- arithmetic, comparison, and boolean operators
- decimal literals, `defer`, and Go address-of expressions such as `&value`
- 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`
## Example
@ -91,6 +95,85 @@ fun main() {
}
```
Rust-style enums:
```kotlin
enum PaymentResult {
Accepted(String)
Rejected(String)
Pending
}
fun describe(result: PaymentResult): String {
var description = ""
match (result) {
PaymentResult::Accepted(id) -> { description = id }
PaymentResult::Rejected(reason) -> { description = reason }
PaymentResult::Pending -> { description = "pending" }
}
return description
}
```
Enum matches must contain each variant exactly once. Variant payload arity is
checked during Gotlin compilation.
Enums whose variants carry no payload are represented as string-backed values.
The exact variant identifier is used for JSON and PostgreSQL text values:
```kotlin
enum PaymentStatus {
PENDING_RESERVATION
INITIATED
}
```
This reads and writes `"PENDING_RESERVATION"` and `"INITIATED"` directly.
Payload-carrying enums remain algebraic sum types.
## Structural mapping
Compatible classes and enums can be converted with `mapTo<T>()`:
```kotlin
data class AddressEntity(var city: String)
data class AddressResponse(var city: String)
data class AccountEntity(var id: String, var address: *AddressEntity)
data class AccountResponse(var address: *AddressResponse, var id: String)
val response = account.mapTo<AccountResponse>()
```
When the surrounding expression provides a target type, the type argument is
optional:
```kotlin
fun response(account: *AccountEntity): *AccountResponse {
return account.mapTo()
}
val response: *AccountResponse = account.mapTo()
val envelope = Envelope(account.mapTo())
```
Use explicit `mapTo<T>()` when assigning to an untyped local or when no target
type can be inferred.
Fields are matched by Gotlin name rather than declaration order. Mapping is
recursive across nested classes, pointers, nullable values, lists, mutable
lists, maps, and enum payloads. Enum variants are matched by name. Extra source
fields and extra target enum variants are allowed; every target field and every
source enum variant must be compatible.
Payloadless enums also map recursively to and from `String`. String-to-enum
mapping validates the runtime value and panics for an unknown variant string.
Incompatible mappings fail compilation with a complete path, for example:
```text
cannot map Account.address.zip: String is incompatible with Int
```
```bash
go run ./cmd/gotlinc build ./examples/hello.gt
./hello
@ -109,6 +192,231 @@ Run directly:
go run ./cmd/gotlinc run ./examples/hello.gt
```
## 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`.
```kotlin
import time
@table("accounts")
data class AccountRow(
@generated @id var id: String,
var customerId: String,
@column("kind") var accountType: String,
var balance: Double,
var closedAt: time.Time?
)
fun accountsFor(customerId: String): GotlinSQLQuery {
return sql.from<AccountRow>()
.where { row -> row.customerId == customerId && row.closedAt == null }
.orderByDescending { row -> row.balance }
.limit(100)
.build()
}
```
The compiler emits a Go value with this generated support type:
```go
type GotlinSQLQuery struct {
SQL string
Args []any
}
```
The example selects every field, including `@generated` fields, and produces PostgreSQL `$n` placeholders. Arguments are emitted in SQL traversal order. A literal `limit(100)` is embedded after validation; a typed non-literal `Int` limit uses the next placeholder.
Nullable types use a `?` suffix, currently including forms such as `String?` and `time.Time?`; generated Go fields use pointers. Comparing a nullable field with `null` lowers to `IS NULL` or `IS NOT NULL`. Ordering comparisons support numeric values and `time.Time`; typed `now()` emits `CURRENT_TIMESTAMP` without an argument:
```kotlin
sql.from<OutboxRow>()
.where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) }
.orderBy { it.createdAt }
.limit(batchSize)
.forUpdate()
.skipLocked()
.build()
```
The canonical select method order is:
```text
sql.from<Row>()
[.select { ... }]
[.where { ... }]
[.orderBy { ... } | .orderByDescending { ... }]
[.limit(Int)]
[.forUpdate()]
[.skipLocked()]
.build() | .fetch(pool, ctx) | .single(pool, ctx) | .iterator(pool, ctx)
```
Each optional method may occur at most once. `select` must be first, `skipLocked` requires `forUpdate`, and clauses are emitted as `WHERE`, `ORDER BY`, `LIMIT`, `FOR UPDATE`, `SKIP LOCKED` in PostgreSQL order.
### Typed projections
Projection targets are local data classes. The constructor must contain one direct source-row field per target field, in target declaration order, with exact matching types:
```kotlin
data class AccountSummary(var id: String, var balance: Double)
fun summaries(pool: *pgxpool.Pool, ctx: context.Context): List<*AccountSummary> {
return sql.from<AccountRow>()
.select { row -> AccountSummary(row.id, row.balance) }
.orderBy { it.balance }
.fetch(pool, ctx)
}
```
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:
```kotlin
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun accountsFor(
pool: *pgxpool.Pool,
ctx: context.Context,
customerId: String
): List<*AccountRow> {
return sql.from<AccountRow>()
.where { it.customerId == customerId }
.orderBy { it.accountType }
.fetch(pool, ctx)
}
```
`single(pool, ctx)` returns `*AccountRow`. It closes the rows and panics through Gotlin's auto-throw path unless the query produces exactly one row:
```kotlin
fun account(pool: *pgxpool.Pool, ctx: context.Context, id: String): *AccountRow {
return sql.from<AccountRow>()
.where { it.id == id }
.single(pool, ctx)
}
```
`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:
```kotlin
fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
val rows = sql.from<AccountRow>().iterator(pool, ctx)
defer rows.close()
while (rows.next()) {
val account: *AccountRow = rows.value()
println(account.customerId)
}
val checked = rows.err()
}
```
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.
### Inserts and returning
Inserts omit every `@generated` field. If all fields are generated, the compiler emits `INSERT ... DEFAULT VALUES`. Conflict handling is optional; conflict fields must have `@id`:
```kotlin
sql.insert<AccountRow>(row)
.onConflict { it.id }
.doNothing()
.build()
```
Composite conflict keys use `listOf`, for example `.onConflict { listOf(it.tenantId, it.id) }`.
Updates use a typed lower-camel `set` form because Gotlin does not currently implement Kotlin callable references (`AccountRow::balance`):
```kotlin
sql.insert<AccountRow>(row)
.onConflict { it.id }
.doUpdate { excluded -> set(AccountRow.balance, excluded.balance) }
.build()
```
Multiple `set(...)` expressions may appear in one `doUpdate` lambda. Update targets must exist and be `var`; excluded fields must exist and have a compatible type.
An insert can return the full row or a typed projection. Write chains can use `fetch`, `single`, or `iterator` only after `returning`:
```kotlin
sql.insert<AccountRow>(row)
.returning { it }
.single(pool, ctx)
sql.insert<AccountRow>(row)
.onConflict { it.id }
.doNothing()
.returning { value -> AccountSummary(value.id, value.balance) }
.single(pool, ctx)
```
The exact insert forms are:
```text
sql.insert<Row>(row)
[.onConflict { field | listOf(fields...) }.doNothing() | .doUpdate { ... }]
[.returning { it | Projection(it.field, ...) }]
.build() | returning execution terminal
```
### Updates and deletes
Typed updates use one `set` lambda. Targets are mutable source-row fields. Values may be typed names/selectors, literals, `null` for nullable targets, `now()` for timestamps, source-row fields, or numeric `+` and `-` expressions:
```kotlin
sql.update<AccountRow>()
.set { row ->
set(row.balance, row.balance + amount)
set(row.closedAt, now())
}
.where { it.id == accountId }
.returning { row -> AccountSummary(row.id, row.balance) }
.single(pool, ctx)
```
Deletes support a typed predicate and the same returning forms:
```kotlin
sql.delete<AccountRow>()
.where { it.id == accountId }
.returning { it }
.single(pool, ctx)
```
The exact write forms are:
```text
sql.update<Row>()
.set { row -> set(row.field, value); ... }
[.where { predicate }]
[.returning { it | Projection(it.field, ...) }]
.build() | returning execution terminal
sql.delete<Row>()
[.where { predicate }]
[.returning { it | Projection(it.field, ...) }]
.build() | returning execution terminal
```
`where` is intentionally optional for updates and deletes, so omitting it affects the whole table. There is no write `execute` terminal yet; use `build()` for manual `Exec`, or add `returning` and use a query execution terminal.
At compile time the DSL checks that the generic row type exists, is a data class with `@table`, has unique mapped columns, and contains every referenced field. It checks known predicate operand types, nullable operations, insert row types, `@id` conflict metadata, update mutability and value compatibility, method cardinality/order, lock dependencies, and projection field counts/types.
Current SQL limitations:
- Predicates support `&&`, `||`, `!`, `==`, `!=`, and numeric/timestamp `<`, `<=`, `>`, `>=`. Values must have a compiler-known type; external values become `$n` arguments.
- Projections and `returning` do not support scalar results, aliases, computed expressions, aggregates, external structs, or reordered/coerced target types. They accept a full row or direct fields passed to a local data-class constructor.
- Standalone update values do not support SQL functions other than `now()`, string expressions, intervals, casts, subqueries, or arbitrary SQL fragments. Upsert `doUpdate` values still come only from the `excluded` row.
- Inserts emit one row at a time. Bulk/multi-row inserts are not implemented.
- Table and column annotations are validated unquoted SQL identifiers. Joins, aliases, grouping, aggregates, `OFFSET`, lock strengths other than `FOR UPDATE`, and conflict predicates are not implemented.
- The compiler does not perform migrations, schema generation, database connections, or schema introspection.
Language server:
```bash
@ -126,7 +434,7 @@ npm run build
## Notes
- `val` and `var` currently compile to the same local-variable semantics in Go.
- `val` is immutable after initialization; `var` can be reassigned.
- Type inference is local to declarations without an explicit type.
- Top-level declarations currently support functions, classes, and interfaces.
- `gotlinc build` produces an executable by default. If `-o` is omitted, the output name is derived from the input file name.

View file

@ -11,6 +11,7 @@ import (
"go/parser"
"go/token"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
@ -35,6 +36,7 @@ const (
symbolKindVariable = 13
symbolKindFunction = 12
symbolKindInterface = 11
symbolKindEnum = 10
symbolKindModule = 2
)
@ -62,10 +64,11 @@ type server struct {
}
type documentState struct {
text string
program *lang.Program
diagnostics []diagnostic
symbols []symbol
text string
program *lang.Program
diagnostics []diagnostic
symbols []symbol
packageSymbols []symbol
}
type symbol struct {
@ -74,6 +77,7 @@ type symbol struct {
Detail string
Range rng
Targets []string
URI string
}
type variableDeclInfo struct {
@ -238,7 +242,7 @@ func (s *server) handle(req request) error {
if err := json.Unmarshal(req.Params, &params); err != nil {
return err
}
s.docs[params.TextDocument.URI] = buildDocumentState(params.TextDocument.Text)
s.docs[params.TextDocument.URI] = s.buildDocumentState(params.TextDocument.URI, params.TextDocument.Text)
return s.publishDiagnostics(params.TextDocument.URI)
case "textDocument/didChange":
var params didChangeParams
@ -249,7 +253,7 @@ func (s *server) handle(req request) error {
if len(params.ContentChanges) > 0 {
text = params.ContentChanges[len(params.ContentChanges)-1].Text
}
s.docs[params.TextDocument.URI] = buildDocumentState(text)
s.docs[params.TextDocument.URI] = s.buildDocumentState(params.TextDocument.URI, text)
return s.publishDiagnostics(params.TextDocument.URI)
case "textDocument/didClose":
var params didCloseParams
@ -310,6 +314,89 @@ func buildDocumentState(text string) documentState {
return state
}
func (s *server) buildDocumentState(uri, text string) documentState {
state := documentState{text: text, diagnostics: []diagnostic{}}
program, err := lang.Parse(text)
if err != nil {
state.diagnostics = []diagnostic{diagnosticFromError(text, err)}
return state
}
state.program = program
state.symbols = indexSymbols(text, program)
programs, symbols := s.packageContext(uri, program.PackagePath, text)
state.packageSymbols = symbols
state.diagnostics = semanticDiagnosticsWithPackage(text, program, state.symbols, programs)
return state
}
func (s *server) packageContext(currentURI, packagePath, currentText string) ([]*lang.Program, []symbol) {
path, ok := filePathFromURI(currentURI)
if !ok {
return nil, nil
}
root := nearestModuleRoot(filepath.Dir(path))
if root == "" {
root = filepath.Dir(path)
}
var programs []*lang.Program
var symbols []symbol
_ = filepath.WalkDir(root, func(candidate string, entry os.DirEntry, err error) error {
if err != nil || entry.IsDir() || filepath.Ext(candidate) != ".gt" {
return nil
}
candidateURI := fileURI(candidate)
text := ""
if candidateURI == currentURI {
text = currentText
} else if open, found := s.docs[candidateURI]; found {
text = open.text
} else if data, readErr := os.ReadFile(candidate); readErr == nil {
text = string(data)
}
if text == "" {
return nil
}
program, parseErr := lang.Parse(text)
if parseErr != nil || program.PackagePath != packagePath {
return nil
}
programs = append(programs, program)
for _, sym := range indexSymbols(text, program) {
sym.URI = candidateURI
symbols = append(symbols, sym)
}
return nil
})
return programs, symbols
}
func nearestModuleRoot(directory string) string {
for {
if _, err := os.Stat(filepath.Join(directory, "go.mod")); err == nil {
return directory
}
parent := filepath.Dir(directory)
if parent == directory {
return ""
}
directory = parent
}
}
func filePathFromURI(uri string) (string, bool) {
parsed, err := url.Parse(uri)
if err != nil || parsed.Scheme != "file" {
return "", false
}
path, err := url.PathUnescape(parsed.Path)
if err != nil {
return "", false
}
return filepath.Clean(filepath.FromSlash(path)), true
}
func fileURI(path string) string { return "file://" + filepath.ToSlash(filepath.Clean(path)) }
func (s *server) publishDiagnostics(uri string) error {
state, ok := s.docs[uri]
if !ok {
@ -355,6 +442,11 @@ func (s *server) hover(uri string, pos position) any {
},
}
}
for _, sym := range state.packageSymbols {
if sym.Name == word {
return map[string]any{"contents": map[string]any{"kind": "markdown", "value": "```gotlin\n" + sym.Detail + "\n```"}}
}
}
if value, ok := builtinHoverDetail(word); ok {
return map[string]any{
"contents": map[string]any{
@ -386,6 +478,11 @@ func (s *server) definition(uri string, pos position) any {
return []location{{URI: uri, Range: sym.Range}}
}
}
for _, sym := range state.packageSymbols {
if sym.Name == word && sym.URI != "" {
return []location{{URI: sym.URI, Range: sym.Range}}
}
}
if result := s.goplsDefinition(state, pos); result != nil {
return result
}
@ -541,7 +638,19 @@ func indexSymbols(text string, program *lang.Program) []symbol {
}
}
classRanges := findAllLineMatches(lines, regexp.MustCompile(`^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)`))
enumRanges := findAllLineMatches(lines, regexp.MustCompile(`^\s*enum\s+([A-Za-z_][A-Za-z0-9_]*)`))
for i, decl := range program.Enums {
r := rng{}
if i < len(enumRanges) {
r = enumRanges[i]
}
symbols = append(symbols, symbol{Name: decl.Name, Kind: symbolKindEnum, Detail: "enum " + decl.Name, Range: r})
for _, variant := range decl.Variants {
symbols = append(symbols, symbol{Name: variant.Name, Kind: symbolKindVariable, Detail: decl.Name + "::" + variant.Name, Range: r, Targets: []string{decl.Name}})
}
}
classRanges := findAllLineMatches(lines, regexp.MustCompile(`^\s*(?:data\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)`))
for i, decl := range program.Classes {
r := rng{}
if i < len(classRanges) {
@ -650,6 +759,10 @@ func indexSymbols(text string, program *lang.Program) []symbol {
}
func semanticDiagnostics(text string, program *lang.Program, symbols []symbol) []diagnostic {
return semanticDiagnosticsWithPackage(text, program, symbols, nil)
}
func semanticDiagnosticsWithPackage(text string, program *lang.Program, symbols []symbol, packagePrograms []*lang.Program) []diagnostic {
var diagnostics []diagnostic
funcSymbols := map[string]symbol{}
@ -671,7 +784,7 @@ func semanticDiagnostics(text string, program *lang.Program, symbols []symbol) [
} else {
importSymbols[sym.Name] = sym
}
case symbolKindClass, symbolKindInterface:
case symbolKindClass, symbolKindInterface, symbolKindEnum:
if prev, ok := typeSymbols[sym.Name]; ok {
diagnostics = append(diagnostics, duplicateDiagnostic(sym.Range, "duplicate type "+sym.Name))
diagnostics = append(diagnostics, duplicateDiagnostic(prev.Range, "duplicate type "+sym.Name))
@ -699,6 +812,26 @@ func semanticDiagnostics(text string, program *lang.Program, symbols []symbol) [
for _, decl := range program.Workers {
types[decl.Name] = true
}
for _, decl := range program.Enums {
types[decl.Name] = true
}
for _, sibling := range packagePrograms {
for _, fn := range sibling.Functions {
functions[fn.Name] = fn
}
for _, decl := range sibling.Interfaces {
types[decl.Name] = true
}
for _, decl := range sibling.Classes {
types[decl.Name] = true
}
for _, decl := range sibling.Workers {
types[decl.Name] = true
}
for _, decl := range sibling.Enums {
types[decl.Name] = true
}
}
for _, fn := range program.Functions {
diagnostics = append(diagnostics, functionSemanticDiagnostics(text, fn, functions, imports, types, nil)...)
}
@ -782,6 +915,8 @@ func functionSemanticDiagnostics(text string, fn lang.FunctionDecl, functions ma
walkExpr(s.Value, scope)
case lang.GoStmt:
walkExpr(s.Value, scope)
case lang.DeferStmt:
walkExpr(s.Value, scope)
case lang.ExprStmt:
walkExpr(s.Value, scope)
case lang.IfStmt:
@ -794,6 +929,11 @@ func functionSemanticDiagnostics(text string, fn lang.FunctionDecl, functions ma
walkExpr(s.Cond, scope)
bodyScope := copyScope(scope)
walkStmts(s.Body, bodyScope)
case lang.ForEachStmt:
walkExpr(s.Source, scope)
bodyScope := copyScope(scope)
bodyScope[s.Name] = true
walkStmts(s.Body, bodyScope)
case lang.SelectStmt:
for _, c := range s.Cases {
walkExpr(c.Source, scope)
@ -801,6 +941,15 @@ func functionSemanticDiagnostics(text string, fn lang.FunctionDecl, functions ma
caseScope["it"] = true
walkStmts(c.Body, caseScope)
}
case lang.MatchStmt:
walkExpr(s.Value, scope)
for _, matchCase := range s.Cases {
caseScope := copyScope(scope)
for _, binding := range matchCase.Bindings {
caseScope[binding] = true
}
walkStmts(matchCase.Body, caseScope)
}
case lang.TryCatchStmt:
tryScope := copyScope(scope)
walkStmts(s.TryBody, tryScope)
@ -823,32 +972,39 @@ func functionSemanticDiagnostics(text string, fn lang.FunctionDecl, functions ma
case lang.BinaryExpr:
walkExpr(e.Left, scope)
walkExpr(e.Right, scope)
case lang.CallExpr:
if ident, ok := e.Callee.(lang.IdentExpr); ok {
switch ident.Name {
case "after", "every":
if len(e.Args) != 1 {
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, ident.Name, ident.Name+"(ms) expects exactly one Int argument"))
case lang.CallExpr:
if ident, ok := e.Callee.(lang.IdentExpr); ok {
switch ident.Name {
case "after", "every":
if len(e.Args) != 1 {
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, ident.Name, ident.Name+"(ms) expects exactly one Int argument"))
}
if len(e.Args) == 1 {
switch e.Args[0].(type) {
case lang.StringExpr, lang.BoolExpr, lang.NullExpr:
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, ident.Name, ident.Name+"(ms) expects an Int argument"))
}
if len(e.Args) == 1 {
switch e.Args[0].(type) {
case lang.StringExpr, lang.BoolExpr, lang.NullExpr:
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, ident.Name, ident.Name+"(ms) expects an Int argument"))
}
}
if ident.Name == "every" && len(e.Args) == 1 {
if value, ok := staticIntExprValue(e.Args[0]); ok && value <= 0 {
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, ident.Name, "every(ms) requires ms > 0"))
}
}
if ident.Name == "every" && len(e.Args) == 1 {
if value, ok := staticIntExprValue(e.Args[0]); ok && value <= 0 {
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, ident.Name, "every(ms) requires ms > 0"))
}
}
}
walkExpr(e.Callee, scope)
for _, arg := range e.Args {
walkExpr(arg, scope)
}
}
walkExpr(e.Callee, scope)
for _, arg := range e.Args {
walkExpr(arg, scope)
}
case lang.SelectorExpr:
walkExpr(e.Receiver, scope)
case lang.IndexExpr:
walkExpr(e.Receiver, scope)
walkExpr(e.Index, scope)
case lang.EnumVariantExpr:
for _, value := range e.Values {
walkExpr(value, scope)
}
case lang.LambdaExpr:
lambdaScope := copyScope(scope)
if e.ImplicitIt {
@ -2026,23 +2182,43 @@ func copyScope(scope map[string]bool) map[string]bool {
}
func isBuiltin(name string) bool {
switch name {
case "println", "runCatching", "Channel", "after", "every", "listOf", "mutableListOf", "mapOf", "mutableMapOf":
return true
default:
return false
}
_, ok := builtinDetails[name]
return ok
}
func builtinHoverDetail(name string) (string, bool) {
switch name {
case "after":
return "fun after(ms: Int): Channel<time.Time>", true
case "every":
return "fun every(ms: Int): Channel<time.Time>", true
default:
return "", false
}
value, ok := builtinDetails[name]
return value, ok
}
var builtinDetails = map[string]string{
"println": "fun println(value: Any): Unit",
"runCatching": "fun runCatching(block: () -> Unit): Result",
"Channel": "fun Channel<T>(capacity: Int = 0): Channel<T>",
"after": "fun after(ms: Int): Channel<time.Time>",
"every": "fun every(ms: Int): Channel<time.Time>",
"listOf": "fun listOf<T>(values: T...): List<T>",
"mutableListOf": "fun mutableListOf<T>(values: T...): MutableList<T>",
"mapOf": "fun mapOf<K, V>(pairs: Any...): Map<K, V>",
"mutableMapOf": "fun mutableMapOf<K, V>(pairs: Any...): MutableMap<K, V>",
"ByteSlice": "fun ByteSlice(value: String): ByteSlice",
"append": "fun append<T>(values: List<T>, value: T): List<T>",
"len": "fun len(value: Any): Int",
"cap": "fun cap(value: Any): Int",
"make": "fun make<T>(size: Int): T",
"new": "fun new<T>(): *T",
"copy": "fun copy(target: Any, source: Any): Int",
"delete": "fun delete(map: Any, key: Any): Unit",
"close": "fun close(channel: Any): Unit",
"panic": "fun panic(value: Any): Unit",
"recover": "fun recover(): Any",
"string": "fun string(value: Any): String",
"int": "fun int(value: Any): Int",
"float64": "fun float64(value: Any): Double",
"bool": "fun bool(value: Any): Boolean",
"sql": "typed PostgreSQL query DSL",
"set": "fun set(target: Any, value: Any): Unit",
"now": "fun now(): time.Time",
}
func contains(values []string, needle string) bool {

View file

@ -1,12 +1,104 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"gotlin/internal/lang"
)
func TestPackageWideDiagnosticsAndDefinition(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n\ngo 1.25\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "model"), 0o755); err != nil {
t.Fatal(err)
}
modelPath := filepath.Join(root, "model", "command.gt")
modelText := "package main\n\ndata class CreateAccountCommand(var name: String)\n"
if err := os.WriteFile(modelPath, []byte(modelText), 0o644); err != nil {
t.Fatal(err)
}
mainPath := filepath.Join(root, "main.gt")
mainText := "package main\n\nfun main() {\n val command = CreateAccountCommand(\"demo\")\n println(command)\n}\n"
if err := os.WriteFile(mainPath, []byte(mainText), 0o644); err != nil {
t.Fatal(err)
}
uri := fileURI(mainPath)
s := server{docs: map[string]documentState{}}
state := s.buildDocumentState(uri, mainText)
for _, d := range state.diagnostics {
if strings.Contains(d.Message, "undefined identifier CreateAccountCommand") {
t.Fatalf("unexpected cross-file diagnostic: %+v", d)
}
}
s.docs[uri] = state
character := strings.Index(strings.Split(mainText, "\n")[3], "CreateAccountCommand")
result, ok := s.definition(uri, position{Line: 3, Character: character + 2}).([]location)
if !ok || len(result) != 1 {
t.Fatalf("expected cross-file definition, got %#v", s.definition(uri, position{Line: 3, Character: character + 2}))
}
if result[0].URI != fileURI(modelPath) {
t.Fatalf("definition URI = %s, want %s", result[0].URI, fileURI(modelPath))
}
}
func TestPackageIndexStopsAtNearestGoModule(t *testing.T) {
root := t.TempDir()
first := filepath.Join(root, "first")
second := filepath.Join(root, "second")
for _, dir := range []string{first, second} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example\n\ngo 1.25\n"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(second, "other.gt"), []byte("package main\ndata class OtherRootType(var id: String)\n"), 0o644); err != nil {
t.Fatal(err)
}
mainPath := filepath.Join(first, "main.gt")
mainText := "package main\nfun main() { OtherRootType(\"x\") }\n"
if err := os.WriteFile(mainPath, []byte(mainText), 0o644); err != nil {
t.Fatal(err)
}
s := server{docs: map[string]documentState{}}
state := s.buildDocumentState(fileURI(mainPath), mainText)
found := false
for _, d := range state.diagnostics {
if strings.Contains(d.Message, "undefined identifier OtherRootType") {
found = true
}
}
if !found {
t.Fatalf("expected module-isolated undefined diagnostic, got %+v", state.diagnostics)
}
}
func TestCurrentBuiltinsDoNotProduceUndefinedDiagnostics(t *testing.T) {
state := buildDocumentState(`package demo
fun main() {
val bytes = ByteSlice("hello")
var values: MutableList<String> = mutableListOf<String>()
values = append(values, string(bytes))
println(len(values))
}`)
for _, diagnostic := range state.diagnostics {
if strings.Contains(diagnostic.Message, "undefined identifier") {
t.Fatalf("unexpected builtin diagnostic: %+v", diagnostic)
}
}
for _, name := range []string{"ByteSlice", "append", "len", "sql", "set", "now"} {
if !isBuiltin(name) {
t.Fatalf("%s is not registered as builtin", name)
}
}
}
func TestResolveStdlibTarget(t *testing.T) {
text := strings.TrimSpace(`
package examples.http
@ -668,7 +760,7 @@ func TestBuildDocumentStateWorkerSemantics(t *testing.T) {
package demo
worker Counter {
val counter = 0
var counter = 0
fun getCount(): Int {
return counter

View file

@ -42,13 +42,13 @@ func runBuild(args []string) {
if err := fs.Parse(normalizedArgs); err != nil {
fail(err)
}
if fs.NArg() != 1 {
if fs.NArg() < 1 {
fs.Usage()
os.Exit(2)
}
inputPath := fs.Arg(0)
goSrc := compileFile(inputPath, !*srcOnly)
goSrc := compileFiles(fs.Args(), !*srcOnly)
if *srcOnly {
if *outPath == "" {
@ -113,17 +113,42 @@ func runRun(args []string) {
}
func compileFile(inputPath string, forceMain bool) string {
src, err := os.ReadFile(inputPath)
if err != nil {
fail(err)
}
return compileFiles([]string{inputPath}, forceMain)
}
program, err := lang.Parse(string(src))
if err != nil {
fail(err)
}
func compileFiles(inputPaths []string, forceMain bool) string {
program := &lang.Program{}
var firstSource string
for _, inputPath := range inputPaths {
src, err := os.ReadFile(inputPath)
if err != nil {
fail(err)
}
parsed, err := lang.Parse(string(src))
if err != nil {
fail(err)
}
if program.PackagePath == "" {
program.PackagePath = parsed.PackagePath
}
if parsed.PackagePath != program.PackagePath {
fail(fmt.Errorf("Gotlin files must use the same package"))
}
program.Imports = append(program.Imports, parsed.Imports...)
program.Interfaces = append(program.Interfaces, parsed.Interfaces...)
program.Enums = append(program.Enums, parsed.Enums...)
program.Classes = append(program.Classes, parsed.Classes...)
program.Workers = append(program.Workers, parsed.Workers...)
program.Functions = append(program.Functions, parsed.Functions...)
program.Embeds = append(program.Embeds, parsed.Embeds...)
if firstSource == "" {
firstSource = string(src)
}
}
var goSrc []byte
var err error
if forceMain {
goSrc, err = lang.GenerateGoMain(program)
} else {
@ -133,7 +158,7 @@ func compileFile(inputPath string, forceMain bool) string {
fail(err)
}
if forceMain {
return addBestEffortLineDirectives(string(goSrc), inputPath, string(src))
return addBestEffortLineDirectives(string(goSrc), inputPaths[0], firstSource)
}
return string(goSrc)
}

22
examples/enums.gt Normal file
View file

@ -0,0 +1,22 @@
package main
enum PaymentResult {
Accepted(String)
Rejected(String)
Pending
}
fun describe(result: PaymentResult): String {
var description = ""
match (result) {
PaymentResult::Accepted(id) -> { description = "accepted " + id }
PaymentResult::Rejected(reason) -> { description = "rejected " + reason }
PaymentResult::Pending -> { description = "pending" }
}
return description
}
fun main() {
val result = PaymentResult::Accepted("payment-1")
println(describe(result))
}

View file

@ -37,7 +37,7 @@ class EpicControllerImpl(val db: *bun.DB) {
}
worker Counter {
val counter = 0
var counter = 0
fun getCount(): Int {
return counter

40
examples/mapping.gt Normal file
View file

@ -0,0 +1,40 @@
package main
data class AddressEntity(var city: String)
data class AddressResponse(var city: String)
enum EntityState {
Active
Failed(String)
}
enum ResponseState {
Active
Failed(String)
Pending
}
data class AccountEntity(
var id: String,
var address: *AddressEntity,
var labels: List<String>,
var state: EntityState
)
data class AccountResponse(
var address: *AddressResponse,
var id: String,
var labels: List<String>,
var state: ResponseState
)
fun main() {
val entity = AccountEntity("account-1", AddressEntity("Berlin"), listOf("active"), EntityState::Active)
val response = entity.mapTo<AccountResponse>()
println(response.address.city)
match (response.state) {
ResponseState::Active -> { println("active") }
ResponseState::Failed(reason) -> { println(reason) }
ResponseState::Pending -> { println("pending") }
}
}

View file

@ -14,7 +14,7 @@ class PrefixGreeter(val prefix: String): Greeter {
}
worker Counter {
val count = 0
var count = 0
fun increment() {
count += 1

7
go.mod
View file

@ -2,8 +2,13 @@ module gotlin
go 1.25.6
require github.com/jackc/pgx/v5 v5.7.6
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/lib/pq v1.11.2 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
@ -16,6 +21,8 @@ require (
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
mellium.im/sasl v0.3.2 // indirect
)

19
go.sum
View file

@ -1,11 +1,24 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.2.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU=
@ -24,7 +37,13 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=

View file

@ -5,6 +5,6 @@ import (
"strings"
)
func main() {
func demoImports() {
fmt.Println(strings.ToUpper("gotlin"))
}

View file

@ -4,9 +4,24 @@ type Program struct {
PackagePath string
Imports []ImportDecl
Interfaces []InterfaceDecl
Enums []EnumDecl
Classes []ClassDecl
Workers []WorkerDecl
Functions []FunctionDecl
Embeds []EmbedDecl
}
type EmbedDecl struct{ Path, Name, Type string }
type EnumDecl struct {
Name string
Variants []EnumVariant
String bool
}
type EnumVariant struct {
Name string
PayloadTypes []string
StringValue string
}
type ImportDecl struct {
@ -20,10 +35,13 @@ type InterfaceDecl struct {
}
type ClassDecl struct {
Name string
Fields []FieldDecl
Parents []string
Methods []FunctionDecl
Name string
Data bool
JSONNaming string
Table string
Fields []FieldDecl
Parents []string
Methods []FunctionDecl
}
type WorkerDecl struct {
@ -40,9 +58,13 @@ type WorkerFieldDecl struct {
}
type FieldDecl struct {
Mutable bool
Name string
Type string
Mutable bool
Private bool
Name string
Type string
Column string
ID bool
Generated bool
}
type FunctionSignature struct {
@ -90,6 +112,7 @@ func (MultiVarDecl) stmtNode() {}
type AssignStmt struct {
Name string
Pos int
Value Expr
}
@ -97,14 +120,16 @@ func (AssignStmt) stmtNode() {}
type AddAssignStmt struct {
Name string
Pos int
Value Expr
}
func (AddAssignStmt) stmtNode() {}
type MultiAssignStmt struct {
Names []string
Value Expr
Names []string
Positions []int
Value Expr
}
func (MultiAssignStmt) stmtNode() {}
@ -127,6 +152,12 @@ type GoStmt struct {
func (GoStmt) stmtNode() {}
type DeferStmt struct {
Value Expr
}
func (DeferStmt) stmtNode() {}
type ExprStmt struct {
Value Expr
}
@ -148,6 +179,14 @@ type WhileStmt struct {
func (WhileStmt) stmtNode() {}
type ForEachStmt struct {
Name string
Source Expr
Body []Stmt
}
func (ForEachStmt) stmtNode() {}
type TryCatchStmt struct {
TryBody []Stmt
CatchName string
@ -163,6 +202,19 @@ type SelectStmt struct {
func (SelectStmt) stmtNode() {}
type MatchStmt struct {
Value Expr
Cases []MatchCase
}
func (MatchStmt) stmtNode() {}
type MatchCase struct {
EnumName, VariantName string
Bindings []string
Body []Stmt
}
type SelectCase struct {
Source Expr
Body []Stmt
@ -180,6 +232,12 @@ type IntExpr struct {
func (IntExpr) exprNode() {}
type FloatExpr struct {
Value string
}
func (FloatExpr) exprNode() {}
type StringExpr struct {
Value string
}
@ -212,9 +270,15 @@ type BinaryExpr struct {
func (BinaryExpr) exprNode() {}
type CallExpr struct {
Callee Expr
Args []Expr
TypeArgs []string
Callee Expr
Args []Expr
TypeArgs []string
NamedArgs []NamedArg
}
type NamedArg struct {
Name string
Value Expr
}
func (CallExpr) exprNode() {}
@ -226,6 +290,20 @@ type SelectorExpr struct {
func (SelectorExpr) exprNode() {}
type IndexExpr struct {
Receiver Expr
Index Expr
}
func (IndexExpr) exprNode() {}
type EnumVariantExpr struct {
EnumName, VariantName string
Values []Expr
}
func (EnumVariantExpr) exprNode() {}
type LambdaExpr struct {
Params []Param
ImplicitIt bool

View file

@ -867,6 +867,183 @@ worker Counter {
}
}
func TestGenerateGoRejectsValReassignment(t *testing.T) {
src := `
package demo
fun main() {
val count = 0
count = 1
}
`
prog, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
_, err = GenerateGo(prog)
if err == nil {
t.Fatal("expected val reassignment error")
}
if !strings.Contains(err.Error(), "cannot reassign immutable name count") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGenerateGoDefer(t *testing.T) {
src := `
package demo
import database.sql
fun main() {
val rows = sql.Open("postgres", "dsn")
defer rows.Close()
}
`
prog, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("go generation failed: %v", err)
}
if !strings.Contains(string(out), "defer rows.Close()") {
t.Fatalf("generated Go missing defer:\n%s", out)
}
}
func TestGenerateGoDecimalLiteral(t *testing.T) {
src := `
package demo
fun interest(): Double {
return 0.025
}
`
prog, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("go generation failed: %v", err)
}
for _, want := range []string{"func interest() float64", "return 0.025"} {
if !strings.Contains(string(out), want) {
t.Fatalf("generated Go missing %q:\n%s", want, out)
}
}
}
func TestGenerateGoRejectsMultiValReassignment(t *testing.T) {
src := `
package demo
import database.sql
fun main() {
val db, err = sql.Open("postgres", "postgres://localhost/postgres?sslmode=disable")
err = nil
}
`
prog, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
_, err = GenerateGo(prog)
if err == nil {
t.Fatal("expected multi-val reassignment error")
}
if !strings.Contains(err.Error(), "cannot reassign immutable name err") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGenerateGoAllowsVarReassignment(t *testing.T) {
src := `
package demo
fun main() {
var count = 0
count = 1
count += 2
println(count)
}
`
prog, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("go generation failed: %v", err)
}
code := string(out)
for _, want := range []string{
`count := 0`,
`count = 1`,
`count += 2`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestGenerateGoRejectsReassignmentToImmutableClassField(t *testing.T) {
src := `
package demo
class Counter(val count: Int, var total: Int) {
fun freeze() {
count = 1
}
}
`
prog, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
_, err = GenerateGo(prog)
if err == nil {
t.Fatal("expected immutable class field reassignment error")
}
if !strings.Contains(err.Error(), "cannot reassign immutable name count") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGenerateGoAllowsReassignmentToMutableClassField(t *testing.T) {
src := `
package demo
class Counter(var count: Int) {
fun inc() {
count += 1
}
}
`
prog, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("go generation failed: %v", err)
}
if !strings.Contains(string(out), `self.count += 1`) {
t.Fatalf("generated Go missing mutable field assignment:\n%s", string(out))
}
}
func TestGenerateGoWorkerSyncPanicPropagationScaffolding(t *testing.T) {
src := `
package demo

View file

@ -0,0 +1,72 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateGoDataClassUsesSnakeCaseJSONTags(t *testing.T) {
prog, err := Parse(`
package demo
data class KeycloakToken(var accessToken: String, var refreshToken: String)
`)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("generation failed: %v", err)
}
for _, want := range []string{"AccessToken", "`json:\"access_token\"`", "RefreshToken", "`json:\"refresh_token\"`"} {
if !strings.Contains(string(out), want) {
t.Fatalf("generated Go missing %q:\n%s", want, out)
}
}
}
func TestGenerateGoDataClassJSONNamingOverride(t *testing.T) {
prog, err := Parse(`
package demo
@jsonNaming(camelCase)
data class Account(var availableBalance: Double)
`)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("generation failed: %v", err)
}
if !strings.Contains(string(out), "`json:\"availableBalance\"`") {
t.Fatalf("generated Go missing camelCase tag:\n%s", out)
}
}
func TestGenerateGoDataClassFieldVisibilityAndSelector(t *testing.T) {
prog, err := Parse(`
package demo
import json encoding.json
data class Request(var email: String, private val traceId: String)
fun email(body: ByteSlice): String {
val request = json.decode<Request>(body)
return request.email
}
`)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("generation failed: %v", err)
}
for _, want := range []string{"Email", "`json:\"email\"`", "traceId string `json:\"-\"`", "return request.Email"} {
if !strings.Contains(string(out), want) {
t.Fatalf("generated Go missing %q:\n%s", want, out)
}
}
}

View file

@ -0,0 +1,79 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateRustStyleEnumAndExhaustiveMatch(t *testing.T) {
prog, err := Parse(`
package demo
enum PaymentResult { Accepted(String), Rejected(String), Pending }
fun describe(result: PaymentResult): String {
var description = ""
match (result) {
PaymentResult::Accepted(id) -> { description = id }
PaymentResult::Rejected(reason) -> { description = reason }
PaymentResult::Pending -> { description = "pending" }
}
return description
}
fun main() { println(describe(PaymentResult::Accepted("p1"))) }
`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"type PaymentResult interface", "type PaymentResultAccepted struct", "&PaymentResultAccepted{Value0: \"p1\"}", "case *PaymentResultRejected:", "reason := gotlinMatch1.Value0"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestRejectNonExhaustiveEnumMatch(t *testing.T) {
prog, err := Parse(`package demo
enum Result { Ok, Error(String) }
fun use(result: Result) { match (result) { Result::Ok -> { println("ok") } } }`)
if err != nil {
t.Fatal(err)
}
_, err = GenerateGo(prog)
if err == nil || !strings.Contains(err.Error(), "missing Error") {
t.Fatalf("expected exhaustive-match error, got %v", err)
}
}
func TestRejectWrongVariantPayloadCount(t *testing.T) {
prog, err := Parse(`package demo
enum Result { Ok(String) }
fun main() { val result = Result::Ok() }`)
if err != nil {
t.Fatal(err)
}
_, err = GenerateGo(prog)
if err == nil || !strings.Contains(err.Error(), "expects 1 values") {
t.Fatalf("expected payload error, got %v", err)
}
}
func TestPayloadlessEnumUsesExactVariantStrings(t *testing.T) {
prog, err := Parse(`package demo
enum Status { PendingReservation, Initiated }
fun main() { println(Status::PendingReservation) }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`type Status string`, `StatusPendingReservation`, `"PendingReservation"`, `StatusInitiated`, `"Initiated"`} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}

View file

@ -0,0 +1,62 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateExternalGoStructConstruction(t *testing.T) {
prog, err := Parse(`package demo
import platform "example/platform"
fun create(issuer: String, audience: String, jwksUrl: String): *platform.Authorizer {
return platform.Authorizer(issuer = issuer, audience = audience, jwksUrl = jwksUrl)
}`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
want := `&platform.Authorizer{Issuer: issuer, Audience: audience, JWKSURL: jwksUrl}`
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
func TestGenerateEmbeddedValue(t *testing.T) {
prog, err := Parse(`package demo
import embed
@embed("assets/*") val assets: embed.FS
fun main() { println(assets) }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"embed"`, `//go:embed assets/*`, `var assets embed.FS`} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestGenerateEmbeddedStringAddsBlankImport(t *testing.T) {
prog, err := Parse(`package demo
@embed("schema.sql") val schema: String
fun main() { println(schema) }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`_ "embed"`, `//go:embed schema.sql`, `var schema string`} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}

28
internal/lang/foreach.go Normal file
View file

@ -0,0 +1,28 @@
package lang
import "fmt"
func (p *parser) parseForEach() (Stmt, error) {
if _, err := p.expect(tokenLParen, "expected '(' after 'for'"); err != nil {
return nil, err
}
name, err := p.expect(tokenIdent, "expected iteration variable")
if err != nil {
return nil, err
}
if _, err := p.expect(tokenIn, "expected 'in' after iteration variable"); err != nil {
return nil, err
}
source, err := p.parseExpr(0)
if err != nil {
return nil, err
}
if _, err := p.expect(tokenRParen, "expected ')' after iteration source"); err != nil {
return nil, err
}
body, err := p.parseBlock()
if err != nil {
return nil, fmt.Errorf("invalid for body: %w", err)
}
return ForEachStmt{Name: name.lexeme, Source: source, Body: body}, nil
}

View file

@ -0,0 +1,31 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateGoForEach(t *testing.T) {
prog, err := Parse(`
package demo
fun main() {
val accounts: List<String> = listOf("one", "two")
for (account in accounts) {
println(account)
}
}
`)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("generation failed: %v", err)
}
for _, want := range []string{"for _, account := range accounts {", "fmt.Println(account)"} {
if !strings.Contains(string(out), want) {
t.Fatalf("generated Go missing %q:\n%s", want, out)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,32 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateGoLowercaseImportedSelectors(t *testing.T) {
prog, err := Parse(`
package demo
import http net.http
import json encoding.json
fun main() {
http.handleFunc("/health", handler)
json.unmarshal(body, &target)
}
`)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("generation failed: %v", err)
}
for _, want := range []string{"http.HandleFunc(\"/health\", handler)", "json.Unmarshal(body, &target)"} {
if !strings.Contains(string(out), want) {
t.Fatalf("generated Go missing %q:\n%s", want, out)
}
}
}

View file

@ -0,0 +1,26 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateGoIndexedDataClassSelector(t *testing.T) {
prog, err := Parse(`
package demo
data class User(var id: String)
fun first(users: List<User>): String { return users[0].id }
`)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("generation failed: %v", err)
}
if !strings.Contains(string(out), "return users[0].Id") {
t.Fatalf("generated Go missing indexed selector:\n%s", out)
}
}

View file

@ -0,0 +1,31 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateGoJSONDecodeReturnsTypedValue(t *testing.T) {
prog, err := Parse(`
package demo
import json encoding.json
fun decode(body: ByteSlice): List<Account> {
val accounts = json.decode<List<Account>>(body)
return accounts
}
`)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("generation failed: %v", err)
}
for _, want := range []string{"accounts := gotlinAutoThrow(gotlinJSONDecode[[]Account](body))", "json.Unmarshal(body, &value)"} {
if !strings.Contains(string(out), want) {
t.Fatalf("generated Go missing %q:\n%s", want, out)
}
}
}

View file

@ -49,6 +49,13 @@ func (l *lexer) next() (token, error) {
for l.pos < len(l.src) && unicode.IsDigit(l.src[l.pos]) {
l.pos++
}
if l.pos+1 < len(l.src) && l.src[l.pos] == '.' && unicode.IsDigit(l.src[l.pos+1]) {
l.pos++
for l.pos < len(l.src) && unicode.IsDigit(l.src[l.pos]) {
l.pos++
}
return token{kind: tokenFloat, lexeme: string(l.src[start:l.pos]), pos: start}, nil
}
return token{kind: tokenInt, lexeme: string(l.src[start:l.pos]), pos: start}, nil
case ch == '"':
l.pos++
@ -77,11 +84,18 @@ func (l *lexer) next() (token, error) {
return token{kind: tokenLBrace, lexeme: "{", pos: start}, nil
case '}':
return token{kind: tokenRBrace, lexeme: "}", pos: start}, nil
case '[':
return token{kind: tokenLBracket, lexeme: "[", pos: start}, nil
case ']':
return token{kind: tokenRBracket, lexeme: "]", pos: start}, nil
case ',':
return token{kind: tokenComma, lexeme: ",", pos: start}, nil
case '.':
return token{kind: tokenDot, lexeme: ".", pos: start}, nil
case ':':
if l.match(':') {
return token{kind: tokenDoubleColon, lexeme: "::", pos: start}, nil
}
return token{kind: tokenColon, lexeme: ":", pos: start}, nil
case ';':
return token{kind: tokenSemicolon, lexeme: ";", pos: start}, nil
@ -131,6 +145,11 @@ func (l *lexer) next() (token, error) {
if l.match('&') {
return token{kind: tokenAnd, lexeme: "&&", pos: start}, nil
}
return token{kind: tokenAmp, lexeme: "&", pos: start}, nil
case '@':
return token{kind: tokenAt, lexeme: "@", pos: start}, nil
case '?':
return token{kind: tokenQuestion, lexeme: "?", pos: start}, nil
case '|':
if l.match('|') {
return token{kind: tokenOr, lexeme: "||", pos: start}, nil

269
internal/lang/mapping.go Normal file
View file

@ -0,0 +1,269 @@
package lang
import (
"fmt"
"strconv"
"strings"
)
type mappingPair struct{ source, target, function string }
type mappingState struct {
functions map[string]string
pairs []mappingPair
}
func (g *goGenerator) mappingTopLevelTarget(target string) string {
if _, ok := g.classForType(target); ok && !strings.HasPrefix(target, "*") {
return "*" + target
}
return target
}
func (g *goGenerator) ensureMapping(source, target, path string) (string, error) {
key := source + "->" + target
if function, ok := g.mappings.functions[key]; ok {
return function, nil
}
if err := g.validateMapping(source, target, path, map[string]bool{}); err != nil {
return "", err
}
function := fmt.Sprintf("gotlinMap%d", len(g.mappings.pairs)+1)
g.mappings.functions[key] = function
g.mappings.pairs = append(g.mappings.pairs, mappingPair{source: source, target: target, function: function})
return function, nil
}
func (g *goGenerator) validateMapping(source, target, path string, seen map[string]bool) error {
if source == target {
return nil
}
key := source + "->" + target
if seen[key] {
return nil
}
seen[key] = true
if strings.HasSuffix(source, "?") || strings.HasSuffix(target, "?") {
if strings.HasSuffix(source, "?") && !strings.HasSuffix(target, "?") {
return mappingError(path, source, target)
}
return g.validateMapping(strings.TrimSuffix(source, "?"), strings.TrimSuffix(target, "?"), path, seen)
}
if sourceBase, sourceArgs, ok := parseGenericType(source); ok {
targetBase, targetArgs, targetOK := parseGenericType(target)
if !targetOK || sourceBase != targetBase || len(sourceArgs) != len(targetArgs) {
return mappingError(path, source, target)
}
for i := range sourceArgs {
if sourceBase == "Map" || sourceBase == "MutableMap" {
if i == 0 && sourceArgs[i] != targetArgs[i] {
return mappingError(path+".<key>", sourceArgs[i], targetArgs[i])
}
}
if err := g.validateMapping(sourceArgs[i], targetArgs[i], path+"[]", seen); err != nil {
return err
}
}
return nil
}
sourceClass, sourceClassOK := g.classForType(source)
targetClass, targetClassOK := g.classForType(target)
if sourceClassOK || targetClassOK {
if !sourceClassOK || !targetClassOK {
return mappingError(path, source, target)
}
for _, targetField := range targetClass.Fields {
sourceField, ok := classFieldByName(sourceClass, targetField.Name)
fieldPath := path + "." + targetField.Name
if !ok {
return fmt.Errorf("cannot map %s: source field is missing for %s.%s", fieldPath, targetClass.Name, targetField.Name)
}
if err := g.validateMapping(sourceField.Type, targetField.Type, fieldPath, seen); err != nil {
return err
}
}
return nil
}
sourceEnum, sourceEnumOK := g.enums[strings.TrimPrefix(source, "*")]
targetEnum, targetEnumOK := g.enums[strings.TrimPrefix(target, "*")]
if sourceEnumOK || targetEnumOK {
if sourceEnumOK && enumIsString(sourceEnum) && target == "String" {
return nil
}
if targetEnumOK && enumIsString(targetEnum) && source == "String" {
return nil
}
if !sourceEnumOK || !targetEnumOK {
return mappingError(path, source, target)
}
for _, sourceVariant := range sourceEnum.Variants {
targetVariant := enumVariant(targetEnum, sourceVariant.Name)
variantPath := path + "::" + sourceVariant.Name
if targetVariant == nil {
return fmt.Errorf("cannot map %s: target enum %s has no compatible variant", variantPath, targetEnum.Name)
}
if len(sourceVariant.PayloadTypes) != len(targetVariant.PayloadTypes) {
return fmt.Errorf("cannot map %s: payload count %d is incompatible with %d", variantPath, len(sourceVariant.PayloadTypes), len(targetVariant.PayloadTypes))
}
for i := range sourceVariant.PayloadTypes {
if err := g.validateMapping(sourceVariant.PayloadTypes[i], targetVariant.PayloadTypes[i], fmt.Sprintf("%s[%d]", variantPath, i), seen); err != nil {
return err
}
}
}
return nil
}
return mappingError(path, source, target)
}
func mappingError(path, source, target string) error {
return fmt.Errorf("cannot map %s: %s is incompatible with %s", path, source, target)
}
func classFieldByName(class ClassDecl, name string) (FieldDecl, bool) {
for _, field := range class.Fields {
if field.Name == name {
return field, true
}
}
return FieldDecl{}, false
}
func mappingFieldName(class ClassDecl, field FieldDecl) string {
if class.Data && !field.Private {
return exportedGoName(field.Name)
}
return field.Name
}
func (g *goGenerator) emitMapping(pair mappingPair) error {
g.line(fmt.Sprintf("func %s(source %s) %s {", pair.function, mapGoType(pair.source), mapGoType(pair.target)))
g.indentLevel++
if sourceEnum, ok := g.enums[strings.TrimPrefix(pair.source, "*")]; ok {
if enumIsString(sourceEnum) && pair.target == "String" {
g.line("return string(source)")
g.indentLevel--
g.line("}")
return nil
}
targetEnum := g.enums[strings.TrimPrefix(pair.target, "*")]
if enumIsString(sourceEnum) && enumIsString(targetEnum) {
g.line("return " + targetEnum.Name + "(source)")
g.indentLevel--
g.line("}")
return nil
}
g.line("switch value := source.(type) {")
g.indentLevel++
for _, sourceVariant := range sourceEnum.Variants {
targetVariant := enumVariant(targetEnum, sourceVariant.Name)
g.line("case *" + sourceEnum.Name + sourceVariant.Name + ":")
g.indentLevel++
fields := make([]string, 0, len(sourceVariant.PayloadTypes))
for i := range sourceVariant.PayloadTypes {
expr, err := g.mappingExpr(fmt.Sprintf("value.Value%d", i), sourceVariant.PayloadTypes[i], targetVariant.PayloadTypes[i], sourceEnum.Name+"::"+sourceVariant.Name)
if err != nil {
return err
}
fields = append(fields, fmt.Sprintf("Value%d: %s", i, expr))
}
g.line("return &" + targetEnum.Name + targetVariant.Name + "{" + strings.Join(fields, ", ") + "}")
g.indentLevel--
}
g.indentLevel--
g.line("}")
g.line(`panic("unreachable enum mapping")`)
} else if targetEnum, ok := g.enums[strings.TrimPrefix(pair.target, "*")]; ok && pair.source == "String" && enumIsString(targetEnum) {
g.line("switch source {")
g.indentLevel++
for _, variant := range targetEnum.Variants {
g.line("case " + strconv.Quote(enumStringValue(variant)) + ":")
g.indentLevel++
g.line("return " + targetEnum.Name + variant.Name)
g.indentLevel--
}
g.indentLevel--
g.line("}")
g.line(`panic("unknown enum string: " + source)`)
} else {
expr, err := g.mappingExpr("source", pair.source, pair.target, strings.TrimPrefix(pair.source, "*"))
if err != nil {
return err
}
g.line("return " + expr)
}
g.indentLevel--
g.line("}")
return nil
}
func (g *goGenerator) mappingExpr(expr, source, target, path string) (string, error) {
if source == target {
return expr, nil
}
if strings.HasSuffix(source, "?") || strings.HasSuffix(target, "?") {
sourceInner := strings.TrimSuffix(source, "?")
targetInner := strings.TrimSuffix(target, "?")
inner, err := g.mappingExpr("*value", sourceInner, targetInner, path)
if err != nil {
return "", err
}
if strings.HasSuffix(source, "?") {
return fmt.Sprintf("func(value %s) %s { if value == nil { return nil }; mapped := %s; return &mapped }(%s)", mapGoType(source), mapGoType(target), inner, expr), nil
}
inner, err = g.mappingExpr("value", sourceInner, targetInner, path)
if err != nil {
return "", err
}
return fmt.Sprintf("func(value %s) %s { mapped := %s; return &mapped }(%s)", mapGoType(source), mapGoType(target), inner, expr), nil
}
if sourceBase, sourceArgs, ok := parseGenericType(source); ok {
_, targetArgs, _ := parseGenericType(target)
if sourceBase == "List" || sourceBase == "MutableList" {
item, err := g.mappingExpr("item", sourceArgs[0], targetArgs[0], path+"[]")
if err != nil {
return "", err
}
return fmt.Sprintf("func(values %s) %s { var result %s; for _, item := range values { result = append(result, %s) }; return result }(%s)", mapGoType(source), mapGoType(target), mapGoType(target), item, expr), nil
}
if sourceBase == "Map" || sourceBase == "MutableMap" {
value, err := g.mappingExpr("item", sourceArgs[1], targetArgs[1], path+"[]")
if err != nil {
return "", err
}
return fmt.Sprintf("func(values %s) %s { result := make(%s, len(values)); for key, item := range values { result[key] = %s }; return result }(%s)", mapGoType(source), mapGoType(target), mapGoType(target), value, expr), nil
}
}
if sourceClass, ok := g.classForType(source); ok {
targetClass, _ := g.classForType(target)
fields := make([]string, 0, len(targetClass.Fields))
for _, targetField := range targetClass.Fields {
sourceField, _ := classFieldByName(sourceClass, targetField.Name)
mapped, err := g.mappingExpr(expr+"."+mappingFieldName(sourceClass, sourceField), sourceField.Type, targetField.Type, path+"."+targetField.Name)
if err != nil {
return "", err
}
fields = append(fields, mappingFieldName(targetClass, targetField)+": "+mapped)
}
literal := targetClass.Name + "{" + strings.Join(fields, ", ") + "}"
if strings.HasPrefix(target, "*") {
literal = "&" + literal
}
if strings.HasPrefix(source, "*") {
return fmt.Sprintf("func(value %s) %s { if value == nil { return nil }; return %s }(%s)", mapGoType(source), mapGoType(target), strings.ReplaceAll(literal, expr+".", "value."), expr), nil
}
return literal, nil
}
if _, ok := g.enums[strings.TrimPrefix(source, "*")]; ok {
function, err := g.ensureMapping(source, target, path)
if err != nil {
return "", err
}
return function + "(" + expr + ")", nil
}
if targetEnum, ok := g.enums[strings.TrimPrefix(target, "*")]; ok && source == "String" && enumIsString(targetEnum) {
function, err := g.ensureMapping(source, target, path)
if err != nil {
return "", err
}
return function + "(" + expr + ")", nil
}
return "", mappingError(path, source, target)
}

View file

@ -0,0 +1,159 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateRecursiveClassMapping(t *testing.T) {
prog, err := Parse(`
package demo
data class SourceAddress(var city: String)
data class TargetAddress(var city: String)
data class Source(var id: String, var address: *SourceAddress)
data class Target(var address: *TargetAddress, var id: String)
fun convert(source: *Source): *Target { return source.mapTo<Target>() }
`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
code := string(out)
for _, want := range []string{"gotlinMap1(source)", "Address:", "Id: value.Id", "TargetAddress{City: value.City}"} {
if !strings.Contains(code, want) {
t.Fatalf("missing %q:\n%s", want, code)
}
}
}
func TestGenerateListAndMapMapping(t *testing.T) {
prog, err := Parse(`
package demo
data class Source(var id: String)
data class Target(var id: String)
fun list(values: List<*Source>): List<*Target> { return values.mapTo<List<*Target>>() }
fun mapping(values: Map<String, *Source>): Map<String, *Target> { return values.mapTo<Map<String, *Target>>() }
`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"for _, item := range values", "for key, item := range values", "result[key]"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestGenerateRecursiveEnumMapping(t *testing.T) {
prog, err := Parse(`
package demo
enum Source { Ready(String), Failed(String) }
enum Target { Ready(String), Failed(String), Pending }
fun convert(value: Source): Target { return value.mapTo<Target>() }
`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"case *SourceReady:", "return &TargetReady{Value0: value.Value0}", "case *SourceFailed:"} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestMappingReportsNestedFieldPath(t *testing.T) {
prog, err := Parse(`
package demo
data class SourceAddress(var zip: String)
data class TargetAddress(var zip: Int)
data class Source(var address: *SourceAddress)
data class Target(var address: *TargetAddress)
fun convert(value: *Source): *Target { return value.mapTo<Target>() }
`)
if err != nil {
t.Fatal(err)
}
_, err = GenerateGo(prog)
if err == nil || !strings.Contains(err.Error(), "Source.address.zip") || !strings.Contains(err.Error(), "String is incompatible with Int") {
t.Fatalf("unexpected mapping error: %v", err)
}
}
func TestMappingRejectsMissingFieldAndEnumVariant(t *testing.T) {
for _, source := range []string{
`package demo data class Source(var id: String) data class Target(var id: String, var name: String) fun convert(value: *Source): *Target { return value.mapTo<Target>() }`,
`package demo enum Source { Ready, Failed } enum Target { Ready } fun convert(value: Source): Target { return value.mapTo<Target>() }`,
} {
prog, err := Parse(source)
if err != nil {
t.Fatal(err)
}
if _, err = GenerateGo(prog); err == nil {
t.Fatalf("expected mapping error for %s", source)
}
}
}
func TestMapStringBackedEnumToAndFromString(t *testing.T) {
prog, err := Parse(`package demo
enum Status { PendingReservation, Initiated }
data class Domain(var status: Status)
data class Row(var status: String)
fun toRow(value: *Domain): *Row { return value.mapTo<Row>() }
fun toDomain(value: *Row): *Domain { return value.mapTo<Domain>() }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`return string(source)`, `case "PendingReservation":`, `return StatusPendingReservation`} {
if !strings.Contains(string(out), want) {
t.Fatalf("missing %q:\n%s", want, out)
}
}
}
func TestMapToInfersExpectedTargetType(t *testing.T) {
prog, err := Parse(`package demo
data class Source(var id: String)
data class Target(var id: String)
data class Wrapper(var target: *Target)
fun returned(value: *Source): *Target { return value.mapTo() }
fun wrapped(value: *Source): *Wrapper { return Wrapper(value.mapTo()) }
fun local(value: *Source): *Target { val target: *Target = value.mapTo(); return target }`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
if strings.Count(string(out), "gotlinMap1(value)") < 3 {
t.Fatalf("expected inferred mapping calls:\n%s", out)
}
}
func TestMapToWithoutTargetContextHasHelpfulError(t *testing.T) {
prog, err := Parse(`package demo
fun convert(value: *Source) { val target = value.mapTo() }`)
if err != nil {
t.Fatal(err)
}
_, err = GenerateGo(prog)
if err == nil || !strings.Contains(err.Error(), "target type cannot be inferred") {
t.Fatalf("unexpected error: %v", err)
}
}

View file

@ -0,0 +1,53 @@
package lang
import (
"strings"
"testing"
)
func TestClassFieldTypeResolvesInternalMethodWithoutAlias(t *testing.T) {
prog, err := Parse(`
package demo
class Repository {
fun healthy(): Boolean { return true }
}
class Service(val repository: *Repository) {
fun healthy(): Boolean { return repository.healthy() }
}
`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "self.repository.healthy()") {
t.Fatalf("internal method was treated as foreign:\n%s", out)
}
}
func TestInternalMethodReturnTypeFlowsThroughSelectors(t *testing.T) {
prog, err := Parse(`
package demo
class Transaction { fun commit(): Boolean { return true } }
class Result(val transaction: *Transaction)
class Repository { fun begin(): *Result { return Result(Transaction()) } }
class Service(val repository: *Repository) {
fun run(): Boolean { return repository.begin().transaction.commit() }
}
`)
if err != nil {
t.Fatal(err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "self.repository.begin().transaction.commit()") {
t.Fatalf("method return type did not flow through selector:\n%s", out)
}
}

View file

@ -2,6 +2,7 @@ package lang
import (
"fmt"
"strconv"
"strings"
)
@ -44,12 +45,92 @@ func (p *parser) parseProgram() (*Program, error) {
return nil, err
}
prog.Interfaces = append(prog.Interfaces, decl)
case p.check(tokenClass):
case p.check(tokenEnum):
decl, err := p.parseEnum()
if err != nil {
return nil, err
}
prog.Enums = append(prog.Enums, decl)
case p.check(tokenClass) || p.check(tokenData):
decl, err := p.parseClass()
if err != nil {
return nil, err
}
prog.Classes = append(prog.Classes, decl)
case p.match(tokenAt):
annotation, err := p.expect(tokenIdent, "expected annotation name")
if err != nil {
return nil, err
}
if annotation.lexeme == "embed" {
if _, err := p.expect(tokenLParen, "expected '(' after embed"); err != nil {
return nil, err
}
path, err := p.expect(tokenString, "expected embed path")
if err != nil {
return nil, err
}
if _, err := p.expect(tokenRParen, "expected ')' after embed path"); err != nil {
return nil, err
}
if _, err := p.expect(tokenVal, "expected 'val' after embed annotation"); err != nil {
return nil, err
}
name, err := p.expect(tokenIdent, "expected embedded value name")
if err != nil {
return nil, err
}
if _, err := p.expect(tokenColon, "expected ':' after embedded value name"); err != nil {
return nil, err
}
typ, err := p.parseTypeRef()
if err != nil {
return nil, err
}
prog.Embeds = append(prog.Embeds, EmbedDecl{Path: strings.Trim(path.lexeme, "\""), Name: name.lexeme, Type: typ})
continue
}
switch annotation.lexeme {
case "jsonNaming":
if _, err := p.expect(tokenLParen, "expected '(' after jsonNaming"); err != nil {
return nil, err
}
policy, err := p.expect(tokenIdent, "expected JSON naming policy")
if err != nil {
return nil, err
}
if _, err := p.expect(tokenRParen, "expected ')' after JSON naming policy"); err != nil {
return nil, err
}
if !p.check(tokenData) {
return nil, fmt.Errorf("jsonNaming is only valid on data classes")
}
decl, err := p.parseClass()
if err != nil {
return nil, err
}
decl.JSONNaming = policy.lexeme
prog.Classes = append(prog.Classes, decl)
case "table":
table, err := p.parseStringAnnotationArgument("table")
if err != nil {
return nil, err
}
if !validSQLName(table) {
return nil, fmt.Errorf("invalid SQL table name %q", table)
}
if !p.check(tokenData) {
return nil, fmt.Errorf("table is only valid on data classes")
}
decl, err := p.parseClass()
if err != nil {
return nil, err
}
decl.Table = table
prog.Classes = append(prog.Classes, decl)
default:
return nil, fmt.Errorf("unsupported annotation %q", annotation.lexeme)
}
case p.check(tokenWorker):
decl, err := p.parseWorker()
if err != nil {
@ -70,6 +151,64 @@ func (p *parser) parseProgram() (*Program, error) {
return prog, nil
}
func (p *parser) parseEnum() (EnumDecl, error) {
if _, err := p.expect(tokenEnum, "expected 'enum'"); err != nil {
return EnumDecl{}, err
}
name, err := p.expect(tokenIdent, "expected enum name")
if err != nil {
return EnumDecl{}, err
}
if _, err := p.expect(tokenLBrace, "expected '{' after enum name"); err != nil {
return EnumDecl{}, err
}
var variants []EnumVariant
seen := map[string]bool{}
for !p.check(tokenRBrace) && !p.check(tokenEOF) {
variant, err := p.expect(tokenIdent, "expected enum variant")
if err != nil {
return EnumDecl{}, err
}
if seen[variant.lexeme] {
return EnumDecl{}, fmt.Errorf("duplicate enum variant %s", variant.lexeme)
}
seen[variant.lexeme] = true
var payload []string
if p.match(tokenLParen) {
if !p.check(tokenRParen) {
for {
typ, err := p.parseTypeRef()
if err != nil {
return EnumDecl{}, err
}
payload = append(payload, typ)
if !p.match(tokenComma) {
break
}
}
}
if _, err := p.expect(tokenRParen, "expected ')' after enum payload"); err != nil {
return EnumDecl{}, err
}
}
stringValue := ""
if p.match(tokenAssign) {
value, err := p.expect(tokenString, "expected string enum value")
if err != nil {
return EnumDecl{}, err
}
stringValue = strings.Trim(value.lexeme, "\"")
}
variants = append(variants, EnumVariant{Name: variant.lexeme, PayloadTypes: payload, StringValue: stringValue})
p.match(tokenComma)
p.match(tokenSemicolon)
}
if _, err := p.expect(tokenRBrace, "expected '}' after enum"); err != nil {
return EnumDecl{}, err
}
return EnumDecl{Name: name.lexeme, Variants: variants}, nil
}
func (p *parser) parsePackageDecl() (string, error) {
path, err := p.parseImportPath()
if err != nil {
@ -151,6 +290,7 @@ func (p *parser) parseInterface() (InterfaceDecl, error) {
}
func (p *parser) parseClass() (ClassDecl, error) {
data := p.match(tokenData)
if _, err := p.expect(tokenClass, "expected 'class'"); err != nil {
return ClassDecl{}, err
}
@ -174,7 +314,7 @@ func (p *parser) parseClass() (ClassDecl, error) {
return ClassDecl{}, err
}
if !p.match(tokenLBrace) {
return ClassDecl{Name: name.lexeme, Fields: fields, Parents: parents, Methods: nil}, nil
return ClassDecl{Name: name.lexeme, Data: data, Fields: fields, Parents: parents, Methods: nil}, nil
}
var methods []FunctionDecl
@ -195,7 +335,7 @@ func (p *parser) parseClass() (ClassDecl, error) {
return ClassDecl{}, err
}
return ClassDecl{Name: name.lexeme, Fields: fields, Parents: parents, Methods: methods}, nil
return ClassDecl{Name: name.lexeme, Data: data, Fields: fields, Parents: parents, Methods: methods}, nil
}
func (p *parser) parseWorker() (WorkerDecl, error) {
@ -284,6 +424,41 @@ func (p *parser) parseClassFields() ([]FieldDecl, error) {
return fields, nil
}
for {
column := ""
id := false
generated := false
for p.match(tokenAt) {
annotation, err := p.expect(tokenIdent, "expected field annotation name")
if err != nil {
return nil, err
}
switch annotation.lexeme {
case "id":
if id {
return nil, fmt.Errorf("duplicate id annotation")
}
id = true
case "generated":
if generated {
return nil, fmt.Errorf("duplicate generated annotation")
}
generated = true
case "column":
if column != "" {
return nil, fmt.Errorf("duplicate column annotation")
}
column, err = p.parseStringAnnotationArgument("column")
if err != nil {
return nil, err
}
if !validSQLName(column) {
return nil, fmt.Errorf("invalid SQL column name %q", column)
}
default:
return nil, fmt.Errorf("unsupported field annotation %q", annotation.lexeme)
}
}
private := p.match(tokenPrivate)
mutable := false
switch {
case p.match(tokenVal):
@ -305,13 +480,49 @@ func (p *parser) parseClassFields() ([]FieldDecl, error) {
if err != nil {
return nil, err
}
fields = append(fields, FieldDecl{Mutable: mutable, Name: name.lexeme, Type: typ})
fields = append(fields, FieldDecl{Mutable: mutable, Private: private, Name: name.lexeme, Type: typ, Column: column, ID: id, Generated: generated})
if !p.match(tokenComma) {
return fields, nil
}
}
}
func (p *parser) parseStringAnnotationArgument(name string) (string, error) {
if _, err := p.expect(tokenLParen, "expected '(' after "+name); err != nil {
return "", err
}
value, err := p.expect(tokenString, "expected string argument for "+name)
if err != nil {
return "", err
}
if _, err := p.expect(tokenRParen, "expected ')' after "+name+" argument"); err != nil {
return "", err
}
decoded, err := strconv.Unquote(value.lexeme)
if err != nil {
return "", fmt.Errorf("invalid string argument for %s: %w", name, err)
}
return decoded, nil
}
func validSQLName(name string) bool {
if name == "" {
return false
}
for i, r := range name {
if i == 0 {
if !isIdentStart(r) {
return false
}
continue
}
if !isIdentPart(r) {
return false
}
}
return true
}
func (p *parser) parseFunction() (FunctionDecl, error) {
signature, err := p.parseFunctionSignature()
if err != nil {
@ -448,12 +659,25 @@ func (p *parser) parseStmt() (Stmt, error) {
return nil, fmt.Errorf("'go' expects a function call expression")
}
return GoStmt{Value: expr}, nil
case p.match(tokenDefer):
expr, err := p.parseExpr(0)
if err != nil {
return nil, err
}
if _, ok := expr.(CallExpr); !ok {
return nil, fmt.Errorf("'defer' expects a function call expression")
}
return DeferStmt{Value: expr}, nil
case p.match(tokenIf):
return p.parseIf()
case p.match(tokenWhile):
return p.parseWhile()
case p.match(tokenFor):
return p.parseForEach()
case p.match(tokenSelect):
return p.parseSelect()
case p.match(tokenMatch):
return p.parseMatch()
case p.match(tokenTry):
return p.parseTryCatch()
case p.check(tokenIdent) && (p.peekN(1).kind == tokenAssign || p.peekN(1).kind == tokenComma || p.peekN(1).kind == tokenPlusAssign):
@ -466,7 +690,7 @@ func (p *parser) parseStmt() (Stmt, error) {
if err != nil {
return nil, err
}
return AddAssignStmt{Name: name.lexeme, Value: value}, nil
return AddAssignStmt{Name: name.lexeme, Pos: name.pos, Value: value}, nil
}
names, err := p.parseNameList()
if err != nil {
@ -480,9 +704,18 @@ func (p *parser) parseStmt() (Stmt, error) {
return nil, err
}
if len(names) == 1 {
return AssignStmt{Name: names[0], Value: value}, nil
return AssignStmt{Name: names[0].lexeme, Pos: names[0].pos, Value: value}, nil
}
return MultiAssignStmt{Names: names, Value: value}, nil
assign := MultiAssignStmt{
Names: make([]string, 0, len(names)),
Positions: make([]int, 0, len(names)),
Value: value,
}
for _, name := range names {
assign.Names = append(assign.Names, name.lexeme)
assign.Positions = append(assign.Positions, name.pos)
}
return assign, nil
default:
expr, err := p.parseExpr(0)
if err != nil {
@ -492,6 +725,68 @@ func (p *parser) parseStmt() (Stmt, error) {
}
}
func (p *parser) parseMatch() (Stmt, error) {
if _, err := p.expect(tokenLParen, "expected '(' after match"); err != nil {
return nil, err
}
value, err := p.parseExpr(0)
if err != nil {
return nil, err
}
if _, err := p.expect(tokenRParen, "expected ')' after match value"); err != nil {
return nil, err
}
if _, err := p.expect(tokenLBrace, "expected '{' after match value"); err != nil {
return nil, err
}
var cases []MatchCase
for !p.check(tokenRBrace) && !p.check(tokenEOF) {
enumName, err := p.expect(tokenIdent, "expected enum name in match case")
if err != nil {
return nil, err
}
if _, err := p.expect(tokenDoubleColon, "expected '::' in match case"); err != nil {
return nil, err
}
variant, err := p.expect(tokenIdent, "expected variant name")
if err != nil {
return nil, err
}
var bindings []string
if p.match(tokenLParen) {
if !p.check(tokenRParen) {
for {
binding, err := p.expect(tokenIdent, "expected variant binding")
if err != nil {
return nil, err
}
bindings = append(bindings, binding.lexeme)
if !p.match(tokenComma) {
break
}
}
}
if _, err := p.expect(tokenRParen, "expected ')' after variant bindings"); err != nil {
return nil, err
}
}
if _, err := p.expect(tokenArrow, "expected '->' after match pattern"); err != nil {
return nil, err
}
body, err := p.parseBlock()
if err != nil {
return nil, err
}
cases = append(cases, MatchCase{EnumName: enumName.lexeme, VariantName: variant.lexeme, Bindings: bindings, Body: body})
p.match(tokenComma)
p.match(tokenSemicolon)
}
if _, err := p.expect(tokenRBrace, "expected '}' after match"); err != nil {
return nil, err
}
return MatchStmt{Value: value, Cases: cases}, nil
}
func (p *parser) parseSelect() (Stmt, error) {
if _, err := p.expect(tokenLBrace, "expected '{' after select"); err != nil {
return nil, err
@ -570,7 +865,7 @@ func (p *parser) parseVarDecl(mutable bool) (Stmt, error) {
if err != nil {
return nil, err
}
name := names[0]
name := names[0].lexeme
var typ string
if p.match(tokenColon) {
if len(names) > 1 {
@ -592,7 +887,11 @@ func (p *parser) parseVarDecl(mutable bool) (Stmt, error) {
if len(names) == 1 {
return VarDecl{Mutable: mutable, Name: name, Type: typ, Value: value}, nil
}
return MultiVarDecl{Mutable: mutable, Names: names, Value: value}, nil
decl := MultiVarDecl{Mutable: mutable, Names: make([]string, 0, len(names)), Value: value}
for _, name := range names {
decl.Names = append(decl.Names, name.lexeme)
}
return decl, nil
}
func (p *parser) parseIf() (Stmt, error) {
@ -662,9 +961,36 @@ func (p *parser) parsePrefix() (Expr, error) {
tok := p.advance()
switch tok.kind {
case tokenIdent:
if p.match(tokenDoubleColon) {
variant, err := p.expect(tokenIdent, "expected enum variant")
if err != nil {
return nil, err
}
var values []Expr
if p.match(tokenLParen) {
if !p.check(tokenRParen) {
for {
value, err := p.parseExpr(0)
if err != nil {
return nil, err
}
values = append(values, value)
if !p.match(tokenComma) {
break
}
}
}
if _, err := p.expect(tokenRParen, "expected ')' after variant values"); err != nil {
return nil, err
}
}
return p.parsePostfix(EnumVariantExpr{EnumName: tok.lexeme, VariantName: variant.lexeme, Values: values})
}
return p.parsePostfix(IdentExpr{Name: tok.lexeme})
case tokenInt:
return IntExpr{Value: tok.lexeme}, nil
case tokenFloat:
return FloatExpr{Value: tok.lexeme}, nil
case tokenString:
return StringExpr{Value: tok.lexeme}, nil
case tokenTrue:
@ -684,7 +1010,7 @@ func (p *parser) parsePrefix() (Expr, error) {
return p.parsePostfix(expr)
case tokenLBrace:
return p.parseLambdaExpr()
case tokenBang, tokenMinus:
case tokenBang, tokenMinus, tokenAmp, tokenStar:
value, err := p.parseExpr(7)
if err != nil {
return nil, err
@ -695,18 +1021,18 @@ func (p *parser) parsePrefix() (Expr, error) {
}
}
func (p *parser) parseNameList() ([]string, error) {
func (p *parser) parseNameList() ([]token, error) {
name, err := p.expect(tokenIdent, "expected variable name")
if err != nil {
return nil, err
}
names := []string{name.lexeme}
names := []token{name}
for p.match(tokenComma) {
next, err := p.expect(tokenIdent, "expected variable name")
if err != nil {
return nil, err
}
names = append(names, next.lexeme)
names = append(names, next)
}
return names, nil
}
@ -774,11 +1100,20 @@ func (p *parser) parsePostfix(expr Expr) (Expr, error) {
for {
switch {
case p.match(tokenDot):
name, err := p.expect(tokenIdent, "expected selector name")
name := p.advance()
if !selectorName(name.lexeme) {
return nil, fmt.Errorf("expected selector name at %d, found %q", name.pos, name.lexeme)
}
expr = SelectorExpr{Receiver: expr, Name: name.lexeme}
case p.match(tokenLBracket):
index, err := p.parseExpr(0)
if err != nil {
return nil, err
}
expr = SelectorExpr{Receiver: expr, Name: name.lexeme}
if _, err := p.expect(tokenRBracket, "expected ']' after index"); err != nil {
return nil, err
}
expr = IndexExpr{Receiver: expr, Index: index}
case p.check(tokenLt):
typeArgs, hasTypeArgs, err := p.tryParseCallTypeArgs()
if err != nil {
@ -790,44 +1125,31 @@ func (p *parser) parsePostfix(expr Expr) (Expr, error) {
if _, err := p.expect(tokenLParen, "expected '(' after generic type arguments"); err != nil {
return nil, err
}
var args []Expr
if !p.check(tokenRParen) {
for {
arg, err := p.parseExpr(0)
if err != nil {
return nil, err
}
args = append(args, arg)
if !p.match(tokenComma) {
break
}
}
args, namedArgs, err := p.parseCallArguments()
if err != nil {
return nil, err
}
if _, err := p.expect(tokenRParen, "expected ')' after arguments"); err != nil {
return nil, err
}
expr = CallExpr{Callee: expr, Args: args, TypeArgs: typeArgs}
expr = CallExpr{Callee: expr, Args: args, NamedArgs: namedArgs, TypeArgs: typeArgs}
case p.match(tokenLParen):
var args []Expr
if !p.check(tokenRParen) {
for {
arg, err := p.parseExpr(0)
if err != nil {
return nil, err
}
args = append(args, arg)
if !p.match(tokenComma) {
break
}
}
args, namedArgs, err := p.parseCallArguments()
if err != nil {
return nil, err
}
if _, err := p.expect(tokenRParen, "expected ')' after arguments"); err != nil {
return nil, err
}
expr = CallExpr{Callee: expr, Args: args}
expr = CallExpr{Callee: expr, Args: args, NamedArgs: namedArgs}
case p.check(tokenLBrace):
call, ok := expr.(CallExpr)
if !ok {
var call CallExpr
switch current := expr.(type) {
case CallExpr:
call = current
case SelectorExpr:
call = CallExpr{Callee: current}
default:
return expr, nil
}
lambda, err := p.parsePrefix()
@ -842,6 +1164,54 @@ func (p *parser) parsePostfix(expr Expr) (Expr, error) {
}
}
func selectorName(value string) bool {
runes := []rune(value)
if len(runes) == 0 || !isIdentStart(runes[0]) {
return false
}
for _, r := range runes[1:] {
if !isIdentPart(r) {
return false
}
}
return true
}
func (p *parser) parseCallArguments() ([]Expr, []NamedArg, error) {
var args []Expr
var named []NamedArg
if p.check(tokenRParen) {
return args, named, nil
}
for {
if p.check(tokenIdent) && p.peekN(1).kind == tokenAssign {
if len(args) > 0 {
return nil, nil, fmt.Errorf("cannot mix positional and named arguments")
}
name := p.advance().lexeme
p.advance()
value, err := p.parseExpr(0)
if err != nil {
return nil, nil, err
}
named = append(named, NamedArg{Name: name, Value: value})
} else {
if len(named) > 0 {
return nil, nil, fmt.Errorf("cannot mix named and positional arguments")
}
value, err := p.parseExpr(0)
if err != nil {
return nil, nil, err
}
args = append(args, value)
}
if !p.match(tokenComma) {
break
}
}
return args, named, nil
}
func (p *parser) tryParseCallTypeArgs() ([]string, bool, error) {
if !p.check(tokenLt) {
return nil, false, nil
@ -937,6 +1307,9 @@ func (p *parser) parseTypeRef() (string, error) {
}
b += "<" + strings.Join(args, ", ") + ">"
}
if p.match(tokenQuestion) {
b += "?"
}
return b, nil
}

View file

@ -0,0 +1,28 @@
package lang
import (
"strings"
"testing"
)
func TestGenerateGoAddressOfExpression(t *testing.T) {
prog, err := Parse(`
package demo
import encoding.json
fun decode(body: ByteSlice, target: Account) {
json.Unmarshal(body, &target)
}
`)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(prog)
if err != nil {
t.Fatalf("generation failed: %v", err)
}
if !strings.Contains(string(out), "json.Unmarshal(body, &target)") {
t.Fatalf("generated Go missing address-of expression:\n%s", out)
}
}

282
internal/lang/semantics.go Normal file
View file

@ -0,0 +1,282 @@
package lang
import "fmt"
func validateMutability(program *Program) error {
checker := mutabilityChecker{}
for _, fn := range program.Functions {
if err := checker.checkFunction(fn, nil, nil); err != nil {
return err
}
}
for _, class := range program.Classes {
fields := make(map[string]bool, len(class.Fields))
for _, field := range class.Fields {
fields[field.Name] = field.Mutable
}
for _, method := range class.Methods {
if err := checker.checkFunction(method, fields, nil); err != nil {
return err
}
}
}
for _, worker := range program.Workers {
fields := make(map[string]bool, len(worker.Fields))
for _, field := range worker.Fields {
fields[field.Name] = field.Mutable
}
for _, method := range worker.Methods {
if err := checker.checkFunction(method, nil, fields); err != nil {
return err
}
}
}
return nil
}
type mutabilityChecker struct {
scopes []map[string]bool
classFields map[string]bool
workerFields map[string]bool
}
func (c *mutabilityChecker) checkFunction(fn FunctionDecl, classFields map[string]bool, workerFields map[string]bool) error {
c.scopes = nil
c.classFields = classFields
c.workerFields = workerFields
c.pushScope()
defer c.popScope()
for _, param := range fn.Params {
c.define(param.Name, false)
}
return c.checkStmts(fn.Body)
}
func (c *mutabilityChecker) checkStmts(stmts []Stmt) error {
for _, stmt := range stmts {
switch s := stmt.(type) {
case VarDecl:
if err := c.checkExpr(s.Value); err != nil {
return err
}
c.define(s.Name, s.Mutable)
case MultiVarDecl:
if err := c.checkExpr(s.Value); err != nil {
return err
}
for _, name := range s.Names {
c.define(name, s.Mutable)
}
case AssignStmt:
if err := c.requireMutable(s.Name, s.Pos); err != nil {
return err
}
if err := c.checkExpr(s.Value); err != nil {
return err
}
case AddAssignStmt:
if err := c.requireMutable(s.Name, s.Pos); err != nil {
return err
}
if err := c.checkExpr(s.Value); err != nil {
return err
}
case MultiAssignStmt:
for i, name := range s.Names {
pos := 0
if i < len(s.Positions) {
pos = s.Positions[i]
}
if err := c.requireMutable(name, pos); err != nil {
return err
}
}
if err := c.checkExpr(s.Value); err != nil {
return err
}
case ReturnStmt:
if s.Value != nil {
if err := c.checkExpr(s.Value); err != nil {
return err
}
}
case ThrowStmt:
if err := c.checkExpr(s.Value); err != nil {
return err
}
case GoStmt:
if err := c.checkExpr(s.Value); err != nil {
return err
}
case DeferStmt:
if err := c.checkExpr(s.Value); err != nil {
return err
}
case ExprStmt:
if err := c.checkExpr(s.Value); err != nil {
return err
}
case IfStmt:
if err := c.checkExpr(s.Cond); err != nil {
return err
}
if err := c.checkBlock(s.Then, nil); err != nil {
return err
}
if err := c.checkBlock(s.Else, nil); err != nil {
return err
}
case WhileStmt:
if err := c.checkExpr(s.Cond); err != nil {
return err
}
if err := c.checkBlock(s.Body, nil); err != nil {
return err
}
case ForEachStmt:
if err := c.checkExpr(s.Source); err != nil {
return err
}
if err := c.checkBlock(s.Body, map[string]bool{s.Name: false}); err != nil {
return err
}
case SelectStmt:
for _, sc := range s.Cases {
if err := c.checkExpr(sc.Source); err != nil {
return err
}
if err := c.checkBlock(sc.Body, map[string]bool{"it": false}); err != nil {
return err
}
}
case MatchStmt:
if err := c.checkExpr(s.Value); err != nil {
return err
}
for _, matchCase := range s.Cases {
bindings := map[string]bool{}
for _, binding := range matchCase.Bindings {
bindings[binding] = false
}
if err := c.checkBlock(matchCase.Body, bindings); err != nil {
return err
}
}
case TryCatchStmt:
if err := c.checkBlock(s.TryBody, nil); err != nil {
return err
}
if err := c.checkBlock(s.CatchBody, map[string]bool{s.CatchName: false}); err != nil {
return err
}
}
}
return nil
}
func (c *mutabilityChecker) checkBlock(stmts []Stmt, bindings map[string]bool) error {
c.pushScope()
defer c.popScope()
for name, mutable := range bindings {
c.define(name, mutable)
}
return c.checkStmts(stmts)
}
func (c *mutabilityChecker) checkExpr(expr Expr) error {
switch e := expr.(type) {
case UnaryExpr:
return c.checkExpr(e.Value)
case BinaryExpr:
if err := c.checkExpr(e.Left); err != nil {
return err
}
return c.checkExpr(e.Right)
case CallExpr:
if err := c.checkExpr(e.Callee); err != nil {
return err
}
for _, arg := range e.Args {
if err := c.checkExpr(arg); err != nil {
return err
}
}
for _, arg := range e.NamedArgs {
if err := c.checkExpr(arg.Value); err != nil {
return err
}
}
case SelectorExpr:
return c.checkExpr(e.Receiver)
case IndexExpr:
if err := c.checkExpr(e.Receiver); err != nil {
return err
}
return c.checkExpr(e.Index)
case EnumVariantExpr:
for _, value := range e.Values {
if err := c.checkExpr(value); err != nil {
return err
}
}
case LambdaExpr:
bindings := map[string]bool{}
if e.ImplicitIt {
bindings["it"] = false
}
for _, param := range e.Params {
bindings[param.Name] = false
}
return c.checkBlock(e.Body, bindings)
}
return nil
}
func (c *mutabilityChecker) pushScope() {
c.scopes = append(c.scopes, map[string]bool{})
}
func (c *mutabilityChecker) popScope() {
if len(c.scopes) == 0 {
return
}
c.scopes = c.scopes[:len(c.scopes)-1]
}
func (c *mutabilityChecker) define(name string, mutable bool) {
if len(c.scopes) == 0 {
c.pushScope()
}
c.scopes[len(c.scopes)-1][name] = mutable
}
func (c *mutabilityChecker) requireMutable(name string, pos int) error {
for i := len(c.scopes) - 1; i >= 0; i-- {
if mutable, ok := c.scopes[i][name]; ok {
if mutable {
return nil
}
return immutableAssignmentError(name, pos)
}
}
if mutable, ok := c.classFields[name]; ok {
if mutable {
return nil
}
return immutableAssignmentError(name, pos)
}
if mutable, ok := c.workerFields[name]; ok {
if mutable {
return nil
}
return immutableAssignmentError(name, pos)
}
return nil
}
func immutableAssignmentError(name string, pos int) error {
if pos > 0 {
return fmt.Errorf("cannot reassign immutable name %s at %d", name, pos)
}
return fmt.Errorf("cannot reassign immutable name %s", name)
}

1244
internal/lang/sql.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,277 @@
package lang
import (
"strings"
"testing"
)
const eventSQLSource = `
import time
@table("outbox_events")
data class EventRow(
@generated @id var id: String,
var payload: String,
var attempts: Int,
var publishedAt: time.Time?,
var claimedUntil: time.Time?,
var createdAt: time.Time
)
data class EventProjection(var id: String, var payload: String)
data class WrongProjection(var id: Int)
`
func TestSQLGeneratedNullableAndLockingMetadata(t *testing.T) {
program, err := Parse(eventSQLSource)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
row := program.Classes[0]
if !row.Fields[0].Generated {
t.Fatal("id field is missing @generated metadata")
}
if row.Fields[3].Type != "time.Time?" {
t.Fatalf("publishedAt type = %q, want time.Time?", row.Fields[3].Type)
}
if got := mapGoType(row.Fields[3].Type); got != "*time.Time" {
t.Fatalf("mapped nullable timestamp = %q, want *time.Time", got)
}
code := compileSQL(t, eventSQLSource+`
fun claimQuery(nowLimit: Int): GotlinSQLQuery {
return sql.from<EventRow>()
.where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) }
.orderByDescending { it.createdAt }
.limit(nowLimit)
.forUpdate()
.skipLocked()
.build()
}
`)
for _, want := range []string{
`SQL: "SELECT id, payload, attempts, published_at, claimed_until, created_at FROM outbox_events WHERE (published_at IS NULL AND (claimed_until IS NULL OR claimed_until < CURRENT_TIMESTAMP)) ORDER BY created_at DESC LIMIT $1 FOR UPDATE SKIP LOCKED"`,
`Args: []any{nowLimit}`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestSQLTypedProjectionExecution(t *testing.T) {
code := compileSQL(t, eventSQLSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun events(pool: *pgxpool.Pool, ctx: context.Context): List<*EventProjection> {
return sql.from<EventRow>()
.select { row -> EventProjection(row.id, row.payload) }
.orderBy { it.createdAt }
.fetch(pool, ctx)
}
fun eventStream(pool: *pgxpool.Pool, ctx: context.Context): GotlinSQLIterator<EventProjection> {
return sql.from<EventRow>()
.select { row -> EventProjection(row.id, row.payload) }
.iterator(pool, ctx)
}
`)
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 gotlinSQLIterate[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events", Args: []any{}}, gotlinSQLScanEventProjection)`,
`func gotlinSQLScanEventProjection(row gotlinSQLRow) (*EventProjection, error)`,
`err := row.Scan(&value.Id, &value.Payload)`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestSQLInsertOmitsGeneratedAndReturnsProjection(t *testing.T) {
code := compileSQL(t, eventSQLSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun create(row: EventRow, pool: *pgxpool.Pool, ctx: context.Context): *EventProjection {
return sql.insert<EventRow>(row)
.returning { value -> EventProjection(value.id, value.payload) }
.single(pool, ctx)
}
`)
for _, want := range []string{
`SQL: "INSERT INTO outbox_events (payload, attempts, published_at, claimed_until, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, payload"`,
`Args: []any{row.Payload, row.Attempts, row.PublishedAt, row.ClaimedUntil, row.CreatedAt}`,
`gotlinSQLSingle[EventProjection]`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestSQLGeneratedOnlyInsertUsesDefaultValues(t *testing.T) {
code := compileSQL(t, `
@table("tokens") data class TokenRow(@generated @id var id: String)
fun insert(row: TokenRow): GotlinSQLQuery { return sql.insert<TokenRow>(row).build() }
`)
if want := `SQL: "INSERT INTO tokens DEFAULT VALUES"`; !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
func TestSQLTypedUpdateAndReturning(t *testing.T) {
code := compileSQL(t, eventSQLSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun claim(id: String, payload: String, pool: *pgxpool.Pool, ctx: context.Context): *EventProjection {
return sql.update<EventRow>()
.set { row ->
set(row.payload, payload)
set(row.attempts, row.attempts + 1)
set(row.claimedUntil, now())
}
.where { it.id == id && it.publishedAt == null }
.returning { row -> EventProjection(row.id, row.payload) }
.single(pool, ctx)
}
`)
for _, want := range []string{
`SQL: "UPDATE outbox_events SET payload = $1, attempts = (attempts + $2), claimed_until = CURRENT_TIMESTAMP WHERE (id = $3 AND published_at IS NULL) RETURNING id, payload"`,
`Args: []any{payload, 1, id}`,
`gotlinSQLSingle[EventProjection]`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestSQLDeleteReturningFullRow(t *testing.T) {
code := compileSQL(t, eventSQLSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun remove(id: String, pool: *pgxpool.Pool, ctx: context.Context): *EventRow {
return sql.delete<EventRow>()
.where { it.id == id }
.returning { it }
.single(pool, ctx)
}
`)
for _, want := range []string{
`SQL: "DELETE FROM outbox_events WHERE id = $1 RETURNING id, payload, attempts, published_at, claimed_until, created_at"`,
`gotlinSQLSingle[EventRow]`,
`err := row.Scan(&value.Id, &value.Payload, &value.Attempts, &value.PublishedAt, &value.ClaimedUntil, &value.CreatedAt)`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
tests := []struct {
name string
src string
want string
}{
{
name: "skip locked without for update",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().skipLocked().build() }`,
want: "requires a preceding forUpdate",
},
{
name: "lock before limit",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().forUpdate().limit(1).build() }`,
want: "limit() may appear once",
},
{
name: "non nullable null comparison",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().where { it.payload == null }.build() }`,
want: "null comparison requires a nullable operand",
},
{
name: "wrong limit type",
src: eventSQLSource + `fun query(limit: String): GotlinSQLQuery { return sql.from<EventRow>().limit(limit).build() }`,
want: "argument must have type Int",
},
{
name: "negative literal limit",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().limit(-1).build() }`,
want: "requires a non-negative Int",
},
{
name: "projection type mismatch",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().select { WrongProjection(it.id) }.build() }`,
want: "has type Int but row field id has type String",
},
{
name: "projection arity mismatch",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().select { EventProjection(it.id) }.build() }`,
want: "expects 2 fields, got 1",
},
{
name: "projection after where",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().where { it.id == "x" }.select { EventProjection(it.id, it.payload) }.build() }`,
want: "must be the first sql.from method",
},
{
name: "update missing set",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().where { it.id == "x" }.build() }`,
want: "requires set() as its first method",
},
{
name: "update incompatible value",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.attempts, "bad") }.build() }`,
want: "has type Int but value has type String",
},
{
name: "update null non nullable",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.payload, null) }.build() }`,
want: "has non-nullable type String",
},
{
name: "update nullable into non nullable",
src: eventSQLSource + `fun query(value: String?): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.payload, value) }.build() }`,
want: "has type String but value has type String?",
},
{
name: "duplicate update target",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.payload, "a"); set(it.payload, "b") }.build() }`,
want: "duplicate set target payload",
},
{
name: "write execution without returning",
src: eventSQLSource + `fun query(pool: Any, ctx: Any): *EventRow { return sql.delete<EventRow>().single(pool, ctx) }`,
want: "requires returning()",
},
{
name: "returning out of order",
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.delete<EventRow>().returning { it }.where { it.id == "x" }.build() }`,
want: "invalid method order",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
program, err := Parse(test.src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
_, err = GenerateGo(program)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want substring %q", err, test.want)
}
})
}
}
func TestRejectDuplicateGeneratedAnnotation(t *testing.T) {
_, err := Parse(`data class Row(@generated @generated var id: String)`)
if err == nil || !strings.Contains(err.Error(), "duplicate generated annotation") {
t.Fatalf("Parse() error = %v", err)
}
}

383
internal/lang/sql_test.go Normal file
View file

@ -0,0 +1,383 @@
package lang
import (
"strings"
"testing"
)
const accountRowSource = `
@table("accounts")
data class AccountRow(
@id var id: String,
var customerId: String,
@column("kind") var accountType: String,
var balance: Double
)
`
func TestParseSQLMetadata(t *testing.T) {
program, err := Parse(accountRowSource)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if len(program.Classes) != 1 {
t.Fatalf("expected one class, got %d", len(program.Classes))
}
class := program.Classes[0]
if class.Table != "accounts" {
t.Fatalf("table = %q, want accounts", class.Table)
}
if !class.Fields[0].ID {
t.Fatal("id field is missing @id metadata")
}
if class.Fields[2].Column != "kind" {
t.Fatalf("accountType column = %q, want kind", class.Fields[2].Column)
}
if got := sqlColumn(class.Fields[1]); got != "customer_id" {
t.Fatalf("default customerId column = %q, want customer_id", got)
}
}
func TestGenerateSQLSelect(t *testing.T) {
code := compileSQL(t, accountRowSource+`
fun accountQuery(customerId: String): GotlinSQLQuery {
return sql.from<AccountRow>()
.where { row -> row.customerId == customerId && row.balance > 0.0 }
.orderBy { row -> row.accountType }
.build()
}
`)
for _, want := range []string{
"type GotlinSQLQuery struct",
"SQL string",
"Args []any",
`SQL: "SELECT id, customer_id, kind, balance FROM accounts WHERE (customer_id = $1 AND balance > $2) ORDER BY kind"`,
`Args: []any{customerId, 0.0}`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
if strings.Contains(code, `github.com/jackc/pgx/v5`) {
t.Fatalf("build-only SQL unexpectedly emitted pgx runtime:\n%s", code)
}
}
func TestGenerateSQLSelectUsesTypedLocalAsArgument(t *testing.T) {
code := compileSQL(t, accountRowSource+`
fun accountQuery(): GotlinSQLQuery {
val customerId = "customer-1"
return sql.from<AccountRow>().where { it.customerId == customerId }.build()
}
`)
if want := `Args: []any{customerId}`; !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
func TestGeneratedQueryCanBePassedToVariadicPGXCall(t *testing.T) {
code := compileSQL(t, accountRowSource+`
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun execute(pool: *pgxpool.Pool, ctx: Context, customerId: String) {
val query = sql.from<AccountRow>().where { it.customerId == customerId }.build()
pool.query(ctx, query.sql, *query.args)
}
`)
if want := `pool.Query(ctx, query.SQL, query.Args...)`; !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
func TestGenerateSQLFetchTerminal(t *testing.T) {
code := compileSQL(t, accountRowSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun accounts(pool: *pgxpool.Pool, ctx: context.Context, customerId: String): List<*AccountRow> {
return sql.from<AccountRow>()
.where { it.customerId == customerId }
.fetch(pool, ctx)
}
`)
for _, want := range []string{
`"github.com/jackc/pgx/v5"`,
`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)`,
`defer rows.Close()`,
`values = append(values, gotlinAutoThrow(scan(rows)))`,
`err := row.Scan(&value.Id, &value.CustomerId, &value.AccountType, &value.Balance)`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestGenerateSQLSingleInfersRowSelectors(t *testing.T) {
code := compileSQL(t, accountRowSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun balance(pool: *pgxpool.Pool, ctx: context.Context, id: String): Double {
val account = sql.from<AccountRow>().where { it.id == id }.single(pool, ctx)
return account.balance
}
`)
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)`,
`return account.Balance`,
`gotlinAutoThrow(gotlinSQLError("SQL single() expected exactly one row, got zero"))`,
`gotlinAutoThrow(gotlinSQLError("SQL single() expected exactly one row, got more than one"))`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestGenerateSQLIteratorTerminalAndTypedValue(t *testing.T) {
code := compileSQL(t, accountRowSource+`
import context
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
val rows = sql.from<AccountRow>().iterator(pool, ctx)
defer rows.close()
while (rows.next()) {
val account = rows.value()
println(account.customerId)
}
val checked = rows.err()
}
`)
for _, want := range []string{
`rows := gotlinSQLIterate[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts", Args: []any{}}, gotlinSQLScanAccountRow)`,
`defer rows.close()`,
`for rows.next()`,
`account := gotlinAutoThrow(rows.value())`,
`fmt.Println(account.CustomerId)`,
`_ = gotlinAutoThrow(rows.err())`,
`type GotlinSQLIterator[T any] struct`,
`func (iterator *GotlinSQLIterator[T]) next() bool`,
`func (iterator *GotlinSQLIterator[T]) value() *T`,
`func (iterator *GotlinSQLIterator[T]) close()`,
`func (iterator *GotlinSQLIterator[T]) err() error`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestGenerateSQLSelectInClassMethodUsesTypedParameter(t *testing.T) {
code := compileSQL(t, accountRowSource+`
class AccountQueries {
fun byCustomer(customerId: String): GotlinSQLQuery {
return sql.from<AccountRow>().where { it.customerId == customerId }.build()
}
}
`)
if want := `Args: []any{customerId}`; !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
func TestGenerateSQLInsertDoNothing(t *testing.T) {
code := compileSQL(t, accountRowSource+`
fun insertAccount(row: AccountRow): GotlinSQLQuery {
return sql.insert<AccountRow>(row).onConflict { it.id }.doNothing().build()
}
`)
for _, want := range []string{
`SQL: "INSERT INTO accounts (id, customer_id, kind, balance) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING"`,
`Args: []any{row.Id, row.CustomerId, row.AccountType, row.Balance}`,
} {
if !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
}
func TestGenerateSQLInsertDoUpdate(t *testing.T) {
code := compileSQL(t, accountRowSource+`
fun upsertAccount(row: AccountRow): GotlinSQLQuery {
return sql.insert<AccountRow>(row)
.onConflict { it.id }
.doUpdate { excluded -> set(AccountRow.balance, excluded.balance) }
.build()
}
`)
if want := `SQL: "INSERT INTO accounts (id, customer_id, kind, balance) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance"`; !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
func TestGenerateSQLCompositeConflictAndMultipleUpdates(t *testing.T) {
code := compileSQL(t, `
@table("balances")
data class BalanceRow(@id var tenantId: String, @id var id: String, var amount: Double, var pending: Double)
fun upsert(row: BalanceRow): GotlinSQLQuery {
return sql.insert<BalanceRow>(row)
.onConflict { listOf(it.tenantId, it.id) }
.doUpdate { excluded ->
set(BalanceRow.amount, excluded.amount)
set(BalanceRow.pending, excluded.pending)
}
.build()
}
`)
if want := `ON CONFLICT (tenant_id, id) DO UPDATE SET amount = EXCLUDED.amount, pending = EXCLUDED.pending`; !strings.Contains(code, want) {
t.Fatalf("generated Go missing %q:\n%s", want, code)
}
}
func TestRejectInvalidSQLQueries(t *testing.T) {
tests := []struct {
name string
src string
want string
}{
{
name: "unknown row class",
src: `fun query(): GotlinSQLQuery { return sql.from<MissingRow>().build() }`,
want: `SQL row class "MissingRow" does not exist`,
},
{
name: "missing table metadata",
src: `data class Row(var id: String) fun query(): GotlinSQLQuery { return sql.from<Row>().build() }`,
want: "requires @table",
},
{
name: "unknown predicate field",
src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from<AccountRow>().where { it.missing == "x" }.build() }`,
want: "has no field missing",
},
{
name: "incompatible predicate operands",
src: accountRowSource + `fun query(customerId: String): GotlinSQLQuery { return sql.from<AccountRow>().where { it.balance == customerId }.build() }`,
want: "incompatible types Double and String",
},
{
name: "non numeric ordering predicate",
src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from<AccountRow>().where { it.customerId > "a" }.build() }`,
want: "ordering comparison requires numeric operands",
},
{
name: "unknown order field",
src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from<AccountRow>().orderBy { it.missing }.build() }`,
want: "has no field missing",
},
{
name: "conflict field must be id",
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.customerId }.doNothing().build() }`,
want: "must be annotated @id",
},
{
name: "unknown conflict field",
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.missing }.doNothing().build() }`,
want: "has no field missing",
},
{
name: "unknown update target",
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doUpdate { excluded -> set(AccountRow.missing, excluded.balance) }.build() }`,
want: "has no field missing",
},
{
name: "incompatible update fields",
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doUpdate { excluded -> set(AccountRow.balance, excluded.accountType) }.build() }`,
want: "has type Double",
},
{
name: "wrong insert row type",
src: accountRowSource + `
@table("other") data class OtherRow(@id var id: String)
fun query(row: OtherRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doNothing().build() }
`,
want: "cannot insert value of type OtherRow",
},
{
name: "incomplete chain",
src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from<AccountRow>() }`,
want: "must end with build()",
},
{
name: "fetch missing arguments",
src: accountRowSource + `fun query(): List<*AccountRow> { return sql.from<AccountRow>().fetch() }`,
want: "fetch() expects exactly pool and ctx positional arguments",
},
{
name: "single extra argument",
src: accountRowSource + `fun query(pool: Any, ctx: Any): *AccountRow { return sql.from<AccountRow>().single(pool, ctx, ctx) }`,
want: "single() expects exactly pool and ctx positional arguments",
},
{
name: "iterator named arguments",
src: accountRowSource + `fun query(pool: Any, ctx: Any): GotlinSQLIterator<AccountRow> { return sql.from<AccountRow>().iterator(pool = pool, ctx = ctx) }`,
want: "iterator() expects exactly pool and ctx positional arguments",
},
{
name: "fetch type arguments",
src: accountRowSource + `fun query(pool: Any, ctx: Any): List<*AccountRow> { return sql.from<AccountRow>().fetch<String>(pool, ctx) }`,
want: "fetch() expects exactly pool and ctx positional arguments",
},
{
name: "fetch invalid pool type",
src: accountRowSource + `fun query(pool: String, ctx: Any): List<*AccountRow> { return sql.from<AccountRow>().fetch(pool, ctx) }`,
want: "pool argument has non-query type String",
},
{
name: "single invalid context type",
src: accountRowSource + `fun query(pool: Any, ctx: Int): *AccountRow { return sql.from<AccountRow>().single(pool, ctx) }`,
want: "ctx argument has non-context type Int",
},
{
name: "insert execution terminal",
src: accountRowSource + `fun query(row: AccountRow, pool: Any, ctx: Any): List<*AccountRow> { return sql.insert<AccountRow>(row).fetch(pool, ctx) }`,
want: "fetch() is only supported for sql.from",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
program, err := Parse(test.src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
_, err = GenerateGo(program)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want substring %q", err, test.want)
}
})
}
}
func TestRejectInvalidSQLAnnotations(t *testing.T) {
for _, test := range []struct {
src string
want string
}{
{`@table("accounts") class Row(var id: String)`, "table is only valid on data classes"},
{`@table("bad-name") data class Row(var id: String)`, "invalid SQL table name"},
{`data class Row(@column("bad-name") var id: String)`, "invalid SQL column name"},
} {
_, err := Parse(test.src)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Parse() error = %v, want substring %q", err, test.want)
}
}
}
func compileSQL(t *testing.T, src string) string {
t.Helper()
program, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
out, err := GenerateGo(program)
if err != nil {
t.Fatalf("Go generation failed: %v", err)
}
return string(out)
}

View file

@ -3,56 +3,70 @@ package lang
type tokenKind string
const (
tokenEOF tokenKind = "EOF"
tokenIdent tokenKind = "IDENT"
tokenInt tokenKind = "INT"
tokenString tokenKind = "STRING"
tokenTrue tokenKind = "TRUE"
tokenFalse tokenKind = "FALSE"
tokenNull tokenKind = "NULL"
tokenImport tokenKind = "IMPORT"
tokenPackage tokenKind = "PACKAGE"
tokenClass tokenKind = "CLASS"
tokenWorker tokenKind = "WORKER"
tokenInterface tokenKind = "INTERFACE"
tokenFun tokenKind = "FUN"
tokenOverride tokenKind = "OVERRIDE"
tokenVal tokenKind = "VAL"
tokenVar tokenKind = "VAR"
tokenIf tokenKind = "IF"
tokenElse tokenKind = "ELSE"
tokenWhile tokenKind = "WHILE"
tokenSelect tokenKind = "SELECT"
tokenReturn tokenKind = "RETURN"
tokenGo tokenKind = "GO"
tokenTry tokenKind = "TRY"
tokenCatch tokenKind = "CATCH"
tokenThrow tokenKind = "THROW"
tokenLParen tokenKind = "("
tokenRParen tokenKind = ")"
tokenLBrace tokenKind = "{"
tokenRBrace tokenKind = "}"
tokenComma tokenKind = ","
tokenDot tokenKind = "."
tokenColon tokenKind = ":"
tokenSemicolon tokenKind = ";"
tokenPlus tokenKind = "+"
tokenMinus tokenKind = "-"
tokenStar tokenKind = "*"
tokenSlash tokenKind = "/"
tokenPercent tokenKind = "%"
tokenBang tokenKind = "!"
tokenAssign tokenKind = "="
tokenPlusAssign tokenKind = "+="
tokenEq tokenKind = "=="
tokenNeq tokenKind = "!="
tokenLt tokenKind = "<"
tokenLte tokenKind = "<="
tokenGt tokenKind = ">"
tokenGte tokenKind = ">="
tokenAnd tokenKind = "&&"
tokenOr tokenKind = "||"
tokenArrow tokenKind = "->"
tokenEOF tokenKind = "EOF"
tokenIdent tokenKind = "IDENT"
tokenInt tokenKind = "INT"
tokenFloat tokenKind = "FLOAT"
tokenString tokenKind = "STRING"
tokenTrue tokenKind = "TRUE"
tokenFalse tokenKind = "FALSE"
tokenNull tokenKind = "NULL"
tokenImport tokenKind = "IMPORT"
tokenPackage tokenKind = "PACKAGE"
tokenClass tokenKind = "CLASS"
tokenData tokenKind = "DATA"
tokenWorker tokenKind = "WORKER"
tokenInterface tokenKind = "INTERFACE"
tokenEnum tokenKind = "ENUM"
tokenMatch tokenKind = "MATCH"
tokenFun tokenKind = "FUN"
tokenOverride tokenKind = "OVERRIDE"
tokenPrivate tokenKind = "PRIVATE"
tokenVal tokenKind = "VAL"
tokenVar tokenKind = "VAR"
tokenIf tokenKind = "IF"
tokenElse tokenKind = "ELSE"
tokenWhile tokenKind = "WHILE"
tokenFor tokenKind = "FOR"
tokenIn tokenKind = "IN"
tokenSelect tokenKind = "SELECT"
tokenReturn tokenKind = "RETURN"
tokenGo tokenKind = "GO"
tokenDefer tokenKind = "DEFER"
tokenTry tokenKind = "TRY"
tokenCatch tokenKind = "CATCH"
tokenThrow tokenKind = "THROW"
tokenLParen tokenKind = "("
tokenRParen tokenKind = ")"
tokenLBrace tokenKind = "{"
tokenRBrace tokenKind = "}"
tokenLBracket tokenKind = "["
tokenRBracket tokenKind = "]"
tokenComma tokenKind = ","
tokenDot tokenKind = "."
tokenColon tokenKind = ":"
tokenDoubleColon tokenKind = "::"
tokenSemicolon tokenKind = ";"
tokenPlus tokenKind = "+"
tokenMinus tokenKind = "-"
tokenStar tokenKind = "*"
tokenSlash tokenKind = "/"
tokenPercent tokenKind = "%"
tokenBang tokenKind = "!"
tokenAssign tokenKind = "="
tokenPlusAssign tokenKind = "+="
tokenEq tokenKind = "=="
tokenNeq tokenKind = "!="
tokenLt tokenKind = "<"
tokenLte tokenKind = "<="
tokenGt tokenKind = ">"
tokenGte tokenKind = ">="
tokenAnd tokenKind = "&&"
tokenAmp tokenKind = "&"
tokenAt tokenKind = "@"
tokenQuestion tokenKind = "?"
tokenOr tokenKind = "||"
tokenArrow tokenKind = "->"
)
var keywords = map[string]tokenKind{
@ -60,17 +74,24 @@ var keywords = map[string]tokenKind{
"import": tokenImport,
"package": tokenPackage,
"class": tokenClass,
"data": tokenData,
"worker": tokenWorker,
"interface": tokenInterface,
"enum": tokenEnum,
"match": tokenMatch,
"val": tokenVal,
"var": tokenVar,
"override": tokenOverride,
"private": tokenPrivate,
"if": tokenIf,
"else": tokenElse,
"while": tokenWhile,
"for": tokenFor,
"in": tokenIn,
"select": tokenSelect,
"return": tokenReturn,
"go": tokenGo,
"defer": tokenDefer,
"try": tokenTry,
"catch": tokenCatch,
"throw": tokenThrow,

View file

@ -1,58 +1,47 @@
# vscode-gotlin
# Gotlin for VS Code
Minimal VS Code extension for `.gt` files.
VS Code language support for Gotlin (`.gt`) files.
It does two things:
## Features
- registers `.gt` as the `gotlin` language
- launches `gotlin-lsp` over stdio
- TextMate highlighting for current Gotlin declarations, control flow, concurrency, exceptions, types, annotations, pointers, nullable types, generics, and numeric literals
- 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
- 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
- `gotlin-lsp` integration, including its optional `gopls` bridge for Go-imported symbols
It also includes:
## Development
- syntax highlighting
- bracket/comment configuration
- basic Gotlin snippets
- optional `gopls` bridge for hover/definition on Go-imported symbols
## Setup
From this folder:
From this directory:
```bash
npm install
npm run build
npm run check
```
Then in VS Code:
`npm run build` compiles the extension and `npm test` validates all JSON contribution files plus key current grammar tokens and snippets without additional test dependencies.
1. Open this folder as an extension project.
2. Press `F5` to launch an Extension Development Host.
3. Open your Gotlin workspace in that host.
To run the extension, open this directory in VS Code and press `F5`. In the Extension Development Host, open a Gotlin workspace containing `.gt` files.
## Server path
## Language Server
By default the extension looks for:
```text
<workspace>/bin/gotlin-lsp
```
If your binary lives somewhere else, set:
The extension first looks for the platform-specific server binary at `<workspace>/bin/gotlin-lsp`, then falls back to `gotlin-lsp` on `PATH`. Override it with:
```json
"gotlin.serverPath": "/absolute/path/to/gotlin-lsp"
```
If `gopls` is not on your PATH, also set:
If `gopls` is not on `PATH`, configure:
```json
"gotlin.goplsPath": "/absolute/path/to/gopls"
```
## Build the language server
From the repo root:
Build the language server from the repository root with:
```bash
go build -o ./bin/gotlin-lsp ./cmd/gotlin-lsp
```
The extension activates when a Gotlin document is opened and communicates with the server over stdio.

View file

@ -2,55 +2,43 @@
"comments": {
"lineComment": "//"
},
"wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\`\\~\\!\\@\\#\\$\\%\\^\\&\\*\\(\\)\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\\"\\'\\,\\.\\<\\>\\/\\?\\s]+)",
"wordPattern": "[A-Za-z_][A-Za-z0-9_]*|-?[0-9]+(?:\\.[0-9]+)?",
"brackets": [
[
"{",
"}"
],
[
"(",
")"
]
["{", "}"],
["[", "]"],
["(", ")"]
],
"autoClosingPairs": [
{
"open": "{",
"close": "}"
},
{
"open": "(",
"close": ")"
},
{
"open": "\"",
"close": "\""
}
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
],
"surroundingPairs": [
[
"{",
"}"
],
[
"(",
")"
],
[
"\"",
"\""
]
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""]
],
"folding": {
"markers": {
"start": "^\\s*//\\s*(?:#?region)\\b",
"end": "^\\s*//\\s*(?:#?endregion)\\b"
}
},
"indentationRules": {
"increaseIndentPattern": "^.*\\{\\s*$",
"increaseIndentPattern": "^.*\\{[^}]*$",
"decreaseIndentPattern": "^\\s*\\}"
},
"onEnterRules": [
{
"beforeText": "^.*\\{\\s*$",
"action": {
"indent": "indent"
}
"afterText": "^\\s*\\}",
"action": { "indent": "indentOutdent" }
},
{
"beforeText": "^.*\\{\\s*$",
"action": { "indent": "indent" }
}
]
}

View file

@ -1,12 +1,12 @@
{
"name": "gotlin-vscode",
"version": "0.0.1",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gotlin-vscode",
"version": "0.0.1",
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
"vscode-languageclient": "^9.0.1"

View file

@ -1,8 +1,8 @@
{
"name": "gotlin-vscode",
"displayName": "Gotlin",
"description": "VS Code support for Gotlin (.gt) files",
"version": "0.0.1",
"description": "Gotlin language support with syntax highlighting, snippets, and LSP integration",
"version": "0.1.0",
"publisher": "local",
"license": "UNLICENSED",
"engines": {
@ -12,7 +12,7 @@
"Programming Languages"
],
"activationEvents": [
"onLanguage:gotlin"
],
"main": "./out/extension.js",
"contributes": {
@ -60,7 +60,9 @@
},
"scripts": {
"build": "tsc -p .",
"watch": "tsc -w -p ."
"watch": "tsc -w -p .",
"test": "node scripts/validate.js",
"check": "npm run build && npm test"
},
"dependencies": {
"vscode-languageclient": "^9.0.1"

View file

@ -0,0 +1,90 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
function readJSON(relativePath) {
return JSON.parse(fs.readFileSync(path.join(root, relativePath), "utf8"));
}
const packageJSON = readJSON("package.json");
const language = readJSON("language-configuration.json");
const grammar = readJSON("syntaxes/gotlin.tmLanguage.json");
const snippets = readJSON("snippets/gotlin.code-snippets");
function validateRegexes(value) {
if (Array.isArray(value)) {
value.forEach(validateRegexes);
return;
}
if (!value || typeof value !== "object") {
return;
}
for (const [key, child] of Object.entries(value)) {
if (["match", "begin", "end"].includes(key)) {
assert.doesNotThrow(() => new RegExp(child), `invalid ${key} regex: ${child}`);
} else {
validateRegexes(child);
}
}
}
validateRegexes(grammar);
assert.equal(packageJSON.version, "0.1.0");
assert(packageJSON.activationEvents.includes("onLanguage:gotlin"));
assert.equal(packageJSON.contributes.grammars[0].scopeName, "source.gotlin");
assert(language.brackets.some(([open, close]) => open === "[" && close === "]"));
assert(language.folding?.markers?.start && language.indentationRules?.increaseIndentPattern);
const grammarSource = JSON.stringify(grammar);
const expectedTokens = [
"data", "class", "worker", "private", "override", "val", "var", "if", "else",
"while", "for", "in", "select", "return", "go", "defer", "try", "catch",
"throw", "enum", "match", "package", "import", "jsonNaming", "embed", "table", "column", "id",
"generated", "Int", "String", "Boolean", "Unit", "Double", "Float", "Any",
"ByteSlice", "List", "MutableList", "Map", "MutableMap", "Channel", "GotlinSQLQuery",
"GotlinSQLIterator", "from", "where", "orderBy", "orderByDescending", "limit",
"forUpdate", "skipLocked", "insert", "update", "delete", "onConflict", "doNothing",
"doUpdate", "returning", "build", "fetch", "single", "iterator", "set", "now", "mapTo"
];
for (const token of expectedTokens) {
assert(grammarSource.includes(token), `grammar is missing ${token}`);
}
const annotationSource = JSON.stringify(grammar.repository.annotations);
for (const annotation of ["jsonNaming", "embed", "table", "column", "id", "generated"]) {
assert(annotationSource.includes(annotation), `annotation grammar is missing ${annotation}`);
}
const sqlSource = JSON.stringify(grammar.repository.sql);
for (const method of [
"from", "where", "select", "orderBy", "orderByDescending", "limit", "forUpdate",
"skipLocked", "insert", "update", "delete", "onConflict", "doNothing", "doUpdate",
"returning", "build", "fetch", "single", "iterator", "set", "now"
]) {
assert(sqlSource.includes(method), `SQL grammar is missing ${method}`);
}
const imports = grammar.repository.imports.patterns.map((pattern) => new RegExp(pattern.match));
for (const declaration of [
"import encoding.json",
"import json encoding.json",
"import pgxpool \"github.com/jackc/pgx/v5/pgxpool\"",
"import \"example.com/module/package\""
]) {
assert(imports.some((pattern) => pattern.test(declaration)), `import grammar rejected: ${declaration}`);
}
const prefixes = new Set(Object.values(snippets).map((snippet) => snippet.prefix));
for (const prefix of [
"dataclass", "tablerow", "embed", "worker", "enum", "match", "mapto", "go", "defer", "foreach", "sqlfetch",
"sqlsingle", "sqliterator", "sqlinsertnothing", "sqlinsertupdate", "sqlupdatereturning"
]) {
assert(prefixes.has(prefix), `snippets are missing prefix ${prefix}`);
}
console.log("Validated Gotlin package metadata, grammar, language configuration, and snippets.");

View file

@ -19,17 +19,169 @@
},
"Package": {
"prefix": "package",
"body": [
"package ${1:demo}"
],
"body": ["package ${1:demo}"],
"description": "Gotlin package declaration"
},
"Go Import": {
"prefix": "importgo",
"body": ["import ${1:alias} \"${2:example.com/module/package}\""],
"description": "Import a Go module path with an alias"
},
"Data Class": {
"prefix": "dataclass",
"body": [
"import go.${1:fmt}"
"data class ${1:Name}(",
" val ${2:value}: ${3:String}",
")"
],
"description": "Import a Go package"
"description": "Gotlin data class"
},
"SQL Table Row": {
"prefix": "tablerow",
"body": [
"@table(\"${1:table_name}\")",
"data class ${2:Row}(",
" @generated @id val ${3:id}: ${4:Int},",
" @column(\"${5:value}\") var ${6:value}: ${7:String}",
")"
],
"description": "Annotated SQL table row data class"
},
"Embedded Value": {
"prefix": "embed",
"body": ["@embed(\"${1:path/to/file}\") val ${2:name}: ${3:ByteSlice}"],
"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": {
"prefix": "enum",
"body": [
"enum ${1:Result} {",
" ${2:Success}(${3:String})",
" ${4:Failure}(${5:String})",
" ${6:Pending}",
"}"
],
"description": "Rust-style algebraic enum"
},
"Exhaustive Enum Match": {
"prefix": "match",
"body": [
"match (${1:result}) {",
" ${2:Result}::${3:Success}(${4:value}) -> {",
" $5",
" }",
" ${2:Result}::${6:Failure}(${7:reason}) -> {",
" $8",
" }",
" ${2:Result}::${9:Pending} -> {",
" $0",
" }",
"}"
],
"description": "Exhaustive match over enum variants"
},
"Recursive Structural Mapping": {
"prefix": "mapto",
"body": ["val ${1:target} = ${2:source}.mapTo<${3:Target}>()"],
"description": "Recursively map compatible classes or enums"
},
"Go Block": {
"prefix": "go",
"body": [
"go {",
" $0",
"}"
],
"description": "Run a block concurrently"
},
"Defer Call": {
"prefix": "defer",
"body": ["defer ${1:resource}.${2:close}()"],
"description": "Defer a function call"
},
"For Each": {
"prefix": "foreach",
"body": [
"for (${1:item} in ${2:items}) {",
" $0",
"}"
],
"description": "Iterate over a collection"
},
"Typed SQL Fetch": {
"prefix": "sqlfetch",
"body": [
"val ${1:rows}: List<*${2:Row}> = sql.from<${2:Row}>()",
" .where { ${3:it.id == id} }",
" .fetch(${4:pool}, ${5:ctx})"
],
"description": "Fetch typed SQL rows"
},
"Typed SQL Single": {
"prefix": "sqlsingle",
"body": [
"val ${1:row}: *${2:Row} = sql.from<${2:Row}>()",
" .where { ${3:it.id == id} }",
" .single(${4:pool}, ${5:ctx})"
],
"description": "Fetch one typed SQL row"
},
"Typed SQL Iterator": {
"prefix": "sqliterator",
"body": [
"val ${1:rows}: GotlinSQLIterator<${2:Row}> = sql.from<${2:Row}>()",
" .where { ${3:it.id == id} }",
" .iterator(${4:pool}, ${5:ctx})"
],
"description": "Iterate over typed SQL rows"
},
"SQL Insert On Conflict Do Nothing": {
"prefix": "sqlinsertnothing",
"body": [
"sql.insert<${1:Row}>(${2:row})",
" .onConflict { ${3:it.id} }",
" .doNothing()",
" .build()"
],
"description": "Build an insert that ignores a typed conflict"
},
"SQL Insert On Conflict Do Update": {
"prefix": "sqlinsertupdate",
"body": [
"sql.insert<${1:Row}>(${2:row})",
" .onConflict { ${3:it.id} }",
" .doUpdate { ${4:excluded} ->",
" set(${1:Row}.${5:value}, ${4:excluded}.${5:value})",
" }",
" .build()"
],
"description": "Build an insert that updates on a typed conflict"
},
"SQL Update Returning": {
"prefix": "sqlupdatereturning",
"body": [
"val ${1:updated}: *${2:Row} = sql.update<${2:Row}>()",
" .set { ${3:row} ->",
" set(${3:row}.${4:value}, ${5:newValue})",
" }",
" .where { ${6:it.id == id} }",
" .returning { it }",
" .single(${7:pool}, ${8:ctx})"
],
"description": "Update and return a typed SQL row"
},
"If": {
"prefix": "if",
@ -42,9 +194,7 @@
},
"Lambda": {
"prefix": "lambda",
"body": [
"{ ${1:it} -> $0 }"
],
"body": ["{ ${1:it} -> $0 }"],
"description": "Lambda expression"
},
"Override Method": {

View file

@ -3,39 +3,21 @@
"name": "Gotlin",
"scopeName": "source.gotlin",
"patterns": [
{
"include": "#comments"
},
{
"include": "#imports"
},
{
"include": "#package"
},
{
"include": "#functions"
},
{
"include": "#typesDecl"
},
{
"include": "#literals"
},
{
"include": "#keywords"
},
{
"include": "#types"
},
{
"include": "#strings"
},
{
"include": "#numbers"
},
{
"include": "#operators"
}
{ "include": "#comments" },
{ "include": "#imports" },
{ "include": "#package" },
{ "include": "#annotations" },
{ "include": "#declarations" },
{ "include": "#functions" },
{ "include": "#strings" },
{ "include": "#sql" },
{ "include": "#generics" },
{ "include": "#literals" },
{ "include": "#keywords" },
{ "include": "#types" },
{ "include": "#numbers" },
{ "include": "#typeOperators" },
{ "include": "#operators" }
],
"repository": {
"comments": {
@ -49,18 +31,29 @@
"imports": {
"patterns": [
{
"name": "meta.import.gotlin",
"match": "\\b(import)\\b\\s+((?:[A-Za-z_][\\w-]*\\.)*[A-Za-z_][\\w-]*)(?:\\s+((?:[A-Za-z_][\\w-]*\\.)*[A-Za-z_][\\w-]*))?",
"name": "meta.import.go.quoted.gotlin",
"match": "^(\\s*)(import)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s+(\"(?:\\\\.|[^\"\\\\])*\")\\s*;?",
"captures": {
"1": {
"name": "keyword.control.import.gotlin"
},
"2": {
"name": "meta.path.gotlin"
},
"3": {
"name": "meta.path.gotlin"
}
"2": { "name": "keyword.control.import.gotlin" },
"3": { "name": "entity.name.namespace.alias.gotlin" },
"4": { "name": "string.quoted.double.import-path.gotlin" }
}
},
{
"name": "meta.import.go.quoted.gotlin",
"match": "^(\\s*)(import)\\s+(\"(?:\\\\.|[^\"\\\\])*\")\\s*;?",
"captures": {
"2": { "name": "keyword.control.import.gotlin" },
"3": { "name": "string.quoted.double.import-path.gotlin" }
}
},
{
"name": "meta.import.go.bare.gotlin",
"match": "^(\\s*)(import)\\s+(?:([A-Za-z_][A-Za-z0-9_]*)\\s+)?([A-Za-z_][A-Za-z0-9_]*(?:[.-][A-Za-z_][A-Za-z0-9_]*)*)\\s*;?\\s*(?://.*)?$",
"captures": {
"2": { "name": "keyword.control.import.gotlin" },
"3": { "name": "entity.name.namespace.alias.gotlin" },
"4": { "name": "entity.name.namespace.import-path.gotlin" }
}
}
]
@ -69,14 +62,108 @@
"patterns": [
{
"name": "meta.package.gotlin",
"match": "\\b(package)\\b\\s+((?:[A-Za-z_][\\w-]*\\.)*[A-Za-z_][\\w-]*)",
"match": "^(\\s*)(package)\\s+([A-Za-z_][A-Za-z0-9_]*(?:[.-][A-Za-z_][A-Za-z0-9_]*)*)\\s*;?",
"captures": {
"1": {
"name": "keyword.control.package.gotlin"
},
"2": {
"name": "meta.path.gotlin"
}
"2": { "name": "keyword.control.package.gotlin" },
"3": { "name": "entity.name.namespace.gotlin" }
}
}
]
},
"annotations": {
"patterns": [
{
"match": "(@)(jsonNaming)\\b",
"captures": {
"1": { "name": "punctuation.definition.annotation.gotlin" },
"2": { "name": "entity.other.attribute-name.annotation.json-naming.gotlin" }
}
},
{
"match": "(@)(embed)\\b",
"captures": {
"1": { "name": "punctuation.definition.annotation.gotlin" },
"2": { "name": "entity.other.attribute-name.annotation.embed.gotlin" }
}
},
{
"match": "(@)(table)\\b",
"captures": {
"1": { "name": "punctuation.definition.annotation.gotlin" },
"2": { "name": "entity.other.attribute-name.annotation.table.gotlin" }
}
},
{
"match": "(@)(column)\\b",
"captures": {
"1": { "name": "punctuation.definition.annotation.gotlin" },
"2": { "name": "entity.other.attribute-name.annotation.column.gotlin" }
}
},
{
"match": "(@)(id)\\b",
"captures": {
"1": { "name": "punctuation.definition.annotation.gotlin" },
"2": { "name": "entity.other.attribute-name.annotation.id.gotlin" }
}
},
{
"match": "(@)(generated)\\b",
"captures": {
"1": { "name": "punctuation.definition.annotation.gotlin" },
"2": { "name": "entity.other.attribute-name.annotation.generated.gotlin" }
}
},
{
"match": "(@)([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "punctuation.definition.annotation.gotlin" },
"2": { "name": "entity.other.attribute-name.annotation.gotlin" }
}
}
]
},
"declarations": {
"patterns": [
{
"name": "meta.class.data.gotlin",
"match": "\\b(data)\\s+(class)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.modifier.data.gotlin" },
"2": { "name": "storage.type.class.gotlin" },
"3": { "name": "entity.name.type.class.gotlin" }
}
},
{
"name": "meta.interface.gotlin",
"match": "\\b(interface)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.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",
"match": "\\b(enum)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.enum.gotlin" },
"2": { "name": "entity.name.type.enum.gotlin" }
}
},
{
"name": "meta.class.gotlin",
"match": "\\b(class)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.class.gotlin" },
"2": { "name": "entity.name.type.class.gotlin" }
}
}
]
@ -85,46 +172,73 @@
"patterns": [
{
"name": "meta.function.gotlin",
"match": "\\b(?:(override)\\s+)?(fun)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(?:(override)\\s+)?(fun)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": {
"name": "storage.modifier.gotlin"
},
"2": {
"name": "keyword.control.function.gotlin"
},
"3": {
"name": "entity.name.function.gotlin"
}
"1": { "name": "storage.modifier.override.gotlin" },
"2": { "name": "storage.type.function.gotlin" },
"3": { "name": "entity.name.function.gotlin" }
}
}
]
},
"typesDecl": {
"strings": {
"patterns": [
{
"name": "meta.interface.gotlin",
"match": "\\b(interface)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": {
"name": "storage.type.interface.gotlin"
},
"2": {
"name": "entity.name.type.interface.gotlin"
"name": "string.quoted.double.gotlin",
"begin": "\"",
"beginCaptures": {
"0": { "name": "punctuation.definition.string.begin.gotlin" }
},
"end": "\"",
"endCaptures": {
"0": { "name": "punctuation.definition.string.end.gotlin" }
},
"patterns": [
{
"name": "constant.character.escape.gotlin",
"match": "\\\\."
}
}
]
}
]
},
"sql": {
"patterns": [
{
"name": "support.type.namespace.sql.gotlin",
"match": "\\bsql\\b(?=\\s*\\.)"
},
{
"name": "meta.class.gotlin",
"match": "\\b(class)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": {
"name": "storage.type.class.gotlin"
},
"2": {
"name": "entity.name.type.class.gotlin"
}
}
"name": "support.function.sql.query.gotlin",
"match": "(?<=\\.)\\b(from|where|select|orderBy|orderByDescending|limit|forUpdate|skipLocked)\\b"
},
{
"name": "support.function.sql.mutation.gotlin",
"match": "(?<=\\.)\\b(insert|update|delete|onConflict|doNothing|doUpdate|returning)\\b"
},
{
"name": "support.function.sql.execution.gotlin",
"match": "(?<=\\.)\\b(build|fetch|single|iterator)\\b"
},
{
"name": "support.function.sql.helper.gotlin",
"match": "\\b(set|now)\\b(?=\\s*\\()"
},
{
"name": "support.function.mapping.gotlin",
"match": "(?<=\\.)\\bmapTo\\b"
}
]
},
"generics": {
"patterns": [
{
"name": "punctuation.definition.generic.begin.gotlin",
"match": "(?<=\\w)<(?=\\s*[*A-Za-z_])"
},
{
"name": "punctuation.definition.generic.end.gotlin",
"match": ">(?=\\??\\s*(?:[>,.()\\[\\]{}:=]|$))"
}
]
},
@ -140,26 +254,54 @@
},
{
"name": "variable.language.this.gotlin",
"match": "\\bthis\\b"
"match": "\\b(this|it)\\b"
}
]
},
"keywords": {
"patterns": [
{
"name": "keyword.control.gotlin",
"match": "\\b(fun|val|var|override|if|else|while|return|try|catch|throw)\\b"
"name": "keyword.control.declaration.gotlin",
"match": "\\b(package|import|data|class|interface|worker|enum|fun)\\b"
},
{
"name": "storage.modifier.gotlin",
"match": "\\b(private|override)\\b"
},
{
"name": "storage.type.variable.gotlin",
"match": "\\b(val|var)\\b"
},
{
"name": "keyword.control.conditional.gotlin",
"match": "\\b(if|else|match)\\b"
},
{
"name": "keyword.control.loop.gotlin",
"match": "\\b(while|for|in)\\b"
},
{
"name": "keyword.control.concurrency.gotlin",
"match": "\\b(select|go|defer)\\b"
},
{
"name": "keyword.control.exception.gotlin",
"match": "\\b(try|catch|throw)\\b"
},
{
"name": "keyword.control.return.gotlin",
"match": "\\breturn\\b"
}
]
},
"types": {
"patterns": [
{
"name": "storage.type.gotlin",
"match": "\\b(Int|String|Boolean|Unit)\\b"
"name": "support.type.builtin.gotlin",
"match": "\\b(Int|String|Boolean|Unit|Double|Float|Any|ByteSlice|List|MutableList|Map|MutableMap|Channel|GotlinSQLQuery|GotlinSQLIterator)\\b"
},
{
"name": "support.type.gotlin",
"name": "support.type.qualified.gotlin",
"match": "\\b[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)+\\b"
},
{
@ -168,34 +310,67 @@
}
]
},
"strings": {
"patterns": [
{
"name": "string.quoted.double.gotlin",
"begin": "\"",
"end": "\"",
"patterns": [
{
"name": "constant.character.escape.gotlin",
"match": "\\\\."
}
]
}
]
},
"numbers": {
"patterns": [
{
"name": "constant.numeric.gotlin",
"match": "\\b\\d+\\b"
"name": "constant.numeric.float.gotlin",
"match": "\\b[0-9]+\\.[0-9]+\\b"
},
{
"name": "constant.numeric.integer.gotlin",
"match": "\\b[0-9]+\\b"
}
]
},
"typeOperators": {
"patterns": [
{
"name": "keyword.operator.type.nullable.gotlin",
"match": "(?<=[A-Za-z0-9_>])\\?"
},
{
"name": "keyword.operator.address.gotlin",
"match": "&(?=\\s*[A-Za-z_(])"
},
{
"name": "keyword.operator.pointer.dereference.spread.gotlin",
"match": "\\*(?=\\s*[A-Za-z_(])"
}
]
},
"operators": {
"patterns": [
{
"name": "keyword.operator.gotlin",
"match": "->|==|!=|<=|>=|&&|\\|\\||[=+\\-*/%<>!:.,]"
"name": "keyword.operator.assignment.gotlin",
"match": "\\+=|="
},
{
"name": "keyword.operator.comparison.gotlin",
"match": "==|!=|<=|>="
},
{
"name": "keyword.operator.logical.gotlin",
"match": "&&|\\|\\||!"
},
{
"name": "keyword.operator.arrow.gotlin",
"match": "->"
},
{
"name": "punctuation.accessor.enum.gotlin",
"match": "::"
},
{
"name": "keyword.operator.arithmetic.gotlin",
"match": "[+\\-*/%<>]"
},
{
"name": "punctuation.accessor.dot.gotlin",
"match": "\\."
},
{
"name": "punctuation.separator.gotlin",
"match": "[,;:]"
}
]
}