diff --git a/pkg/server/cache.go b/pkg/server/cache.go index 6628649..c350249 100644 --- a/pkg/server/cache.go +++ b/pkg/server/cache.go @@ -3,6 +3,8 @@ package server import ( "errors" "fmt" + "os" + "strings" "sync" "github.com/google/go-jsonnet/ast" @@ -43,8 +45,6 @@ type cache struct { } // put adds or replaces a document in the cache. -// Documents are only replaced if the new document version is greater than the currently -// cached version. func (c *cache) put(new *document) error { c.mu.Lock() defer c.mu.Unlock() @@ -72,3 +72,49 @@ func (c *cache) get(uri protocol.DocumentURI) (*document, error) { return doc, nil } + +func (c *cache) getContents(uri protocol.DocumentURI, position protocol.Range) (string, error) { + text := "" + doc, err := c.get(uri) + if err == nil { + text = doc.item.Text + } else { + // Read the file from disk (TODO: cache this) + bytes, err := os.ReadFile(uri.SpanURI().Filename()) + if err != nil { + return "", err + } + text = string(bytes) + } + + lines := strings.Split(text, "\n") + if int(position.Start.Line) >= len(lines) { + return "", fmt.Errorf("line %d out of range", position.Start.Line) + } + if int(position.Start.Character) >= len(lines[position.Start.Line]) { + return "", fmt.Errorf("character %d out of range", position.Start.Character) + } + if int(position.End.Line) >= len(lines) { + return "", fmt.Errorf("line %d out of range", position.End.Line) + } + if int(position.End.Character) >= len(lines[position.End.Line]) { + return "", fmt.Errorf("character %d out of range", position.End.Character) + } + + contentBuilder := strings.Builder{} + for i := position.Start.Line; i <= position.End.Line; i++ { + switch i { + case position.Start.Line: + contentBuilder.WriteString(lines[i][position.Start.Character:]) + case position.End.Line: + contentBuilder.WriteString(lines[i][:position.End.Character]) + default: + contentBuilder.WriteString(lines[i]) + } + if i != position.End.Line { + contentBuilder.WriteRune('\n') + } + } + + return contentBuilder.String(), nil +} diff --git a/pkg/server/hover.go b/pkg/server/hover.go index 671fece..f3e95cc 100644 --- a/pkg/server/hover.go +++ b/pkg/server/hover.go @@ -35,27 +35,7 @@ func (s *Server) Hover(_ context.Context, params *protocol.HoverParams) (*protoc return nil, nil } - node := stack.Pop() - - // // DEBUG - // var node2 ast.Node - // if !stack.IsEmpty() { - // _, node2 = stack.Pop() - // } - // r := protocol.Range{ - // Start: protocol.Position{ - // Line: uint32(node.Loc().Begin.Line) - 1, - // Character: uint32(node.Loc().Begin.Column) - 1, - // }, - // End: protocol.Position{ - // Line: uint32(node.Loc().End.Line) - 1, - // Character: uint32(node.Loc().End.Column) - 1, - // }, - // } - // return &protocol.Hover{Range: r, - // Contents: protocol.MarkupContent{Kind: protocol.PlainText, - // Value: fmt.Sprintf("%v: %+v\n\n%v: %+v", reflect.TypeOf(node), node, reflect.TypeOf(node2), node2)}, - // }, nil + node := stack.Peek() _, isIndex := node.(*ast.Index) _, isVar := node.(*ast.Var) @@ -84,5 +64,56 @@ func (s *Server) Hover(_ context.Context, params *protocol.HoverParams) (*protoc } } - return nil, nil + definitionParams := &protocol.DefinitionParams{ + TextDocumentPositionParams: params.TextDocumentPositionParams, + } + definitions, err := findDefinition(doc.ast, definitionParams, s.getVM(doc.item.URI.SpanURI().Filename())) + if err != nil { + log.Debugf("Hover: error finding definition: %s", err) + return nil, nil + } + + if len(definitions) == 0 { + return nil, nil + } + + // Show the contents at the target range + // If there are multiple definitions, show the filenames+line numbers + contentBuilder := strings.Builder{} + for _, def := range definitions { + if len(definitions) > 1 { + header := fmt.Sprintf("%s:%d", def.TargetURI, def.TargetRange.Start.Line+1) + if def.TargetRange.Start.Line != def.TargetRange.End.Line { + header += fmt.Sprintf("-%d", def.TargetRange.End.Line+1) + } + contentBuilder.WriteString(fmt.Sprintf("## `%s`\n", header)) + } + + targetContent, err := s.cache.getContents(def.TargetURI, def.TargetRange) + if err != nil { + log.Debugf("Hover: error reading target content: %s", err) + return nil, nil + } + // Limit the content to 5 lines + if strings.Count(targetContent, "\n") > 5 { + targetContent = strings.Join(strings.Split(targetContent, "\n")[:5], "\n") + "\n..." + } + contentBuilder.WriteString(fmt.Sprintf("```jsonnet\n%s\n```\n", targetContent)) + + if len(definitions) > 1 { + contentBuilder.WriteString("\n") + } + } + + result := &protocol.Hover{ + Contents: protocol.MarkupContent{ + Kind: protocol.Markdown, + Value: contentBuilder.String(), + }, + } + if loc := node.Loc(); loc != nil { + result.Range = position.RangeASTToProtocol(*loc) + } + + return result, nil } diff --git a/pkg/server/hover_test.go b/pkg/server/hover_test.go index a99d27d..d1ffea6 100644 --- a/pkg/server/hover_test.go +++ b/pkg/server/hover_test.go @@ -4,6 +4,7 @@ import ( "context" "io" "os" + "path/filepath" "testing" "github.com/grafana/jsonnet-language-server/pkg/stdlib" @@ -66,7 +67,7 @@ var ( } ) -func TestHover(t *testing.T) { +func TestHoverOnStdLib(t *testing.T) { logrus.SetOutput(io.Discard) var testCases = []struct { @@ -241,3 +242,94 @@ func TestHover(t *testing.T) { }) } } + +func TestHover(t *testing.T) { + logrus.SetOutput(io.Discard) + + testCases := []struct { + name string + filename string + position protocol.Position + expectedContent protocol.Hover + }{ + { + name: "hover on nested attribute", + filename: "testdata/goto-indexes.jsonnet", + position: protocol.Position{Line: 9, Character: 16}, + expectedContent: protocol.Hover{ + Contents: protocol.MarkupContent{ + Kind: protocol.Markdown, + Value: "```jsonnet\nbar: 'innerfoo',\n```\n", + }, + Range: protocol.Range{ + Start: protocol.Position{Line: 9, Character: 5}, + End: protocol.Position{Line: 9, Character: 18}, + }, + }, + }, + { + name: "hover on multi-line string", + filename: "testdata/goto-indexes.jsonnet", + position: protocol.Position{Line: 8, Character: 9}, + expectedContent: protocol.Hover{ + Contents: protocol.MarkupContent{ + Kind: protocol.Markdown, + Value: "```jsonnet\nobj = {\n foo: {\n bar: 'innerfoo',\n },\n bar: 'foo',\n}\n```\n", + }, + Range: protocol.Range{ + Start: protocol.Position{Line: 8, Character: 8}, + End: protocol.Position{Line: 8, Character: 11}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + params := &protocol.HoverParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: protocol.URIFromPath(tc.filename), + }, + Position: tc.position, + }, + } + + server := NewServer("any", "test version", nil, Configuration{ + JPaths: []string{"testdata", filepath.Join(filepath.Dir(tc.filename), "vendor")}, + }) + serverOpenTestFile(t, server, tc.filename) + response, err := server.Hover(context.Background(), params) + + require.NoError(t, err) + assert.Equal(t, &tc.expectedContent, response) + }) + } +} + +func TestHoverGoToDefinitionTests(t *testing.T) { + logrus.SetOutput(io.Discard) + + for _, tc := range definitionTestCases { + t.Run(tc.name, func(t *testing.T) { + params := &protocol.HoverParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: protocol.URIFromPath(tc.filename), + }, + Position: tc.position, + }, + } + + server := NewServer("any", "test version", nil, Configuration{ + JPaths: []string{"testdata", filepath.Join(filepath.Dir(tc.filename), "vendor")}, + }) + serverOpenTestFile(t, server, tc.filename) + response, err := server.Hover(context.Background(), params) + + // We only want to check that it found something. In combination with other tests, we can assume the content is OK. + require.NoError(t, err) + require.NotNil(t, response) + }) + } +}