package lsp import ( "context" "encoding/json" "errors" "testing" ) // The three shapes a symbol answer arrives in, written as the servers send // them rather than as Go values, because the decoding is the thing under test. const ( nestedSymbols = `[ {"name":"Server","kind":23, "range":{"start":{"line":8,"character":0},"end":{"line":20,"character":1}}, "selectionRange":{"start":{"line":9,"character":5},"end":{"line":9,"character":11}}, "children":[ {"name":"ServeHTTP","kind":6, "range":{"start":{"line":12,"character":1},"end":{"line":14,"character":2}}, "selectionRange":{"start":{"line":12,"character":18},"end":{"line":12,"character":27}}} ]} ]` flatSymbols = `[ {"name":"Server","kind":23,"containerName":"main", "location":{"uri":"file:///tmp/p/main.go","range":{"start":{"line":9,"character":5}}}}, {"name":"ServeHTTP","kind":6,"containerName":"Server", "location":{"uri":"file:///tmp/p/main.go","range":{"start":{"line":12,"character":18}}}} ]` // A 3.17 server answering with a location that has no range at all. rangelessSymbols = `[ {"name":"Config","kind":23,"location":{"uri":"file:///tmp/p/config.go"}} ]` ) func TestNestedDocumentSymbolsAreFlattenedInFileOrder(t *testing.T) { symbols, err := decodeDocumentSymbols(json.RawMessage(nestedSymbols), "file:///tmp/p/main.go") if err != nil { t.Fatalf("decodeDocumentSymbols() error = %v", err) } if len(symbols) != 2 { t.Fatalf("got %d symbols, want the type and its method", len(symbols)) } if symbols[0].Name != "Server" || symbols[1].Name != "ServeHTTP" { t.Errorf("got %q then %q, want them in the order they appear in the file", symbols[0].Name, symbols[1].Name) } if symbols[0].Depth != 0 || symbols[1].Depth != 1 { t.Errorf("depths are %d and %d, want 0 then 1 so a caller can indent", symbols[0].Depth, symbols[1].Depth) } if symbols[1].Container != "Server" { t.Errorf("the method's container is %q, want the type it hangs off", symbols[1].Container) } } func TestANestedSymbolIsLocatedByItsNameNotItsWholeDeclaration(t *testing.T) { // range covers the doc comment and the body; selectionRange is the name. // Jumping to a function must put the cursor on the function, not several // lines above it. symbols, err := decodeDocumentSymbols(json.RawMessage(nestedSymbols), "file:///tmp/p/main.go") if err != nil { t.Fatalf("decodeDocumentSymbols() error = %v", err) } if got := symbols[0].Location.Range.Start; got.Line != 9 || got.Character != 5 { t.Errorf("Server is at %+v, want the selectionRange at 9:5, not the range at 8:0", got) } } func TestNestedSymbolsInheritTheFileTheyWereAskedAbout(t *testing.T) { // The nested shape carries no URI: every symbol is in the file requested. // Losing it means every jump goes nowhere. symbols, err := decodeDocumentSymbols(json.RawMessage(nestedSymbols), "file:///tmp/p/main.go") if err != nil { t.Fatalf("decodeDocumentSymbols() error = %v", err) } for _, symbol := range symbols { if symbol.Location.URI != "file:///tmp/p/main.go" { t.Errorf("%s has URI %q, want the file it was found in", symbol.Name, symbol.Location.URI) } } } func TestFlatDocumentSymbolsAreReadToo(t *testing.T) { // Still legal, and still sent by older servers. symbols, err := decodeDocumentSymbols(json.RawMessage(flatSymbols), "file:///tmp/p/main.go") if err != nil { t.Fatalf("decodeDocumentSymbols() error = %v", err) } if len(symbols) != 2 { t.Fatalf("got %d symbols, want 2", len(symbols)) } if symbols[1].Container != "Server" { t.Errorf("container = %q, want the containerName the flat shape carries", symbols[1].Container) } if got := symbols[0].Location.Range.Start.Line; got != 9 { t.Errorf("Server is on line %d, want 9", got) } } func TestTheTwoShapesAreToldApartByAFieldOnlyOneHas(t *testing.T) { // "children" is optional, so a file whose symbols happen to have none // would be read as flat — and every symbol would lose its position. // "selectionRange" is the discriminator for that reason. const nestedWithoutChildren = `[ {"name":"main","kind":12, "range":{"start":{"line":3,"character":0}}, "selectionRange":{"start":{"line":3,"character":5}}} ]` symbols, err := decodeDocumentSymbols(json.RawMessage(nestedWithoutChildren), "file:///tmp/p/main.go") if err != nil { t.Fatalf("decodeDocumentSymbols() error = %v", err) } if len(symbols) != 1 { t.Fatalf("got %d symbols, want 1", len(symbols)) } if got := symbols[0].Location.Range.Start; got.Line != 3 || got.Character != 5 { t.Errorf("main is at %+v, want 3:5 — the nested shape was read as flat", got) } } func TestAnEmptyOrNullSymbolAnswerIsNotAnError(t *testing.T) { // A file with nothing in it, and a server that has nothing to say, are // both ordinary. for _, raw := range []string{"", "null", "[]"} { symbols, err := decodeDocumentSymbols(json.RawMessage(raw), "file:///tmp/p/main.go") if err != nil { t.Errorf("decodeDocumentSymbols(%q) error = %v", raw, err) } if len(symbols) != 0 { t.Errorf("decodeDocumentSymbols(%q) returned %d symbols", raw, len(symbols)) } } } func TestAWorkspaceSymbolWithNoRangeIsKept(t *testing.T) { // Since 3.17 a server may answer "I know which file, ask me later for // where". There is no asking later here, and the top of the right file // beats no answer at all. symbols, err := decodeWorkspaceSymbols(json.RawMessage(rangelessSymbols)) if err != nil { t.Fatalf("decodeWorkspaceSymbols() error = %v", err) } if len(symbols) != 1 { t.Fatalf("got %d symbols, want the one with no range", len(symbols)) } if symbols[0].Location.URI != "file:///tmp/p/config.go" { t.Errorf("URI = %q, want the file the server named", symbols[0].Location.URI) } } func TestSymbolRequestsGoOutUnderTheirOwnNames(t *testing.T) { for _, request := range []struct { method string ask func(*Client) ([]Symbol, error) }{ {"textDocument/documentSymbol", func(c *Client) ([]Symbol, error) { return c.DocumentSymbols(context.Background(), "main.go") }}, {"workspace/symbol", func(c *Client) ([]Symbol, error) { return c.WorkspaceSymbols(context.Background(), "Server") }}, } { t.Run(request.method, func(t *testing.T) { client, server := newFakeServer(t) server.setHandler(func(method string, _ json.RawMessage) (any, *ResponseError) { if method != request.method { return nil, nil } return json.RawMessage(flatSymbols), nil }) mustInitialize(t, client) symbols, err := request.ask(client) if err != nil { t.Fatalf("error = %v", err) } if len(symbols) != 2 { t.Errorf("got %d symbols, want the two the server answered to %s", len(symbols), request.method) } }) } } func TestSymbolRequestsRefuseBeforeTheServerIsReady(t *testing.T) { client, _ := newFakeServer(t) if _, err := client.DocumentSymbols(t.Context(), "main.go"); !errors.Is(err, ErrNotReady) { t.Errorf("DocumentSymbols() error = %v, want ErrNotReady", err) } if _, err := client.WorkspaceSymbols(t.Context(), "x"); !errors.Is(err, ErrNotReady) { t.Errorf("WorkspaceSymbols() error = %v, want ErrNotReady", err) } } func TestASymbolKindWithNoTagIsDrawnWithoutOne(t *testing.T) { if got := SymbolMethod.String(); got != "method" { t.Errorf("SymbolMethod.String() = %q, want %q", got, "method") } if got := SymbolKind(999).String(); got != "" { t.Errorf("an unknown kind reads %q, want the empty tag rather than a number", got) } }