gotlin/cmd/gotlin-lsp/main_test.go

784 lines
18 KiB
Go

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", "keys", "goAssert", "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
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() {
runBlocking { launch { 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 TestBuildDocumentStateChannelSemantics(t *testing.T) {
text := strings.TrimSpace(`
package demo
fun writer(ch: Channel<Int>) {
ch.send(7)
}
fun main() {
val ch = Channel<Int>(1)
writer(ch)
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 {
var 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("worker syntax should no longer parse")
}
if len(state.diagnostics) == 0 {
t.Fatal("expected removed worker syntax diagnostic")
}
}