| 1 | package lsp |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "runtime" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | ) |
| 16 | |
| 17 | func TestDefaultSpecsInvariants(t *testing.T) { |
| 18 | seen := map[string]string{} |
| 19 | for lang, s := range DefaultSpecs() { |
| 20 | if s.Command == "" || s.LanguageID == "" || len(s.Extensions) == 0 { |
| 21 | t.Errorf("lang %q: incomplete spec %+v", lang, s) |
| 22 | } |
| 23 | for _, ext := range s.Extensions { |
| 24 | if prev, dup := seen[ext]; dup { |
| 25 | t.Errorf("extension %q claimed by both %q and %q", ext, prev, lang) |
| 26 | } |
| 27 | seen[ext] = lang |
| 28 | } |
| 29 | for _, fb := range s.Fallbacks { |
| 30 | if fb == "" || fb == s.Command { |
| 31 | t.Errorf("lang %q: bad fallback %q", lang, fb) |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | if seen[".go"] != "go" || seen[".rs"] != "rust" || seen[".cpp"] != "cpp" || seen[".cs"] != "csharp" { |
| 36 | t.Errorf("unexpected routing: %v", seen) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func TestExtensionRouting(t *testing.T) { |
| 41 | m := NewManager(t.TempDir(), map[string]ServerSpec{ |
| 42 | "elixir": {Command: "no-such-elixir-ls-xyz", LanguageID: "elixir", Extensions: []string{".ex", ".exs"}, InstallHint: "mix archive.install"}, |
| 43 | }) |
| 44 | defer m.Close() |
| 45 | |
| 46 | if _, err := m.resolve("a.ex"); !errors.As(err, new(*notInstalledError)) { |
| 47 | t.Fatalf("configured-but-missing language should yield notInstalledError, got %v", err) |
| 48 | } |
| 49 | _, err := m.resolve("a.go") |
| 50 | if err == nil || !strings.Contains(err.Error(), "no language server") { |
| 51 | t.Fatalf("unconfigured extension should report no server, got %v", err) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestKotlinDefaultSpec(t *testing.T) { |
| 56 | spec, ok := DefaultSpecs()["kotlin"] |
| 57 | if !ok { |
| 58 | t.Fatal("kotlin default spec missing") |
| 59 | } |
| 60 | if spec.Command != "kotlin-lsp" { |
| 61 | t.Errorf("kotlin Command = %q, want the official PATH name kotlin-lsp", spec.Command) |
| 62 | } |
| 63 | if len(spec.Args) != 1 || spec.Args[0] != "--stdio" { |
| 64 | t.Errorf("kotlin Args = %v, want [--stdio] (client speaks stdio, server defaults to socket)", spec.Args) |
| 65 | } |
| 66 | hasFallback := false |
| 67 | for _, fb := range spec.Fallbacks { |
| 68 | if fb == "intellij-server" { |
| 69 | hasFallback = true |
| 70 | } |
| 71 | } |
| 72 | if !hasFallback { |
| 73 | t.Errorf("kotlin Fallbacks = %v, want intellij-server fallback for the Windows zip layout", spec.Fallbacks) |
| 74 | } |
| 75 | for _, want := range []string{ |
| 76 | "macOS", |
| 77 | "brew install JetBrains/utils/kotlin-lsp", |
| 78 | "Linux", |
| 79 | "kotlin-lsp.sh", |
| 80 | "Windows", |
| 81 | "intellij-server.exe", |
| 82 | } { |
| 83 | if !strings.Contains(spec.InstallHint, want) { |
| 84 | t.Errorf("kotlin InstallHint = %q, want platform guidance containing %q", spec.InstallHint, want) |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestResolveCommandFallback(t *testing.T) { |
| 90 | binDir := t.TempDir() |
| 91 | fake := func(name string) string { |
| 92 | if runtime.GOOS == "windows" { |
| 93 | name += ".exe" |
| 94 | } |
| 95 | path := filepath.Join(binDir, name) |
| 96 | if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | return path |
| 100 | } |
| 101 | spec := ServerSpec{Command: "kotlin-lsp", Fallbacks: []string{"intellij-server"}, InstallHint: "hint"} |
| 102 | t.Setenv("PATH", binDir) // hermetic: the real PATH may already have kotlin-lsp |
| 103 | |
| 104 | // Only the fallback on PATH → it is used. |
| 105 | fallback := fake("intellij-server") |
| 106 | bin, err := resolveCommand(spec) |
| 107 | if err != nil { |
| 108 | t.Fatalf("resolveCommand: %v", err) |
| 109 | } |
| 110 | if bin != fallback { |
| 111 | t.Errorf("resolved %q, want fallback %q", bin, fallback) |
| 112 | } |
| 113 | |
| 114 | // Both on PATH → the primary command wins. |
| 115 | primary := fake("kotlin-lsp") |
| 116 | bin, err = resolveCommand(spec) |
| 117 | if err != nil { |
| 118 | t.Fatalf("resolveCommand with both: %v", err) |
| 119 | } |
| 120 | if bin != primary { |
| 121 | t.Errorf("resolved %q, want primary %q", bin, primary) |
| 122 | } |
| 123 | |
| 124 | // Neither name on PATH surfaces the primary command in the install error. |
| 125 | t.Setenv("PATH", t.TempDir()) |
| 126 | if _, err := resolveCommand(spec); !errors.As(err, new(*notInstalledError)) { |
| 127 | t.Fatalf("expected notInstalledError, got %v", err) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestConnBidirectional(t *testing.T) { |
| 132 | caR, caW := io.Pipe() |
| 133 | acR, acW := io.Pipe() |
| 134 | // Close the writers at the end so both readLoop goroutines see EOF and exit |
| 135 | // (in production the subprocess pipe EOFs on kill; here nothing else closes it). |
| 136 | defer caW.Close() |
| 137 | defer acW.Close() |
| 138 | |
| 139 | notif := make(chan string, 4) |
| 140 | var client *conn |
| 141 | client = newConn(caW, acR, |
| 142 | func(method string, _ json.RawMessage) { notif <- method }, |
| 143 | func(id int64, _ string, _ json.RawMessage) { _ = client.reply(id, map[string]any{"ok": true}) }) |
| 144 | |
| 145 | var server *conn |
| 146 | server = newConn(acW, caR, |
| 147 | func(string, json.RawMessage) {}, |
| 148 | func(id int64, method string, _ json.RawMessage) { _ = server.reply(id, map[string]any{"echo": method}) }) |
| 149 | |
| 150 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 151 | defer cancel() |
| 152 | |
| 153 | res, err := client.call(ctx, "ping", map[string]any{"x": 1}) |
| 154 | if err != nil { |
| 155 | t.Fatalf("client call: %v", err) |
| 156 | } |
| 157 | if !strings.Contains(string(res), `"echo":"ping"`) { |
| 158 | t.Fatalf("unexpected response: %s", res) |
| 159 | } |
| 160 | |
| 161 | if err := server.notify("textDocument/publishDiagnostics", map[string]any{}); err != nil { |
| 162 | t.Fatalf("server notify: %v", err) |
| 163 | } |
| 164 | select { |
| 165 | case m := <-notif: |
| 166 | if m != "textDocument/publishDiagnostics" { |
| 167 | t.Fatalf("notify method = %q", m) |
| 168 | } |
| 169 | case <-ctx.Done(): |
| 170 | t.Fatal("notification not delivered") |
| 171 | } |
| 172 | |
| 173 | sres, err := server.call(ctx, "workspace/configuration", nil) |
| 174 | if err != nil { |
| 175 | t.Fatalf("server→client call: %v", err) |
| 176 | } |
| 177 | if !strings.Contains(string(sres), `"ok":true`) { |
| 178 | t.Fatalf("server→client reply: %s", sres) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestReadFrame(t *testing.T) { |
| 183 | in := "Content-Length: 17\r\nContent-Type: x\r\n\r\n" + `{"jsonrpc":"2.0"}` + "Content-Length: 2\r\n\r\n{}" |
| 184 | r := bufio.NewReader(strings.NewReader(in)) |
| 185 | first, err := readFrame(r) |
| 186 | if err != nil || string(first) != `{"jsonrpc":"2.0"}` { |
| 187 | t.Fatalf("first frame = %q, err %v", first, err) |
| 188 | } |
| 189 | second, err := readFrame(r) |
| 190 | if err != nil || string(second) != `{}` { |
| 191 | t.Fatalf("second frame = %q, err %v", second, err) |
| 192 | } |
| 193 | if _, err := readFrame(r); err == nil { |
| 194 | t.Fatal("expected EOF on third read") |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | func TestURIRoundtrip(t *testing.T) { |
| 199 | paths := []string{"/home/u/a b.go", "/x/y.rs"} |
| 200 | if runtime.GOOS == "windows" { |
| 201 | paths = []string{`C:\Users\u\a b.go`, `D:\x\y.rs`} |
| 202 | } |
| 203 | for _, p := range paths { |
| 204 | uri := pathToURI(p) |
| 205 | if !strings.HasPrefix(uri, "file://") { |
| 206 | t.Errorf("%q → %q is not a file URI", p, uri) |
| 207 | } |
| 208 | if got, err := uriToPath(uri); err != nil || got != p { |
| 209 | t.Errorf("roundtrip %q → %q, %v", p, got, err) |
| 210 | } |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestWindowsFileURIConversions(t *testing.T) { |
| 215 | tests := []struct { |
| 216 | path string |
| 217 | uri string |
| 218 | }{ |
| 219 | {`C:\Users\Test User\中文%20.go`, `file:///C:/Users/Test%20User/%E4%B8%AD%E6%96%87%2520.go`}, |
| 220 | {`\\server\share\Test User\中文%20.go`, `file://server/share/Test%20User/%E4%B8%AD%E6%96%87%2520.go`}, |
| 221 | } |
| 222 | for _, tt := range tests { |
| 223 | if got := pathToURIForOS(tt.path, "windows"); got != tt.uri { |
| 224 | t.Errorf("pathToURIForOS(%q) = %q, want %q", tt.path, got, tt.uri) |
| 225 | } |
| 226 | if got, err := uriToPathForOS(tt.uri, "windows"); err != nil || got != tt.path { |
| 227 | t.Errorf("uriToPathForOS(%q) = %q, %v; want %q", tt.uri, got, err, tt.path) |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | func TestURIToPathAuthorityAndValidation(t *testing.T) { |
| 233 | if got, err := uriToPathForOS("file://localhost/tmp/a%20b%2520.go", "linux"); err != nil || got != "/tmp/a b%20.go" { |
| 234 | t.Fatalf("localhost URI = %q, %v", got, err) |
| 235 | } |
| 236 | for _, uri := range []string{ |
| 237 | "https://server/share/a.go", |
| 238 | "file://server/share/a.go", |
| 239 | "file://server:123/share/a.go", |
| 240 | "file:///tmp/a.go?mode=ro", |
| 241 | "file:///tmp/a.go#fragment", |
| 242 | "file:///tmp/%00.go", |
| 243 | "%", |
| 244 | } { |
| 245 | if _, err := uriToPathForOS(uri, "linux"); err == nil { |
| 246 | t.Errorf("uriToPathForOS(%q) unexpectedly succeeded", uri) |
| 247 | } |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | func TestFormatLocationsKeepsInvalidURIAndSkipsSnippet(t *testing.T) { |
| 252 | root := t.TempDir() |
| 253 | path := filepath.Join(root, "valid.go") |
| 254 | if err := os.WriteFile(path, []byte("package valid\n"), 0o600); err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | m := &Manager{wsRoot: root} |
| 258 | remote := "https://server/share/secret.go" |
| 259 | got := m.formatLocations("definition", []Location{ |
| 260 | {URI: pathToURI(path), Range: Range{Start: Position{Line: 0}}}, |
| 261 | {URI: remote, Range: Range{Start: Position{Line: 6}}}, |
| 262 | }) |
| 263 | if !strings.Contains(got, "valid.go:1 package valid") { |
| 264 | t.Fatalf("valid location lost snippet:\n%s", got) |
| 265 | } |
| 266 | if !strings.Contains(got, remote+":7") || strings.Contains(got, remote+":7 ") { |
| 267 | t.Fatalf("invalid URI was treated as a local path:\n%s", got) |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | func TestLocateEncoding(t *testing.T) { |
| 272 | content := "package x\nαβ foo()\n" // line 2 has two 2-byte runes then a space |
| 273 | u16, err := locate(content, 2, "foo", encodingUTF16) |
| 274 | if err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | if u16.Line != 1 || u16.Character != 3 { |
| 278 | t.Errorf("utf16 pos = %+v, want line 1 char 3", u16) |
| 279 | } |
| 280 | u8, err := locate(content, 2, "foo", encodingUTF8) |
| 281 | if err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | if u8.Character != 5 { |
| 285 | t.Errorf("utf8 char = %d, want 5", u8.Character) |
| 286 | } |
| 287 | if _, err := locate(content, 2, "missing", encodingUTF16); err == nil { |
| 288 | t.Error("expected not-found error") |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func TestParseLocations(t *testing.T) { |
| 293 | single := `{"uri":"file:///a","range":{"start":{"line":1,"character":0},"end":{"line":1,"character":2}}}` |
| 294 | if got := parseLocations(json.RawMessage(single)); len(got) != 1 || got[0].URI != "file:///a" { |
| 295 | t.Errorf("single: %+v", got) |
| 296 | } |
| 297 | arr := `[{"uri":"file:///a","range":{}},{"uri":"file:///b","range":{}}]` |
| 298 | if got := parseLocations(json.RawMessage(arr)); len(got) != 2 { |
| 299 | t.Errorf("array: %+v", got) |
| 300 | } |
| 301 | link := `[{"targetUri":"file:///c","targetRange":{"start":{"line":2,"character":0},"end":{"line":2,"character":1}}}]` |
| 302 | got := parseLocations(json.RawMessage(link)) |
| 303 | if len(got) != 1 || got[0].URI != "file:///c" || got[0].Range.Start.Line != 2 { |
| 304 | t.Errorf("locationlink: %+v", got) |
| 305 | } |
| 306 | if parseLocations(json.RawMessage("null")) != nil { |
| 307 | t.Error("null should yield nil") |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | func TestParseHover(t *testing.T) { |
| 312 | markup := `{"contents":{"kind":"markdown","value":"func F()"}}` |
| 313 | if got := parseHover(json.RawMessage(markup)); got != "func F()" { |
| 314 | t.Errorf("markup hover = %q", got) |
| 315 | } |
| 316 | marked := `{"contents":[{"language":"go","value":"func F()"},"docs"]}` |
| 317 | if got := parseHover(json.RawMessage(marked)); got != "func F()\ndocs" { |
| 318 | t.Errorf("marked array hover = %q", got) |
| 319 | } |
| 320 | if got := parseHover(json.RawMessage(`{"contents":""}`)); got != "" { |
| 321 | t.Errorf("empty hover = %q", got) |
| 322 | } |
| 323 | } |
| 324 |