| 1 | package lsp |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "runtime" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | func TestDefaultSpecsInvariants(t *testing.T) { |
| 16 | seen := map[string]string{} |
| 17 | for lang, s := range DefaultSpecs() { |
| 18 | if s.Command == "" || s.LanguageID == "" || len(s.Extensions) == 0 { |
| 19 | t.Errorf("lang %q: incomplete spec %+v", lang, s) |
| 20 | } |
| 21 | for _, ext := range s.Extensions { |
| 22 | if prev, dup := seen[ext]; dup { |
| 23 | t.Errorf("extension %q claimed by both %q and %q", ext, prev, lang) |
| 24 | } |
| 25 | seen[ext] = lang |
| 26 | } |
| 27 | } |
| 28 | if seen[".go"] != "go" || seen[".rs"] != "rust" || seen[".cpp"] != "cpp" || seen[".cs"] != "csharp" { |
| 29 | t.Errorf("unexpected routing: %v", seen) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestExtensionRouting(t *testing.T) { |
| 34 | m := NewManager(t.TempDir(), map[string]ServerSpec{ |
| 35 | "elixir": {Command: "no-such-elixir-ls-xyz", LanguageID: "elixir", Extensions: []string{".ex", ".exs"}, InstallHint: "mix archive.install"}, |
| 36 | }) |
| 37 | defer m.Close() |
| 38 | |
| 39 | if _, err := m.resolve("a.ex"); !errors.As(err, new(*notInstalledError)) { |
| 40 | t.Fatalf("configured-but-missing language should yield notInstalledError, got %v", err) |
| 41 | } |
| 42 | _, err := m.resolve("a.go") |
| 43 | if err == nil || !strings.Contains(err.Error(), "no language server") { |
| 44 | t.Fatalf("unconfigured extension should report no server, got %v", err) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestConnBidirectional(t *testing.T) { |
| 49 | caR, caW := io.Pipe() |
| 50 | acR, acW := io.Pipe() |
| 51 | // Close the writers at the end so both readLoop goroutines see EOF and exit |
| 52 | // (in production the subprocess pipe EOFs on kill; here nothing else closes it). |
| 53 | defer caW.Close() |
| 54 | defer acW.Close() |
| 55 | |
| 56 | notif := make(chan string, 4) |
| 57 | var client *conn |
| 58 | client = newConn(caW, acR, |
| 59 | func(method string, _ json.RawMessage) { notif <- method }, |
| 60 | func(id int64, _ string, _ json.RawMessage) { _ = client.reply(id, map[string]any{"ok": true}) }) |
| 61 | |
| 62 | var server *conn |
| 63 | server = newConn(acW, caR, |
| 64 | func(string, json.RawMessage) {}, |
| 65 | func(id int64, method string, _ json.RawMessage) { _ = server.reply(id, map[string]any{"echo": method}) }) |
| 66 | |
| 67 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 68 | defer cancel() |
| 69 | |
| 70 | res, err := client.call(ctx, "ping", map[string]any{"x": 1}) |
| 71 | if err != nil { |
| 72 | t.Fatalf("client call: %v", err) |
| 73 | } |
| 74 | if !strings.Contains(string(res), `"echo":"ping"`) { |
| 75 | t.Fatalf("unexpected response: %s", res) |
| 76 | } |
| 77 | |
| 78 | if err := server.notify("textDocument/publishDiagnostics", map[string]any{}); err != nil { |
| 79 | t.Fatalf("server notify: %v", err) |
| 80 | } |
| 81 | select { |
| 82 | case m := <-notif: |
| 83 | if m != "textDocument/publishDiagnostics" { |
| 84 | t.Fatalf("notify method = %q", m) |
| 85 | } |
| 86 | case <-ctx.Done(): |
| 87 | t.Fatal("notification not delivered") |
| 88 | } |
| 89 | |
| 90 | sres, err := server.call(ctx, "workspace/configuration", nil) |
| 91 | if err != nil { |
| 92 | t.Fatalf("server→client call: %v", err) |
| 93 | } |
| 94 | if !strings.Contains(string(sres), `"ok":true`) { |
| 95 | t.Fatalf("server→client reply: %s", sres) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestReadFrame(t *testing.T) { |
| 100 | in := "Content-Length: 17\r\nContent-Type: x\r\n\r\n" + `{"jsonrpc":"2.0"}` + "Content-Length: 2\r\n\r\n{}" |
| 101 | r := bufio.NewReader(strings.NewReader(in)) |
| 102 | first, err := readFrame(r) |
| 103 | if err != nil || string(first) != `{"jsonrpc":"2.0"}` { |
| 104 | t.Fatalf("first frame = %q, err %v", first, err) |
| 105 | } |
| 106 | second, err := readFrame(r) |
| 107 | if err != nil || string(second) != `{}` { |
| 108 | t.Fatalf("second frame = %q, err %v", second, err) |
| 109 | } |
| 110 | if _, err := readFrame(r); err == nil { |
| 111 | t.Fatal("expected EOF on third read") |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestURIRoundtrip(t *testing.T) { |
| 116 | paths := []string{"/home/u/a b.go", "/x/y.rs"} |
| 117 | if runtime.GOOS == "windows" { |
| 118 | paths = []string{`C:\Users\u\a b.go`, `D:\x\y.rs`} |
| 119 | } |
| 120 | for _, p := range paths { |
| 121 | uri := pathToURI(p) |
| 122 | if !strings.HasPrefix(uri, "file://") { |
| 123 | t.Errorf("%q → %q is not a file URI", p, uri) |
| 124 | } |
| 125 | if got := uriToPath(uri); got != p { |
| 126 | t.Errorf("roundtrip %q → %q", p, got) |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestLocateEncoding(t *testing.T) { |
| 132 | content := "package x\nαβ foo()\n" // line 2 has two 2-byte runes then a space |
| 133 | u16, err := locate(content, 2, "foo", encodingUTF16) |
| 134 | if err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if u16.Line != 1 || u16.Character != 3 { |
| 138 | t.Errorf("utf16 pos = %+v, want line 1 char 3", u16) |
| 139 | } |
| 140 | u8, err := locate(content, 2, "foo", encodingUTF8) |
| 141 | if err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | if u8.Character != 5 { |
| 145 | t.Errorf("utf8 char = %d, want 5", u8.Character) |
| 146 | } |
| 147 | if _, err := locate(content, 2, "missing", encodingUTF16); err == nil { |
| 148 | t.Error("expected not-found error") |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestParseLocations(t *testing.T) { |
| 153 | single := `{"uri":"file:///a","range":{"start":{"line":1,"character":0},"end":{"line":1,"character":2}}}` |
| 154 | if got := parseLocations(json.RawMessage(single)); len(got) != 1 || got[0].URI != "file:///a" { |
| 155 | t.Errorf("single: %+v", got) |
| 156 | } |
| 157 | arr := `[{"uri":"file:///a","range":{}},{"uri":"file:///b","range":{}}]` |
| 158 | if got := parseLocations(json.RawMessage(arr)); len(got) != 2 { |
| 159 | t.Errorf("array: %+v", got) |
| 160 | } |
| 161 | link := `[{"targetUri":"file:///c","targetRange":{"start":{"line":2,"character":0},"end":{"line":2,"character":1}}}]` |
| 162 | got := parseLocations(json.RawMessage(link)) |
| 163 | if len(got) != 1 || got[0].URI != "file:///c" || got[0].Range.Start.Line != 2 { |
| 164 | t.Errorf("locationlink: %+v", got) |
| 165 | } |
| 166 | if parseLocations(json.RawMessage("null")) != nil { |
| 167 | t.Error("null should yield nil") |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestParseHover(t *testing.T) { |
| 172 | markup := `{"contents":{"kind":"markdown","value":"func F()"}}` |
| 173 | if got := parseHover(json.RawMessage(markup)); got != "func F()" { |
| 174 | t.Errorf("markup hover = %q", got) |
| 175 | } |
| 176 | marked := `{"contents":[{"language":"go","value":"func F()"},"docs"]}` |
| 177 | if got := parseHover(json.RawMessage(marked)); got != "func F()\ndocs" { |
| 178 | t.Errorf("marked array hover = %q", got) |
| 179 | } |
| 180 | if got := parseHover(json.RawMessage(`{"contents":""}`)); got != "" { |
| 181 | t.Errorf("empty hover = %q", got) |
| 182 | } |
| 183 | } |
| 184 |