Expand Gotlin language and tooling
This commit is contained in:
parent
8f4f858d86
commit
de1262b4cf
41 changed files with 6059 additions and 379 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,3 +2,6 @@ node_modules
|
|||
.gocache
|
||||
bin
|
||||
out
|
||||
/gotlinc
|
||||
/gotlin-lsp
|
||||
*.vsix
|
||||
|
|
|
|||
312
README.md
312
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<T>()`:
|
||||
|
||||
```kotlin
|
||||
data class AddressEntity(var city: String)
|
||||
data class AddressResponse(var city: String)
|
||||
data class AccountEntity(var id: String, var address: *AddressEntity)
|
||||
data class AccountResponse(var address: *AddressResponse, var id: String)
|
||||
|
||||
val response = account.mapTo<AccountResponse>()
|
||||
```
|
||||
|
||||
When the surrounding expression provides a target type, the type argument is
|
||||
optional:
|
||||
|
||||
```kotlin
|
||||
fun response(account: *AccountEntity): *AccountResponse {
|
||||
return account.mapTo()
|
||||
}
|
||||
|
||||
val response: *AccountResponse = account.mapTo()
|
||||
val envelope = Envelope(account.mapTo())
|
||||
```
|
||||
|
||||
Use explicit `mapTo<T>()` when assigning to an untyped local or when no target
|
||||
type can be inferred.
|
||||
|
||||
Fields are matched by Gotlin name rather than declaration order. Mapping is
|
||||
recursive across nested classes, pointers, nullable values, lists, mutable
|
||||
lists, maps, and enum payloads. Enum variants are matched by name. Extra source
|
||||
fields and extra target enum variants are allowed; every target field and every
|
||||
source enum variant must be compatible.
|
||||
|
||||
Payloadless enums also map recursively to and from `String`. String-to-enum
|
||||
mapping validates the runtime value and panics for an unknown variant string.
|
||||
|
||||
Incompatible mappings fail compilation with a complete path, for example:
|
||||
|
||||
```text
|
||||
cannot map Account.address.zip: String is incompatible with Int
|
||||
```
|
||||
|
||||
```bash
|
||||
go run ./cmd/gotlinc build ./examples/hello.gt
|
||||
./hello
|
||||
|
|
@ -109,6 +192,231 @@ Run directly:
|
|||
go run ./cmd/gotlinc run ./examples/hello.gt
|
||||
```
|
||||
|
||||
## Type-checked SQL queries
|
||||
|
||||
Gotlin recognizes a PostgreSQL SQL DSL at compile time. SQL row mappings must be data classes annotated with `@table`. Fields map from lower-camel Gotlin names to `snake_case` columns by default and can override the SQL name with `@column`. Conflict keys use `@id`; database-generated or defaulted fields use `@generated`.
|
||||
|
||||
```kotlin
|
||||
import time
|
||||
|
||||
@table("accounts")
|
||||
data class AccountRow(
|
||||
@generated @id var id: String,
|
||||
var customerId: String,
|
||||
@column("kind") var accountType: String,
|
||||
var balance: Double,
|
||||
var closedAt: time.Time?
|
||||
)
|
||||
|
||||
fun accountsFor(customerId: String): GotlinSQLQuery {
|
||||
return sql.from<AccountRow>()
|
||||
.where { row -> row.customerId == customerId && row.closedAt == null }
|
||||
.orderByDescending { row -> row.balance }
|
||||
.limit(100)
|
||||
.build()
|
||||
}
|
||||
```
|
||||
|
||||
The compiler emits a Go value with this generated support type:
|
||||
|
||||
```go
|
||||
type GotlinSQLQuery struct {
|
||||
SQL string
|
||||
Args []any
|
||||
}
|
||||
```
|
||||
|
||||
The example selects every field, including `@generated` fields, and produces PostgreSQL `$n` placeholders. Arguments are emitted in SQL traversal order. A literal `limit(100)` is embedded after validation; a typed non-literal `Int` limit uses the next placeholder.
|
||||
|
||||
Nullable types use a `?` suffix, currently including forms such as `String?` and `time.Time?`; generated Go fields use pointers. Comparing a nullable field with `null` lowers to `IS NULL` or `IS NOT NULL`. Ordering comparisons support numeric values and `time.Time`; typed `now()` emits `CURRENT_TIMESTAMP` without an argument:
|
||||
|
||||
```kotlin
|
||||
sql.from<OutboxRow>()
|
||||
.where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) }
|
||||
.orderBy { it.createdAt }
|
||||
.limit(batchSize)
|
||||
.forUpdate()
|
||||
.skipLocked()
|
||||
.build()
|
||||
```
|
||||
|
||||
The canonical select method order is:
|
||||
|
||||
```text
|
||||
sql.from<Row>()
|
||||
[.select { ... }]
|
||||
[.where { ... }]
|
||||
[.orderBy { ... } | .orderByDescending { ... }]
|
||||
[.limit(Int)]
|
||||
[.forUpdate()]
|
||||
[.skipLocked()]
|
||||
.build() | .fetch(pool, ctx) | .single(pool, ctx) | .iterator(pool, ctx)
|
||||
```
|
||||
|
||||
Each optional method may occur at most once. `select` must be first, `skipLocked` requires `forUpdate`, and clauses are emitted as `WHERE`, `ORDER BY`, `LIMIT`, `FOR UPDATE`, `SKIP LOCKED` in PostgreSQL order.
|
||||
|
||||
### Typed projections
|
||||
|
||||
Projection targets are local data classes. The constructor must contain one direct source-row field per target field, in target declaration order, with exact matching types:
|
||||
|
||||
```kotlin
|
||||
data class AccountSummary(var id: String, var balance: Double)
|
||||
|
||||
fun summaries(pool: *pgxpool.Pool, ctx: context.Context): List<*AccountSummary> {
|
||||
return sql.from<AccountRow>()
|
||||
.select { row -> AccountSummary(row.id, row.balance) }
|
||||
.orderBy { it.balance }
|
||||
.fetch(pool, ctx)
|
||||
}
|
||||
```
|
||||
|
||||
The compiler emits `SELECT id, balance`, scans in projection declaration order, and makes `fetch`, `single`, and `iterator` target `AccountSummary` rather than `AccountRow`. The row parameter in later `where` and ordering methods still represents `AccountRow`.
|
||||
|
||||
Select chains can execute directly against a pgx/v5 pool. `fetch(pool, ctx)` returns `List<*AccountRow>` and closes the pgx rows after reading and scanning every result in data-class field declaration order:
|
||||
|
||||
```kotlin
|
||||
import context
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
fun accountsFor(
|
||||
pool: *pgxpool.Pool,
|
||||
ctx: context.Context,
|
||||
customerId: String
|
||||
): List<*AccountRow> {
|
||||
return sql.from<AccountRow>()
|
||||
.where { it.customerId == customerId }
|
||||
.orderBy { it.accountType }
|
||||
.fetch(pool, ctx)
|
||||
}
|
||||
```
|
||||
|
||||
`single(pool, ctx)` returns `*AccountRow`. It closes the rows and panics through Gotlin's auto-throw path unless the query produces exactly one row:
|
||||
|
||||
```kotlin
|
||||
fun account(pool: *pgxpool.Pool, ctx: context.Context, id: String): *AccountRow {
|
||||
return sql.from<AccountRow>()
|
||||
.where { it.id == id }
|
||||
.single(pool, ctx)
|
||||
}
|
||||
```
|
||||
|
||||
`iterator(pool, ctx)` streams pgx rows. Call `next()` before each `value()`, always arrange an explicit `close()` (normally with `defer`), and call `err()` after iteration. Assigning the error result invokes the existing auto-throw convention:
|
||||
|
||||
```kotlin
|
||||
fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
|
||||
val rows = sql.from<AccountRow>().iterator(pool, ctx)
|
||||
defer rows.close()
|
||||
|
||||
while (rows.next()) {
|
||||
val account: *AccountRow = rows.value()
|
||||
println(account.customerId)
|
||||
}
|
||||
|
||||
val checked = rows.err()
|
||||
}
|
||||
```
|
||||
|
||||
Query and scan failures panic through `gotlinAutoThrow`. `fetch` and `single` close rows internally; an iterator leaves lifecycle control with the caller, and `value()` panics unless the immediately preceding `next()` succeeded. None of the execution terminals require an intermediate `build()`, `query.sql`, or `query.args` access. `build()` remains available when a query value is needed for manual execution.
|
||||
|
||||
### Inserts and returning
|
||||
|
||||
Inserts omit every `@generated` field. If all fields are generated, the compiler emits `INSERT ... DEFAULT VALUES`. Conflict handling is optional; conflict fields must have `@id`:
|
||||
|
||||
```kotlin
|
||||
sql.insert<AccountRow>(row)
|
||||
.onConflict { it.id }
|
||||
.doNothing()
|
||||
.build()
|
||||
```
|
||||
|
||||
Composite conflict keys use `listOf`, for example `.onConflict { listOf(it.tenantId, it.id) }`.
|
||||
|
||||
Updates use a typed lower-camel `set` form because Gotlin does not currently implement Kotlin callable references (`AccountRow::balance`):
|
||||
|
||||
```kotlin
|
||||
sql.insert<AccountRow>(row)
|
||||
.onConflict { it.id }
|
||||
.doUpdate { excluded -> set(AccountRow.balance, excluded.balance) }
|
||||
.build()
|
||||
```
|
||||
|
||||
Multiple `set(...)` expressions may appear in one `doUpdate` lambda. Update targets must exist and be `var`; excluded fields must exist and have a compatible type.
|
||||
|
||||
An insert can return the full row or a typed projection. Write chains can use `fetch`, `single`, or `iterator` only after `returning`:
|
||||
|
||||
```kotlin
|
||||
sql.insert<AccountRow>(row)
|
||||
.returning { it }
|
||||
.single(pool, ctx)
|
||||
|
||||
sql.insert<AccountRow>(row)
|
||||
.onConflict { it.id }
|
||||
.doNothing()
|
||||
.returning { value -> AccountSummary(value.id, value.balance) }
|
||||
.single(pool, ctx)
|
||||
```
|
||||
|
||||
The exact insert forms are:
|
||||
|
||||
```text
|
||||
sql.insert<Row>(row)
|
||||
[.onConflict { field | listOf(fields...) }.doNothing() | .doUpdate { ... }]
|
||||
[.returning { it | Projection(it.field, ...) }]
|
||||
.build() | returning execution terminal
|
||||
```
|
||||
|
||||
### Updates and deletes
|
||||
|
||||
Typed updates use one `set` lambda. Targets are mutable source-row fields. Values may be typed names/selectors, literals, `null` for nullable targets, `now()` for timestamps, source-row fields, or numeric `+` and `-` expressions:
|
||||
|
||||
```kotlin
|
||||
sql.update<AccountRow>()
|
||||
.set { row ->
|
||||
set(row.balance, row.balance + amount)
|
||||
set(row.closedAt, now())
|
||||
}
|
||||
.where { it.id == accountId }
|
||||
.returning { row -> AccountSummary(row.id, row.balance) }
|
||||
.single(pool, ctx)
|
||||
```
|
||||
|
||||
Deletes support a typed predicate and the same returning forms:
|
||||
|
||||
```kotlin
|
||||
sql.delete<AccountRow>()
|
||||
.where { it.id == accountId }
|
||||
.returning { it }
|
||||
.single(pool, ctx)
|
||||
```
|
||||
|
||||
The exact write forms are:
|
||||
|
||||
```text
|
||||
sql.update<Row>()
|
||||
.set { row -> set(row.field, value); ... }
|
||||
[.where { predicate }]
|
||||
[.returning { it | Projection(it.field, ...) }]
|
||||
.build() | returning execution terminal
|
||||
|
||||
sql.delete<Row>()
|
||||
[.where { predicate }]
|
||||
[.returning { it | Projection(it.field, ...) }]
|
||||
.build() | returning execution terminal
|
||||
```
|
||||
|
||||
`where` is intentionally optional for updates and deletes, so omitting it affects the whole table. There is no write `execute` terminal yet; use `build()` for manual `Exec`, or add `returning` and use a query execution terminal.
|
||||
|
||||
At compile time the DSL checks that the generic row type exists, is a data class with `@table`, has unique mapped columns, and contains every referenced field. It checks known predicate operand types, nullable operations, insert row types, `@id` conflict metadata, update mutability and value compatibility, method cardinality/order, lock dependencies, and projection field counts/types.
|
||||
|
||||
Current SQL limitations:
|
||||
|
||||
- Predicates support `&&`, `||`, `!`, `==`, `!=`, and numeric/timestamp `<`, `<=`, `>`, `>=`. Values must have a compiler-known type; external values become `$n` arguments.
|
||||
- Projections and `returning` do not support scalar results, aliases, computed expressions, aggregates, external structs, or reordered/coerced target types. They accept a full row or direct fields passed to a local data-class constructor.
|
||||
- Standalone update values do not support SQL functions other than `now()`, string expressions, intervals, casts, subqueries, or arbitrary SQL fragments. Upsert `doUpdate` values still come only from the `excluded` row.
|
||||
- Inserts emit one row at a time. Bulk/multi-row inserts are not implemented.
|
||||
- Table and column annotations are validated unquoted SQL identifiers. Joins, aliases, grouping, aggregates, `OFFSET`, lock strengths other than `FOR UPDATE`, and conflict predicates are not implemented.
|
||||
- The compiler does not perform migrations, schema generation, database connections, or schema introspection.
|
||||
|
||||
Language server:
|
||||
|
||||
```bash
|
||||
|
|
@ -126,7 +434,7 @@ npm run build
|
|||
|
||||
## Notes
|
||||
|
||||
- `val` and `var` currently compile to the same local-variable semantics in Go.
|
||||
- `val` is immutable after initialization; `var` can be reassigned.
|
||||
- Type inference is local to declarations without an explicit type.
|
||||
- Top-level declarations currently support functions, classes, and interfaces.
|
||||
- `gotlinc build` produces an executable by default. If `-o` is omitted, the output name is derived from the input file name.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
@ -66,6 +68,7 @@ type documentState struct {
|
|||
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)
|
||||
|
|
@ -849,6 +998,13 @@ func functionSemanticDiagnostics(text string, fn lang.FunctionDecl, functions ma
|
|||
}
|
||||
case lang.SelectorExpr:
|
||||
walkExpr(e.Receiver, scope)
|
||||
case lang.IndexExpr:
|
||||
walkExpr(e.Receiver, scope)
|
||||
walkExpr(e.Index, scope)
|
||||
case lang.EnumVariantExpr:
|
||||
for _, value := range e.Values {
|
||||
walkExpr(value, scope)
|
||||
}
|
||||
case lang.LambdaExpr:
|
||||
lambdaScope := copyScope(scope)
|
||||
if e.ImplicitIt {
|
||||
|
|
@ -2026,23 +2182,43 @@ func copyScope(scope map[string]bool) map[string]bool {
|
|||
}
|
||||
|
||||
func isBuiltin(name string) bool {
|
||||
switch name {
|
||||
case "println", "runCatching", "Channel", "after", "every", "listOf", "mutableListOf", "mapOf", "mutableMapOf":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
_, ok := builtinDetails[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func builtinHoverDetail(name string) (string, bool) {
|
||||
switch name {
|
||||
case "after":
|
||||
return "fun after(ms: Int): Channel<time.Time>", true
|
||||
case "every":
|
||||
return "fun every(ms: Int): Channel<time.Time>", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
value, ok := builtinDetails[name]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
var builtinDetails = map[string]string{
|
||||
"println": "fun println(value: Any): Unit",
|
||||
"runCatching": "fun runCatching(block: () -> Unit): Result",
|
||||
"Channel": "fun Channel<T>(capacity: Int = 0): Channel<T>",
|
||||
"after": "fun after(ms: Int): Channel<time.Time>",
|
||||
"every": "fun every(ms: Int): Channel<time.Time>",
|
||||
"listOf": "fun listOf<T>(values: T...): List<T>",
|
||||
"mutableListOf": "fun mutableListOf<T>(values: T...): MutableList<T>",
|
||||
"mapOf": "fun mapOf<K, V>(pairs: Any...): Map<K, V>",
|
||||
"mutableMapOf": "fun mutableMapOf<K, V>(pairs: Any...): MutableMap<K, V>",
|
||||
"ByteSlice": "fun ByteSlice(value: String): ByteSlice",
|
||||
"append": "fun append<T>(values: List<T>, value: T): List<T>",
|
||||
"len": "fun len(value: Any): Int",
|
||||
"cap": "fun cap(value: Any): Int",
|
||||
"make": "fun make<T>(size: Int): T",
|
||||
"new": "fun new<T>(): *T",
|
||||
"copy": "fun copy(target: Any, source: Any): Int",
|
||||
"delete": "fun delete(map: Any, key: Any): Unit",
|
||||
"close": "fun close(channel: Any): Unit",
|
||||
"panic": "fun panic(value: Any): Unit",
|
||||
"recover": "fun recover(): Any",
|
||||
"string": "fun string(value: Any): String",
|
||||
"int": "fun int(value: Any): Int",
|
||||
"float64": "fun float64(value: Any): Double",
|
||||
"bool": "fun bool(value: Any): Boolean",
|
||||
"sql": "typed PostgreSQL query DSL",
|
||||
"set": "fun set(target: Any, value: Any): Unit",
|
||||
"now": "fun now(): time.Time",
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,104 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gotlin/internal/lang"
|
||||
)
|
||||
|
||||
func TestPackageWideDiagnosticsAndDefinition(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example\n\ngo 1.25\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "model"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modelPath := filepath.Join(root, "model", "command.gt")
|
||||
modelText := "package main\n\ndata class CreateAccountCommand(var name: String)\n"
|
||||
if err := os.WriteFile(modelPath, []byte(modelText), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := filepath.Join(root, "main.gt")
|
||||
mainText := "package main\n\nfun main() {\n val command = CreateAccountCommand(\"demo\")\n println(command)\n}\n"
|
||||
if err := os.WriteFile(mainPath, []byte(mainText), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uri := fileURI(mainPath)
|
||||
s := server{docs: map[string]documentState{}}
|
||||
state := s.buildDocumentState(uri, mainText)
|
||||
for _, d := range state.diagnostics {
|
||||
if strings.Contains(d.Message, "undefined identifier CreateAccountCommand") {
|
||||
t.Fatalf("unexpected cross-file diagnostic: %+v", d)
|
||||
}
|
||||
}
|
||||
s.docs[uri] = state
|
||||
character := strings.Index(strings.Split(mainText, "\n")[3], "CreateAccountCommand")
|
||||
result, ok := s.definition(uri, position{Line: 3, Character: character + 2}).([]location)
|
||||
if !ok || len(result) != 1 {
|
||||
t.Fatalf("expected cross-file definition, got %#v", s.definition(uri, position{Line: 3, Character: character + 2}))
|
||||
}
|
||||
if result[0].URI != fileURI(modelPath) {
|
||||
t.Fatalf("definition URI = %s, want %s", result[0].URI, fileURI(modelPath))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageIndexStopsAtNearestGoModule(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "first")
|
||||
second := filepath.Join(root, "second")
|
||||
for _, dir := range []string{first, second} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example\n\ngo 1.25\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(second, "other.gt"), []byte("package main\ndata class OtherRootType(var id: String)\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := filepath.Join(first, "main.gt")
|
||||
mainText := "package main\nfun main() { OtherRootType(\"x\") }\n"
|
||||
if err := os.WriteFile(mainPath, []byte(mainText), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := server{docs: map[string]documentState{}}
|
||||
state := s.buildDocumentState(fileURI(mainPath), mainText)
|
||||
found := false
|
||||
for _, d := range state.diagnostics {
|
||||
if strings.Contains(d.Message, "undefined identifier OtherRootType") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected module-isolated undefined diagnostic, got %+v", state.diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentBuiltinsDoNotProduceUndefinedDiagnostics(t *testing.T) {
|
||||
state := buildDocumentState(`package demo
|
||||
fun main() {
|
||||
val bytes = ByteSlice("hello")
|
||||
var values: MutableList<String> = mutableListOf<String>()
|
||||
values = append(values, string(bytes))
|
||||
println(len(values))
|
||||
}`)
|
||||
for _, diagnostic := range state.diagnostics {
|
||||
if strings.Contains(diagnostic.Message, "undefined identifier") {
|
||||
t.Fatalf("unexpected builtin diagnostic: %+v", diagnostic)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"ByteSlice", "append", "len", "sql", "set", "now"} {
|
||||
if !isBuiltin(name) {
|
||||
t.Fatalf("%s is not registered as builtin", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStdlibTarget(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package examples.http
|
||||
|
|
@ -668,7 +760,7 @@ func TestBuildDocumentStateWorkerSemantics(t *testing.T) {
|
|||
package demo
|
||||
|
||||
worker Counter {
|
||||
val counter = 0
|
||||
var counter = 0
|
||||
|
||||
fun getCount(): Int {
|
||||
return counter
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
return compileFiles([]string{inputPath}, forceMain)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
program, err := lang.Parse(string(src))
|
||||
parsed, err := lang.Parse(string(src))
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
if program.PackagePath == "" {
|
||||
program.PackagePath = parsed.PackagePath
|
||||
}
|
||||
if parsed.PackagePath != program.PackagePath {
|
||||
fail(fmt.Errorf("Gotlin files must use the same package"))
|
||||
}
|
||||
program.Imports = append(program.Imports, parsed.Imports...)
|
||||
program.Interfaces = append(program.Interfaces, parsed.Interfaces...)
|
||||
program.Enums = append(program.Enums, parsed.Enums...)
|
||||
program.Classes = append(program.Classes, parsed.Classes...)
|
||||
program.Workers = append(program.Workers, parsed.Workers...)
|
||||
program.Functions = append(program.Functions, parsed.Functions...)
|
||||
program.Embeds = append(program.Embeds, parsed.Embeds...)
|
||||
if firstSource == "" {
|
||||
firstSource = string(src)
|
||||
}
|
||||
}
|
||||
var goSrc []byte
|
||||
var err error
|
||||
if forceMain {
|
||||
goSrc, err = lang.GenerateGoMain(program)
|
||||
} else {
|
||||
|
|
@ -133,7 +158,7 @@ func compileFile(inputPath string, forceMain bool) string {
|
|||
fail(err)
|
||||
}
|
||||
if forceMain {
|
||||
return addBestEffortLineDirectives(string(goSrc), inputPath, string(src))
|
||||
return addBestEffortLineDirectives(string(goSrc), inputPaths[0], firstSource)
|
||||
}
|
||||
return string(goSrc)
|
||||
}
|
||||
|
|
|
|||
22
examples/enums.gt
Normal file
22
examples/enums.gt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package main
|
||||
|
||||
enum PaymentResult {
|
||||
Accepted(String)
|
||||
Rejected(String)
|
||||
Pending
|
||||
}
|
||||
|
||||
fun describe(result: PaymentResult): String {
|
||||
var description = ""
|
||||
match (result) {
|
||||
PaymentResult::Accepted(id) -> { description = "accepted " + id }
|
||||
PaymentResult::Rejected(reason) -> { description = "rejected " + reason }
|
||||
PaymentResult::Pending -> { description = "pending" }
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val result = PaymentResult::Accepted("payment-1")
|
||||
println(describe(result))
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ class EpicControllerImpl(val db: *bun.DB) {
|
|||
}
|
||||
|
||||
worker Counter {
|
||||
val counter = 0
|
||||
var counter = 0
|
||||
|
||||
fun getCount(): Int {
|
||||
return counter
|
||||
|
|
|
|||
40
examples/mapping.gt
Normal file
40
examples/mapping.gt
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package main
|
||||
|
||||
data class AddressEntity(var city: String)
|
||||
data class AddressResponse(var city: String)
|
||||
|
||||
enum EntityState {
|
||||
Active
|
||||
Failed(String)
|
||||
}
|
||||
|
||||
enum ResponseState {
|
||||
Active
|
||||
Failed(String)
|
||||
Pending
|
||||
}
|
||||
|
||||
data class AccountEntity(
|
||||
var id: String,
|
||||
var address: *AddressEntity,
|
||||
var labels: List<String>,
|
||||
var state: EntityState
|
||||
)
|
||||
|
||||
data class AccountResponse(
|
||||
var address: *AddressResponse,
|
||||
var id: String,
|
||||
var labels: List<String>,
|
||||
var state: ResponseState
|
||||
)
|
||||
|
||||
fun main() {
|
||||
val entity = AccountEntity("account-1", AddressEntity("Berlin"), listOf("active"), EntityState::Active)
|
||||
val response = entity.mapTo<AccountResponse>()
|
||||
println(response.address.city)
|
||||
match (response.state) {
|
||||
ResponseState::Active -> { println("active") }
|
||||
ResponseState::Failed(reason) -> { println(reason) }
|
||||
ResponseState::Pending -> { println("pending") }
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ class PrefixGreeter(val prefix: String): Greeter {
|
|||
}
|
||||
|
||||
worker Counter {
|
||||
val count = 0
|
||||
var count = 0
|
||||
|
||||
fun increment() {
|
||||
count += 1
|
||||
|
|
|
|||
7
go.mod
7
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
|
||||
)
|
||||
|
|
|
|||
19
go.sum
19
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=
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
func demoImports() {
|
||||
fmt.Println(strings.ToUpper("gotlin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -21,6 +36,9 @@ type InterfaceDecl struct {
|
|||
|
||||
type ClassDecl struct {
|
||||
Name string
|
||||
Data bool
|
||||
JSONNaming string
|
||||
Table string
|
||||
Fields []FieldDecl
|
||||
Parents []string
|
||||
Methods []FunctionDecl
|
||||
|
|
@ -41,8 +59,12 @@ type WorkerFieldDecl struct {
|
|||
|
||||
type FieldDecl struct {
|
||||
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,6 +120,7 @@ func (AssignStmt) stmtNode() {}
|
|||
|
||||
type AddAssignStmt struct {
|
||||
Name string
|
||||
Pos int
|
||||
Value Expr
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +128,7 @@ func (AddAssignStmt) stmtNode() {}
|
|||
|
||||
type MultiAssignStmt struct {
|
||||
Names []string
|
||||
Positions []int
|
||||
Value Expr
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -215,6 +273,12 @@ type CallExpr struct {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
internal/lang/data_class_test.go
Normal file
72
internal/lang/data_class_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateGoDataClassUsesSnakeCaseJSONTags(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
|
||||
data class KeycloakToken(var accessToken: String, var refreshToken: String)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatalf("generation failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{"AccessToken", "`json:\"access_token\"`", "RefreshToken", "`json:\"refresh_token\"`"} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateGoDataClassJSONNamingOverride(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
|
||||
@jsonNaming(camelCase)
|
||||
data class Account(var availableBalance: Double)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatalf("generation failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "`json:\"availableBalance\"`") {
|
||||
t.Fatalf("generated Go missing camelCase tag:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateGoDataClassFieldVisibilityAndSelector(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
|
||||
import json encoding.json
|
||||
|
||||
data class Request(var email: String, private val traceId: String)
|
||||
|
||||
fun email(body: ByteSlice): String {
|
||||
val request = json.decode<Request>(body)
|
||||
return request.email
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatalf("generation failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Email", "`json:\"email\"`", "traceId string `json:\"-\"`", "return request.Email"} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
79
internal/lang/enum_test.go
Normal file
79
internal/lang/enum_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateRustStyleEnumAndExhaustiveMatch(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
enum PaymentResult { Accepted(String), Rejected(String), Pending }
|
||||
fun describe(result: PaymentResult): String {
|
||||
var description = ""
|
||||
match (result) {
|
||||
PaymentResult::Accepted(id) -> { description = id }
|
||||
PaymentResult::Rejected(reason) -> { description = reason }
|
||||
PaymentResult::Pending -> { description = "pending" }
|
||||
}
|
||||
return description
|
||||
}
|
||||
fun main() { println(describe(PaymentResult::Accepted("p1"))) }
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"type PaymentResult interface", "type PaymentResultAccepted struct", "&PaymentResultAccepted{Value0: \"p1\"}", "case *PaymentResultRejected:", "reason := gotlinMatch1.Value0"} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectNonExhaustiveEnumMatch(t *testing.T) {
|
||||
prog, err := Parse(`package demo
|
||||
enum Result { Ok, Error(String) }
|
||||
fun use(result: Result) { match (result) { Result::Ok -> { println("ok") } } }`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = GenerateGo(prog)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing Error") {
|
||||
t.Fatalf("expected exhaustive-match error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectWrongVariantPayloadCount(t *testing.T) {
|
||||
prog, err := Parse(`package demo
|
||||
enum Result { Ok(String) }
|
||||
fun main() { val result = Result::Ok() }`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = GenerateGo(prog)
|
||||
if err == nil || !strings.Contains(err.Error(), "expects 1 values") {
|
||||
t.Fatalf("expected payload error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadlessEnumUsesExactVariantStrings(t *testing.T) {
|
||||
prog, err := Parse(`package demo
|
||||
enum Status { PendingReservation, Initiated }
|
||||
fun main() { println(Status::PendingReservation) }`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`type Status string`, `StatusPendingReservation`, `"PendingReservation"`, `StatusInitiated`, `"Initiated"`} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
62
internal/lang/external_struct_test.go
Normal file
62
internal/lang/external_struct_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateExternalGoStructConstruction(t *testing.T) {
|
||||
prog, err := Parse(`package demo
|
||||
import platform "example/platform"
|
||||
fun create(issuer: String, audience: String, jwksUrl: String): *platform.Authorizer {
|
||||
return platform.Authorizer(issuer = issuer, audience = audience, jwksUrl = jwksUrl)
|
||||
}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `&platform.Authorizer{Issuer: issuer, Audience: audience, JWKSURL: jwksUrl}`
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEmbeddedValue(t *testing.T) {
|
||||
prog, err := Parse(`package demo
|
||||
import embed
|
||||
@embed("assets/*") val assets: embed.FS
|
||||
fun main() { println(assets) }`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"embed"`, `//go:embed assets/*`, `var assets embed.FS`} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEmbeddedStringAddsBlankImport(t *testing.T) {
|
||||
prog, err := Parse(`package demo
|
||||
@embed("schema.sql") val schema: String
|
||||
fun main() { println(schema) }`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`_ "embed"`, `//go:embed schema.sql`, `var schema string`} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
28
internal/lang/foreach.go
Normal file
28
internal/lang/foreach.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package lang
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (p *parser) parseForEach() (Stmt, error) {
|
||||
if _, err := p.expect(tokenLParen, "expected '(' after 'for'"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := p.expect(tokenIdent, "expected iteration variable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := p.expect(tokenIn, "expected 'in' after iteration variable"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, err := p.parseExpr(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := p.expect(tokenRParen, "expected ')' after iteration source"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := p.parseBlock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid for body: %w", err)
|
||||
}
|
||||
return ForEachStmt{Name: name.lexeme, Source: source, Body: body}, nil
|
||||
}
|
||||
31
internal/lang/foreach_test.go
Normal file
31
internal/lang/foreach_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateGoForEach(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
|
||||
fun main() {
|
||||
val accounts: List<String> = listOf("one", "two")
|
||||
for (account in accounts) {
|
||||
println(account)
|
||||
}
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatalf("generation failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{"for _, account := range accounts {", "fmt.Println(account)"} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
32
internal/lang/go_interop_test.go
Normal file
32
internal/lang/go_interop_test.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateGoLowercaseImportedSelectors(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
|
||||
import http net.http
|
||||
import json encoding.json
|
||||
|
||||
fun main() {
|
||||
http.handleFunc("/health", handler)
|
||||
json.unmarshal(body, &target)
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatalf("generation failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{"http.HandleFunc(\"/health\", handler)", "json.Unmarshal(body, &target)"} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
internal/lang/index_test.go
Normal file
26
internal/lang/index_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateGoIndexedDataClassSelector(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
|
||||
data class User(var id: String)
|
||||
|
||||
fun first(users: List<User>): String { return users[0].id }
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatalf("generation failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "return users[0].Id") {
|
||||
t.Fatalf("generated Go missing indexed selector:\n%s", out)
|
||||
}
|
||||
}
|
||||
31
internal/lang/json_decode_test.go
Normal file
31
internal/lang/json_decode_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateGoJSONDecodeReturnsTypedValue(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
|
||||
import json encoding.json
|
||||
|
||||
fun decode(body: ByteSlice): List<Account> {
|
||||
val accounts = json.decode<List<Account>>(body)
|
||||
return accounts
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatalf("generation failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{"accounts := gotlinAutoThrow(gotlinJSONDecode[[]Account](body))", "json.Unmarshal(body, &value)"} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,13 @@ func (l *lexer) next() (token, error) {
|
|||
for l.pos < len(l.src) && unicode.IsDigit(l.src[l.pos]) {
|
||||
l.pos++
|
||||
}
|
||||
if l.pos+1 < len(l.src) && l.src[l.pos] == '.' && unicode.IsDigit(l.src[l.pos+1]) {
|
||||
l.pos++
|
||||
for l.pos < len(l.src) && unicode.IsDigit(l.src[l.pos]) {
|
||||
l.pos++
|
||||
}
|
||||
return token{kind: tokenFloat, lexeme: string(l.src[start:l.pos]), pos: start}, nil
|
||||
}
|
||||
return token{kind: tokenInt, lexeme: string(l.src[start:l.pos]), pos: start}, nil
|
||||
case ch == '"':
|
||||
l.pos++
|
||||
|
|
@ -77,11 +84,18 @@ func (l *lexer) next() (token, error) {
|
|||
return token{kind: tokenLBrace, lexeme: "{", pos: start}, nil
|
||||
case '}':
|
||||
return token{kind: tokenRBrace, lexeme: "}", pos: start}, nil
|
||||
case '[':
|
||||
return token{kind: tokenLBracket, lexeme: "[", pos: start}, nil
|
||||
case ']':
|
||||
return token{kind: tokenRBracket, lexeme: "]", pos: start}, nil
|
||||
case ',':
|
||||
return token{kind: tokenComma, lexeme: ",", pos: start}, nil
|
||||
case '.':
|
||||
return token{kind: tokenDot, lexeme: ".", pos: start}, nil
|
||||
case ':':
|
||||
if l.match(':') {
|
||||
return token{kind: tokenDoubleColon, lexeme: "::", pos: start}, nil
|
||||
}
|
||||
return token{kind: tokenColon, lexeme: ":", pos: start}, nil
|
||||
case ';':
|
||||
return token{kind: tokenSemicolon, lexeme: ";", pos: start}, nil
|
||||
|
|
@ -131,6 +145,11 @@ func (l *lexer) next() (token, error) {
|
|||
if l.match('&') {
|
||||
return token{kind: tokenAnd, lexeme: "&&", pos: start}, nil
|
||||
}
|
||||
return token{kind: tokenAmp, lexeme: "&", pos: start}, nil
|
||||
case '@':
|
||||
return token{kind: tokenAt, lexeme: "@", pos: start}, nil
|
||||
case '?':
|
||||
return token{kind: tokenQuestion, lexeme: "?", pos: start}, nil
|
||||
case '|':
|
||||
if l.match('|') {
|
||||
return token{kind: tokenOr, lexeme: "||", pos: start}, nil
|
||||
|
|
|
|||
269
internal/lang/mapping.go
Normal file
269
internal/lang/mapping.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type mappingPair struct{ source, target, function string }
|
||||
type mappingState struct {
|
||||
functions map[string]string
|
||||
pairs []mappingPair
|
||||
}
|
||||
|
||||
func (g *goGenerator) mappingTopLevelTarget(target string) string {
|
||||
if _, ok := g.classForType(target); ok && !strings.HasPrefix(target, "*") {
|
||||
return "*" + target
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func (g *goGenerator) ensureMapping(source, target, path string) (string, error) {
|
||||
key := source + "->" + target
|
||||
if function, ok := g.mappings.functions[key]; ok {
|
||||
return function, nil
|
||||
}
|
||||
if err := g.validateMapping(source, target, path, map[string]bool{}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
function := fmt.Sprintf("gotlinMap%d", len(g.mappings.pairs)+1)
|
||||
g.mappings.functions[key] = function
|
||||
g.mappings.pairs = append(g.mappings.pairs, mappingPair{source: source, target: target, function: function})
|
||||
return function, nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) validateMapping(source, target, path string, seen map[string]bool) error {
|
||||
if source == target {
|
||||
return nil
|
||||
}
|
||||
key := source + "->" + target
|
||||
if seen[key] {
|
||||
return nil
|
||||
}
|
||||
seen[key] = true
|
||||
if strings.HasSuffix(source, "?") || strings.HasSuffix(target, "?") {
|
||||
if strings.HasSuffix(source, "?") && !strings.HasSuffix(target, "?") {
|
||||
return mappingError(path, source, target)
|
||||
}
|
||||
return g.validateMapping(strings.TrimSuffix(source, "?"), strings.TrimSuffix(target, "?"), path, seen)
|
||||
}
|
||||
if sourceBase, sourceArgs, ok := parseGenericType(source); ok {
|
||||
targetBase, targetArgs, targetOK := parseGenericType(target)
|
||||
if !targetOK || sourceBase != targetBase || len(sourceArgs) != len(targetArgs) {
|
||||
return mappingError(path, source, target)
|
||||
}
|
||||
for i := range sourceArgs {
|
||||
if sourceBase == "Map" || sourceBase == "MutableMap" {
|
||||
if i == 0 && sourceArgs[i] != targetArgs[i] {
|
||||
return mappingError(path+".<key>", sourceArgs[i], targetArgs[i])
|
||||
}
|
||||
}
|
||||
if err := g.validateMapping(sourceArgs[i], targetArgs[i], path+"[]", seen); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
sourceClass, sourceClassOK := g.classForType(source)
|
||||
targetClass, targetClassOK := g.classForType(target)
|
||||
if sourceClassOK || targetClassOK {
|
||||
if !sourceClassOK || !targetClassOK {
|
||||
return mappingError(path, source, target)
|
||||
}
|
||||
for _, targetField := range targetClass.Fields {
|
||||
sourceField, ok := classFieldByName(sourceClass, targetField.Name)
|
||||
fieldPath := path + "." + targetField.Name
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot map %s: source field is missing for %s.%s", fieldPath, targetClass.Name, targetField.Name)
|
||||
}
|
||||
if err := g.validateMapping(sourceField.Type, targetField.Type, fieldPath, seen); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
sourceEnum, sourceEnumOK := g.enums[strings.TrimPrefix(source, "*")]
|
||||
targetEnum, targetEnumOK := g.enums[strings.TrimPrefix(target, "*")]
|
||||
if sourceEnumOK || targetEnumOK {
|
||||
if sourceEnumOK && enumIsString(sourceEnum) && target == "String" {
|
||||
return nil
|
||||
}
|
||||
if targetEnumOK && enumIsString(targetEnum) && source == "String" {
|
||||
return nil
|
||||
}
|
||||
if !sourceEnumOK || !targetEnumOK {
|
||||
return mappingError(path, source, target)
|
||||
}
|
||||
for _, sourceVariant := range sourceEnum.Variants {
|
||||
targetVariant := enumVariant(targetEnum, sourceVariant.Name)
|
||||
variantPath := path + "::" + sourceVariant.Name
|
||||
if targetVariant == nil {
|
||||
return fmt.Errorf("cannot map %s: target enum %s has no compatible variant", variantPath, targetEnum.Name)
|
||||
}
|
||||
if len(sourceVariant.PayloadTypes) != len(targetVariant.PayloadTypes) {
|
||||
return fmt.Errorf("cannot map %s: payload count %d is incompatible with %d", variantPath, len(sourceVariant.PayloadTypes), len(targetVariant.PayloadTypes))
|
||||
}
|
||||
for i := range sourceVariant.PayloadTypes {
|
||||
if err := g.validateMapping(sourceVariant.PayloadTypes[i], targetVariant.PayloadTypes[i], fmt.Sprintf("%s[%d]", variantPath, i), seen); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return mappingError(path, source, target)
|
||||
}
|
||||
|
||||
func mappingError(path, source, target string) error {
|
||||
return fmt.Errorf("cannot map %s: %s is incompatible with %s", path, source, target)
|
||||
}
|
||||
func classFieldByName(class ClassDecl, name string) (FieldDecl, bool) {
|
||||
for _, field := range class.Fields {
|
||||
if field.Name == name {
|
||||
return field, true
|
||||
}
|
||||
}
|
||||
return FieldDecl{}, false
|
||||
}
|
||||
func mappingFieldName(class ClassDecl, field FieldDecl) string {
|
||||
if class.Data && !field.Private {
|
||||
return exportedGoName(field.Name)
|
||||
}
|
||||
return field.Name
|
||||
}
|
||||
|
||||
func (g *goGenerator) emitMapping(pair mappingPair) error {
|
||||
g.line(fmt.Sprintf("func %s(source %s) %s {", pair.function, mapGoType(pair.source), mapGoType(pair.target)))
|
||||
g.indentLevel++
|
||||
if sourceEnum, ok := g.enums[strings.TrimPrefix(pair.source, "*")]; ok {
|
||||
if enumIsString(sourceEnum) && pair.target == "String" {
|
||||
g.line("return string(source)")
|
||||
g.indentLevel--
|
||||
g.line("}")
|
||||
return nil
|
||||
}
|
||||
targetEnum := g.enums[strings.TrimPrefix(pair.target, "*")]
|
||||
if enumIsString(sourceEnum) && enumIsString(targetEnum) {
|
||||
g.line("return " + targetEnum.Name + "(source)")
|
||||
g.indentLevel--
|
||||
g.line("}")
|
||||
return nil
|
||||
}
|
||||
g.line("switch value := source.(type) {")
|
||||
g.indentLevel++
|
||||
for _, sourceVariant := range sourceEnum.Variants {
|
||||
targetVariant := enumVariant(targetEnum, sourceVariant.Name)
|
||||
g.line("case *" + sourceEnum.Name + sourceVariant.Name + ":")
|
||||
g.indentLevel++
|
||||
fields := make([]string, 0, len(sourceVariant.PayloadTypes))
|
||||
for i := range sourceVariant.PayloadTypes {
|
||||
expr, err := g.mappingExpr(fmt.Sprintf("value.Value%d", i), sourceVariant.PayloadTypes[i], targetVariant.PayloadTypes[i], sourceEnum.Name+"::"+sourceVariant.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields = append(fields, fmt.Sprintf("Value%d: %s", i, expr))
|
||||
}
|
||||
g.line("return &" + targetEnum.Name + targetVariant.Name + "{" + strings.Join(fields, ", ") + "}")
|
||||
g.indentLevel--
|
||||
}
|
||||
g.indentLevel--
|
||||
g.line("}")
|
||||
g.line(`panic("unreachable enum mapping")`)
|
||||
} else if targetEnum, ok := g.enums[strings.TrimPrefix(pair.target, "*")]; ok && pair.source == "String" && enumIsString(targetEnum) {
|
||||
g.line("switch source {")
|
||||
g.indentLevel++
|
||||
for _, variant := range targetEnum.Variants {
|
||||
g.line("case " + strconv.Quote(enumStringValue(variant)) + ":")
|
||||
g.indentLevel++
|
||||
g.line("return " + targetEnum.Name + variant.Name)
|
||||
g.indentLevel--
|
||||
}
|
||||
g.indentLevel--
|
||||
g.line("}")
|
||||
g.line(`panic("unknown enum string: " + source)`)
|
||||
} else {
|
||||
expr, err := g.mappingExpr("source", pair.source, pair.target, strings.TrimPrefix(pair.source, "*"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.line("return " + expr)
|
||||
}
|
||||
g.indentLevel--
|
||||
g.line("}")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *goGenerator) mappingExpr(expr, source, target, path string) (string, error) {
|
||||
if source == target {
|
||||
return expr, nil
|
||||
}
|
||||
if strings.HasSuffix(source, "?") || strings.HasSuffix(target, "?") {
|
||||
sourceInner := strings.TrimSuffix(source, "?")
|
||||
targetInner := strings.TrimSuffix(target, "?")
|
||||
inner, err := g.mappingExpr("*value", sourceInner, targetInner, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasSuffix(source, "?") {
|
||||
return fmt.Sprintf("func(value %s) %s { if value == nil { return nil }; mapped := %s; return &mapped }(%s)", mapGoType(source), mapGoType(target), inner, expr), nil
|
||||
}
|
||||
inner, err = g.mappingExpr("value", sourceInner, targetInner, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("func(value %s) %s { mapped := %s; return &mapped }(%s)", mapGoType(source), mapGoType(target), inner, expr), nil
|
||||
}
|
||||
if sourceBase, sourceArgs, ok := parseGenericType(source); ok {
|
||||
_, targetArgs, _ := parseGenericType(target)
|
||||
if sourceBase == "List" || sourceBase == "MutableList" {
|
||||
item, err := g.mappingExpr("item", sourceArgs[0], targetArgs[0], path+"[]")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("func(values %s) %s { var result %s; for _, item := range values { result = append(result, %s) }; return result }(%s)", mapGoType(source), mapGoType(target), mapGoType(target), item, expr), nil
|
||||
}
|
||||
if sourceBase == "Map" || sourceBase == "MutableMap" {
|
||||
value, err := g.mappingExpr("item", sourceArgs[1], targetArgs[1], path+"[]")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("func(values %s) %s { result := make(%s, len(values)); for key, item := range values { result[key] = %s }; return result }(%s)", mapGoType(source), mapGoType(target), mapGoType(target), value, expr), nil
|
||||
}
|
||||
}
|
||||
if sourceClass, ok := g.classForType(source); ok {
|
||||
targetClass, _ := g.classForType(target)
|
||||
fields := make([]string, 0, len(targetClass.Fields))
|
||||
for _, targetField := range targetClass.Fields {
|
||||
sourceField, _ := classFieldByName(sourceClass, targetField.Name)
|
||||
mapped, err := g.mappingExpr(expr+"."+mappingFieldName(sourceClass, sourceField), sourceField.Type, targetField.Type, path+"."+targetField.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fields = append(fields, mappingFieldName(targetClass, targetField)+": "+mapped)
|
||||
}
|
||||
literal := targetClass.Name + "{" + strings.Join(fields, ", ") + "}"
|
||||
if strings.HasPrefix(target, "*") {
|
||||
literal = "&" + literal
|
||||
}
|
||||
if strings.HasPrefix(source, "*") {
|
||||
return fmt.Sprintf("func(value %s) %s { if value == nil { return nil }; return %s }(%s)", mapGoType(source), mapGoType(target), strings.ReplaceAll(literal, expr+".", "value."), expr), nil
|
||||
}
|
||||
return literal, nil
|
||||
}
|
||||
if _, ok := g.enums[strings.TrimPrefix(source, "*")]; ok {
|
||||
function, err := g.ensureMapping(source, target, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return function + "(" + expr + ")", nil
|
||||
}
|
||||
if targetEnum, ok := g.enums[strings.TrimPrefix(target, "*")]; ok && source == "String" && enumIsString(targetEnum) {
|
||||
function, err := g.ensureMapping(source, target, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return function + "(" + expr + ")", nil
|
||||
}
|
||||
return "", mappingError(path, source, target)
|
||||
}
|
||||
159
internal/lang/mapping_test.go
Normal file
159
internal/lang/mapping_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateRecursiveClassMapping(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
data class SourceAddress(var city: String)
|
||||
data class TargetAddress(var city: String)
|
||||
data class Source(var id: String, var address: *SourceAddress)
|
||||
data class Target(var address: *TargetAddress, var id: String)
|
||||
fun convert(source: *Source): *Target { return source.mapTo<Target>() }
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := string(out)
|
||||
for _, want := range []string{"gotlinMap1(source)", "Address:", "Id: value.Id", "TargetAddress{City: value.City}"} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateListAndMapMapping(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
data class Source(var id: String)
|
||||
data class Target(var id: String)
|
||||
fun list(values: List<*Source>): List<*Target> { return values.mapTo<List<*Target>>() }
|
||||
fun mapping(values: Map<String, *Source>): Map<String, *Target> { return values.mapTo<Map<String, *Target>>() }
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"for _, item := range values", "for key, item := range values", "result[key]"} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRecursiveEnumMapping(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
enum Source { Ready(String), Failed(String) }
|
||||
enum Target { Ready(String), Failed(String), Pending }
|
||||
fun convert(value: Source): Target { return value.mapTo<Target>() }
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"case *SourceReady:", "return &TargetReady{Value0: value.Value0}", "case *SourceFailed:"} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingReportsNestedFieldPath(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
data class SourceAddress(var zip: String)
|
||||
data class TargetAddress(var zip: Int)
|
||||
data class Source(var address: *SourceAddress)
|
||||
data class Target(var address: *TargetAddress)
|
||||
fun convert(value: *Source): *Target { return value.mapTo<Target>() }
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = GenerateGo(prog)
|
||||
if err == nil || !strings.Contains(err.Error(), "Source.address.zip") || !strings.Contains(err.Error(), "String is incompatible with Int") {
|
||||
t.Fatalf("unexpected mapping error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingRejectsMissingFieldAndEnumVariant(t *testing.T) {
|
||||
for _, source := range []string{
|
||||
`package demo data class Source(var id: String) data class Target(var id: String, var name: String) fun convert(value: *Source): *Target { return value.mapTo<Target>() }`,
|
||||
`package demo enum Source { Ready, Failed } enum Target { Ready } fun convert(value: Source): Target { return value.mapTo<Target>() }`,
|
||||
} {
|
||||
prog, err := Parse(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = GenerateGo(prog); err == nil {
|
||||
t.Fatalf("expected mapping error for %s", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapStringBackedEnumToAndFromString(t *testing.T) {
|
||||
prog, err := Parse(`package demo
|
||||
enum Status { PendingReservation, Initiated }
|
||||
data class Domain(var status: Status)
|
||||
data class Row(var status: String)
|
||||
fun toRow(value: *Domain): *Row { return value.mapTo<Row>() }
|
||||
fun toDomain(value: *Row): *Domain { return value.mapTo<Domain>() }`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`return string(source)`, `case "PendingReservation":`, `return StatusPendingReservation`} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToInfersExpectedTargetType(t *testing.T) {
|
||||
prog, err := Parse(`package demo
|
||||
data class Source(var id: String)
|
||||
data class Target(var id: String)
|
||||
data class Wrapper(var target: *Target)
|
||||
fun returned(value: *Source): *Target { return value.mapTo() }
|
||||
fun wrapped(value: *Source): *Wrapper { return Wrapper(value.mapTo()) }
|
||||
fun local(value: *Source): *Target { val target: *Target = value.mapTo(); return target }`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Count(string(out), "gotlinMap1(value)") < 3 {
|
||||
t.Fatalf("expected inferred mapping calls:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToWithoutTargetContextHasHelpfulError(t *testing.T) {
|
||||
prog, err := Parse(`package demo
|
||||
fun convert(value: *Source) { val target = value.mapTo() }`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = GenerateGo(prog)
|
||||
if err == nil || !strings.Contains(err.Error(), "target type cannot be inferred") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
53
internal/lang/member_type_test.go
Normal file
53
internal/lang/member_type_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassFieldTypeResolvesInternalMethodWithoutAlias(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
|
||||
class Repository {
|
||||
fun healthy(): Boolean { return true }
|
||||
}
|
||||
|
||||
class Service(val repository: *Repository) {
|
||||
fun healthy(): Boolean { return repository.healthy() }
|
||||
}
|
||||
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "self.repository.healthy()") {
|
||||
t.Fatalf("internal method was treated as foreign:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalMethodReturnTypeFlowsThroughSelectors(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
class Transaction { fun commit(): Boolean { return true } }
|
||||
class Result(val transaction: *Transaction)
|
||||
class Repository { fun begin(): *Result { return Result(Transaction()) } }
|
||||
class Service(val repository: *Repository) {
|
||||
fun run(): Boolean { return repository.begin().transaction.commit() }
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "self.repository.begin().transaction.commit()") {
|
||||
t.Fatalf("method return type did not flow through selector:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
args, namedArgs, err := p.parseCallArguments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, arg)
|
||||
if !p.match(tokenComma) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
args, namedArgs, err := p.parseCallArguments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, arg)
|
||||
if !p.match(tokenComma) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
28
internal/lang/pointer_test.go
Normal file
28
internal/lang/pointer_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateGoAddressOfExpression(t *testing.T) {
|
||||
prog, err := Parse(`
|
||||
package demo
|
||||
|
||||
import encoding.json
|
||||
|
||||
fun decode(body: ByteSlice, target: Account) {
|
||||
json.Unmarshal(body, &target)
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
out, err := GenerateGo(prog)
|
||||
if err != nil {
|
||||
t.Fatalf("generation failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "json.Unmarshal(body, &target)") {
|
||||
t.Fatalf("generated Go missing address-of expression:\n%s", out)
|
||||
}
|
||||
}
|
||||
282
internal/lang/semantics.go
Normal file
282
internal/lang/semantics.go
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
package lang
|
||||
|
||||
import "fmt"
|
||||
|
||||
func validateMutability(program *Program) error {
|
||||
checker := mutabilityChecker{}
|
||||
for _, fn := range program.Functions {
|
||||
if err := checker.checkFunction(fn, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, class := range program.Classes {
|
||||
fields := make(map[string]bool, len(class.Fields))
|
||||
for _, field := range class.Fields {
|
||||
fields[field.Name] = field.Mutable
|
||||
}
|
||||
for _, method := range class.Methods {
|
||||
if err := checker.checkFunction(method, fields, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, worker := range program.Workers {
|
||||
fields := make(map[string]bool, len(worker.Fields))
|
||||
for _, field := range worker.Fields {
|
||||
fields[field.Name] = field.Mutable
|
||||
}
|
||||
for _, method := range worker.Methods {
|
||||
if err := checker.checkFunction(method, nil, fields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mutabilityChecker struct {
|
||||
scopes []map[string]bool
|
||||
classFields map[string]bool
|
||||
workerFields map[string]bool
|
||||
}
|
||||
|
||||
func (c *mutabilityChecker) checkFunction(fn FunctionDecl, classFields map[string]bool, workerFields map[string]bool) error {
|
||||
c.scopes = nil
|
||||
c.classFields = classFields
|
||||
c.workerFields = workerFields
|
||||
c.pushScope()
|
||||
defer c.popScope()
|
||||
for _, param := range fn.Params {
|
||||
c.define(param.Name, false)
|
||||
}
|
||||
return c.checkStmts(fn.Body)
|
||||
}
|
||||
|
||||
func (c *mutabilityChecker) checkStmts(stmts []Stmt) error {
|
||||
for _, stmt := range stmts {
|
||||
switch s := stmt.(type) {
|
||||
case VarDecl:
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
c.define(s.Name, s.Mutable)
|
||||
case MultiVarDecl:
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range s.Names {
|
||||
c.define(name, s.Mutable)
|
||||
}
|
||||
case AssignStmt:
|
||||
if err := c.requireMutable(s.Name, s.Pos); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case AddAssignStmt:
|
||||
if err := c.requireMutable(s.Name, s.Pos); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case MultiAssignStmt:
|
||||
for i, name := range s.Names {
|
||||
pos := 0
|
||||
if i < len(s.Positions) {
|
||||
pos = s.Positions[i]
|
||||
}
|
||||
if err := c.requireMutable(name, pos); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case ReturnStmt:
|
||||
if s.Value != nil {
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case ThrowStmt:
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case GoStmt:
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case DeferStmt:
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case ExprStmt:
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case IfStmt:
|
||||
if err := c.checkExpr(s.Cond); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.checkBlock(s.Then, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.checkBlock(s.Else, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
case WhileStmt:
|
||||
if err := c.checkExpr(s.Cond); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.checkBlock(s.Body, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
case ForEachStmt:
|
||||
if err := c.checkExpr(s.Source); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.checkBlock(s.Body, map[string]bool{s.Name: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
case SelectStmt:
|
||||
for _, sc := range s.Cases {
|
||||
if err := c.checkExpr(sc.Source); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.checkBlock(sc.Body, map[string]bool{"it": false}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case MatchStmt:
|
||||
if err := c.checkExpr(s.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, matchCase := range s.Cases {
|
||||
bindings := map[string]bool{}
|
||||
for _, binding := range matchCase.Bindings {
|
||||
bindings[binding] = false
|
||||
}
|
||||
if err := c.checkBlock(matchCase.Body, bindings); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case TryCatchStmt:
|
||||
if err := c.checkBlock(s.TryBody, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.checkBlock(s.CatchBody, map[string]bool{s.CatchName: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *mutabilityChecker) checkBlock(stmts []Stmt, bindings map[string]bool) error {
|
||||
c.pushScope()
|
||||
defer c.popScope()
|
||||
for name, mutable := range bindings {
|
||||
c.define(name, mutable)
|
||||
}
|
||||
return c.checkStmts(stmts)
|
||||
}
|
||||
|
||||
func (c *mutabilityChecker) checkExpr(expr Expr) error {
|
||||
switch e := expr.(type) {
|
||||
case UnaryExpr:
|
||||
return c.checkExpr(e.Value)
|
||||
case BinaryExpr:
|
||||
if err := c.checkExpr(e.Left); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.checkExpr(e.Right)
|
||||
case CallExpr:
|
||||
if err := c.checkExpr(e.Callee); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, arg := range e.Args {
|
||||
if err := c.checkExpr(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, arg := range e.NamedArgs {
|
||||
if err := c.checkExpr(arg.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case SelectorExpr:
|
||||
return c.checkExpr(e.Receiver)
|
||||
case IndexExpr:
|
||||
if err := c.checkExpr(e.Receiver); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.checkExpr(e.Index)
|
||||
case EnumVariantExpr:
|
||||
for _, value := range e.Values {
|
||||
if err := c.checkExpr(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case LambdaExpr:
|
||||
bindings := map[string]bool{}
|
||||
if e.ImplicitIt {
|
||||
bindings["it"] = false
|
||||
}
|
||||
for _, param := range e.Params {
|
||||
bindings[param.Name] = false
|
||||
}
|
||||
return c.checkBlock(e.Body, bindings)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *mutabilityChecker) pushScope() {
|
||||
c.scopes = append(c.scopes, map[string]bool{})
|
||||
}
|
||||
|
||||
func (c *mutabilityChecker) popScope() {
|
||||
if len(c.scopes) == 0 {
|
||||
return
|
||||
}
|
||||
c.scopes = c.scopes[:len(c.scopes)-1]
|
||||
}
|
||||
|
||||
func (c *mutabilityChecker) define(name string, mutable bool) {
|
||||
if len(c.scopes) == 0 {
|
||||
c.pushScope()
|
||||
}
|
||||
c.scopes[len(c.scopes)-1][name] = mutable
|
||||
}
|
||||
|
||||
func (c *mutabilityChecker) requireMutable(name string, pos int) error {
|
||||
for i := len(c.scopes) - 1; i >= 0; i-- {
|
||||
if mutable, ok := c.scopes[i][name]; ok {
|
||||
if mutable {
|
||||
return nil
|
||||
}
|
||||
return immutableAssignmentError(name, pos)
|
||||
}
|
||||
}
|
||||
if mutable, ok := c.classFields[name]; ok {
|
||||
if mutable {
|
||||
return nil
|
||||
}
|
||||
return immutableAssignmentError(name, pos)
|
||||
}
|
||||
if mutable, ok := c.workerFields[name]; ok {
|
||||
if mutable {
|
||||
return nil
|
||||
}
|
||||
return immutableAssignmentError(name, pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func immutableAssignmentError(name string, pos int) error {
|
||||
if pos > 0 {
|
||||
return fmt.Errorf("cannot reassign immutable name %s at %d", name, pos)
|
||||
}
|
||||
return fmt.Errorf("cannot reassign immutable name %s", name)
|
||||
}
|
||||
1244
internal/lang/sql.go
Normal file
1244
internal/lang/sql.go
Normal file
File diff suppressed because it is too large
Load diff
277
internal/lang/sql_expansion_test.go
Normal file
277
internal/lang/sql_expansion_test.go
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const eventSQLSource = `
|
||||
import time
|
||||
|
||||
@table("outbox_events")
|
||||
data class EventRow(
|
||||
@generated @id var id: String,
|
||||
var payload: String,
|
||||
var attempts: Int,
|
||||
var publishedAt: time.Time?,
|
||||
var claimedUntil: time.Time?,
|
||||
var createdAt: time.Time
|
||||
)
|
||||
|
||||
data class EventProjection(var id: String, var payload: String)
|
||||
data class WrongProjection(var id: Int)
|
||||
`
|
||||
|
||||
func TestSQLGeneratedNullableAndLockingMetadata(t *testing.T) {
|
||||
program, err := Parse(eventSQLSource)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
row := program.Classes[0]
|
||||
if !row.Fields[0].Generated {
|
||||
t.Fatal("id field is missing @generated metadata")
|
||||
}
|
||||
if row.Fields[3].Type != "time.Time?" {
|
||||
t.Fatalf("publishedAt type = %q, want time.Time?", row.Fields[3].Type)
|
||||
}
|
||||
if got := mapGoType(row.Fields[3].Type); got != "*time.Time" {
|
||||
t.Fatalf("mapped nullable timestamp = %q, want *time.Time", got)
|
||||
}
|
||||
|
||||
code := compileSQL(t, eventSQLSource+`
|
||||
fun claimQuery(nowLimit: Int): GotlinSQLQuery {
|
||||
return sql.from<EventRow>()
|
||||
.where { it.publishedAt == null && (it.claimedUntil == null || it.claimedUntil < now()) }
|
||||
.orderByDescending { it.createdAt }
|
||||
.limit(nowLimit)
|
||||
.forUpdate()
|
||||
.skipLocked()
|
||||
.build()
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
`SQL: "SELECT id, payload, attempts, published_at, claimed_until, created_at FROM outbox_events WHERE (published_at IS NULL AND (claimed_until IS NULL OR claimed_until < CURRENT_TIMESTAMP)) ORDER BY created_at DESC LIMIT $1 FOR UPDATE SKIP LOCKED"`,
|
||||
`Args: []any{nowLimit}`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLTypedProjectionExecution(t *testing.T) {
|
||||
code := compileSQL(t, eventSQLSource+`
|
||||
import context
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
fun events(pool: *pgxpool.Pool, ctx: context.Context): List<*EventProjection> {
|
||||
return sql.from<EventRow>()
|
||||
.select { row -> EventProjection(row.id, row.payload) }
|
||||
.orderBy { it.createdAt }
|
||||
.fetch(pool, ctx)
|
||||
}
|
||||
|
||||
fun eventStream(pool: *pgxpool.Pool, ctx: context.Context): GotlinSQLIterator<EventProjection> {
|
||||
return sql.from<EventRow>()
|
||||
.select { row -> EventProjection(row.id, row.payload) }
|
||||
.iterator(pool, ctx)
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
`return gotlinSQLFetch[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events ORDER BY created_at", Args: []any{}}, gotlinSQLScanEventProjection)`,
|
||||
`return gotlinSQLIterate[EventProjection](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, payload FROM outbox_events", Args: []any{}}, gotlinSQLScanEventProjection)`,
|
||||
`func gotlinSQLScanEventProjection(row gotlinSQLRow) (*EventProjection, error)`,
|
||||
`err := row.Scan(&value.Id, &value.Payload)`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLInsertOmitsGeneratedAndReturnsProjection(t *testing.T) {
|
||||
code := compileSQL(t, eventSQLSource+`
|
||||
import context
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
fun create(row: EventRow, pool: *pgxpool.Pool, ctx: context.Context): *EventProjection {
|
||||
return sql.insert<EventRow>(row)
|
||||
.returning { value -> EventProjection(value.id, value.payload) }
|
||||
.single(pool, ctx)
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
`SQL: "INSERT INTO outbox_events (payload, attempts, published_at, claimed_until, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, payload"`,
|
||||
`Args: []any{row.Payload, row.Attempts, row.PublishedAt, row.ClaimedUntil, row.CreatedAt}`,
|
||||
`gotlinSQLSingle[EventProjection]`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLGeneratedOnlyInsertUsesDefaultValues(t *testing.T) {
|
||||
code := compileSQL(t, `
|
||||
@table("tokens") data class TokenRow(@generated @id var id: String)
|
||||
fun insert(row: TokenRow): GotlinSQLQuery { return sql.insert<TokenRow>(row).build() }
|
||||
`)
|
||||
if want := `SQL: "INSERT INTO tokens DEFAULT VALUES"`; !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLTypedUpdateAndReturning(t *testing.T) {
|
||||
code := compileSQL(t, eventSQLSource+`
|
||||
import context
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
fun claim(id: String, payload: String, pool: *pgxpool.Pool, ctx: context.Context): *EventProjection {
|
||||
return sql.update<EventRow>()
|
||||
.set { row ->
|
||||
set(row.payload, payload)
|
||||
set(row.attempts, row.attempts + 1)
|
||||
set(row.claimedUntil, now())
|
||||
}
|
||||
.where { it.id == id && it.publishedAt == null }
|
||||
.returning { row -> EventProjection(row.id, row.payload) }
|
||||
.single(pool, ctx)
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
`SQL: "UPDATE outbox_events SET payload = $1, attempts = (attempts + $2), claimed_until = CURRENT_TIMESTAMP WHERE (id = $3 AND published_at IS NULL) RETURNING id, payload"`,
|
||||
`Args: []any{payload, 1, id}`,
|
||||
`gotlinSQLSingle[EventProjection]`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLDeleteReturningFullRow(t *testing.T) {
|
||||
code := compileSQL(t, eventSQLSource+`
|
||||
import context
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
fun remove(id: String, pool: *pgxpool.Pool, ctx: context.Context): *EventRow {
|
||||
return sql.delete<EventRow>()
|
||||
.where { it.id == id }
|
||||
.returning { it }
|
||||
.single(pool, ctx)
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
`SQL: "DELETE FROM outbox_events WHERE id = $1 RETURNING id, payload, attempts, published_at, claimed_until, created_at"`,
|
||||
`gotlinSQLSingle[EventRow]`,
|
||||
`err := row.Scan(&value.Id, &value.Payload, &value.Attempts, &value.PublishedAt, &value.ClaimedUntil, &value.CreatedAt)`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectExpandedInvalidSQLQueries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
src string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "skip locked without for update",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().skipLocked().build() }`,
|
||||
want: "requires a preceding forUpdate",
|
||||
},
|
||||
{
|
||||
name: "lock before limit",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().forUpdate().limit(1).build() }`,
|
||||
want: "limit() may appear once",
|
||||
},
|
||||
{
|
||||
name: "non nullable null comparison",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().where { it.payload == null }.build() }`,
|
||||
want: "null comparison requires a nullable operand",
|
||||
},
|
||||
{
|
||||
name: "wrong limit type",
|
||||
src: eventSQLSource + `fun query(limit: String): GotlinSQLQuery { return sql.from<EventRow>().limit(limit).build() }`,
|
||||
want: "argument must have type Int",
|
||||
},
|
||||
{
|
||||
name: "negative literal limit",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().limit(-1).build() }`,
|
||||
want: "requires a non-negative Int",
|
||||
},
|
||||
{
|
||||
name: "projection type mismatch",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().select { WrongProjection(it.id) }.build() }`,
|
||||
want: "has type Int but row field id has type String",
|
||||
},
|
||||
{
|
||||
name: "projection arity mismatch",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().select { EventProjection(it.id) }.build() }`,
|
||||
want: "expects 2 fields, got 1",
|
||||
},
|
||||
{
|
||||
name: "projection after where",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.from<EventRow>().where { it.id == "x" }.select { EventProjection(it.id, it.payload) }.build() }`,
|
||||
want: "must be the first sql.from method",
|
||||
},
|
||||
{
|
||||
name: "update missing set",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().where { it.id == "x" }.build() }`,
|
||||
want: "requires set() as its first method",
|
||||
},
|
||||
{
|
||||
name: "update incompatible value",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.attempts, "bad") }.build() }`,
|
||||
want: "has type Int but value has type String",
|
||||
},
|
||||
{
|
||||
name: "update null non nullable",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.payload, null) }.build() }`,
|
||||
want: "has non-nullable type String",
|
||||
},
|
||||
{
|
||||
name: "update nullable into non nullable",
|
||||
src: eventSQLSource + `fun query(value: String?): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.payload, value) }.build() }`,
|
||||
want: "has type String but value has type String?",
|
||||
},
|
||||
{
|
||||
name: "duplicate update target",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.update<EventRow>().set { set(it.payload, "a"); set(it.payload, "b") }.build() }`,
|
||||
want: "duplicate set target payload",
|
||||
},
|
||||
{
|
||||
name: "write execution without returning",
|
||||
src: eventSQLSource + `fun query(pool: Any, ctx: Any): *EventRow { return sql.delete<EventRow>().single(pool, ctx) }`,
|
||||
want: "requires returning()",
|
||||
},
|
||||
{
|
||||
name: "returning out of order",
|
||||
src: eventSQLSource + `fun query(): GotlinSQLQuery { return sql.delete<EventRow>().returning { it }.where { it.id == "x" }.build() }`,
|
||||
want: "invalid method order",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
program, err := Parse(test.src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
_, err = GenerateGo(program)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectDuplicateGeneratedAnnotation(t *testing.T) {
|
||||
_, err := Parse(`data class Row(@generated @generated var id: String)`)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate generated annotation") {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
}
|
||||
383
internal/lang/sql_test.go
Normal file
383
internal/lang/sql_test.go
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const accountRowSource = `
|
||||
@table("accounts")
|
||||
data class AccountRow(
|
||||
@id var id: String,
|
||||
var customerId: String,
|
||||
@column("kind") var accountType: String,
|
||||
var balance: Double
|
||||
)
|
||||
`
|
||||
|
||||
func TestParseSQLMetadata(t *testing.T) {
|
||||
program, err := Parse(accountRowSource)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if len(program.Classes) != 1 {
|
||||
t.Fatalf("expected one class, got %d", len(program.Classes))
|
||||
}
|
||||
class := program.Classes[0]
|
||||
if class.Table != "accounts" {
|
||||
t.Fatalf("table = %q, want accounts", class.Table)
|
||||
}
|
||||
if !class.Fields[0].ID {
|
||||
t.Fatal("id field is missing @id metadata")
|
||||
}
|
||||
if class.Fields[2].Column != "kind" {
|
||||
t.Fatalf("accountType column = %q, want kind", class.Fields[2].Column)
|
||||
}
|
||||
if got := sqlColumn(class.Fields[1]); got != "customer_id" {
|
||||
t.Fatalf("default customerId column = %q, want customer_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLSelect(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
fun accountQuery(customerId: String): GotlinSQLQuery {
|
||||
return sql.from<AccountRow>()
|
||||
.where { row -> row.customerId == customerId && row.balance > 0.0 }
|
||||
.orderBy { row -> row.accountType }
|
||||
.build()
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
"type GotlinSQLQuery struct",
|
||||
"SQL string",
|
||||
"Args []any",
|
||||
`SQL: "SELECT id, customer_id, kind, balance FROM accounts WHERE (customer_id = $1 AND balance > $2) ORDER BY kind"`,
|
||||
`Args: []any{customerId, 0.0}`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
if strings.Contains(code, `github.com/jackc/pgx/v5`) {
|
||||
t.Fatalf("build-only SQL unexpectedly emitted pgx runtime:\n%s", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLSelectUsesTypedLocalAsArgument(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
fun accountQuery(): GotlinSQLQuery {
|
||||
val customerId = "customer-1"
|
||||
return sql.from<AccountRow>().where { it.customerId == customerId }.build()
|
||||
}
|
||||
|
||||
`)
|
||||
if want := `Args: []any{customerId}`; !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedQueryCanBePassedToVariadicPGXCall(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
fun execute(pool: *pgxpool.Pool, ctx: Context, customerId: String) {
|
||||
val query = sql.from<AccountRow>().where { it.customerId == customerId }.build()
|
||||
pool.query(ctx, query.sql, *query.args)
|
||||
}
|
||||
`)
|
||||
if want := `pool.Query(ctx, query.SQL, query.Args...)`; !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLFetchTerminal(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
import context
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
fun accounts(pool: *pgxpool.Pool, ctx: context.Context, customerId: String): List<*AccountRow> {
|
||||
return sql.from<AccountRow>()
|
||||
.where { it.customerId == customerId }
|
||||
.fetch(pool, ctx)
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
`"github.com/jackc/pgx/v5"`,
|
||||
`func accounts(pool *pgxpool.Pool, ctx context.Context, customerId string) []*AccountRow`,
|
||||
`return gotlinSQLFetch[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts WHERE customer_id = $1", Args: []any{customerId}}, gotlinSQLScanAccountRow)`,
|
||||
`defer rows.Close()`,
|
||||
`values = append(values, gotlinAutoThrow(scan(rows)))`,
|
||||
`err := row.Scan(&value.Id, &value.CustomerId, &value.AccountType, &value.Balance)`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLSingleInfersRowSelectors(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
import context
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
fun balance(pool: *pgxpool.Pool, ctx: context.Context, id: String): Double {
|
||||
val account = sql.from<AccountRow>().where { it.id == id }.single(pool, ctx)
|
||||
return account.balance
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
`account := gotlinSQLSingle[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts WHERE id = $1", Args: []any{id}}, gotlinSQLScanAccountRow)`,
|
||||
`return account.Balance`,
|
||||
`gotlinAutoThrow(gotlinSQLError("SQL single() expected exactly one row, got zero"))`,
|
||||
`gotlinAutoThrow(gotlinSQLError("SQL single() expected exactly one row, got more than one"))`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLIteratorTerminalAndTypedValue(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
import context
|
||||
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
fun printAccounts(pool: *pgxpool.Pool, ctx: context.Context) {
|
||||
val rows = sql.from<AccountRow>().iterator(pool, ctx)
|
||||
defer rows.close()
|
||||
while (rows.next()) {
|
||||
val account = rows.value()
|
||||
println(account.customerId)
|
||||
}
|
||||
val checked = rows.err()
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
`rows := gotlinSQLIterate[AccountRow](pool, ctx, GotlinSQLQuery{SQL: "SELECT id, customer_id, kind, balance FROM accounts", Args: []any{}}, gotlinSQLScanAccountRow)`,
|
||||
`defer rows.close()`,
|
||||
`for rows.next()`,
|
||||
`account := gotlinAutoThrow(rows.value())`,
|
||||
`fmt.Println(account.CustomerId)`,
|
||||
`_ = gotlinAutoThrow(rows.err())`,
|
||||
`type GotlinSQLIterator[T any] struct`,
|
||||
`func (iterator *GotlinSQLIterator[T]) next() bool`,
|
||||
`func (iterator *GotlinSQLIterator[T]) value() *T`,
|
||||
`func (iterator *GotlinSQLIterator[T]) close()`,
|
||||
`func (iterator *GotlinSQLIterator[T]) err() error`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLSelectInClassMethodUsesTypedParameter(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
class AccountQueries {
|
||||
fun byCustomer(customerId: String): GotlinSQLQuery {
|
||||
return sql.from<AccountRow>().where { it.customerId == customerId }.build()
|
||||
}
|
||||
}
|
||||
`)
|
||||
if want := `Args: []any{customerId}`; !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLInsertDoNothing(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
fun insertAccount(row: AccountRow): GotlinSQLQuery {
|
||||
return sql.insert<AccountRow>(row).onConflict { it.id }.doNothing().build()
|
||||
}
|
||||
`)
|
||||
for _, want := range []string{
|
||||
`SQL: "INSERT INTO accounts (id, customer_id, kind, balance) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING"`,
|
||||
`Args: []any{row.Id, row.CustomerId, row.AccountType, row.Balance}`,
|
||||
} {
|
||||
if !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLInsertDoUpdate(t *testing.T) {
|
||||
code := compileSQL(t, accountRowSource+`
|
||||
fun upsertAccount(row: AccountRow): GotlinSQLQuery {
|
||||
return sql.insert<AccountRow>(row)
|
||||
.onConflict { it.id }
|
||||
.doUpdate { excluded -> set(AccountRow.balance, excluded.balance) }
|
||||
.build()
|
||||
}
|
||||
`)
|
||||
if want := `SQL: "INSERT INTO accounts (id, customer_id, kind, balance) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance"`; !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLCompositeConflictAndMultipleUpdates(t *testing.T) {
|
||||
code := compileSQL(t, `
|
||||
@table("balances")
|
||||
data class BalanceRow(@id var tenantId: String, @id var id: String, var amount: Double, var pending: Double)
|
||||
|
||||
fun upsert(row: BalanceRow): GotlinSQLQuery {
|
||||
return sql.insert<BalanceRow>(row)
|
||||
.onConflict { listOf(it.tenantId, it.id) }
|
||||
.doUpdate { excluded ->
|
||||
set(BalanceRow.amount, excluded.amount)
|
||||
set(BalanceRow.pending, excluded.pending)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
`)
|
||||
if want := `ON CONFLICT (tenant_id, id) DO UPDATE SET amount = EXCLUDED.amount, pending = EXCLUDED.pending`; !strings.Contains(code, want) {
|
||||
t.Fatalf("generated Go missing %q:\n%s", want, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectInvalidSQLQueries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
src string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unknown row class",
|
||||
src: `fun query(): GotlinSQLQuery { return sql.from<MissingRow>().build() }`,
|
||||
want: `SQL row class "MissingRow" does not exist`,
|
||||
},
|
||||
{
|
||||
name: "missing table metadata",
|
||||
src: `data class Row(var id: String) fun query(): GotlinSQLQuery { return sql.from<Row>().build() }`,
|
||||
want: "requires @table",
|
||||
},
|
||||
{
|
||||
name: "unknown predicate field",
|
||||
src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from<AccountRow>().where { it.missing == "x" }.build() }`,
|
||||
want: "has no field missing",
|
||||
},
|
||||
{
|
||||
name: "incompatible predicate operands",
|
||||
src: accountRowSource + `fun query(customerId: String): GotlinSQLQuery { return sql.from<AccountRow>().where { it.balance == customerId }.build() }`,
|
||||
want: "incompatible types Double and String",
|
||||
},
|
||||
{
|
||||
name: "non numeric ordering predicate",
|
||||
src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from<AccountRow>().where { it.customerId > "a" }.build() }`,
|
||||
want: "ordering comparison requires numeric operands",
|
||||
},
|
||||
{
|
||||
name: "unknown order field",
|
||||
src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from<AccountRow>().orderBy { it.missing }.build() }`,
|
||||
want: "has no field missing",
|
||||
},
|
||||
{
|
||||
name: "conflict field must be id",
|
||||
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.customerId }.doNothing().build() }`,
|
||||
want: "must be annotated @id",
|
||||
},
|
||||
{
|
||||
name: "unknown conflict field",
|
||||
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.missing }.doNothing().build() }`,
|
||||
want: "has no field missing",
|
||||
},
|
||||
{
|
||||
name: "unknown update target",
|
||||
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doUpdate { excluded -> set(AccountRow.missing, excluded.balance) }.build() }`,
|
||||
want: "has no field missing",
|
||||
},
|
||||
{
|
||||
name: "incompatible update fields",
|
||||
src: accountRowSource + `fun query(row: AccountRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doUpdate { excluded -> set(AccountRow.balance, excluded.accountType) }.build() }`,
|
||||
want: "has type Double",
|
||||
},
|
||||
{
|
||||
name: "wrong insert row type",
|
||||
src: accountRowSource + `
|
||||
@table("other") data class OtherRow(@id var id: String)
|
||||
fun query(row: OtherRow): GotlinSQLQuery { return sql.insert<AccountRow>(row).onConflict { it.id }.doNothing().build() }
|
||||
`,
|
||||
want: "cannot insert value of type OtherRow",
|
||||
},
|
||||
{
|
||||
name: "incomplete chain",
|
||||
src: accountRowSource + `fun query(): GotlinSQLQuery { return sql.from<AccountRow>() }`,
|
||||
want: "must end with build()",
|
||||
},
|
||||
{
|
||||
name: "fetch missing arguments",
|
||||
src: accountRowSource + `fun query(): List<*AccountRow> { return sql.from<AccountRow>().fetch() }`,
|
||||
want: "fetch() expects exactly pool and ctx positional arguments",
|
||||
},
|
||||
{
|
||||
name: "single extra argument",
|
||||
src: accountRowSource + `fun query(pool: Any, ctx: Any): *AccountRow { return sql.from<AccountRow>().single(pool, ctx, ctx) }`,
|
||||
want: "single() expects exactly pool and ctx positional arguments",
|
||||
},
|
||||
{
|
||||
name: "iterator named arguments",
|
||||
src: accountRowSource + `fun query(pool: Any, ctx: Any): GotlinSQLIterator<AccountRow> { return sql.from<AccountRow>().iterator(pool = pool, ctx = ctx) }`,
|
||||
want: "iterator() expects exactly pool and ctx positional arguments",
|
||||
},
|
||||
{
|
||||
name: "fetch type arguments",
|
||||
src: accountRowSource + `fun query(pool: Any, ctx: Any): List<*AccountRow> { return sql.from<AccountRow>().fetch<String>(pool, ctx) }`,
|
||||
want: "fetch() expects exactly pool and ctx positional arguments",
|
||||
},
|
||||
{
|
||||
name: "fetch invalid pool type",
|
||||
src: accountRowSource + `fun query(pool: String, ctx: Any): List<*AccountRow> { return sql.from<AccountRow>().fetch(pool, ctx) }`,
|
||||
want: "pool argument has non-query type String",
|
||||
},
|
||||
{
|
||||
name: "single invalid context type",
|
||||
src: accountRowSource + `fun query(pool: Any, ctx: Int): *AccountRow { return sql.from<AccountRow>().single(pool, ctx) }`,
|
||||
want: "ctx argument has non-context type Int",
|
||||
},
|
||||
{
|
||||
name: "insert execution terminal",
|
||||
src: accountRowSource + `fun query(row: AccountRow, pool: Any, ctx: Any): List<*AccountRow> { return sql.insert<AccountRow>(row).fetch(pool, ctx) }`,
|
||||
want: "fetch() is only supported for sql.from",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
program, err := Parse(test.src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
_, err = GenerateGo(program)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectInvalidSQLAnnotations(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
src string
|
||||
want string
|
||||
}{
|
||||
{`@table("accounts") class Row(var id: String)`, "table is only valid on data classes"},
|
||||
{`@table("bad-name") data class Row(var id: String)`, "invalid SQL table name"},
|
||||
{`data class Row(@column("bad-name") var id: String)`, "invalid SQL column name"},
|
||||
} {
|
||||
_, err := Parse(test.src)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Parse() error = %v, want substring %q", err, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compileSQL(t *testing.T, src string) string {
|
||||
t.Helper()
|
||||
program, err := Parse(src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
out, err := GenerateGo(program)
|
||||
if err != nil {
|
||||
t.Fatalf("Go generation failed: %v", err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ const (
|
|||
tokenEOF tokenKind = "EOF"
|
||||
tokenIdent tokenKind = "IDENT"
|
||||
tokenInt tokenKind = "INT"
|
||||
tokenFloat tokenKind = "FLOAT"
|
||||
tokenString tokenKind = "STRING"
|
||||
tokenTrue tokenKind = "TRUE"
|
||||
tokenFalse tokenKind = "FALSE"
|
||||
|
|
@ -13,18 +14,25 @@ const (
|
|||
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"
|
||||
|
|
@ -32,9 +40,12 @@ const (
|
|||
tokenRParen tokenKind = ")"
|
||||
tokenLBrace tokenKind = "{"
|
||||
tokenRBrace tokenKind = "}"
|
||||
tokenLBracket tokenKind = "["
|
||||
tokenRBracket tokenKind = "]"
|
||||
tokenComma tokenKind = ","
|
||||
tokenDot tokenKind = "."
|
||||
tokenColon tokenKind = ":"
|
||||
tokenDoubleColon tokenKind = "::"
|
||||
tokenSemicolon tokenKind = ";"
|
||||
tokenPlus tokenKind = "+"
|
||||
tokenMinus tokenKind = "-"
|
||||
|
|
@ -51,6 +62,9 @@ const (
|
|||
tokenGt tokenKind = ">"
|
||||
tokenGte tokenKind = ">="
|
||||
tokenAnd tokenKind = "&&"
|
||||
tokenAmp tokenKind = "&"
|
||||
tokenAt tokenKind = "@"
|
||||
tokenQuestion tokenKind = "?"
|
||||
tokenOr tokenKind = "||"
|
||||
tokenArrow 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,
|
||||
|
|
|
|||
|
|
@ -1,58 +1,47 @@
|
|||
# vscode-gotlin
|
||||
# Gotlin for VS Code
|
||||
|
||||
Minimal VS Code extension for `.gt` files.
|
||||
VS Code language support for Gotlin (`.gt`) files.
|
||||
|
||||
It does two things:
|
||||
## Features
|
||||
|
||||
- registers `.gt` as the `gotlin` language
|
||||
- launches `gotlin-lsp` over stdio
|
||||
- TextMate highlighting for current Gotlin declarations, control flow, concurrency, exceptions, types, annotations, pointers, nullable types, generics, and numeric literals
|
||||
- Dedicated highlighting for the typed `sql` DSL and its query, mutation, and execution methods
|
||||
- Go import highlighting for bare dotted paths, aliases, and quoted module paths
|
||||
- Bracket matching, indentation, folding markers, comments, and editor pairs
|
||||
- Snippets for data classes, SQL table rows and operations, workers, embeds, concurrency, defer, and foreach loops
|
||||
- `gotlin-lsp` integration, including its optional `gopls` bridge for Go-imported symbols
|
||||
|
||||
It also includes:
|
||||
## Development
|
||||
|
||||
- syntax highlighting
|
||||
- bracket/comment configuration
|
||||
- basic Gotlin snippets
|
||||
- optional `gopls` bridge for hover/definition on Go-imported symbols
|
||||
|
||||
## Setup
|
||||
|
||||
From this folder:
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm run check
|
||||
```
|
||||
|
||||
Then in VS Code:
|
||||
`npm run build` compiles the extension and `npm test` validates all JSON contribution files plus key current grammar tokens and snippets without additional test dependencies.
|
||||
|
||||
1. Open this folder as an extension project.
|
||||
2. Press `F5` to launch an Extension Development Host.
|
||||
3. Open your Gotlin workspace in that host.
|
||||
To run the extension, open this directory in VS Code and press `F5`. In the Extension Development Host, open a Gotlin workspace containing `.gt` files.
|
||||
|
||||
## Server path
|
||||
## Language Server
|
||||
|
||||
By default the extension looks for:
|
||||
|
||||
```text
|
||||
<workspace>/bin/gotlin-lsp
|
||||
```
|
||||
|
||||
If your binary lives somewhere else, set:
|
||||
The extension first looks for the platform-specific server binary at `<workspace>/bin/gotlin-lsp`, then falls back to `gotlin-lsp` on `PATH`. Override it with:
|
||||
|
||||
```json
|
||||
"gotlin.serverPath": "/absolute/path/to/gotlin-lsp"
|
||||
```
|
||||
|
||||
If `gopls` is not on your PATH, also set:
|
||||
If `gopls` is not on `PATH`, configure:
|
||||
|
||||
```json
|
||||
"gotlin.goplsPath": "/absolute/path/to/gopls"
|
||||
```
|
||||
|
||||
## Build the language server
|
||||
|
||||
From the repo root:
|
||||
Build the language server from the repository root with:
|
||||
|
||||
```bash
|
||||
go build -o ./bin/gotlin-lsp ./cmd/gotlin-lsp
|
||||
```
|
||||
|
||||
The extension activates when a Gotlin document is opened and communicates with the server over stdio.
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
4
tools/vscode-gotlin/package-lock.json
generated
4
tools/vscode-gotlin/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
90
tools/vscode-gotlin/scripts/validate.js
Normal file
90
tools/vscode-gotlin/scripts/validate.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
|
||||
function readJSON(relativePath) {
|
||||
return JSON.parse(fs.readFileSync(path.join(root, relativePath), "utf8"));
|
||||
}
|
||||
|
||||
const packageJSON = readJSON("package.json");
|
||||
const language = readJSON("language-configuration.json");
|
||||
const grammar = readJSON("syntaxes/gotlin.tmLanguage.json");
|
||||
const snippets = readJSON("snippets/gotlin.code-snippets");
|
||||
|
||||
function validateRegexes(value) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(validateRegexes);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (["match", "begin", "end"].includes(key)) {
|
||||
assert.doesNotThrow(() => new RegExp(child), `invalid ${key} regex: ${child}`);
|
||||
} else {
|
||||
validateRegexes(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateRegexes(grammar);
|
||||
|
||||
assert.equal(packageJSON.version, "0.1.0");
|
||||
assert(packageJSON.activationEvents.includes("onLanguage:gotlin"));
|
||||
assert.equal(packageJSON.contributes.grammars[0].scopeName, "source.gotlin");
|
||||
assert(language.brackets.some(([open, close]) => open === "[" && close === "]"));
|
||||
assert(language.folding?.markers?.start && language.indentationRules?.increaseIndentPattern);
|
||||
|
||||
const grammarSource = JSON.stringify(grammar);
|
||||
const expectedTokens = [
|
||||
"data", "class", "worker", "private", "override", "val", "var", "if", "else",
|
||||
"while", "for", "in", "select", "return", "go", "defer", "try", "catch",
|
||||
"throw", "enum", "match", "package", "import", "jsonNaming", "embed", "table", "column", "id",
|
||||
"generated", "Int", "String", "Boolean", "Unit", "Double", "Float", "Any",
|
||||
"ByteSlice", "List", "MutableList", "Map", "MutableMap", "Channel", "GotlinSQLQuery",
|
||||
"GotlinSQLIterator", "from", "where", "orderBy", "orderByDescending", "limit",
|
||||
"forUpdate", "skipLocked", "insert", "update", "delete", "onConflict", "doNothing",
|
||||
"doUpdate", "returning", "build", "fetch", "single", "iterator", "set", "now", "mapTo"
|
||||
];
|
||||
for (const token of expectedTokens) {
|
||||
assert(grammarSource.includes(token), `grammar is missing ${token}`);
|
||||
}
|
||||
|
||||
const annotationSource = JSON.stringify(grammar.repository.annotations);
|
||||
for (const annotation of ["jsonNaming", "embed", "table", "column", "id", "generated"]) {
|
||||
assert(annotationSource.includes(annotation), `annotation grammar is missing ${annotation}`);
|
||||
}
|
||||
|
||||
const sqlSource = JSON.stringify(grammar.repository.sql);
|
||||
for (const method of [
|
||||
"from", "where", "select", "orderBy", "orderByDescending", "limit", "forUpdate",
|
||||
"skipLocked", "insert", "update", "delete", "onConflict", "doNothing", "doUpdate",
|
||||
"returning", "build", "fetch", "single", "iterator", "set", "now"
|
||||
]) {
|
||||
assert(sqlSource.includes(method), `SQL grammar is missing ${method}`);
|
||||
}
|
||||
|
||||
const imports = grammar.repository.imports.patterns.map((pattern) => new RegExp(pattern.match));
|
||||
for (const declaration of [
|
||||
"import encoding.json",
|
||||
"import json encoding.json",
|
||||
"import pgxpool \"github.com/jackc/pgx/v5/pgxpool\"",
|
||||
"import \"example.com/module/package\""
|
||||
]) {
|
||||
assert(imports.some((pattern) => pattern.test(declaration)), `import grammar rejected: ${declaration}`);
|
||||
}
|
||||
|
||||
const prefixes = new Set(Object.values(snippets).map((snippet) => snippet.prefix));
|
||||
for (const prefix of [
|
||||
"dataclass", "tablerow", "embed", "worker", "enum", "match", "mapto", "go", "defer", "foreach", "sqlfetch",
|
||||
"sqlsingle", "sqliterator", "sqlinsertnothing", "sqlinsertupdate", "sqlupdatereturning"
|
||||
]) {
|
||||
assert(prefixes.has(prefix), `snippets are missing prefix ${prefix}`);
|
||||
}
|
||||
|
||||
console.log("Validated Gotlin package metadata, grammar, language configuration, and snippets.");
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,15 +62,109 @@
|
|||
"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"
|
||||
"name": "string.quoted.double.gotlin",
|
||||
"begin": "\"",
|
||||
"beginCaptures": {
|
||||
"0": { "name": "punctuation.definition.string.begin.gotlin" }
|
||||
},
|
||||
"2": {
|
||||
"name": "entity.name.type.interface.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"
|
||||
"name": "support.function.sql.query.gotlin",
|
||||
"match": "(?<=\\.)\\b(from|where|select|orderBy|orderByDescending|limit|forUpdate|skipLocked)\\b"
|
||||
},
|
||||
"2": {
|
||||
"name": "entity.name.type.class.gotlin"
|
||||
}
|
||||
{
|
||||
"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": "[,;:]"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue