169 lines
4.8 KiB
Go
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
|
|
}
|