From de1262b4cfcde5ee591f600816c6a52cebc0242c Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 27 Aug 2026 01:46:57 +0200 Subject: [PATCH] Expand Gotlin language and tooling --- .gitignore | 5 +- README.md | 312 ++++- cmd/gotlin-lsp/main.go | 260 +++- cmd/gotlin-lsp/main_test.go | 94 +- cmd/gotlinc/main.go | 47 +- examples/enums.gt | 22 + examples/http_server.gt | 2 +- examples/mapping.gt | 40 + examples/showcase.gt | 2 +- go.mod | 7 + go.sum | 19 + imports.go | 2 +- internal/lang/ast.go | 102 +- internal/lang/compiler_test.go | 177 +++ internal/lang/data_class_test.go | 72 + internal/lang/enum_test.go | 79 ++ internal/lang/external_struct_test.go | 62 + internal/lang/foreach.go | 28 + internal/lang/foreach_test.go | 31 + internal/lang/generate_go.go | 932 +++++++++++- internal/lang/go_interop_test.go | 32 + internal/lang/index_test.go | 26 + internal/lang/json_decode_test.go | 31 + internal/lang/lexer.go | 19 + internal/lang/mapping.go | 269 ++++ internal/lang/mapping_test.go | 159 +++ internal/lang/member_type_test.go | 53 + internal/lang/parser.go | 459 +++++- internal/lang/pointer_test.go | 28 + internal/lang/semantics.go | 282 ++++ internal/lang/sql.go | 1244 +++++++++++++++++ internal/lang/sql_expansion_test.go | 277 ++++ internal/lang/sql_test.go | 383 +++++ internal/lang/token.go | 121 +- tools/vscode-gotlin/README.md | 51 +- .../vscode-gotlin/language-configuration.json | 62 +- tools/vscode-gotlin/package-lock.json | 4 +- tools/vscode-gotlin/package.json | 10 +- tools/vscode-gotlin/scripts/validate.js | 90 ++ .../snippets/gotlin.code-snippets | 166 ++- .../syntaxes/gotlin.tmLanguage.json | 387 +++-- 41 files changed, 6064 insertions(+), 384 deletions(-) create mode 100644 examples/enums.gt create mode 100644 examples/mapping.gt create mode 100644 internal/lang/data_class_test.go create mode 100644 internal/lang/enum_test.go create mode 100644 internal/lang/external_struct_test.go create mode 100644 internal/lang/foreach.go create mode 100644 internal/lang/foreach_test.go create mode 100644 internal/lang/go_interop_test.go create mode 100644 internal/lang/index_test.go create mode 100644 internal/lang/json_decode_test.go create mode 100644 internal/lang/mapping.go create mode 100644 internal/lang/mapping_test.go create mode 100644 internal/lang/member_type_test.go create mode 100644 internal/lang/pointer_test.go create mode 100644 internal/lang/semantics.go create mode 100644 internal/lang/sql.go create mode 100644 internal/lang/sql_expansion_test.go create mode 100644 internal/lang/sql_test.go create mode 100644 tools/vscode-gotlin/scripts/validate.js diff --git a/.gitignore b/.gitignore index dc04bec..809bceb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ node_modules .gocache bin -out \ No newline at end of file +out +/gotlinc +/gotlin-lsp +*.vsix diff --git a/README.md b/README.md index 0d9ee57..74f83ae 100644 --- a/README.md +++ b/README.md @@ -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()`: + +```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() +``` + +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()` 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() + .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() + .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() + [.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() + .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() + .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() + .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().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(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(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(row) + .returning { it } + .single(pool, ctx) + +sql.insert(row) + .onConflict { it.id } + .doNothing() + .returning { value -> AccountSummary(value.id, value.balance) } + .single(pool, ctx) +``` + +The exact insert forms are: + +```text +sql.insert(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() + .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() + .where { it.id == accountId } + .returning { it } + .single(pool, ctx) +``` + +The exact write forms are: + +```text +sql.update() + .set { row -> set(row.field, value); ... } + [.where { predicate }] + [.returning { it | Projection(it.field, ...) }] + .build() | returning execution terminal + +sql.delete() + [.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. diff --git a/cmd/gotlin-lsp/main.go b/cmd/gotlin-lsp/main.go index 0f42a61..e520cb6 100644 --- a/cmd/gotlin-lsp/main.go +++ b/cmd/gotlin-lsp/main.go @@ -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, ¶ms); 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", true - case "every": - return "fun every(ms: Int): Channel", 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(capacity: Int = 0): Channel", + "after": "fun after(ms: Int): Channel", + "every": "fun every(ms: Int): Channel", + "listOf": "fun listOf(values: T...): List", + "mutableListOf": "fun mutableListOf(values: T...): MutableList", + "mapOf": "fun mapOf(pairs: Any...): Map", + "mutableMapOf": "fun mutableMapOf(pairs: Any...): MutableMap", + "ByteSlice": "fun ByteSlice(value: String): ByteSlice", + "append": "fun append(values: List, value: T): List", + "len": "fun len(value: Any): Int", + "cap": "fun cap(value: Any): Int", + "make": "fun make(size: Int): T", + "new": "fun new(): *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 { diff --git a/cmd/gotlin-lsp/main_test.go b/cmd/gotlin-lsp/main_test.go index b24e4a7..5ba652a 100644 --- a/cmd/gotlin-lsp/main_test.go +++ b/cmd/gotlin-lsp/main_test.go @@ -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 = mutableListOf() + 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 diff --git a/cmd/gotlinc/main.go b/cmd/gotlinc/main.go index cd2c7f7..4e908d1 100644 --- a/cmd/gotlinc/main.go +++ b/cmd/gotlinc/main.go @@ -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) } diff --git a/examples/enums.gt b/examples/enums.gt new file mode 100644 index 0000000..6a60506 --- /dev/null +++ b/examples/enums.gt @@ -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)) +} diff --git a/examples/http_server.gt b/examples/http_server.gt index e78c9b0..a528fe4 100644 --- a/examples/http_server.gt +++ b/examples/http_server.gt @@ -37,7 +37,7 @@ class EpicControllerImpl(val db: *bun.DB) { } worker Counter { - val counter = 0 + var counter = 0 fun getCount(): Int { return counter diff --git a/examples/mapping.gt b/examples/mapping.gt new file mode 100644 index 0000000..8956e1a --- /dev/null +++ b/examples/mapping.gt @@ -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, + var state: EntityState +) + +data class AccountResponse( + var address: *AddressResponse, + var id: String, + var labels: List, + var state: ResponseState +) + +fun main() { + val entity = AccountEntity("account-1", AddressEntity("Berlin"), listOf("active"), EntityState::Active) + val response = entity.mapTo() + println(response.address.city) + match (response.state) { + ResponseState::Active -> { println("active") } + ResponseState::Failed(reason) -> { println(reason) } + ResponseState::Pending -> { println("pending") } + } +} diff --git a/examples/showcase.gt b/examples/showcase.gt index 0437a6a..6f281ed 100644 --- a/examples/showcase.gt +++ b/examples/showcase.gt @@ -14,7 +14,7 @@ class PrefixGreeter(val prefix: String): Greeter { } worker Counter { - val count = 0 + var count = 0 fun increment() { count += 1 diff --git a/go.mod b/go.mod index 7b22240..b9f127a 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index e9784ea..007e8ab 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/imports.go b/imports.go index 22f4097..57a4ee8 100644 --- a/imports.go +++ b/imports.go @@ -5,6 +5,6 @@ import ( "strings" ) -func main() { +func demoImports() { fmt.Println(strings.ToUpper("gotlin")) } diff --git a/internal/lang/ast.go b/internal/lang/ast.go index af0046d..04a7300 100644 --- a/internal/lang/ast.go +++ b/internal/lang/ast.go @@ -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 diff --git a/internal/lang/compiler_test.go b/internal/lang/compiler_test.go index 89c0179..4ee5917 100644 --- a/internal/lang/compiler_test.go +++ b/internal/lang/compiler_test.go @@ -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 diff --git a/internal/lang/data_class_test.go b/internal/lang/data_class_test.go new file mode 100644 index 0000000..03a3f47 --- /dev/null +++ b/internal/lang/data_class_test.go @@ -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(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) + } + } +} diff --git a/internal/lang/enum_test.go b/internal/lang/enum_test.go new file mode 100644 index 0000000..fd3de25 --- /dev/null +++ b/internal/lang/enum_test.go @@ -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) + } + } +} diff --git a/internal/lang/external_struct_test.go b/internal/lang/external_struct_test.go new file mode 100644 index 0000000..9c02cb1 --- /dev/null +++ b/internal/lang/external_struct_test.go @@ -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) + } + } +} diff --git a/internal/lang/foreach.go b/internal/lang/foreach.go new file mode 100644 index 0000000..b9e2185 --- /dev/null +++ b/internal/lang/foreach.go @@ -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 +} diff --git a/internal/lang/foreach_test.go b/internal/lang/foreach_test.go new file mode 100644 index 0000000..684d880 --- /dev/null +++ b/internal/lang/foreach_test.go @@ -0,0 +1,31 @@ +package lang + +import ( + "strings" + "testing" +) + +func TestGenerateGoForEach(t *testing.T) { + prog, err := Parse(` +package demo + +fun main() { + val accounts: List = 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) + } + } +} diff --git a/internal/lang/generate_go.go b/internal/lang/generate_go.go index 6541834..5903a92 100644 --- a/internal/lang/generate_go.go +++ b/internal/lang/generate_go.go @@ -5,6 +5,7 @@ import ( "fmt" "go/format" "regexp" + "sort" "strconv" "strings" ) @@ -18,6 +19,9 @@ func GenerateGoMain(program *Program) ([]byte, error) { } func generateGo(program *Program, packageOverride string) ([]byte, error) { + if err := validateMutability(program); err != nil { + return nil, err + } var g goGenerator if err := g.program(program, packageOverride); err != nil { return nil, err @@ -31,24 +35,49 @@ func generateGo(program *Program, packageOverride string) ([]byte, error) { } type goGenerator struct { - buf bytes.Buffer - indentLevel int - needsFmt bool - needsRunCatch bool - needsAutoThrow bool - needsTime bool - needsEveryMs bool - functions map[string]FunctionDecl - classes map[string]ClassDecl - workers map[string]WorkerDecl - currentFunc FunctionDecl - currentClass *ClassDecl - currentWorker *WorkerDecl + buf bytes.Buffer + indentLevel int + needsFmt bool + needsRunCatch bool + needsAutoThrow bool + needsTime bool + needsEveryMs bool + needsJSONDecode bool + sqlContextAlias string + sqlPGXAlias string + functions map[string]FunctionDecl + classes map[string]ClassDecl + workers map[string]WorkerDecl + enums map[string]EnumDecl + imports map[string]bool + currentFunc FunctionDecl + currentClass *ClassDecl + currentWorker *WorkerDecl currentWorkerFieldReceiver string - scopes []map[string]bool + scopes []map[string]bool + typeScopes []map[string]string + matchCounter int + mappings *mappingState } func (g *goGenerator) program(program *Program, packageOverride string) error { + g.mappings = &mappingState{functions: map[string]string{}} + containsSQL := programContainsSQL(program) + containsSQLExecution := programContainsSQLExecution(program) + if containsSQLExecution { + g.needsAutoThrow = true + g.sqlContextAlias = runtimeImportAlias(program, "context", "gotlincontext") + g.sqlPGXAlias = runtimeImportAlias(program, "github.com/jackc/pgx/v5", "gotlinpgx") + } + g.imports = make(map[string]bool, len(program.Imports)) + for _, imp := range program.Imports { + name := imp.Alias + if name == "" { + parts := strings.Split(imp.Path, "/") + name = parts[len(parts)-1] + } + g.imports[name] = true + } g.functions = make(map[string]FunctionDecl, len(program.Functions)) for _, fn := range program.Functions { g.functions[fn.Name] = fn @@ -61,6 +90,10 @@ func (g *goGenerator) program(program *Program, packageOverride string) error { for _, worker := range program.Workers { g.workers[worker.Name] = worker } + g.enums = make(map[string]EnumDecl, len(program.Enums)) + for _, enum := range program.Enums { + g.enums[enum.Name] = enum + } packageName := goPackageName(program.PackagePath) if packageOverride != "" { @@ -224,7 +257,27 @@ func (g *goGenerator) program(program *Program, packageOverride string) error { } } } - imports := collectImports(program, g.needsFmt, g.needsTime) + var runtimeImports map[string]string + if containsSQLExecution { + runtimeImports = map[string]string{ + "context": g.sqlContextAlias, + "github.com/jackc/pgx/v5": g.sqlPGXAlias, + } + } + imports := collectImports(program, g.needsFmt, g.needsTime, runtimeImports) + if len(program.Embeds) > 0 { + hasEmbed := false + for _, imp := range program.Imports { + if imp.Path == "embed" { + hasEmbed = true + break + } + } + if !hasEmbed { + imports = append(imports, `_ "embed"`) + sort.Strings(imports) + } + } if len(imports) == 1 { g.line("import " + imports[0]) g.line("") @@ -251,8 +304,17 @@ func (g *goGenerator) program(program *Program, packageOverride string) error { g.emitEveryMsSupport() g.line("") } + for _, embedded := range program.Embeds { + g.line("//go:embed " + embedded.Path) + g.line("var " + embedded.Name + " " + mapGoType(embedded.Type)) + g.line("") + } emitted := false + if containsSQL { + g.emitSQLSupport(program, containsSQLExecution) + emitted = true + } for _, decl := range program.Interfaces { if emitted { g.line("") @@ -260,6 +322,13 @@ func (g *goGenerator) program(program *Program, packageOverride string) error { g.interfaceDecl(decl) emitted = true } + for _, enum := range program.Enums { + if emitted { + g.line("") + } + g.enumDecl(enum) + emitted = true + } for _, class := range program.Classes { if emitted { g.line("") @@ -287,6 +356,18 @@ func (g *goGenerator) program(program *Program, packageOverride string) error { } emitted = true } + for i := 0; i < len(g.mappings.pairs); i++ { + g.line("") + if err := g.emitMapping(g.mappings.pairs[i]); err != nil { + return err + } + } + if g.needsJSONDecode { + if emitted { + g.line("") + } + g.emitJSONDecodeSupport() + } return nil } @@ -297,7 +378,7 @@ func (g *goGenerator) function(fn FunctionDecl) error { g.scopes = nil g.pushScope() for _, param := range fn.Params { - g.define(param.Name) + g.defineType(param.Name, param.Type) } g.write("func ") g.write(fn.Name) @@ -327,11 +408,73 @@ func (g *goGenerator) interfaceDecl(decl InterfaceDecl) { g.line("}") } +func (g *goGenerator) enumDecl(decl EnumDecl) { + if enumIsString(decl) { + g.line("type " + decl.Name + " string") + g.line("const (") + g.indentLevel++ + for _, variant := range decl.Variants { + g.line(decl.Name + variant.Name + " " + decl.Name + " = " + strconv.Quote(enumStringValue(variant))) + } + g.indentLevel-- + g.line(")") + return + } + g.line("type " + decl.Name + " interface { is" + decl.Name + "() }") + for _, variant := range decl.Variants { + name := decl.Name + variant.Name + g.line("") + g.line("type " + name + " struct {") + g.indentLevel++ + for i, typ := range variant.PayloadTypes { + g.line(fmt.Sprintf("Value%d %s", i, mapGoType(typ))) + } + g.indentLevel-- + g.line("}") + g.line("func (*" + name + ") is" + decl.Name + "() {}") + } +} + +func enumIsString(decl EnumDecl) bool { + for _, variant := range decl.Variants { + if len(variant.PayloadTypes) > 0 { + return false + } + } + return true +} + +func enumStringValue(variant EnumVariant) string { + if variant.StringValue != "" { + return variant.StringValue + } + return variant.Name +} + +func enumVariant(decl EnumDecl, name string) *EnumVariant { + for i := range decl.Variants { + if decl.Variants[i].Name == name { + return &decl.Variants[i] + } + } + return nil +} + func (g *goGenerator) classDecl(class ClassDecl) error { g.line("type " + class.Name + " struct {") g.indentLevel++ for _, field := range class.Fields { - g.line(field.Name + " " + mapGoType(field.Type)) + name := field.Name + tag := "" + if class.Data { + if field.Private { + tag = " `json:\"-\"`" + } else { + name = exportedGoName(name) + tag = fmt.Sprintf(" `json:\"%s\"`", jsonFieldName(field.Name, class.JSONNaming)) + } + } + g.line(name + " " + mapGoType(field.Type) + tag) } g.indentLevel-- g.line("}") @@ -359,7 +502,11 @@ func (g *goGenerator) classDecl(class ClassDecl) error { if i > 0 { g.write(", ") } - g.write(field.Name) + if class.Data && !field.Private { + g.write(exportedGoName(field.Name)) + } else { + g.write(field.Name) + } g.write(": ") g.write(field.Name) } @@ -455,7 +602,7 @@ func (g *goGenerator) method(class ClassDecl, fn FunctionDecl) error { g.define("self") g.define("this") for _, param := range fn.Params { - g.define(param.Name) + g.defineType(param.Name, param.Type) } g.write("func (self *") @@ -489,7 +636,7 @@ func (g *goGenerator) workerMethod(worker WorkerDecl, fn FunctionDecl) error { g.define("self") g.define("this") for _, param := range fn.Params { - g.define(param.Name) + g.defineType(param.Name, param.Type) } g.write("func (self *") @@ -610,7 +757,11 @@ func (g *goGenerator) stmt(stmt Stmt, tail []Stmt) error { } else { g.line(fmt.Sprintf("var %s %s = %s", s.Name, mapGoType(s.Type), value)) } - g.define(s.Name) + typ := s.Type + if typ == "" { + typ = g.exprType(s.Value) + } + g.defineType(s.Name, typ) case MultiVarDecl: value, err := g.expr(s.Value, "") if err != nil { @@ -696,6 +847,12 @@ func (g *goGenerator) stmt(stmt Stmt, tail []Stmt) error { g.line(value) g.indentLevel-- g.line("}()") + case DeferStmt: + value, err := g.expr(s.Value, "") + if err != nil { + return err + } + g.line("defer " + value) case ExprStmt: value, err := g.expr(s.Value, "") if err != nil { @@ -746,6 +903,21 @@ func (g *goGenerator) stmt(stmt Stmt, tail []Stmt) error { g.popScope() g.indentLevel-- g.line("}") + case ForEachStmt: + source, err := g.expr(s.Source, "") + if err != nil { + return err + } + g.line(fmt.Sprintf("for _, %s := range %s {", s.Name, source)) + g.indentLevel++ + g.pushScope() + g.define(s.Name) + if err := g.block(s.Body); err != nil { + return err + } + g.popScope() + g.indentLevel-- + g.line("}") case SelectStmt: g.line("select {") g.indentLevel++ @@ -774,6 +946,90 @@ func (g *goGenerator) stmt(stmt Stmt, tail []Stmt) error { } g.indentLevel-- g.line("}") + case MatchStmt: + enumName := strings.TrimPrefix(g.exprType(s.Value), "*") + if enumName == "" && len(s.Cases) > 0 { + enumName = s.Cases[0].EnumName + } + decl, ok := g.enums[enumName] + if !ok { + return fmt.Errorf("match value is not a known enum") + } + seen := map[string]bool{} + for _, c := range s.Cases { + if c.EnumName != enumName { + return fmt.Errorf("match case %s::%s does not match enum %s", c.EnumName, c.VariantName, enumName) + } + if seen[c.VariantName] { + return fmt.Errorf("duplicate match case %s::%s", enumName, c.VariantName) + } + seen[c.VariantName] = true + variant := enumVariant(decl, c.VariantName) + if variant == nil { + return fmt.Errorf("unknown variant %s::%s", enumName, c.VariantName) + } + if len(c.Bindings) != len(variant.PayloadTypes) { + return fmt.Errorf("match case %s::%s expects %d bindings", enumName, c.VariantName, len(variant.PayloadTypes)) + } + } + for _, variant := range decl.Variants { + if !seen[variant.Name] { + return fmt.Errorf("non-exhaustive match for %s: missing %s", enumName, variant.Name) + } + } + value, err := g.expr(s.Value, enumName) + if err != nil { + return err + } + g.matchCounter++ + matchName := fmt.Sprintf("gotlinMatch%d", g.matchCounter) + if enumIsString(decl) { + g.line("switch " + value + " {") + g.indentLevel++ + for _, c := range s.Cases { + g.line("case " + enumName + c.VariantName + ":") + g.indentLevel++ + g.pushScope() + if err := g.block(c.Body); err != nil { + return err + } + g.popScope() + g.indentLevel-- + } + g.indentLevel-- + g.line("}") + break + } + hasBindings := false + for _, c := range s.Cases { + if len(c.Bindings) > 0 { + hasBindings = true + break + } + } + if hasBindings { + g.line(fmt.Sprintf("switch %s := %s.(type) {", matchName, value)) + } else { + g.line(fmt.Sprintf("switch %s.(type) {", value)) + } + g.indentLevel++ + for _, c := range s.Cases { + variant := enumVariant(decl, c.VariantName) + g.line("case *" + enumName + c.VariantName + ":") + g.indentLevel++ + g.pushScope() + for i, binding := range c.Bindings { + g.line(fmt.Sprintf("%s := %s.Value%d", binding, matchName, i)) + g.defineType(binding, variant.PayloadTypes[i]) + } + if err := g.block(c.Body); err != nil { + return err + } + g.popScope() + g.indentLevel-- + } + g.indentLevel-- + g.line("}") case TryCatchStmt: g.writeIndent() g.write("func() {\n") @@ -809,6 +1065,9 @@ func (g *goGenerator) stmt(stmt Stmt, tail []Stmt) error { } func (g *goGenerator) expr(expr Expr, expectedType string) (string, error) { + if lowered, handled, err := g.lowerSQLQuery(expr); handled || err != nil { + return lowered, err + } switch e := expr.(type) { case IdentExpr: if g.currentClass != nil { @@ -834,6 +1093,8 @@ func (g *goGenerator) expr(expr Expr, expectedType string) (string, error) { return e.Name, nil case IntExpr: return e.Value, nil + case FloatExpr: + return e.Value, nil case StringExpr: return e.Value, nil case BoolExpr: @@ -860,6 +1121,59 @@ func (g *goGenerator) expr(expr Expr, expectedType string) (string, error) { } return fmt.Sprintf("%s %s %s", left, e.Op, right), nil case CallExpr: + if selector, ok := e.Callee.(SelectorExpr); ok && selector.Name == "mapTo" { + if len(e.TypeArgs) > 1 || len(e.Args) != 0 || len(e.NamedArgs) != 0 { + return "", fmt.Errorf("mapTo expects at most one type argument and no value arguments") + } + sourceType := g.exprType(selector.Receiver) + if sourceType == "" { + return "", fmt.Errorf("mapTo source requires a known type") + } + targetType := expectedType + if len(e.TypeArgs) == 1 { + targetType = g.mappingTopLevelTarget(e.TypeArgs[0]) + } + if targetType == "" { + return "", fmt.Errorf("mapTo target type cannot be inferred; use mapTo() or provide a typed context") + } + function, err := g.ensureMapping(sourceType, targetType, strings.TrimPrefix(sourceType, "*")) + if err != nil { + return "", err + } + source, err := g.expr(selector.Receiver, sourceType) + if err != nil { + return "", err + } + return function + "(" + source + ")", nil + } + if len(e.NamedArgs) > 0 { + callee, err := g.expr(e.Callee, "") + if err != nil { + return "", err + } + fields := make([]string, 0, len(e.NamedArgs)) + for _, arg := range e.NamedArgs { + value, err := g.expr(arg.Value, "") + if err != nil { + return "", err + } + fields = append(fields, exportedGoFieldName(arg.Name)+": "+value) + } + return "&" + callee + "{" + strings.Join(fields, ", ") + "}", nil + } + if selector, ok := e.Callee.(SelectorExpr); ok { + if receiver, ok := selector.Receiver.(IdentExpr); ok && receiver.Name == "json" && selector.Name == "decode" { + if len(e.TypeArgs) != 1 || len(e.Args) != 1 { + return "", fmt.Errorf("json.decode expects one type argument and one body argument") + } + body, err := g.expr(e.Args[0], "") + if err != nil { + return "", err + } + g.needsJSONDecode = true + return fmt.Sprintf("gotlinJSONDecode[%s](%s)", mapGoType(e.TypeArgs[0]), body), nil + } + } if g.currentWorker != nil && g.isWorkerSelfCall(e.Callee) { return "", fmt.Errorf("worker self-calls are forbidden") } @@ -915,11 +1229,24 @@ func (g *goGenerator) expr(expr Expr, expectedType string) (string, error) { if err != nil { return "", err } + if spread, ok := arg.(UnaryExpr); ok && spread.Op == "*" { + inner, err := g.expr(spread.Value, "") + if err != nil { + return "", err + } + value = inner + "..." + } args = append(args, value) } if ident, ok := e.Callee.(IdentExpr); ok && ident.Name == "println" { return fmt.Sprintf("fmt.Println(%s)", strings.Join(args, ", ")), nil } + if ident, ok := e.Callee.(IdentExpr); ok && ident.Name == "ByteSlice" { + if len(args) != 1 { + return "", fmt.Errorf("ByteSlice expects exactly one argument") + } + return "[]byte(" + args[0] + ")", nil + } if ident, ok := e.Callee.(IdentExpr); ok && ident.Name == "runCatching" { if len(args) != 1 { return "", fmt.Errorf("runCatching expects exactly one argument") @@ -947,7 +1274,78 @@ func (g *goGenerator) expr(expr Expr, expectedType string) (string, error) { if err != nil { return "", err } - return fmt.Sprintf("%s.%s", receiver, e.Name), nil + if call, ok := e.Receiver.(CallExpr); ok && len(call.NamedArgs) > 0 { + receiver = "(" + receiver + ")" + } + name := e.Name + if g.exprType(e.Receiver) == "GotlinSQLQuery" { + if name == "sql" { + name = "SQL" + } + if name == "args" { + name = "Args" + } + } + if ident, ok := e.Receiver.(IdentExpr); ok && g.imports[ident.Name] { + name = exportedGoName(name) + } else if class, ok := g.classForType(g.exprType(e.Receiver)); ok { + for _, field := range class.Fields { + if field.Name == name && class.Data && !field.Private { + name = exportedGoName(name) + break + } + } + } else if ident, ok := e.Receiver.(IdentExpr); ok && (ident.Name == "this" || ident.Name == "self") && g.currentClass != nil { + for _, field := range g.currentClass.Fields { + if field.Name == name && g.currentClass.Data && !field.Private { + name = exportedGoName(name) + break + } + } + } else if typ := g.exprType(e.Receiver); typ == "" || strings.Contains(typ, ".") { + name = exportedGoName(name) + } + return fmt.Sprintf("%s.%s", receiver, name), nil + case IndexExpr: + receiver, err := g.expr(e.Receiver, "") + if err != nil { + return "", err + } + index, err := g.expr(e.Index, "") + if err != nil { + return "", err + } + return receiver + "[" + index + "]", nil + case EnumVariantExpr: + decl, ok := g.enums[e.EnumName] + if !ok { + return "", fmt.Errorf("unknown enum %s", e.EnumName) + } + var variant *EnumVariant + for i := range decl.Variants { + if decl.Variants[i].Name == e.VariantName { + variant = &decl.Variants[i] + break + } + } + if variant == nil { + return "", fmt.Errorf("unknown variant %s::%s", e.EnumName, e.VariantName) + } + if len(e.Values) != len(variant.PayloadTypes) { + return "", fmt.Errorf("variant %s::%s expects %d values", e.EnumName, e.VariantName, len(variant.PayloadTypes)) + } + if enumIsString(decl) { + return e.EnumName + e.VariantName, nil + } + fields := make([]string, 0, len(e.Values)) + for i, valueExpr := range e.Values { + value, err := g.expr(valueExpr, variant.PayloadTypes[i]) + if err != nil { + return "", err + } + fields = append(fields, fmt.Sprintf("Value%d: %s", i, value)) + } + return "&" + e.EnumName + e.VariantName + "{" + strings.Join(fields, ", ") + "}", nil case LambdaExpr: return g.lambda(e, expectedType) default: @@ -955,6 +1353,227 @@ func (g *goGenerator) expr(expr Expr, expectedType string) (string, error) { } } +func (g *goGenerator) emitJSONDecodeSupport() { + g.line("func gotlinJSONDecode[T any](body []byte) T {") + g.indentLevel++ + g.line("var value T") + g.line("gotlinAutoThrow(json.Unmarshal(body, &value))") + g.line("return value") + g.indentLevel-- + g.line("}") +} + +func (g *goGenerator) emitSQLSupport(program *Program, execution bool) { + g.line("type GotlinSQLQuery struct {") + g.indentLevel++ + g.line("SQL string") + g.line("Args []any") + g.indentLevel-- + g.line("}") + if !execution { + return + } + g.line("") + g.line("type gotlinSQLQuerier interface {") + g.indentLevel++ + g.line("Query(" + g.sqlContextAlias + ".Context, string, ...any) (" + g.sqlPGXAlias + ".Rows, error)") + g.indentLevel-- + g.line("}") + g.line("") + g.line("type gotlinSQLRow interface {") + g.indentLevel++ + g.line("Scan(...any) error") + g.indentLevel-- + g.line("}") + g.line("") + g.line("type gotlinSQLScanner[T any] func(gotlinSQLRow) (*T, error)") + g.line("") + g.line("type gotlinSQLError string") + g.line("") + g.line("func (err gotlinSQLError) Error() string {") + g.indentLevel++ + g.line("return string(err)") + g.indentLevel-- + g.line("}") + g.line("") + g.line("func gotlinSQLFetch[T any](pool gotlinSQLQuerier, ctx " + g.sqlContextAlias + ".Context, query GotlinSQLQuery, scan gotlinSQLScanner[T]) []*T {") + g.indentLevel++ + g.line("rows := gotlinAutoThrow(pool.Query(ctx, query.SQL, query.Args...))") + g.line("defer rows.Close()") + g.line("values := make([]*T, 0)") + g.line("for rows.Next() {") + g.indentLevel++ + g.line("values = append(values, gotlinAutoThrow(scan(rows)))") + g.indentLevel-- + g.line("}") + g.line("gotlinAutoThrow(rows.Err())") + g.line("return values") + g.indentLevel-- + g.line("}") + g.line("") + g.line("func gotlinSQLSingle[T any](pool gotlinSQLQuerier, ctx " + g.sqlContextAlias + ".Context, query GotlinSQLQuery, scan gotlinSQLScanner[T]) *T {") + g.indentLevel++ + g.line("rows := gotlinAutoThrow(pool.Query(ctx, query.SQL, query.Args...))") + g.line("defer rows.Close()") + g.line("if !rows.Next() {") + g.indentLevel++ + g.line("gotlinAutoThrow(rows.Err())") + g.line(`gotlinAutoThrow(gotlinSQLError("SQL single() expected exactly one row, got zero"))`) + g.line("return nil") + g.indentLevel-- + g.line("}") + g.line("value := gotlinAutoThrow(scan(rows))") + g.line("if rows.Next() {") + g.indentLevel++ + g.line(`gotlinAutoThrow(gotlinSQLError("SQL single() expected exactly one row, got more than one"))`) + g.indentLevel-- + g.line("}") + g.line("gotlinAutoThrow(rows.Err())") + g.line("return value") + g.indentLevel-- + g.line("}") + g.line("") + g.line("type GotlinSQLIterator[T any] struct {") + g.indentLevel++ + g.line("rows " + g.sqlPGXAlias + ".Rows") + g.line("scan gotlinSQLScanner[T]") + g.line("current *T") + g.line("streamErr error") + g.indentLevel-- + g.line("}") + g.line("") + g.line("func gotlinSQLIterate[T any](pool gotlinSQLQuerier, ctx " + g.sqlContextAlias + ".Context, query GotlinSQLQuery, scan gotlinSQLScanner[T]) *GotlinSQLIterator[T] {") + g.indentLevel++ + g.line("return &GotlinSQLIterator[T]{rows: gotlinAutoThrow(pool.Query(ctx, query.SQL, query.Args...)), scan: scan}") + g.indentLevel-- + g.line("}") + g.line("") + g.line("func (iterator *GotlinSQLIterator[T]) next() bool {") + g.indentLevel++ + g.line("iterator.current = nil") + g.line("if iterator.rows == nil || !iterator.rows.Next() {") + g.indentLevel++ + g.line("if iterator.rows != nil {") + g.indentLevel++ + g.line("iterator.streamErr = iterator.rows.Err()") + g.indentLevel-- + g.line("}") + g.line("return false") + g.indentLevel-- + g.line("}") + g.line("value, err := iterator.scan(iterator.rows)") + g.line("if err != nil {") + g.indentLevel++ + g.line("iterator.streamErr = err") + g.line("gotlinAutoThrow(err)") + g.indentLevel-- + g.line("}") + g.line("iterator.current = value") + g.line("return true") + g.indentLevel-- + g.line("}") + g.line("") + g.line("func (iterator *GotlinSQLIterator[T]) value() *T {") + g.indentLevel++ + g.line("if iterator.current == nil {") + g.indentLevel++ + g.line(`panic("SQL iterator value() requires a successful next()")`) + g.indentLevel-- + g.line("}") + g.line("return iterator.current") + g.indentLevel-- + g.line("}") + g.line("") + g.line("func (iterator *GotlinSQLIterator[T]) close() {") + g.indentLevel++ + g.line("if iterator.rows != nil {") + g.indentLevel++ + g.line("iterator.rows.Close()") + g.indentLevel-- + g.line("}") + g.indentLevel-- + g.line("}") + g.line("") + g.line("func (iterator *GotlinSQLIterator[T]) err() error {") + g.indentLevel++ + g.line("if iterator.streamErr != nil {") + g.indentLevel++ + g.line("return iterator.streamErr") + g.indentLevel-- + g.line("}") + g.line("if iterator.rows != nil {") + g.indentLevel++ + g.line("return iterator.rows.Err()") + g.indentLevel-- + g.line("}") + g.line("return nil") + g.indentLevel-- + g.line("}") + + for _, class := range program.Classes { + if !class.Data { + continue + } + g.line("") + g.line("func gotlinSQLScan" + class.Name + "(row gotlinSQLRow) (*" + class.Name + ", error) {") + g.indentLevel++ + g.line("value := new(" + class.Name + ")") + destinations := make([]string, 0, len(class.Fields)) + for _, field := range class.Fields { + name := field.Name + if !field.Private { + name = exportedGoName(name) + } + destinations = append(destinations, "&value."+name) + } + g.line("err := row.Scan(" + strings.Join(destinations, ", ") + ")") + g.line("return value, err") + g.indentLevel-- + g.line("}") + } +} + +func exportedGoName(name string) string { + if name == "" || name[0] < 'a' || name[0] > 'z' { + return name + } + return strings.ToUpper(name[:1]) + name[1:] +} + +func exportedGoFieldName(name string) string { + name = exportedGoName(name) + for _, pair := range [][2]string{{"Jwks", "JWKS"}, {"Jwt", "JWT"}, {"Url", "URL"}, {"Http", "HTTP"}, {"Https", "HTTPS"}, {"Api", "API"}, {"Sql", "SQL"}, {"Id", "ID"}} { + name = strings.ReplaceAll(name, pair[0], pair[1]) + } + return name +} + +func snakeCase(name string) string { + var b strings.Builder + for i, r := range name { + if i > 0 && r >= 'A' && r <= 'Z' { + b.WriteByte('_') + } + b.WriteRune(rune(strings.ToLower(string(r))[0])) + } + return b.String() +} + +func jsonFieldName(name, policy string) string { + switch policy { + case "", "snakeCase": + return snakeCase(name) + case "camelCase": + return name + case "pascalCase": + return exportedGoName(name) + case "kebabCase": + return strings.ReplaceAll(snakeCase(name), "_", "-") + default: + return snakeCase(name) + } +} + func (g *goGenerator) listLiteral(args []Expr, typeArgs []string, expectedType string) (string, error) { elemType := "" if len(typeArgs) > 0 { @@ -1133,6 +1752,9 @@ func (g *goGenerator) write(text string) { } func mapGoType(name string) string { + if strings.HasSuffix(name, "?") { + return "*" + mapGoType(strings.TrimSuffix(name, "?")) + } if params, ret, ok := parseFunctionType(name); ok { var goParams []string for _, param := range params { @@ -1170,8 +1792,14 @@ func mapGoType(name string) string { switch name { case "Int": return "int" + case "Float", "Double": + return "float64" case "String": return "string" + case "Any": + return "any" + case "ByteSlice": + return "[]byte" case "Boolean": return "bool" case "Unit": @@ -1227,6 +1855,10 @@ func usesPrintln(stmts []Stmt) bool { if exprUsesPrintln(s.Value) { return true } + case DeferStmt: + if exprUsesPrintln(s.Value) { + return true + } case IfStmt: if exprUsesPrintln(s.Cond) || usesPrintln(s.Then) || usesPrintln(s.Else) { return true @@ -1235,12 +1867,25 @@ func usesPrintln(stmts []Stmt) bool { if exprUsesPrintln(s.Cond) || usesPrintln(s.Body) { return true } + case ForEachStmt: + if exprUsesPrintln(s.Source) || usesPrintln(s.Body) { + return true + } case SelectStmt: for _, c := range s.Cases { if exprUsesPrintln(c.Source) || usesPrintln(c.Body) { return true } } + case MatchStmt: + if exprUsesPrintln(s.Value) { + return true + } + for _, c := range s.Cases { + if usesPrintln(c.Body) { + return true + } + } case TryCatchStmt: if usesPrintln(s.TryBody) || usesPrintln(s.CatchBody) { return true @@ -1539,24 +2184,35 @@ func (g *goGenerator) lambda(lambda LambdaExpr, expectedType string) (string, er b.WriteString(" {\n") sub := goGenerator{ - indentLevel: 1, - needsFmt: g.needsFmt, - functions: g.functions, - classes: g.classes, - currentFunc: FunctionDecl{ReturnType: returnType}, - currentClass: g.currentClass, + indentLevel: 1, + needsFmt: g.needsFmt, + functions: g.functions, + classes: g.classes, + workers: g.workers, + enums: g.enums, + imports: g.imports, + currentFunc: FunctionDecl{ReturnType: returnType}, + currentClass: g.currentClass, + currentWorker: g.currentWorker, + currentWorkerFieldReceiver: g.currentWorkerFieldReceiver, + mappings: g.mappings, } if g.currentClass != nil { classCopy := *g.currentClass sub.currentClass = &classCopy } sub.scopes = g.cloneScopes() + sub.typeScopes = g.cloneTypeScopes() sub.pushScope() if lambda.ImplicitIt { - sub.define("it") + if len(params) == 1 { + sub.defineType("it", params[0].Type) + } else { + sub.define("it") + } } for _, param := range params { - sub.define(param.Name) + sub.defineType(param.Name, param.Type) } if err := sub.block(lambda.Body); err != nil { return "", err @@ -1665,6 +2321,10 @@ func nameUsedInStmtsWithShadow(name string, stmts []Stmt, shadowed bool) bool { if exprUsesName(s.Value, name, localShadowed) { return true } + case DeferStmt: + if exprUsesName(s.Value, name, localShadowed) { + return true + } case ExprStmt: if exprUsesName(s.Value, name, localShadowed) { return true @@ -1686,6 +2346,13 @@ func nameUsedInStmtsWithShadow(name string, stmts []Stmt, shadowed bool) bool { if nameUsedInStmtsWithShadow(name, s.Body, localShadowed) { return true } + case ForEachStmt: + if exprUsesName(s.Source, name, localShadowed) { + return true + } + if nameUsedInStmtsWithShadow(name, s.Body, localShadowed || s.Name == name) { + return true + } case SelectStmt: for _, c := range s.Cases { if exprUsesName(c.Source, name, localShadowed) { @@ -1695,6 +2362,21 @@ func nameUsedInStmtsWithShadow(name string, stmts []Stmt, shadowed bool) bool { return true } } + case MatchStmt: + if exprUsesName(s.Value, name, localShadowed) { + return true + } + for _, c := range s.Cases { + caseShadowed := localShadowed + for _, binding := range c.Bindings { + if binding == name { + caseShadowed = true + } + } + if nameUsedInStmtsWithShadow(name, c.Body, caseShadowed) { + return true + } + } case TryCatchStmt: if nameUsedInStmtsWithShadow(name, s.TryBody, localShadowed) { return true @@ -1728,6 +2410,15 @@ func exprUsesName(expr Expr, name string, shadowed bool) bool { return false case SelectorExpr: return exprUsesName(e.Receiver, name, shadowed) + case IndexExpr: + return exprUsesName(e.Receiver, name, shadowed) || exprUsesName(e.Index, name, shadowed) + case EnumVariantExpr: + for _, value := range e.Values { + if exprUsesName(value, name, shadowed) { + return true + } + } + return false case LambdaExpr: lambdaShadowed := shadowed if e.ImplicitIt && name == "it" { @@ -1747,6 +2438,7 @@ func exprUsesName(expr Expr, name string, shadowed bool) bool { func (g *goGenerator) pushScope() { g.scopes = append(g.scopes, map[string]bool{}) + g.typeScopes = append(g.typeScopes, map[string]string{}) } func (g *goGenerator) popScope() { @@ -1754,13 +2446,148 @@ func (g *goGenerator) popScope() { return } g.scopes = g.scopes[:len(g.scopes)-1] + g.typeScopes = g.typeScopes[:len(g.typeScopes)-1] } func (g *goGenerator) define(name string) { + g.defineType(name, "") +} + +func (g *goGenerator) defineType(name, typ string) { if len(g.scopes) == 0 { g.pushScope() } g.scopes[len(g.scopes)-1][name] = true + g.typeScopes[len(g.typeScopes)-1][name] = typ +} + +func (g *goGenerator) lookupType(name string) string { + for i := len(g.typeScopes) - 1; i >= 0; i-- { + if typ, ok := g.typeScopes[i][name]; ok { + return typ + } + } + return "" +} + +func (g *goGenerator) exprType(expr Expr) string { + switch e := expr.(type) { + case IdentExpr: + if typ := g.lookupType(e.Name); typ != "" { + return typ + } + if g.currentClass != nil { + if e.Name == "this" { + return "*" + g.currentClass.Name + } + for _, field := range g.currentClass.Fields { + if field.Name == e.Name { + return field.Type + } + } + } + if g.currentWorker != nil { + if e.Name == "this" { + return "*" + g.currentWorker.Name + } + for _, field := range g.currentWorker.Fields { + if field.Name == e.Name { + return field.Type + } + } + } + return "" + case IntExpr: + return "Int" + case FloatExpr: + return "Double" + case StringExpr: + return "String" + case BoolExpr: + return "Boolean" + case CallExpr: + if selector, ok := e.Callee.(SelectorExpr); ok && selector.Name == "mapTo" && len(e.TypeArgs) == 1 { + return g.mappingTopLevelTarget(e.TypeArgs[0]) + } + if typ, ok := sqlChainResultType(e); ok { + return typ + } + if selector, ok := e.Callee.(SelectorExpr); ok { + if base, args, ok := parseGenericType(g.exprType(selector.Receiver)); ok && base == "GotlinSQLIterator" && len(args) == 1 { + switch selector.Name { + case "next": + return "Boolean" + case "value": + return "*" + args[0] + case "err": + return "Error" + case "close": + return "Unit" + } + } + if receiver, ok := selector.Receiver.(IdentExpr); ok && receiver.Name == "json" && selector.Name == "decode" && len(e.TypeArgs) == 1 { + return e.TypeArgs[0] + } + if class, ok := g.classForType(g.exprType(selector.Receiver)); ok { + for _, method := range class.Methods { + if method.Name == selector.Name { + return method.ReturnType + } + } + } + if worker, ok := g.workerForType(g.exprType(selector.Receiver)); ok { + for _, method := range worker.Methods { + if method.Name == selector.Name { + return method.ReturnType + } + } + } + } + if ident, ok := e.Callee.(IdentExpr); ok { + if fn, ok := g.functions[ident.Name]; ok { + return fn.ReturnType + } + if _, ok := g.classes[ident.Name]; ok { + return "*" + ident.Name + } + } + if len(e.NamedArgs) > 0 { + if selector, ok := e.Callee.(SelectorExpr); ok { + if receiver, ok := selector.Receiver.(IdentExpr); ok { + return receiver.Name + "." + selector.Name + } + } + } + case SelectorExpr: + if class, ok := g.classForType(g.exprType(e.Receiver)); ok { + for _, field := range class.Fields { + if field.Name == e.Name { + return field.Type + } + } + } + case IndexExpr: + if _, args, ok := parseGenericType(g.exprType(e.Receiver)); ok && len(args) > 0 { + return args[len(args)-1] + } + case EnumVariantExpr: + return e.EnumName + } + return "" +} + +func (g *goGenerator) classForType(typ string) (ClassDecl, bool) { + typ = strings.TrimPrefix(typ, "*") + typ = strings.TrimSuffix(typ, "?") + class, ok := g.classes[typ] + return class, ok +} + +func (g *goGenerator) workerForType(typ string) (WorkerDecl, bool) { + typ = strings.TrimPrefix(typ, "*") + typ = strings.TrimSuffix(typ, "?") + worker, ok := g.workers[typ] + return worker, ok } func (g *goGenerator) isDefined(name string) bool { @@ -1850,6 +2677,18 @@ func (g *goGenerator) cloneScopes() []map[string]bool { return dup } +func (g *goGenerator) cloneTypeScopes() []map[string]string { + dup := make([]map[string]string, 0, len(g.typeScopes)) + for _, scope := range g.typeScopes { + copyScope := make(map[string]string, len(scope)) + for k, v := range scope { + copyScope[k] = v + } + dup = append(dup, copyScope) + } + return dup +} + func renderGoParams(params []Param) string { var b strings.Builder b.WriteString("(") @@ -1978,8 +2817,9 @@ func isBuiltinPrintln(expr Expr) bool { return ok && ident.Name == "println" } -func collectImports(program *Program, needsFmt bool, needsTime bool) []string { +func collectImports(program *Program, needsFmt bool, needsTime bool, runtimeImports map[string]string) []string { seen := map[string]bool{} + runtimePaths := map[string]bool{} var imports []string usedAliases := usedImportAliases(program) @@ -1991,6 +2831,12 @@ func collectImports(program *Program, needsFmt bool, needsTime bool) []string { seen[`"time"`] = true imports = append(imports, `"time"`) } + for path, alias := range runtimeImports { + rendered := alias + ` "` + path + `"` + seen[rendered] = true + runtimePaths[path] = true + imports = append(imports, rendered) + } for _, imp := range program.Imports { path := imp.Path goPath := path @@ -1998,6 +2844,9 @@ func collectImports(program *Program, needsFmt bool, needsTime bool) []string { goPath = importPathToGoPath(path) path = `"` + goPath + `"` } + if runtimePaths[goPath] { + continue + } rendered := path if imp.Alias != "" { rendered = imp.Alias + " " + path @@ -2014,6 +2863,24 @@ func collectImports(program *Program, needsFmt bool, needsTime bool) []string { return imports } +func runtimeImportAlias(program *Program, goPath, fallback string) string { + for _, imp := range program.Imports { + path := strings.Trim(imp.Path, `"`) + if !strings.Contains(path, "/") { + path = importPathToGoPath(path) + } + if path != goPath { + continue + } + if imp.Alias != "" && imp.Alias != "_" { + return imp.Alias + } + parts := strings.Split(goPath, "/") + return parts[len(parts)-1] + } + return fallback +} + func goPackageName(packagePath string) string { if packagePath == "" { return "main" @@ -2342,6 +3209,9 @@ func (g *goGenerator) emitEveryMsSupport() { } func (g *goGenerator) autoThrowValue(original Expr, rendered string) string { + if _, _, _, sql := splitSQLChain(original); sql { + return rendered + } if _, ok := original.(CallExpr); ok { return "gotlinAutoThrow(" + rendered + ")" } diff --git a/internal/lang/go_interop_test.go b/internal/lang/go_interop_test.go new file mode 100644 index 0000000..ba8a548 --- /dev/null +++ b/internal/lang/go_interop_test.go @@ -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) + } + } +} diff --git a/internal/lang/index_test.go b/internal/lang/index_test.go new file mode 100644 index 0000000..e3c8834 --- /dev/null +++ b/internal/lang/index_test.go @@ -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): 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) + } +} diff --git a/internal/lang/json_decode_test.go b/internal/lang/json_decode_test.go new file mode 100644 index 0000000..e51fa70 --- /dev/null +++ b/internal/lang/json_decode_test.go @@ -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 { + val accounts = json.decode>(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) + } + } +} diff --git a/internal/lang/lexer.go b/internal/lang/lexer.go index 5e5e47b..a2ca31a 100644 --- a/internal/lang/lexer.go +++ b/internal/lang/lexer.go @@ -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 diff --git a/internal/lang/mapping.go b/internal/lang/mapping.go new file mode 100644 index 0000000..4d25dd2 --- /dev/null +++ b/internal/lang/mapping.go @@ -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+".", 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) +} diff --git a/internal/lang/mapping_test.go b/internal/lang/mapping_test.go new file mode 100644 index 0000000..a377f82 --- /dev/null +++ b/internal/lang/mapping_test.go @@ -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() } +`) + 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>() } +fun mapping(values: Map): Map { return values.mapTo>() } +`) + 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() } +`) + 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() } +`) + 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() }`, + `package demo enum Source { Ready, Failed } enum Target { Ready } fun convert(value: Source): Target { return value.mapTo() }`, + } { + 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() } +fun toDomain(value: *Row): *Domain { return value.mapTo() }`) + 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) + } +} diff --git a/internal/lang/member_type_test.go b/internal/lang/member_type_test.go new file mode 100644 index 0000000..8cc522f --- /dev/null +++ b/internal/lang/member_type_test.go @@ -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) + } +} diff --git a/internal/lang/parser.go b/internal/lang/parser.go index 942f3ed..73e140a 100644 --- a/internal/lang/parser.go +++ b/internal/lang/parser.go @@ -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 } diff --git a/internal/lang/pointer_test.go b/internal/lang/pointer_test.go new file mode 100644 index 0000000..5267843 --- /dev/null +++ b/internal/lang/pointer_test.go @@ -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) + } +} diff --git a/internal/lang/semantics.go b/internal/lang/semantics.go new file mode 100644 index 0000000..c6abac2 --- /dev/null +++ b/internal/lang/semantics.go @@ -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) +} diff --git a/internal/lang/sql.go b/internal/lang/sql.go new file mode 100644 index 0000000..2c8f909 --- /dev/null +++ b/internal/lang/sql.go @@ -0,0 +1,1244 @@ +package lang + +import ( + "fmt" + "strconv" + "strings" +) + +type sqlCallStep struct { + name string + call CallExpr +} + +type sqlLowered struct { + value string + result ClassDecl + hasResult bool +} + +func splitSQLChain(expr Expr) (CallExpr, string, []sqlCallStep, bool) { + var reversed []sqlCallStep + current := expr + for { + call, ok := current.(CallExpr) + if !ok { + return CallExpr{}, "", nil, false + } + selector, ok := call.Callee.(SelectorExpr) + if !ok { + return CallExpr{}, "", nil, false + } + if receiver, ok := selector.Receiver.(IdentExpr); ok && receiver.Name == "sql" { + switch selector.Name { + case "from", "insert", "update", "delete": + default: + return CallExpr{}, "", nil, false + } + steps := make([]sqlCallStep, len(reversed)) + for i := range reversed { + steps[len(reversed)-1-i] = reversed[i] + } + return call, selector.Name, steps, true + } + reversed = append(reversed, sqlCallStep{name: selector.Name, call: call}) + current = selector.Receiver + } +} + +func (g *goGenerator) lowerSQLQuery(expr Expr) (string, bool, error) { + root, operation, steps, ok := splitSQLChain(expr) + if !ok { + return "", false, nil + } + if len(steps) == 0 { + return "", true, sqlTerminalError(operation) + } + terminal := steps[len(steps)-1] + switch terminal.name { + case "build": + if len(terminal.call.Args) != 0 || len(terminal.call.NamedArgs) != 0 || len(terminal.call.TypeArgs) != 0 { + return "", true, fmt.Errorf("SQL build() does not accept arguments") + } + case "fetch", "single", "iterator": + if err := g.validateSQLExecutionTerminal(terminal); err != nil { + return "", true, err + } + default: + return "", true, sqlTerminalError(operation) + } + steps = steps[:len(steps)-1] + + var lowered sqlLowered + var err error + switch operation { + case "from": + lowered, err = g.lowerSQLSelect(root, steps) + case "insert": + lowered, err = g.lowerSQLInsert(root, steps) + case "update": + lowered, err = g.lowerSQLUpdate(root, steps) + case "delete": + lowered, err = g.lowerSQLDelete(root, steps) + default: + err = fmt.Errorf("unsupported sql operation %q", operation) + } + if err != nil || terminal.name == "build" { + return lowered.value, true, err + } + if !lowered.hasResult { + return "", true, fmt.Errorf("SQL %s() is only supported for sql.from or writes with returning(); sql.%s requires returning()", terminal.name, operation) + } + pool, err := g.expr(terminal.call.Args[0], "") + if err != nil { + return "", true, err + } + ctx, err := g.expr(terminal.call.Args[1], "") + if err != nil { + return "", true, err + } + helper := map[string]string{ + "fetch": "gotlinSQLFetch", + "single": "gotlinSQLSingle", + "iterator": "gotlinSQLIterate", + }[terminal.name] + return fmt.Sprintf("%s[%s](%s, %s, %s, gotlinSQLScan%s)", helper, lowered.result.Name, pool, ctx, lowered.value, lowered.result.Name), true, nil +} + +func sqlTerminalError(operation string) error { + return fmt.Errorf("sql.%s query must end with build(), fetch(pool, ctx), single(pool, ctx), or iterator(pool, ctx)", operation) +} + +func (g *goGenerator) validateSQLExecutionTerminal(terminal sqlCallStep) error { + call := terminal.call + if len(call.TypeArgs) != 0 || len(call.NamedArgs) != 0 || len(call.Args) != 2 { + return fmt.Errorf("SQL %s() expects exactly pool and ctx positional arguments", terminal.name) + } + if typ := g.exprType(call.Args[0]); sqlInvalidExecutionArgType(typ) { + return fmt.Errorf("SQL %s() pool argument has non-query type %s", terminal.name, typ) + } + if typ := g.exprType(call.Args[1]); sqlInvalidExecutionArgType(typ) { + return fmt.Errorf("SQL %s() ctx argument has non-context type %s", terminal.name, typ) + } + return nil +} + +func sqlInvalidExecutionArgType(typ string) bool { + switch sqlBaseType(typ) { + case "Int", "Float", "Double", "String", "Boolean", "Unit", "ByteSlice": + return true + default: + return false + } +} + +func sqlChainResultType(expr Expr) (string, bool) { + root, operation, steps, ok := splitSQLChain(expr) + if !ok || len(root.TypeArgs) != 1 || len(steps) == 0 { + return "", false + } + terminal := steps[len(steps)-1].name + if terminal == "build" { + return "GotlinSQLQuery", true + } + if terminal != "fetch" && terminal != "single" && terminal != "iterator" { + return "", false + } + resultType := root.TypeArgs[0] + if operation == "from" { + for _, step := range steps[:len(steps)-1] { + if step.name == "select" { + if typ, ok := sqlProjectionType(step.call, resultType); ok { + resultType = typ + } + } + } + } else { + found := false + for _, step := range steps[:len(steps)-1] { + if step.name == "returning" { + resultType, found = sqlProjectionType(step.call, resultType) + } + } + if !found { + return "", false + } + } + switch terminal { + case "fetch": + return "List<*" + resultType + ">", true + case "single": + return "*" + resultType, true + case "iterator": + return "GotlinSQLIterator<" + resultType + ">", true + default: + return "", false + } +} + +func sqlProjectionType(call CallExpr, rowType string) (string, bool) { + lambda, err := sqlLambdaArg(call, "projection") + if err != nil { + return "", false + } + rowName := "it" + if !lambda.ImplicitIt { + if len(lambda.Params) != 1 { + return "", false + } + rowName = lambda.Params[0].Name + } + if len(lambda.Body) != 1 { + return "", false + } + stmt, ok := lambda.Body[0].(ExprStmt) + if !ok { + return "", false + } + if ident, ok := stmt.Value.(IdentExpr); ok && ident.Name == rowName { + return rowType, true + } + constructor, ok := stmt.Value.(CallExpr) + if !ok || len(constructor.NamedArgs) != 0 || len(constructor.TypeArgs) != 0 { + return "", false + } + ident, ok := constructor.Callee.(IdentExpr) + return ident.Name, ok +} + +func (g *goGenerator) sqlClass(root CallExpr, operation string) (ClassDecl, error) { + if len(root.TypeArgs) != 1 { + return ClassDecl{}, fmt.Errorf("sql.%s expects exactly one row type", operation) + } + class, ok := g.classes[root.TypeArgs[0]] + if !ok { + return ClassDecl{}, fmt.Errorf("SQL row class %q does not exist", root.TypeArgs[0]) + } + if !class.Data { + return ClassDecl{}, fmt.Errorf("SQL row class %s must be a data class", class.Name) + } + if class.Table == "" { + return ClassDecl{}, fmt.Errorf("SQL row class %s requires @table", class.Name) + } + columns := map[string]string{} + for _, field := range class.Fields { + column := sqlColumn(field) + if previous, exists := columns[column]; exists { + return ClassDecl{}, fmt.Errorf("SQL fields %s and %s map to duplicate column %q", previous, field.Name, column) + } + columns[column] = field.Name + } + return class, nil +} + +func (g *goGenerator) lowerSQLSelect(root CallExpr, steps []sqlCallStep) (sqlLowered, error) { + if len(root.Args) != 0 || len(root.NamedArgs) != 0 { + return sqlLowered{}, fmt.Errorf("sql.from does not accept value arguments") + } + class, err := g.sqlClass(root, "from") + if err != nil { + return sqlLowered{}, err + } + columns := sqlAllColumns(class) + result := class + var where, order, limit string + var args []string + stage := 0 + seenSelect := false + seenWhere := false + seenOrder := false + seenLimit := false + seenForUpdate := false + seenSkipLocked := false + for _, step := range steps { + switch step.name { + case "select": + if seenSelect || stage != 0 { + return sqlLowered{}, fmt.Errorf("select() may appear once and must be the first sql.from method") + } + result, columns, err = g.sqlProjection(step.call, class, "select") + if err != nil { + return sqlLowered{}, err + } + seenSelect = true + stage = 1 + case "where": + if seenWhere || stage > 1 { + return sqlLowered{}, fmt.Errorf("where() may appear once after select() and before ordering") + } + where, args, err = g.sqlWhere(step.call, class, len(args)+1) + if err != nil { + return sqlLowered{}, err + } + seenWhere = true + stage = 2 + case "orderBy", "orderByDescending": + if seenOrder || stage > 2 { + return sqlLowered{}, fmt.Errorf("orderBy()/orderByDescending() may appear once before limit()") + } + order, err = sqlOrder(step, class) + if err != nil { + return sqlLowered{}, err + } + seenOrder = true + stage = 3 + case "limit": + if seenLimit || stage > 3 { + return sqlLowered{}, fmt.Errorf("limit() may appear once after ordering and before forUpdate()") + } + var limitArgs []string + limit, limitArgs, err = g.sqlLimit(step.call) + if err != nil { + return sqlLowered{}, err + } + if limit == "?" { + limit = "$" + strconv.Itoa(len(args)+1) + } + args = append(args, limitArgs...) + seenLimit = true + stage = 4 + case "forUpdate": + if seenForUpdate || stage > 4 || !sqlNoArgs(step.call) { + return sqlLowered{}, fmt.Errorf("forUpdate() accepts no arguments and must appear once after limit()") + } + seenForUpdate = true + stage = 5 + case "skipLocked": + if seenSkipLocked || !seenForUpdate || stage != 5 || !sqlNoArgs(step.call) { + return sqlLowered{}, fmt.Errorf("skipLocked() accepts no arguments and requires a preceding forUpdate()") + } + seenSkipLocked = true + stage = 6 + default: + return sqlLowered{}, fmt.Errorf("unsupported sql.from method %q", step.name) + } + } + query := "SELECT " + strings.Join(columns, ", ") + " FROM " + class.Table + if where != "" { + query += " WHERE " + where + } + if order != "" { + query += " ORDER BY " + order + } + if limit != "" { + query += " LIMIT " + limit + } + if seenForUpdate { + query += " FOR UPDATE" + } + if seenSkipLocked { + query += " SKIP LOCKED" + } + return sqlLowered{value: sqlQueryValue(query, args), result: result, hasResult: true}, nil +} + +func (g *goGenerator) lowerSQLInsert(root CallExpr, steps []sqlCallStep) (sqlLowered, error) { + if len(root.Args) != 1 || len(root.NamedArgs) != 0 { + return sqlLowered{}, fmt.Errorf("sql.insert expects exactly one row argument") + } + class, err := g.sqlClass(root, "insert") + if err != nil { + return sqlLowered{}, err + } + if typ := strings.TrimPrefix(g.exprType(root.Args[0]), "*"); typ == "" { + return sqlLowered{}, fmt.Errorf("sql.insert row argument requires a known type") + } else if typ != class.Name { + return sqlLowered{}, fmt.Errorf("sql.insert<%s> cannot insert value of type %s", class.Name, typ) + } + row, err := g.expr(root.Args[0], class.Name) + if err != nil { + return sqlLowered{}, err + } + var columns, placeholders, args []string + for _, field := range class.Fields { + if field.Generated { + continue + } + columns = append(columns, sqlColumn(field)) + placeholders = append(placeholders, "$"+strconv.Itoa(len(args)+1)) + args = append(args, sqlGoField(row, root.Args[0], class, field)) + } + query := "INSERT INTO " + class.Table + if len(columns) == 0 { + query += " DEFAULT VALUES" + } else { + query += " (" + strings.Join(columns, ", ") + ") VALUES (" + strings.Join(placeholders, ", ") + ")" + } + + index := 0 + if index < len(steps) && steps[index].name == "onConflict" { + conflictLambda, err := sqlLambdaArg(steps[index].call, "onConflict") + if err != nil { + return sqlLowered{}, err + } + rowName, body, err := sqlLambdaExpr(conflictLambda, class, "onConflict") + if err != nil { + return sqlLowered{}, err + } + conflictFields, err := sqlConflictFields(body, class, rowName) + if err != nil { + return sqlLowered{}, err + } + var conflictColumns []string + for _, field := range conflictFields { + if !field.ID { + return sqlLowered{}, fmt.Errorf("onConflict field %s must be annotated @id", field.Name) + } + conflictColumns = append(conflictColumns, sqlColumn(field)) + } + query += " ON CONFLICT (" + strings.Join(conflictColumns, ", ") + ")" + index++ + if index >= len(steps) { + return sqlLowered{}, fmt.Errorf("onConflict() requires doNothing() or doUpdate()") + } + action := steps[index] + switch action.name { + case "doNothing": + if !sqlNoArgs(action.call) { + return sqlLowered{}, fmt.Errorf("doNothing() does not accept arguments") + } + query += " DO NOTHING" + case "doUpdate": + lambda, err := sqlLambdaArg(action.call, "doUpdate") + if err != nil { + return sqlLowered{}, err + } + assignments, err := sqlConflictUpdateAssignments(lambda, class) + if err != nil { + return sqlLowered{}, err + } + query += " DO UPDATE SET " + strings.Join(assignments, ", ") + default: + return sqlLowered{}, fmt.Errorf("onConflict() requires doNothing() or doUpdate(), found %s()", action.name) + } + index++ + } + + lowered := sqlLowered{} + if index < len(steps) && steps[index].name == "returning" { + result, returning, err := g.sqlProjection(steps[index].call, class, "returning") + if err != nil { + return sqlLowered{}, err + } + query += " RETURNING " + strings.Join(returning, ", ") + lowered.result = result + lowered.hasResult = true + index++ + } + if index != len(steps) { + return sqlLowered{}, fmt.Errorf("unsupported sql.insert method %q or invalid method order", steps[index].name) + } + lowered.value = sqlQueryValue(query, args) + return lowered, nil +} + +func (g *goGenerator) lowerSQLUpdate(root CallExpr, steps []sqlCallStep) (sqlLowered, error) { + if len(root.Args) != 0 || len(root.NamedArgs) != 0 { + return sqlLowered{}, fmt.Errorf("sql.update does not accept value arguments") + } + class, err := g.sqlClass(root, "update") + if err != nil { + return sqlLowered{}, err + } + if len(steps) == 0 || steps[0].name != "set" { + return sqlLowered{}, fmt.Errorf("sql.update requires set() as its first method") + } + assignments, args, err := g.sqlTypedUpdateAssignments(steps[0].call, class) + if err != nil { + return sqlLowered{}, err + } + query := "UPDATE " + class.Table + " SET " + postgresPlaceholders(strings.Join(assignments, ", "), 1) + index := 1 + if index < len(steps) && steps[index].name == "where" { + where, whereArgs, err := g.sqlWhere(steps[index].call, class, len(args)+1) + if err != nil { + return sqlLowered{}, err + } + query += " WHERE " + where + args = append(args, whereArgs...) + index++ + } + lowered := sqlLowered{} + if index < len(steps) && steps[index].name == "returning" { + result, returning, err := g.sqlProjection(steps[index].call, class, "returning") + if err != nil { + return sqlLowered{}, err + } + query += " RETURNING " + strings.Join(returning, ", ") + lowered.result = result + lowered.hasResult = true + index++ + } + if index != len(steps) { + return sqlLowered{}, fmt.Errorf("unsupported sql.update method %q or invalid method order", steps[index].name) + } + lowered.value = sqlQueryValue(query, args) + return lowered, nil +} + +func (g *goGenerator) lowerSQLDelete(root CallExpr, steps []sqlCallStep) (sqlLowered, error) { + if len(root.Args) != 0 || len(root.NamedArgs) != 0 { + return sqlLowered{}, fmt.Errorf("sql.delete does not accept value arguments") + } + class, err := g.sqlClass(root, "delete") + if err != nil { + return sqlLowered{}, err + } + query := "DELETE FROM " + class.Table + var args []string + index := 0 + if index < len(steps) && steps[index].name == "where" { + where, whereArgs, err := g.sqlWhere(steps[index].call, class, 1) + if err != nil { + return sqlLowered{}, err + } + query += " WHERE " + where + args = whereArgs + index++ + } + lowered := sqlLowered{} + if index < len(steps) && steps[index].name == "returning" { + result, returning, err := g.sqlProjection(steps[index].call, class, "returning") + if err != nil { + return sqlLowered{}, err + } + query += " RETURNING " + strings.Join(returning, ", ") + lowered.result = result + lowered.hasResult = true + index++ + } + if index != len(steps) { + return sqlLowered{}, fmt.Errorf("unsupported sql.delete method %q or invalid method order", steps[index].name) + } + lowered.value = sqlQueryValue(query, args) + return lowered, nil +} + +func sqlGoField(row string, source Expr, class ClassDecl, field FieldDecl) string { + fieldName := field.Name + if class.Data && !field.Private { + fieldName = exportedGoName(fieldName) + } + receiver := row + switch source.(type) { + case IdentExpr, SelectorExpr, IndexExpr: + default: + receiver = "(" + receiver + ")" + } + return receiver + "." + fieldName +} + +func sqlAllColumns(class ClassDecl) []string { + columns := make([]string, 0, len(class.Fields)) + for _, field := range class.Fields { + columns = append(columns, sqlColumn(field)) + } + return columns +} + +func (g *goGenerator) sqlProjection(call CallExpr, rowClass ClassDecl, method string) (ClassDecl, []string, error) { + lambda, err := sqlLambdaArg(call, method) + if err != nil { + return ClassDecl{}, nil, err + } + rowName, body, err := sqlLambdaExpr(lambda, rowClass, method) + if err != nil { + return ClassDecl{}, nil, err + } + if ident, ok := body.(IdentExpr); ok && ident.Name == rowName { + return rowClass, sqlAllColumns(rowClass), nil + } + constructor, ok := body.(CallExpr) + if !ok || len(constructor.TypeArgs) != 0 || len(constructor.NamedArgs) != 0 { + return ClassDecl{}, nil, fmt.Errorf("%s expects Projection(row.field, ...) or the row parameter", method) + } + callee, ok := constructor.Callee.(IdentExpr) + if !ok { + return ClassDecl{}, nil, fmt.Errorf("%s projection must construct a local data class", method) + } + projection, ok := g.classes[callee.Name] + if !ok || !projection.Data { + return ClassDecl{}, nil, fmt.Errorf("%s projection type %s must be a data class", method, callee.Name) + } + if len(constructor.Args) != len(projection.Fields) { + return ClassDecl{}, nil, fmt.Errorf("%s projection %s expects %d fields, got %d", method, projection.Name, len(projection.Fields), len(constructor.Args)) + } + columns := make([]string, 0, len(constructor.Args)) + for i, arg := range constructor.Args { + source, err := sqlRowField(arg, rowClass, rowName) + if err != nil { + return ClassDecl{}, nil, fmt.Errorf("%s projection argument %d: %w", method, i+1, err) + } + target := projection.Fields[i] + if !sqlProjectionTypesCompatible(target.Type, source.Type) { + return ClassDecl{}, nil, fmt.Errorf("%s projection field %s has type %s but row field %s has type %s", method, target.Name, target.Type, source.Name, source.Type) + } + columns = append(columns, sqlColumn(source)) + } + return projection, columns, nil +} + +func (g *goGenerator) sqlWhere(call CallExpr, class ClassDecl, placeholderStart int) (string, []string, error) { + lambda, err := sqlLambdaArg(call, "where") + if err != nil { + return "", nil, err + } + rowName, body, err := sqlLambdaExpr(lambda, class, "where") + if err != nil { + return "", nil, err + } + predicate, args, err := g.sqlPredicate(body, class, rowName) + if err != nil { + return "", nil, err + } + return postgresPlaceholders(predicate, placeholderStart), args, nil +} + +func sqlOrder(step sqlCallStep, class ClassDecl) (string, error) { + lambda, err := sqlLambdaArg(step.call, step.name) + if err != nil { + return "", err + } + rowName, body, err := sqlLambdaExpr(lambda, class, step.name) + if err != nil { + return "", err + } + field, err := sqlRowField(body, class, rowName) + if err != nil { + return "", fmt.Errorf("%s: %w", step.name, err) + } + direction := "" + if step.name == "orderByDescending" { + direction = " DESC" + } + return sqlColumn(field) + direction, nil +} + +func (g *goGenerator) sqlLimit(call CallExpr) (string, []string, error) { + if len(call.Args) != 1 || len(call.NamedArgs) != 0 || len(call.TypeArgs) != 0 { + return "", nil, fmt.Errorf("limit() expects exactly one Int argument") + } + if literal, ok := call.Args[0].(IntExpr); ok { + value, err := strconv.Atoi(literal.Value) + if err != nil || value < 0 { + return "", nil, fmt.Errorf("limit() requires a non-negative Int") + } + return literal.Value, nil, nil + } + if unary, ok := call.Args[0].(UnaryExpr); ok && unary.Op == "-" { + if _, ok := unary.Value.(IntExpr); ok { + return "", nil, fmt.Errorf("limit() requires a non-negative Int") + } + } + if typ := g.exprType(call.Args[0]); typ != "Int" { + return "", nil, fmt.Errorf("limit() argument must have type Int, got %s", typ) + } + value, err := g.expr(call.Args[0], "Int") + if err != nil { + return "", nil, err + } + return "?", []string{value}, nil +} + +func sqlNoArgs(call CallExpr) bool { + return len(call.Args) == 0 && len(call.NamedArgs) == 0 && len(call.TypeArgs) == 0 +} + +func postgresPlaceholders(query string, start int) string { + var out strings.Builder + next := start + for _, r := range query { + if r == '?' { + out.WriteString("$") + out.WriteString(strconv.Itoa(next)) + next++ + } else { + out.WriteRune(r) + } + } + return out.String() +} + +func sqlLambdaArg(call CallExpr, method string) (LambdaExpr, error) { + if len(call.TypeArgs) != 0 || len(call.NamedArgs) != 0 || len(call.Args) != 1 { + return LambdaExpr{}, fmt.Errorf("%s expects exactly one lambda", method) + } + lambda, ok := call.Args[0].(LambdaExpr) + if !ok { + return LambdaExpr{}, fmt.Errorf("%s expects a lambda", method) + } + return lambda, nil +} + +func sqlLambdaExpr(lambda LambdaExpr, class ClassDecl, method string) (string, Expr, error) { + rowName := "it" + if !lambda.ImplicitIt { + if len(lambda.Params) != 1 { + return "", nil, fmt.Errorf("%s lambda expects one row parameter", method) + } + if lambda.Params[0].Type != "" && lambda.Params[0].Type != class.Name { + return "", nil, fmt.Errorf("%s lambda parameter must have type %s", method, class.Name) + } + rowName = lambda.Params[0].Name + } + if len(lambda.Body) != 1 { + return "", nil, fmt.Errorf("%s lambda must contain one expression", method) + } + stmt, ok := lambda.Body[0].(ExprStmt) + if !ok { + return "", nil, fmt.Errorf("%s lambda must contain one expression", method) + } + return rowName, stmt.Value, nil +} + +func (g *goGenerator) sqlPredicate(expr Expr, class ClassDecl, rowName string) (string, []string, error) { + switch e := expr.(type) { + case BinaryExpr: + if e.Op == "&&" || e.Op == "||" { + left, leftArgs, err := g.sqlPredicate(e.Left, class, rowName) + if err != nil { + return "", nil, err + } + right, rightArgs, err := g.sqlPredicate(e.Right, class, rowName) + if err != nil { + return "", nil, err + } + op := "AND" + if e.Op == "||" { + op = "OR" + } + return "(" + left + " " + op + " " + right + ")", append(leftArgs, rightArgs...), nil + } + if !isSQLComparison(e.Op) { + return "", nil, fmt.Errorf("unsupported SQL predicate operator %q", e.Op) + } + left, err := g.sqlOperand(e.Left, class, rowName) + if err != nil { + return "", nil, err + } + right, err := g.sqlOperand(e.Right, class, rowName) + if err != nil { + return "", nil, err + } + if left.typ == "Null" || right.typ == "Null" { + if e.Op != "==" && e.Op != "!=" { + return "", nil, fmt.Errorf("null only supports == and != in SQL predicates") + } + if left.typ == "Null" && right.typ == "Null" { + return "", nil, fmt.Errorf("SQL predicate cannot compare null with null") + } + value := left + if value.typ == "Null" { + value = right + } + if !sqlNullableType(value.typ) { + return "", nil, fmt.Errorf("SQL null comparison requires a nullable operand, got %s", value.typ) + } + op := "IS NULL" + if e.Op == "!=" { + op = "IS NOT NULL" + } + return value.sql + " " + op, value.args, nil + } + if !sqlTypesCompatible(left.typ, right.typ) { + return "", nil, fmt.Errorf("SQL predicate compares incompatible types %s and %s", left.typ, right.typ) + } + if e.Op == ">" || e.Op == ">=" || e.Op == "<" || e.Op == "<=" { + if !(sqlNumericType(left.typ) && sqlNumericType(right.typ)) && !(sqlTimestampType(left.typ) && sqlTimestampType(right.typ)) { + return "", nil, fmt.Errorf("SQL ordering comparison requires numeric operands or timestamp operands, got %s and %s", left.typ, right.typ) + } + } + op := map[string]string{"==": "=", "!=": "<>", ">": ">", ">=": ">=", "<": "<", "<=": "<="}[e.Op] + return left.sql + " " + op + " " + right.sql, append(left.args, right.args...), nil + case UnaryExpr: + if e.Op != "!" { + return "", nil, fmt.Errorf("unsupported SQL predicate unary operator %q", e.Op) + } + inner, args, err := g.sqlPredicate(e.Value, class, rowName) + if err != nil { + return "", nil, err + } + return "(NOT " + inner + ")", args, nil + case SelectorExpr: + field, err := sqlRowField(e, class, rowName) + if err != nil { + return "", nil, err + } + if field.Type != "Boolean" { + return "", nil, fmt.Errorf("SQL predicate field %s has type %s, not Boolean", field.Name, field.Type) + } + return sqlColumn(field), nil, nil + default: + return "", nil, fmt.Errorf("SQL where lambda must produce a Boolean predicate") + } +} + +type sqlOperandValue struct { + sql string + typ string + args []string +} + +func (g *goGenerator) sqlOperand(expr Expr, class ClassDecl, rowName string) (sqlOperandValue, error) { + if selector, ok := expr.(SelectorExpr); ok { + if receiver, ok := selector.Receiver.(IdentExpr); ok && receiver.Name == rowName { + field, err := sqlField(class, selector.Name) + if err != nil { + return sqlOperandValue{}, err + } + return sqlOperandValue{sql: sqlColumn(field), typ: field.Type}, nil + } + } + if call, ok := expr.(CallExpr); ok { + if ident, ok := call.Callee.(IdentExpr); ok && ident.Name == "now" { + if !sqlNoArgs(call) { + return sqlOperandValue{}, fmt.Errorf("now() does not accept arguments") + } + return sqlOperandValue{sql: "CURRENT_TIMESTAMP", typ: "time.Time"}, nil + } + } + if binary, ok := expr.(BinaryExpr); ok && (binary.Op == "+" || binary.Op == "-") { + left, err := g.sqlOperand(binary.Left, class, rowName) + if err != nil { + return sqlOperandValue{}, err + } + right, err := g.sqlOperand(binary.Right, class, rowName) + if err != nil { + return sqlOperandValue{}, err + } + if !sqlNumericType(left.typ) || !sqlNumericType(right.typ) { + return sqlOperandValue{}, fmt.Errorf("SQL arithmetic requires numeric operands, got %s and %s", left.typ, right.typ) + } + return sqlOperandValue{sql: "(" + left.sql + " " + binary.Op + " " + right.sql + ")", typ: left.typ, args: append(left.args, right.args...)}, nil + } + typ := "" + switch e := expr.(type) { + case IntExpr: + typ = "Int" + case FloatExpr: + typ = "Double" + case StringExpr: + typ = "String" + case BoolExpr: + typ = "Boolean" + case NullExpr: + return sqlOperandValue{sql: "NULL", typ: "Null"}, nil + case UnaryExpr: + if e.Op != "-" { + return sqlOperandValue{}, fmt.Errorf("unsupported SQL value expression") + } + switch e.Value.(type) { + case IntExpr: + typ = "Int" + case FloatExpr: + typ = "Double" + default: + return sqlOperandValue{}, fmt.Errorf("SQL unary parameters only support numeric literals") + } + default: + typ = g.exprType(expr) + } + if typ == "" { + return sqlOperandValue{}, fmt.Errorf("SQL parameter has unknown type") + } + value, err := g.expr(expr, typ) + if err != nil { + return sqlOperandValue{}, err + } + return sqlOperandValue{sql: "?", typ: typ, args: []string{value}}, nil +} + +func (g *goGenerator) sqlTypedUpdateAssignments(call CallExpr, class ClassDecl) ([]string, []string, error) { + lambda, err := sqlLambdaArg(call, "set") + if err != nil { + return nil, nil, err + } + rowName := "it" + if !lambda.ImplicitIt { + if len(lambda.Params) != 1 { + return nil, nil, fmt.Errorf("set lambda expects one row parameter") + } + if lambda.Params[0].Type != "" && lambda.Params[0].Type != class.Name { + return nil, nil, fmt.Errorf("set lambda parameter must have type %s", class.Name) + } + rowName = lambda.Params[0].Name + } + if len(lambda.Body) == 0 { + return nil, nil, fmt.Errorf("set lambda requires at least one set(row.field, value) expression") + } + var assignments, args []string + seen := map[string]bool{} + for _, stmt := range lambda.Body { + exprStmt, ok := stmt.(ExprStmt) + if !ok { + return nil, nil, fmt.Errorf("set lambda only supports set(row.field, value) expressions") + } + setCall, ok := exprStmt.Value.(CallExpr) + if !ok { + return nil, nil, fmt.Errorf("set lambda only supports set(row.field, value) expressions") + } + callee, ok := setCall.Callee.(IdentExpr) + if !ok || callee.Name != "set" || len(setCall.Args) != 2 || len(setCall.NamedArgs) != 0 || len(setCall.TypeArgs) != 0 { + return nil, nil, fmt.Errorf("set lambda expects set(%s.field, value)", rowName) + } + target, err := sqlRowField(setCall.Args[0], class, rowName) + if err != nil { + return nil, nil, fmt.Errorf("set target: %w", err) + } + if !target.Mutable { + return nil, nil, fmt.Errorf("set target %s is immutable", target.Name) + } + if seen[target.Name] { + return nil, nil, fmt.Errorf("duplicate set target %s", target.Name) + } + value, err := g.sqlOperand(setCall.Args[1], class, rowName) + if err != nil { + return nil, nil, fmt.Errorf("set value for %s: %w", target.Name, err) + } + if value.typ == "Null" { + if !sqlNullableType(target.Type) { + return nil, nil, fmt.Errorf("set target %s has non-nullable type %s", target.Name, target.Type) + } + } else if !sqlAssignmentTypesCompatible(target.Type, value.typ) { + return nil, nil, fmt.Errorf("set target %s has type %s but value has type %s", target.Name, target.Type, value.typ) + } + seen[target.Name] = true + assignments = append(assignments, sqlColumn(target)+" = "+value.sql) + args = append(args, value.args...) + } + return assignments, args, nil +} + +func sqlConflictUpdateAssignments(lambda LambdaExpr, class ClassDecl) ([]string, error) { + excludedName := "it" + if !lambda.ImplicitIt { + if len(lambda.Params) != 1 { + return nil, fmt.Errorf("doUpdate lambda expects one excluded-row parameter") + } + if lambda.Params[0].Type != "" && lambda.Params[0].Type != class.Name { + return nil, fmt.Errorf("doUpdate lambda parameter must have type %s", class.Name) + } + excludedName = lambda.Params[0].Name + } + if len(lambda.Body) == 0 { + return nil, fmt.Errorf("doUpdate lambda requires at least one set() expression") + } + assignments := make([]string, 0, len(lambda.Body)) + seen := map[string]bool{} + for _, stmt := range lambda.Body { + exprStmt, ok := stmt.(ExprStmt) + if !ok { + return nil, fmt.Errorf("doUpdate only supports set() expressions") + } + call, ok := exprStmt.Value.(CallExpr) + if !ok { + return nil, fmt.Errorf("doUpdate only supports set() expressions") + } + callee, ok := call.Callee.(IdentExpr) + if !ok || callee.Name != "set" || len(call.Args) != 2 || len(call.NamedArgs) != 0 || len(call.TypeArgs) != 0 { + return nil, fmt.Errorf("doUpdate expects set(%s.field, %s.field)", class.Name, excludedName) + } + targetRef, ok := call.Args[0].(SelectorExpr) + if !ok { + return nil, fmt.Errorf("set target must be %s.field", class.Name) + } + targetType, ok := targetRef.Receiver.(IdentExpr) + if !ok || targetType.Name != class.Name { + return nil, fmt.Errorf("set target must be %s.field", class.Name) + } + target, err := sqlField(class, targetRef.Name) + if err != nil { + return nil, fmt.Errorf("set target: %w", err) + } + if !target.Mutable { + return nil, fmt.Errorf("set target %s is immutable", target.Name) + } + if seen[target.Name] { + return nil, fmt.Errorf("duplicate set target %s", target.Name) + } + sourceRef, ok := call.Args[1].(SelectorExpr) + if !ok { + return nil, fmt.Errorf("set value must be %s.field", excludedName) + } + sourceReceiver, ok := sourceRef.Receiver.(IdentExpr) + if !ok || sourceReceiver.Name != excludedName { + return nil, fmt.Errorf("set value must be %s.field", excludedName) + } + source, err := sqlField(class, sourceRef.Name) + if err != nil { + return nil, fmt.Errorf("set value: %w", err) + } + if !sqlAssignmentTypesCompatible(target.Type, source.Type) { + return nil, fmt.Errorf("set target %s has type %s but excluded.%s has type %s", target.Name, target.Type, source.Name, source.Type) + } + seen[target.Name] = true + assignments = append(assignments, sqlColumn(target)+" = EXCLUDED."+sqlColumn(source)) + } + return assignments, nil +} + +func sqlConflictFields(expr Expr, class ClassDecl, rowName string) ([]FieldDecl, error) { + if call, ok := expr.(CallExpr); ok { + callee, ok := call.Callee.(IdentExpr) + if !ok || (callee.Name != "listOf" && callee.Name != "mutableListOf") || len(call.Args) == 0 || len(call.TypeArgs) != 0 || len(call.NamedArgs) != 0 { + return nil, fmt.Errorf("onConflict expects a row field or listOf(row.field, ...)") + } + fields := make([]FieldDecl, 0, len(call.Args)) + seen := map[string]bool{} + for _, arg := range call.Args { + field, err := sqlRowField(arg, class, rowName) + if err != nil { + return nil, fmt.Errorf("onConflict: %w", err) + } + if seen[field.Name] { + return nil, fmt.Errorf("onConflict contains duplicate field %s", field.Name) + } + seen[field.Name] = true + fields = append(fields, field) + } + return fields, nil + } + field, err := sqlRowField(expr, class, rowName) + if err != nil { + return nil, fmt.Errorf("onConflict: %w", err) + } + return []FieldDecl{field}, nil +} + +func sqlRowField(expr Expr, class ClassDecl, rowName string) (FieldDecl, error) { + selector, ok := expr.(SelectorExpr) + if !ok { + return FieldDecl{}, fmt.Errorf("expected %s.field", rowName) + } + receiver, ok := selector.Receiver.(IdentExpr) + if !ok || receiver.Name != rowName { + return FieldDecl{}, fmt.Errorf("expected %s.field", rowName) + } + return sqlField(class, selector.Name) +} + +func sqlField(class ClassDecl, name string) (FieldDecl, error) { + for _, field := range class.Fields { + if field.Name == name { + return field, nil + } + } + return FieldDecl{}, fmt.Errorf("SQL row class %s has no field %s", class.Name, name) +} + +func sqlColumn(field FieldDecl) string { + if field.Column != "" { + return field.Column + } + return snakeCase(field.Name) +} + +func sqlQueryValue(query string, args []string) string { + return "GotlinSQLQuery{SQL: " + strconv.Quote(query) + ", Args: []any{" + strings.Join(args, ", ") + "}}" +} + +func isSQLComparison(op string) bool { + switch op { + case "==", "!=", ">", ">=", "<", "<=": + return true + default: + return false + } +} + +func sqlBaseType(typ string) string { + return strings.TrimSuffix(typ, "?") +} + +func sqlNullableType(typ string) bool { + return strings.HasSuffix(typ, "?") +} + +func sqlNumericType(typ string) bool { + switch sqlBaseType(typ) { + case "Int", "Float", "Double": + return true + default: + return false + } +} + +func sqlTimestampType(typ string) bool { + return sqlBaseType(typ) == "time.Time" +} + +func sqlTypesCompatible(left, right string) bool { + if left == "Null" || right == "Null" { + return true + } + left = sqlBaseType(left) + right = sqlBaseType(right) + if left == right { + return true + } + return sqlNumericType(left) && sqlNumericType(right) +} + +func sqlProjectionTypesCompatible(target, source string) bool { + return target == source +} + +func sqlAssignmentTypesCompatible(target, source string) bool { + if sqlBaseType(target) != sqlBaseType(source) { + return false + } + return sqlNullableType(target) || !sqlNullableType(source) +} + +func programContainsSQL(program *Program) bool { + return programExprMatches(program, func(expr Expr) bool { + _, _, _, ok := splitSQLChain(expr) + return ok + }) +} + +func programContainsSQLExecution(program *Program) bool { + return programExprMatches(program, func(expr Expr) bool { + _, _, steps, ok := splitSQLChain(expr) + if !ok || len(steps) == 0 { + return false + } + switch steps[len(steps)-1].name { + case "fetch", "single", "iterator": + return true + default: + return false + } + }) +} + +func exprMatches(expr Expr, match func(Expr) bool) bool { + if match(expr) { + return true + } + switch e := expr.(type) { + case UnaryExpr: + return exprMatches(e.Value, match) + case BinaryExpr: + return exprMatches(e.Left, match) || exprMatches(e.Right, match) + case CallExpr: + if exprMatches(e.Callee, match) { + return true + } + for _, arg := range e.Args { + if exprMatches(arg, match) { + return true + } + } + for _, arg := range e.NamedArgs { + if exprMatches(arg.Value, match) { + return true + } + } + case SelectorExpr: + return exprMatches(e.Receiver, match) + case IndexExpr: + return exprMatches(e.Receiver, match) || exprMatches(e.Index, match) + case LambdaExpr: + return stmtsMatch(e.Body, match) + } + return false +} + +func stmtsMatch(stmts []Stmt, match func(Expr) bool) bool { + for _, stmt := range stmts { + switch s := stmt.(type) { + case VarDecl: + if exprMatches(s.Value, match) { + return true + } + case MultiVarDecl: + if exprMatches(s.Value, match) { + return true + } + case AssignStmt: + if exprMatches(s.Value, match) { + return true + } + case AddAssignStmt: + if exprMatches(s.Value, match) { + return true + } + case MultiAssignStmt: + if exprMatches(s.Value, match) { + return true + } + case ReturnStmt: + if s.Value != nil && exprMatches(s.Value, match) { + return true + } + case ThrowStmt: + if exprMatches(s.Value, match) { + return true + } + case GoStmt: + if exprMatches(s.Value, match) { + return true + } + case DeferStmt: + if exprMatches(s.Value, match) { + return true + } + case ExprStmt: + if exprMatches(s.Value, match) { + return true + } + case IfStmt: + if exprMatches(s.Cond, match) || stmtsMatch(s.Then, match) || stmtsMatch(s.Else, match) { + return true + } + case WhileStmt: + if exprMatches(s.Cond, match) || stmtsMatch(s.Body, match) { + return true + } + case ForEachStmt: + if exprMatches(s.Source, match) || stmtsMatch(s.Body, match) { + return true + } + case SelectStmt: + for _, c := range s.Cases { + if exprMatches(c.Source, match) || stmtsMatch(c.Body, match) { + return true + } + } + case TryCatchStmt: + if stmtsMatch(s.TryBody, match) || stmtsMatch(s.CatchBody, match) { + return true + } + } + } + return false +} + +func programExprMatches(program *Program, match func(Expr) bool) bool { + for _, fn := range program.Functions { + if stmtsMatch(fn.Body, match) { + return true + } + } + for _, class := range program.Classes { + for _, fn := range class.Methods { + if stmtsMatch(fn.Body, match) { + return true + } + } + } + for _, worker := range program.Workers { + for _, field := range worker.Fields { + if exprMatches(field.Value, match) { + return true + } + } + for _, fn := range worker.Methods { + if stmtsMatch(fn.Body, match) { + return true + } + } + } + return false +} diff --git a/internal/lang/sql_expansion_test.go b/internal/lang/sql_expansion_test.go new file mode 100644 index 0000000..19be4e6 --- /dev/null +++ b/internal/lang/sql_expansion_test.go @@ -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() + .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() + .select { row -> EventProjection(row.id, row.payload) } + .orderBy { it.createdAt } + .fetch(pool, ctx) +} + +fun eventStream(pool: *pgxpool.Pool, ctx: context.Context): GotlinSQLIterator { + return sql.from() + .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(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(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() + .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() + .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().skipLocked().build() }`, + want: "requires a preceding forUpdate", + }, + { + name: "lock before limit", + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().forUpdate().limit(1).build() }`, + want: "limit() may appear once", + }, + { + name: "non nullable null comparison", + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().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().limit(limit).build() }`, + want: "argument must have type Int", + }, + { + name: "negative literal limit", + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().limit(-1).build() }`, + want: "requires a non-negative Int", + }, + { + name: "projection type mismatch", + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().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().select { EventProjection(it.id) }.build() }`, + want: "expects 2 fields, got 1", + }, + { + name: "projection after where", + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from().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().where { it.id == "x" }.build() }`, + want: "requires set() as its first method", + }, + { + name: "update incompatible value", + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update().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().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().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().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().single(pool, ctx) }`, + want: "requires returning()", + }, + { + name: "returning out of order", + src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.delete().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) + } +} diff --git a/internal/lang/sql_test.go b/internal/lang/sql_test.go new file mode 100644 index 0000000..dddcd25 --- /dev/null +++ b/internal/lang/sql_test.go @@ -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() + .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().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().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() + .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().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().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().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(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(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(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().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().build() }`, + want: "requires @table", + }, + { + name: "unknown predicate field", + src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from().where { it.missing == "x" }.build() }`, + want: "has no field missing", + }, + { + name: "incompatible predicate operands", + src: accountRowSource + `fun query(customerId: String): GotlinSQLQuery { return sql.from().where { it.balance == customerId }.build() }`, + want: "incompatible types Double and String", + }, + { + name: "non numeric ordering predicate", + src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from().where { it.customerId > "a" }.build() }`, + want: "ordering comparison requires numeric operands", + }, + { + name: "unknown order field", + src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from().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(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(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(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(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(row).onConflict { it.id }.doNothing().build() } +`, + want: "cannot insert value of type OtherRow", + }, + { + name: "incomplete chain", + src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from() }`, + want: "must end with build()", + }, + { + name: "fetch missing arguments", + src: accountRowSource + `fun query(): List<*AccountRow> { return sql.from().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().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 { return sql.from().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().fetch(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().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().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(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) +} diff --git a/internal/lang/token.go b/internal/lang/token.go index f63a240..a3b41e5 100644 --- a/internal/lang/token.go +++ b/internal/lang/token.go @@ -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, diff --git a/tools/vscode-gotlin/README.md b/tools/vscode-gotlin/README.md index 7a27b92..9cb014f 100644 --- a/tools/vscode-gotlin/README.md +++ b/tools/vscode-gotlin/README.md @@ -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 -/bin/gotlin-lsp -``` - -If your binary lives somewhere else, set: +The extension first looks for the platform-specific server binary at `/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. diff --git a/tools/vscode-gotlin/language-configuration.json b/tools/vscode-gotlin/language-configuration.json index 867069a..0a3956b 100644 --- a/tools/vscode-gotlin/language-configuration.json +++ b/tools/vscode-gotlin/language-configuration.json @@ -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" } } ] } diff --git a/tools/vscode-gotlin/package-lock.json b/tools/vscode-gotlin/package-lock.json index 6c0686d..5e33515 100644 --- a/tools/vscode-gotlin/package-lock.json +++ b/tools/vscode-gotlin/package-lock.json @@ -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" diff --git a/tools/vscode-gotlin/package.json b/tools/vscode-gotlin/package.json index 17b5a2e..5634e17 100644 --- a/tools/vscode-gotlin/package.json +++ b/tools/vscode-gotlin/package.json @@ -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" diff --git a/tools/vscode-gotlin/scripts/validate.js b/tools/vscode-gotlin/scripts/validate.js new file mode 100644 index 0000000..cb558eb --- /dev/null +++ b/tools/vscode-gotlin/scripts/validate.js @@ -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."); diff --git a/tools/vscode-gotlin/snippets/gotlin.code-snippets b/tools/vscode-gotlin/snippets/gotlin.code-snippets index febc051..5ce89d8 100644 --- a/tools/vscode-gotlin/snippets/gotlin.code-snippets +++ b/tools/vscode-gotlin/snippets/gotlin.code-snippets @@ -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": { diff --git a/tools/vscode-gotlin/syntaxes/gotlin.tmLanguage.json b/tools/vscode-gotlin/syntaxes/gotlin.tmLanguage.json index 9881661..6bbb4f0 100644 --- a/tools/vscode-gotlin/syntaxes/gotlin.tmLanguage.json +++ b/tools/vscode-gotlin/syntaxes/gotlin.tmLanguage.json @@ -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": "[,;:]" } ] }