Files
2026-07-13 13:00:08 +08:00

169 lines
4.8 KiB
Go

//go:build manual
package lsp
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// TestLanguageServers drives each installed mainstream server against a per-
// language fixture under testdata/lsp. Servers not on PATH are skipped. Run with:
//
// go test -tags manual -run TestLanguageServers -v ./internal/lsp/
func TestLanguageServers(t *testing.T) {
specs := DefaultSpecs()
cases := []struct {
lang, dir, file, callNeedle, symbol string
}{
{"go", "golang", "main.go", "_ = greet(", "greet"},
{"rust", "rust", filepath.Join("src", "main.rs"), "let _ = greet(", "greet"},
{"typescript", "typescript", "index.ts", "const msg = greet(", "greet"},
{"python", "python", "main.py", "msg = greet(", "greet"},
{"bash", "bash", "script.sh", `greet "world"`, "greet"},
}
for _, tc := range cases {
t.Run(tc.lang, func(t *testing.T) {
spec := specs[tc.lang]
if _, err := exec.LookPath(spec.Command); err != nil {
t.Skipf("%s not on PATH (%s)", spec.Command, spec.InstallHint)
}
root, err := filepath.Abs(filepath.Join("testdata", "lsp", tc.dir))
if err != nil {
t.Fatal(err)
}
m := NewManager(root, specs)
defer m.Close()
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Second)
defer cancel()
line := findLine(t, root, tc.file, tc.callNeedle)
def := defWithRetry(t, ctx, m, tc.file, line, tc.symbol, 150*time.Second)
t.Logf("[%s] definition → %s", tc.lang, def)
if strings.Contains(def, "no definition") || !strings.Contains(def, filepath.Base(tc.file)) {
t.Errorf("%s: definition did not resolve into %s: %s", tc.lang, tc.file, def)
}
if hov, err := m.Hover(ctx, tc.file, line, tc.symbol); err != nil {
t.Errorf("%s hover: %v", tc.lang, err)
} else {
t.Logf("[%s] hover → %s", tc.lang, oneLine(hov))
}
if diag, err := m.Diagnostics(ctx, tc.file); err != nil {
t.Errorf("%s diagnostics: %v", tc.lang, err)
} else {
t.Logf("[%s] diagnostics → %s", tc.lang, oneLine(diag))
}
})
}
}
// defWithRetry polls Definition until it resolves or the budget elapses — some
// servers (rust-analyzer) keep indexing for a while after initialize returns.
func defWithRetry(t *testing.T, ctx context.Context, m *Manager, file string, line int, symbol string, budget time.Duration) string {
end := time.Now().Add(budget)
var last string
for {
d, err := m.Definition(ctx, file, line, symbol)
if err != nil {
// ContentModified means the server is still indexing — retry per LSP §-32801.
if strings.Contains(err.Error(), "content modified") && time.Now().Before(end) {
time.Sleep(time.Second)
continue
}
t.Fatalf("definition: %v", err)
}
last = d
if !strings.Contains(d, "no definition") || time.Now().After(end) {
return last
}
time.Sleep(500 * time.Millisecond)
}
}
func oneLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i] + " …"
}
return s
}
// TestGoplsSmoke drives a real gopls against this module's own source. Run with:
//
// go test -tags manual -run TestGoplsSmoke -v ./internal/lsp/
func TestGoplsSmoke(t *testing.T) {
root := moduleRoot(t)
m := NewManager(root, DefaultSpecs())
defer m.Close()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
managerRel := filepath.Join("internal", "lsp", "manager.go")
callLine := findLine(t, root, managerRel, "err := locate(")
def, err := m.Definition(ctx, managerRel, callLine, "locate")
if err != nil {
t.Fatalf("definition: %v", err)
}
t.Logf("definition →\n%s", def)
if !strings.Contains(def, "position.go") {
t.Errorf("definition of locate should land in position.go, got:\n%s", def)
}
hov, err := m.Hover(ctx, managerRel, callLine, "locate")
if err != nil {
t.Fatalf("hover: %v", err)
}
t.Logf("hover →\n%s", hov)
if !strings.Contains(hov, "func locate") {
t.Errorf("hover should show locate's signature, got:\n%s", hov)
}
diag, err := m.Diagnostics(ctx, filepath.Join("internal", "lsp", "jsonrpc.go"))
if err != nil {
t.Fatalf("diagnostics: %v", err)
}
t.Logf("diagnostics → %s", diag)
if !strings.Contains(diag, "no diagnostics") {
t.Errorf("clean file should have no diagnostics, got: %s", diag)
}
}
func moduleRoot(t *testing.T) string {
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod not found")
}
dir = parent
}
}
func findLine(t *testing.T, root, rel, needle string) int {
b, err := os.ReadFile(filepath.Join(root, rel))
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(string(b), "\n") {
if strings.Contains(line, needle) {
return i + 1
}
}
t.Fatalf("needle %q not found in %s", needle, rel)
return 0
}