init
This commit is contained in:
commit
8f4f858d86
30 changed files with 9765 additions and 0 deletions
39
cmd/gotlin-lsp/README.md
Normal file
39
cmd/gotlin-lsp/README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# gotlin-lsp
|
||||
|
||||
Minimal stdio language server for `.gt` files.
|
||||
|
||||
Current support:
|
||||
|
||||
- `initialize`
|
||||
- `shutdown`
|
||||
- `exit`
|
||||
- `textDocument/didOpen`
|
||||
- `textDocument/didChange`
|
||||
- `textDocument/didClose`
|
||||
- publish diagnostics from the existing Gotlin parser/code generator
|
||||
- `textDocument/hover` for packages, imports, and top-level functions
|
||||
- `textDocument/definition` for imported namespaces and top-level functions
|
||||
- simple semantic diagnostics for duplicate imports/functions and undefined names
|
||||
- optional fallback to `gopls` for hover/definition on Go-imported symbols
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
go build -o ./bin/gotlin-lsp ./cmd/gotlin-lsp
|
||||
```
|
||||
|
||||
Use your editor's custom LSP configuration to launch:
|
||||
|
||||
```bash
|
||||
./bin/gotlin-lsp
|
||||
```
|
||||
|
||||
## gopls bridge
|
||||
|
||||
If `gopls` is installed, `gotlin-lsp` will try to use it as a fallback for hover and definition on Go-imported symbols.
|
||||
|
||||
You can set an explicit path with:
|
||||
|
||||
```bash
|
||||
GOTLIN_GOPLS_PATH=/absolute/path/to/gopls ./bin/gotlin-lsp
|
||||
```
|
||||
2293
cmd/gotlin-lsp/main.go
Normal file
2293
cmd/gotlin-lsp/main.go
Normal file
File diff suppressed because it is too large
Load diff
841
cmd/gotlin-lsp/main_test.go
Normal file
841
cmd/gotlin-lsp/main_test.go
Normal file
|
|
@ -0,0 +1,841 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gotlin/internal/lang"
|
||||
)
|
||||
|
||||
func TestResolveStdlibTarget(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package examples.http
|
||||
|
||||
import fmt
|
||||
import net.http
|
||||
|
||||
fun main() {
|
||||
fmt.Println("hello")
|
||||
http.HandleFunc("/") { w, r -> helloHandler(w, r) }
|
||||
}
|
||||
`)
|
||||
|
||||
program, err := lang.Parse(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
state := documentState{
|
||||
text: text,
|
||||
program: program,
|
||||
}
|
||||
|
||||
printPos := position{Line: 6, Character: 9}
|
||||
target, ok := resolveStdlibTarget(state, printPos)
|
||||
if !ok {
|
||||
t.Fatal("expected fmt.Println to resolve as stdlib target")
|
||||
}
|
||||
if target.PackagePath != "fmt" || target.SymbolName != "Println" {
|
||||
t.Fatalf("unexpected target: %+v", target)
|
||||
}
|
||||
|
||||
httpPos := position{Line: 7, Character: 6}
|
||||
target, ok = resolveStdlibTarget(state, httpPos)
|
||||
if !ok {
|
||||
t.Fatal("expected import alias to resolve as stdlib package")
|
||||
}
|
||||
if target.PackagePath != "net/http" || target.SymbolName != "" {
|
||||
t.Fatalf("unexpected package target: %+v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindStdlibSymbol(t *testing.T) {
|
||||
sym, ok := findStdlibSymbol(stdlibTarget{
|
||||
PackagePath: "fmt",
|
||||
SymbolName: "Println",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected fmt.Println to resolve in local Go stdlib")
|
||||
}
|
||||
if !strings.HasSuffix(sym.FileName, "/src/fmt/print.go") {
|
||||
t.Fatalf("unexpected file: %s", sym.FileName)
|
||||
}
|
||||
if !strings.Contains(sym.Decl, "func Println") {
|
||||
t.Fatalf("unexpected decl: %s", sym.Decl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindStdlibSymbolMethod(t *testing.T) {
|
||||
sym, ok := findStdlibSymbol(stdlibTarget{
|
||||
PackagePath: "bytes",
|
||||
SymbolName: "WriteString",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected bytes.(*Buffer).WriteString to resolve in local Go sources")
|
||||
}
|
||||
if !strings.Contains(sym.Decl, "WriteString") {
|
||||
t.Fatalf("unexpected decl: %s", sym.Decl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStdlibTargetThirdPartyImportPathMapping(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
import github.com.uptrace.bun
|
||||
|
||||
fun main() {
|
||||
bun.NewDB(nil, nil)
|
||||
}
|
||||
`)
|
||||
|
||||
program, err := lang.Parse(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
state := documentState{
|
||||
text: text,
|
||||
program: program,
|
||||
}
|
||||
|
||||
pos := position{Line: 5, Character: 8}
|
||||
target, ok := resolveStdlibTarget(state, pos)
|
||||
if !ok {
|
||||
t.Fatal("expected bun.NewDB to resolve as package target")
|
||||
}
|
||||
if target.PackagePath != "github.com/uptrace/bun" || target.SymbolName != "NewDB" {
|
||||
t.Fatalf("unexpected target: %+v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStdlibTargetFieldSelectorImportMapping(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
import github.com.uptrace.bun
|
||||
|
||||
class EpicControllerImpl(val db: *bun.DB) {
|
||||
fun bunHealth() {
|
||||
db.NewSelect()
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
program, err := lang.Parse(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
state := documentState{
|
||||
text: text,
|
||||
program: program,
|
||||
}
|
||||
|
||||
pos := position{Line: 6, Character: 12}
|
||||
target, ok := resolveStdlibTarget(state, pos)
|
||||
if !ok {
|
||||
t.Fatal("expected db.NewSelect to resolve as imported package symbol")
|
||||
}
|
||||
if target.PackagePath != "github.com/uptrace/bun" || target.SymbolName != "NewSelect" {
|
||||
t.Fatalf("unexpected target: %+v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStdlibTargetNestedFieldSelectorImportMapping(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
import context
|
||||
import github.com.uptrace.bun
|
||||
|
||||
class EpicControllerImpl(val db: *bun.DB) {
|
||||
fun bunHealth() {
|
||||
val ctx = context.Background()
|
||||
db.NewSelect().ColumnExpr("1").Scan(ctx)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
program, err := lang.Parse(text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
state := documentState{
|
||||
text: text,
|
||||
program: program,
|
||||
}
|
||||
|
||||
pos := position{Line: 8, Character: 40}
|
||||
target, ok := resolveStdlibTarget(state, pos)
|
||||
if !ok {
|
||||
t.Fatal("expected nested db selector to resolve as imported package symbol")
|
||||
}
|
||||
if target.PackagePath != "github.com/uptrace/bun" || target.SymbolName != "Scan" {
|
||||
t.Fatalf("unexpected target: %+v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateIndexesClassesAndInterfaces(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
interface Greeter {
|
||||
fun greet(name: String): String
|
||||
}
|
||||
|
||||
class ConsoleGreeter(val prefix: String) {
|
||||
fun greet(name: String): String {
|
||||
return prefix + name
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
|
||||
wantKinds := map[string]int{
|
||||
"Greeter": symbolKindInterface,
|
||||
"ConsoleGreeter": symbolKindClass,
|
||||
"greet": symbolKindMethod,
|
||||
}
|
||||
for name, kind := range wantKinds {
|
||||
found := false
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Name == name && sym.Kind == kind {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing symbol %q of kind %d", name, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateClassSemantics(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
interface Greeter {
|
||||
fun greet(name: String): String
|
||||
}
|
||||
|
||||
class ConsoleGreeter(val prefix: String) {
|
||||
fun greet(name: String): String {
|
||||
return prefix + name
|
||||
}
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val greeter: Greeter = ConsoleGreeter("hello, ")
|
||||
println(greeter.greet("gotlin"))
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateHTTPServerClassSyntax(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package examples.http
|
||||
|
||||
import fmt
|
||||
import net.http
|
||||
|
||||
interface EpicController {
|
||||
fun hello(w: http.ResponseWriter, r: *http.Request)
|
||||
}
|
||||
|
||||
class EpicControllerImpl: EpicController {
|
||||
override fun hello(w: http.ResponseWriter, r: *http.Request) {
|
||||
fmt.Fprintln(w, "hello from gotlin")
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
|
||||
foundClass := false
|
||||
foundMethod := false
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Name == "EpicControllerImpl" && sym.Kind == symbolKindClass {
|
||||
foundClass = true
|
||||
if sym.Detail != "class EpicControllerImpl: EpicController" {
|
||||
t.Fatalf("unexpected class detail: %q", sym.Detail)
|
||||
}
|
||||
}
|
||||
if sym.Name == "hello" && sym.Kind == symbolKindMethod && strings.Contains(sym.Detail, "EpicControllerImpl.fun hello") {
|
||||
foundMethod = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundClass {
|
||||
t.Fatal("expected EpicControllerImpl class symbol")
|
||||
}
|
||||
if !foundMethod {
|
||||
t.Fatal("expected EpicControllerImpl.hello method symbol")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateIndexesClassFieldSymbols(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
import github.com.uptrace.bun
|
||||
|
||||
class EpicControllerImpl(val db: *bun.DB) {
|
||||
fun hello() {
|
||||
println(db)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
|
||||
foundField := false
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Name == "db" && sym.Kind == symbolKindField {
|
||||
foundField = true
|
||||
if !strings.Contains(sym.Detail, "EpicControllerImpl.val db: *bun.DB") {
|
||||
t.Fatalf("unexpected field detail: %q", sym.Detail)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundField {
|
||||
t.Fatal("expected db field symbol")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateIndexesLocalVariableSymbols(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun main() {
|
||||
val ctx = "x"
|
||||
var total = 1
|
||||
println(ctx)
|
||||
println(total)
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
|
||||
foundCtx := false
|
||||
foundTotal := false
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Name == "ctx" && sym.Kind == symbolKindVariable {
|
||||
foundCtx = true
|
||||
if sym.Detail != "val ctx: String" {
|
||||
t.Fatalf("unexpected ctx detail: %q", sym.Detail)
|
||||
}
|
||||
}
|
||||
if sym.Name == "total" && sym.Kind == symbolKindVariable {
|
||||
foundTotal = true
|
||||
if sym.Detail != "var total: Int" {
|
||||
t.Fatalf("unexpected total detail: %q", sym.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundCtx || !foundTotal {
|
||||
t.Fatalf("expected local variable symbols, got %+v", state.symbols)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateInfersHttpServerVariableTypes(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
import context
|
||||
import github.com.uptrace.bun
|
||||
|
||||
class C(val db: *bun.DB) {
|
||||
fun run() {
|
||||
val ctx = context.Background()
|
||||
val total = db.NewSelect().ColumnExpr("1").Count(ctx)
|
||||
println(total)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
|
||||
seenCtx := false
|
||||
seenTotal := false
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Kind != symbolKindVariable {
|
||||
continue
|
||||
}
|
||||
if sym.Name == "ctx" {
|
||||
seenCtx = true
|
||||
if sym.Detail != "val ctx: context.Context" {
|
||||
t.Fatalf("unexpected ctx detail: %q", sym.Detail)
|
||||
}
|
||||
}
|
||||
if sym.Name == "total" {
|
||||
seenTotal = true
|
||||
if sym.Detail != "val total: Int" {
|
||||
t.Fatalf("unexpected total detail: %q", sym.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenCtx || !seenTotal {
|
||||
t.Fatalf("missing inferred variable symbols; got %+v", state.symbols)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateVariableTypeMatchingByName(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
class BunUser(val Name: String)
|
||||
class EpicControllerImpl
|
||||
|
||||
fun main() {
|
||||
val epicController = EpicControllerImpl()
|
||||
val user = BunUser("user-from-gotlin")
|
||||
println(epicController)
|
||||
println(user)
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
|
||||
var userDetail string
|
||||
var controllerDetail string
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Kind != symbolKindVariable {
|
||||
continue
|
||||
}
|
||||
if sym.Name == "user" {
|
||||
userDetail = sym.Detail
|
||||
}
|
||||
if sym.Name == "epicController" {
|
||||
controllerDetail = sym.Detail
|
||||
}
|
||||
}
|
||||
|
||||
if userDetail != "val user: BunUser" {
|
||||
t.Fatalf("unexpected user detail: %q", userDetail)
|
||||
}
|
||||
if controllerDetail != "val epicController: EpicControllerImpl" {
|
||||
t.Fatalf("unexpected epicController detail: %q", controllerDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateMultiAssignAndNullSemantics(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
import database.sql
|
||||
|
||||
fun main() {
|
||||
val db, err = sql.Open("postgres", "postgres://localhost/postgres?sslmode=disable")
|
||||
if (err != null) {
|
||||
return
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateExceptionsAndRunCatchingSemantics(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun mayFail() {
|
||||
throw "boom"
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val result = runCatching({ mayFail() })
|
||||
if (result.isSuccess()) {
|
||||
println("ok")
|
||||
} else {
|
||||
println(result.exceptionOrNull())
|
||||
}
|
||||
try {
|
||||
mayFail()
|
||||
} catch (e: String) {
|
||||
println(e)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateCollectionLiteralBuiltins(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun main() {
|
||||
val names = listOf("alice", "bob")
|
||||
val test = listOf(1, 7)
|
||||
val ages = mapOf("alice", 30, "bob", 25)
|
||||
println(names)
|
||||
println(test)
|
||||
println(ages)
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
|
||||
var namesDetail string
|
||||
var testDetail string
|
||||
var agesDetail string
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Kind != symbolKindVariable {
|
||||
continue
|
||||
}
|
||||
if sym.Name == "names" {
|
||||
namesDetail = sym.Detail
|
||||
}
|
||||
if sym.Name == "test" {
|
||||
testDetail = sym.Detail
|
||||
}
|
||||
if sym.Name == "ages" {
|
||||
agesDetail = sym.Detail
|
||||
}
|
||||
}
|
||||
if namesDetail != "val names: List<String>" {
|
||||
t.Fatalf("unexpected names detail: %q", namesDetail)
|
||||
}
|
||||
if testDetail != "val test: List<Int>" {
|
||||
t.Fatalf("unexpected test detail: %q", testDetail)
|
||||
}
|
||||
if agesDetail != "val ages: Map<String, Int>" {
|
||||
t.Fatalf("unexpected ages detail: %q", agesDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateCollectionLiteralBuiltinsWithTypeArgs(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun main() {
|
||||
val test = listOf<Int>()
|
||||
val labels = mapOf<String, Int>()
|
||||
println(test)
|
||||
println(labels)
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
|
||||
var testDetail string
|
||||
var labelsDetail string
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Kind != symbolKindVariable {
|
||||
continue
|
||||
}
|
||||
if sym.Name == "test" {
|
||||
testDetail = sym.Detail
|
||||
}
|
||||
if sym.Name == "labels" {
|
||||
labelsDetail = sym.Detail
|
||||
}
|
||||
}
|
||||
|
||||
if testDetail != "val test: List<Int>" {
|
||||
t.Fatalf("unexpected test detail: %q", testDetail)
|
||||
}
|
||||
if labelsDetail != "val labels: Map<String, Int>" {
|
||||
t.Fatalf("unexpected labels detail: %q", labelsDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateGoStatementSemantics(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun runWorker(name: String) {
|
||||
println(name)
|
||||
}
|
||||
|
||||
fun main() {
|
||||
go runWorker("alice")
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateChannelsAndSelectSemantics(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun writer(ch: Channel<Int>) {
|
||||
ch.send(7)
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val ch = Channel<Int>()
|
||||
go writer(ch)
|
||||
select {
|
||||
ch -> println(it)
|
||||
}
|
||||
val v = ch.read()
|
||||
println(v)
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
|
||||
var chDetail string
|
||||
var vDetail string
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Kind != symbolKindVariable {
|
||||
continue
|
||||
}
|
||||
if sym.Name == "ch" {
|
||||
chDetail = sym.Detail
|
||||
}
|
||||
if sym.Name == "v" {
|
||||
vDetail = sym.Detail
|
||||
}
|
||||
}
|
||||
if chDetail != "val ch: Channel<Int>" {
|
||||
t.Fatalf("unexpected ch detail: %q", chDetail)
|
||||
}
|
||||
if vDetail != "val v: Int" {
|
||||
t.Fatalf("unexpected v detail: %q", vDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateWorkerSemantics(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
worker Counter {
|
||||
val counter = 0
|
||||
|
||||
fun getCount(): Int {
|
||||
return counter
|
||||
}
|
||||
fun increment() {
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val counter = Counter()
|
||||
counter.increment()
|
||||
println(counter.getCount())
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
|
||||
var counterDetail string
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Kind == symbolKindVariable && sym.Name == "counter" {
|
||||
counterDetail = sym.Detail
|
||||
break
|
||||
}
|
||||
}
|
||||
if counterDetail != "val counter: Counter" {
|
||||
t.Fatalf("unexpected counter detail: %q", counterDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateTimerBuiltinsSemantics(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun main() {
|
||||
val once = after(1000)
|
||||
val repeat = every(250)
|
||||
select {
|
||||
after(1000) -> println(it)
|
||||
every(250) -> println("tick")
|
||||
}
|
||||
println(once)
|
||||
println(repeat)
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) != 0 {
|
||||
t.Fatalf("expected no diagnostics, got %+v", state.diagnostics)
|
||||
}
|
||||
|
||||
var onceDetail string
|
||||
var repeatDetail string
|
||||
for _, sym := range state.symbols {
|
||||
if sym.Kind != symbolKindVariable {
|
||||
continue
|
||||
}
|
||||
if sym.Name == "once" {
|
||||
onceDetail = sym.Detail
|
||||
}
|
||||
if sym.Name == "repeat" {
|
||||
repeatDetail = sym.Detail
|
||||
}
|
||||
}
|
||||
if onceDetail != "val once: Channel<time.Time>" {
|
||||
t.Fatalf("unexpected once detail: %q", onceDetail)
|
||||
}
|
||||
if repeatDetail != "val repeat: Channel<time.Time>" {
|
||||
t.Fatalf("unexpected repeat detail: %q", repeatDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateEveryNonPositiveDiagnostics(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun main() {
|
||||
select {
|
||||
every(0) -> println("x")
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
if len(state.diagnostics) == 0 {
|
||||
t.Fatal("expected diagnostics")
|
||||
}
|
||||
found := false
|
||||
for _, d := range state.diagnostics {
|
||||
if strings.Contains(d.Message, "every(ms) requires ms > 0") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected every(ms) diagnostic, got %+v", state.diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentStateAfterIntArgumentDiagnostics(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun main() {
|
||||
select {
|
||||
after("1s") -> println("x")
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
state := buildDocumentState(text)
|
||||
if state.program == nil {
|
||||
t.Fatal("expected parsed program")
|
||||
}
|
||||
found := false
|
||||
for _, d := range state.diagnostics {
|
||||
if strings.Contains(d.Message, "after(ms) expects an Int argument") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected after int argument diagnostic, got %+v", state.diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverBuiltinEverySignature(t *testing.T) {
|
||||
text := strings.TrimSpace(`
|
||||
package demo
|
||||
|
||||
fun main() {
|
||||
every(100)
|
||||
}
|
||||
`)
|
||||
uri := "file:///tmp/demo.gt"
|
||||
s := &server{
|
||||
docs: map[string]documentState{
|
||||
uri: buildDocumentState(text),
|
||||
},
|
||||
}
|
||||
|
||||
result := s.hover(uri, position{Line: 3, Character: 5})
|
||||
if result == nil {
|
||||
t.Fatal("expected hover result")
|
||||
}
|
||||
m, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected hover type: %T", result)
|
||||
}
|
||||
contents, ok := m["contents"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected hover contents: %+v", m)
|
||||
}
|
||||
value, _ := contents["value"].(string)
|
||||
if !strings.Contains(value, "fun every(ms: Int): Channel<time.Time>") {
|
||||
t.Fatalf("unexpected hover value: %q", value)
|
||||
}
|
||||
}
|
||||
247
cmd/gotlinc/main.go
Normal file
247
cmd/gotlinc/main.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gotlin/internal/lang"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "build":
|
||||
runBuild(os.Args[2:])
|
||||
case "run":
|
||||
runRun(os.Args[2:])
|
||||
default:
|
||||
runBuild(os.Args[1:])
|
||||
}
|
||||
}
|
||||
|
||||
func runBuild(args []string) {
|
||||
fs := flag.NewFlagSet("build", flag.ExitOnError)
|
||||
srcOnly := fs.Bool("src", false, "emit Go source instead of building an executable")
|
||||
outPath := fs.String("o", "", "output file path")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(os.Stderr, "usage: gotlinc build [-src] [-o output] <input.gt>")
|
||||
}
|
||||
normalizedArgs, err := normalizeBuildArgs(args)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if err := fs.Parse(normalizedArgs); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
inputPath := fs.Arg(0)
|
||||
goSrc := compileFile(inputPath, !*srcOnly)
|
||||
|
||||
if *srcOnly {
|
||||
if *outPath == "" {
|
||||
_, _ = os.Stdout.WriteString(goSrc)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(*outPath, []byte(goSrc), 0o644); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
outputPath := *outPath
|
||||
if outputPath == "" {
|
||||
outputPath = defaultExecutablePath(inputPath)
|
||||
}
|
||||
|
||||
if err := buildExecutable(goSrc, outputPath); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func runRun(args []string) {
|
||||
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(os.Stderr, "usage: gotlinc run <input.gt> [program args...]")
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if fs.NArg() < 1 {
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
inputPath := fs.Arg(0)
|
||||
goSrc := compileFile(inputPath, true)
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "gotlinc-*.go")
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmpFile.WriteString(goSrc); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
fail(err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
cmdArgs := append([]string{"run", tmpPath}, fs.Args()[1:]...)
|
||||
cmd := exec.Command("go", cmdArgs...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
if err := cmd.Run(); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func compileFile(inputPath string, forceMain bool) string {
|
||||
src, err := os.ReadFile(inputPath)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
program, err := lang.Parse(string(src))
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
var goSrc []byte
|
||||
if forceMain {
|
||||
goSrc, err = lang.GenerateGoMain(program)
|
||||
} else {
|
||||
goSrc, err = lang.GenerateGo(program)
|
||||
}
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if forceMain {
|
||||
return addBestEffortLineDirectives(string(goSrc), inputPath, string(src))
|
||||
}
|
||||
return string(goSrc)
|
||||
}
|
||||
|
||||
func buildExecutable(goSrc string, outputPath string) error {
|
||||
tmpFile, err := os.CreateTemp("", "gotlinc-build-*.go")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmpFile.WriteString(goSrc); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", outputPath, tmpPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func defaultExecutablePath(inputPath string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))
|
||||
if runtime.GOOS == "windows" && filepath.Ext(base) != ".exe" {
|
||||
base += ".exe"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func normalizeBuildArgs(args []string) ([]string, error) {
|
||||
var flags []string
|
||||
var positional []string
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "-src":
|
||||
flags = append(flags, arg)
|
||||
case arg == "-o":
|
||||
if i+1 >= len(args) {
|
||||
return nil, fmt.Errorf("missing value for -o")
|
||||
}
|
||||
flags = append(flags, arg, args[i+1])
|
||||
i++
|
||||
case strings.HasPrefix(arg, "-o="):
|
||||
flags = append(flags, arg)
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
flags = append(flags, arg)
|
||||
default:
|
||||
positional = append(positional, arg)
|
||||
}
|
||||
}
|
||||
|
||||
return append(flags, positional...), nil
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:")
|
||||
fmt.Fprintln(os.Stderr, " gotlinc build [-src] [-o output] <input.gt>")
|
||||
fmt.Fprintln(os.Stderr, " gotlinc run <input.gt> [program args...]")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "default command: build")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func addBestEffortLineDirectives(goSrc string, inputPath string, gtSrc string) string {
|
||||
fnLines := sourceFunctionLines(gtSrc)
|
||||
if len(fnLines) == 0 {
|
||||
return goSrc
|
||||
}
|
||||
|
||||
pattern := regexp.MustCompile(`^\s*func\s+(?:\(\s*[^)]*\)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
|
||||
lines := strings.Split(goSrc, "\n")
|
||||
out := make([]string, 0, len(lines)+len(fnLines))
|
||||
for _, line := range lines {
|
||||
m := pattern.FindStringSubmatch(line)
|
||||
if len(m) == 2 {
|
||||
if gtLine, ok := fnLines[m[1]]; ok {
|
||||
out = append(out, fmt.Sprintf("//line %s:%d", inputPath, gtLine))
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func sourceFunctionLines(src string) map[string]int {
|
||||
out := map[string]int{}
|
||||
pattern := regexp.MustCompile(`^\s*fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
|
||||
lines := strings.Split(src, "\n")
|
||||
for i, line := range lines {
|
||||
m := pattern.FindStringSubmatch(line)
|
||||
if len(m) == 2 {
|
||||
if _, exists := out[m[1]]; !exists {
|
||||
out[m[1]] = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue