diff --git a/langserver/definition.go b/langserver/definition.go index 54d9271c..5d235f50 100644 --- a/langserver/definition.go +++ b/langserver/definition.go @@ -7,7 +7,6 @@ import ( "go/ast" "go/build" "go/token" - "io/ioutil" "log" "path/filepath" @@ -23,7 +22,8 @@ var UseBinaryPkgCache = false func (h *LangHandler) handleDefinition(ctx context.Context, conn jsonrpc2.JSONRPC2, req *jsonrpc2.Request, params lsp.TextDocumentPositionParams) ([]lsp.Location, error) { if UseBinaryPkgCache { - return h.handleDefinitionGodef(ctx, conn, req, params) + _, _, locs, err := h.definitionGodef(ctx, params) + return locs, err } res, err := h.handleXDefinition(ctx, conn, req, params) @@ -37,28 +37,38 @@ func (h *LangHandler) handleDefinition(ctx context.Context, conn jsonrpc2.JSONRP return locs, nil } -func (h *LangHandler) handleDefinitionGodef(ctx context.Context, conn jsonrpc2.JSONRPC2, req *jsonrpc2.Request, params lsp.TextDocumentPositionParams) ([]lsp.Location, error) { +var testOSToVFSPath func(osPath string) string + +func (h *LangHandler) definitionGodef(ctx context.Context, params lsp.TextDocumentPositionParams) (*token.FileSet, *godef.Result, []lsp.Location, error) { + // In the case of testing, our OS paths and VFS paths do not match. In the + // real world, this is never the case. Give the test suite the opportunity + // to correct the path now. + vfsURI := params.TextDocument.URI + if testOSToVFSPath != nil { + vfsURI = pathToURI(testOSToVFSPath(uriToFilePath(vfsURI))) + } + // Read file contents and calculate byte offset. - filename := h.FilePath(params.TextDocument.URI) - contents, err := ioutil.ReadFile(filename) + contents, err := h.readFile(ctx, vfsURI) if err != nil { - return nil, err + return nil, nil, nil, err } + filename := h.FilePath(params.TextDocument.URI) offset, valid, why := offsetForPosition(contents, params.Position) if !valid { - return nil, fmt.Errorf("invalid position: %s:%d:%d (%s)", filename, params.Position.Line, params.Position.Character, why) + return nil, nil, nil, fmt.Errorf("invalid position: %s:%d:%d (%s)", filename, params.Position.Line, params.Position.Character, why) } // Invoke godef to determine the position of the definition. fset := token.NewFileSet() res, err := godef.Godef(fset, offset, filename, contents) if err != nil { - return nil, err + return nil, nil, nil, err } if res.Package != nil { // TODO: return directory location. This right now at least matches our // other implementation. - return []lsp.Location{}, nil + return fset, res, []lsp.Location{}, nil } loc := goRangeToLSPLocation(fset, res.Start, res.End) @@ -69,7 +79,8 @@ func (h *LangHandler) handleDefinitionGodef(ctx context.Context, conn jsonrpc2.J loc.URI = pathToURI(filepath.Join(build.Default.GOROOT, "/src/builtin/builtin.go")) loc.Range = lsp.Range{} } - return []lsp.Location{loc}, nil + + return fset, res, []lsp.Location{loc}, nil } func (h *LangHandler) handleXDefinition(ctx context.Context, conn jsonrpc2.JSONRPC2, req *jsonrpc2.Request, params lsp.TextDocumentPositionParams) ([]symbolLocationInformation, error) { diff --git a/langserver/hover.go b/langserver/hover.go index ca145fa3..85538ad6 100644 --- a/langserver/hover.go +++ b/langserver/hover.go @@ -6,8 +6,12 @@ import ( "fmt" "go/ast" "go/build" + "go/format" + "go/parser" "go/token" "go/types" + "path/filepath" + "sort" "strings" doc "github.com/slimsag/godocmd" @@ -16,6 +20,10 @@ import ( ) func (h *LangHandler) handleHover(ctx context.Context, conn jsonrpc2.JSONRPC2, req *jsonrpc2.Request, params lsp.TextDocumentPositionParams) (*lsp.Hover, error) { + if UseBinaryPkgCache { + return h.handleHoverGodef(ctx, conn, req, params) + } + if !isFileURI(params.TextDocument.URI) { return nil, &jsonrpc2.Error{ Code: jsonrpc2.CodeInvalidParams, @@ -245,3 +253,272 @@ func prettyPrintTypesString(s string) string { } return b.String() } + +func (h *LangHandler) handleHoverGodef(ctx context.Context, conn jsonrpc2.JSONRPC2, req *jsonrpc2.Request, params lsp.TextDocumentPositionParams) (*lsp.Hover, error) { + // First perform the equivalent of a textDocument/definition request in + // order to resolve the definition position. + fset, res, _, err := h.definitionGodef(ctx, params) + if err != nil { + return nil, err + } + + // If our target is a package import statement or package selector, then we + // handle that separately now. + if res.Package != nil { + // res.Package.Name is invalid since it was imported with FindOnly, so + // import normally now. + bpkg, err := build.Default.ImportDir(res.Package.Dir, 0) + if err != nil { + return nil, err + } + + // Parse the entire dir into its respective AST packages. + pkgs, err := parser.ParseDir(fset, res.Package.Dir, nil, parser.ParseComments) + if err != nil { + return nil, err + } + pkg := pkgs[bpkg.Name] + + // Find the package doc comments. + pkgFiles := make([]*ast.File, 0, len(pkg.Files)) + for _, f := range pkg.Files { + pkgFiles = append(pkgFiles, f) + } + comments := packageDoc(pkgFiles, bpkg.Name) + + return &lsp.Hover{ + Contents: maybeAddComments(comments, []lsp.MarkedString{{Language: "go", Value: fmt.Sprintf("package %s (%q)", bpkg.Name, bpkg.ImportPath)}}), + + // TODO(slimsag): I think we can add Range here, but not exactly + // sure. res.Start and res.End are only present if it's a package + // selector, not an import statement. Since Range is optional, + // we're omitting it here. + }, nil + } + + loc := goRangeToLSPLocation(fset, res.Start, res.End) + + if loc.URI == "file://" { + // TODO: builtins do not have valid URIs or locations. + return &lsp.Hover{}, nil + } + + filename := uriToFilePath(loc.URI) + + // Parse the entire dir into its respective AST packages. + pkgs, err := parser.ParseDir(fset, filepath.Dir(filename), nil, parser.ParseComments) + if err != nil { + return nil, err + } + + // Locate the AST package that contains the file we're interested in. + foundImportPath, foundPackage, err := packageForFile(pkgs, filename) + if err != nil { + return nil, err + } + + // Create documentation for the package. + docPkg := doc.New(foundPackage, foundImportPath, doc.AllDecls) + + // Locate the target in the docs. + target := fset.Position(res.Start) + docObject := findDocTarget(fset, target, docPkg) + if docObject == nil { + return nil, fmt.Errorf("failed to find doc object for %s", target) + } + + contents, node := fmtDocObject(fset, docObject, target) + r := rangeForNode(fset, node) + return &lsp.Hover{ + Contents: contents, + Range: &r, + }, nil +} + +// packageForFile returns the import path and pkg from pkgs that contains the +// named file. +func packageForFile(pkgs map[string]*ast.Package, filename string) (string, *ast.Package, error) { + for path, pkg := range pkgs { + for pkgFile := range pkg.Files { + if pkgFile == filename { + return path, pkg, nil + } + } + } + return "", nil, fmt.Errorf("failed to find %q in packages %q", filename, pkgs) +} + +// inRange tells if x is in the range of a-b inclusive. +func inRange(x, a, b token.Position) bool { + if x.Filename != a.Filename || x.Filename != b.Filename { + return false + } + return x.Offset >= a.Offset && x.Offset <= b.Offset +} + +// findDocTarget walks an input *doc.Package and locates the *doc.Value, +// *doc.Type, or *doc.Func for the given target position. +func findDocTarget(fset *token.FileSet, target token.Position, in interface{}) interface{} { + switch v := in.(type) { + case *doc.Package: + for _, x := range v.Consts { + if r := findDocTarget(fset, target, x); r != nil { + return r + } + } + for _, x := range v.Types { + if r := findDocTarget(fset, target, x); r != nil { + return r + } + } + for _, x := range v.Vars { + if r := findDocTarget(fset, target, x); r != nil { + return r + } + } + for _, x := range v.Funcs { + if r := findDocTarget(fset, target, x); r != nil { + return r + } + } + return nil + case *doc.Value: + if inRange(target, fset.Position(v.Decl.Pos()), fset.Position(v.Decl.End())) { + return v + } + return nil + case *doc.Type: + if inRange(target, fset.Position(v.Decl.Pos()), fset.Position(v.Decl.End())) { + return v + } + return nil + case *doc.Func: + if inRange(target, fset.Position(v.Decl.Pos()), fset.Position(v.Decl.End())) { + return v + } + return nil + default: + panic("unreachable") + } +} + +// fmtDocObject formats one of: +// +// *doc.Value +// *doc.Type +// *doc.Func +// +func fmtDocObject(fset *token.FileSet, x interface{}, target token.Position) ([]lsp.MarkedString, ast.Node) { + switch v := x.(type) { + case *doc.Value: // Vars and Consts + // Sort the specs by distance to find the one nearest to target. + sort.Sort(byDistance{v.Decl.Specs, fset, target}) + spec := v.Decl.Specs[0].(*ast.ValueSpec) + + // Use the doc directly above the var inside a var() block, or if there + // is none, fall back to the doc directly above the var() block. + doc := spec.Doc.Text() + if doc == "" { + doc = v.Doc + } + + // Create a copy of the spec with no doc for formatting separately. + cpy := *spec + cpy.Doc = nil + value := v.Decl.Tok.String() + " " + fmtNode(fset, &cpy) + return maybeAddComments(doc, []lsp.MarkedString{{Language: "go", Value: value}}), spec + + case *doc.Type: // Type declarations + spec := v.Decl.Specs[0].(*ast.TypeSpec) + + // Handle interfaces methods and struct fields separately now. + switch s := spec.Type.(type) { + case *ast.InterfaceType: + // Find the method that is an exact match for our target position. + for _, field := range s.Methods.List { + if fset.Position(field.Pos()).Offset == target.Offset { + // An exact match. + value := fmt.Sprintf("func (%s).%s%s", spec.Name.Name, field.Names[0].Name, strings.TrimPrefix(fmtNode(fset, field.Type), "func")) + return maybeAddComments(field.Doc.Text(), []lsp.MarkedString{{Language: "go", Value: value}}), field + } + } + + case *ast.StructType: + // Find the field that is an exact match for our target position. + for _, field := range s.Fields.List { + if fset.Position(field.Pos()).Offset == target.Offset { + // An exact match. + value := fmt.Sprintf("struct field %s %s", field.Names[0], fmtNode(fset, field.Type)) + return maybeAddComments(field.Doc.Text(), []lsp.MarkedString{{Language: "go", Value: value}}), field + } + } + } + + // Formatting of all type declarations: structs, interfaces, integers, etc. + name := v.Decl.Tok.String() + " " + spec.Name.Name + " " + typeName(fset, spec.Type) + res := []lsp.MarkedString{{Language: "go", Value: name}} + + doc := spec.Doc.Text() + if doc == "" { + doc = v.Doc + } + res = maybeAddComments(doc, res) + + if n := typeName(fset, spec.Type); n == "interface" || n == "struct" { + res = append(res, lsp.MarkedString{Language: "go", Value: fmtNode(fset, spec.Type)}) + } + return res, spec + + case *doc.Func: // Functions + return maybeAddComments(v.Doc, []lsp.MarkedString{{Language: "go", Value: fmtNode(fset, v.Decl)}}), v.Decl + default: + panic("unreachable") + } +} + +// typeName returns the name of typ, shortening interface and struct types to +// just "interface" and "struct" rather than their full contents (incl. methods +// and fields). +func typeName(fset *token.FileSet, typ ast.Expr) string { + switch typ.(type) { + case *ast.InterfaceType: + return "interface" + case *ast.StructType: + return "struct" + default: + return fmtNode(fset, typ) + } +} + +// fmtNode formats the given node as a string. +func fmtNode(fset *token.FileSet, n ast.Node) string { + var buf bytes.Buffer + err := format.Node(&buf, fset, n) + if err != nil { + panic("unreachable") + } + return buf.String() +} + +// byDistance sorts specs by distance to the target position. +type byDistance struct { + specs []ast.Spec + fset *token.FileSet + target token.Position +} + +func (b byDistance) Len() int { return len(b.specs) } +func (b byDistance) Swap(i, j int) { b.specs[i], b.specs[j] = b.specs[j], b.specs[i] } +func (b byDistance) Less(ii, jj int) bool { + i := b.fset.Position(b.specs[ii].Pos()) + j := b.fset.Position(b.specs[jj].Pos()) + return abs(b.target.Offset-i.Offset) < abs(b.target.Offset-j.Offset) +} + +// abs returns the absolute value of x. +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/langserver/internal/godef/godef.go b/langserver/internal/godef/godef.go index e9908fa1..de3c6ae6 100644 --- a/langserver/internal/godef/godef.go +++ b/langserver/internal/godef/godef.go @@ -26,7 +26,8 @@ type Result struct { // Start and end positions of the definition (only if not an import statement). Start, End token.Pos - // Package in question (only if an import statement). + // Package in question, only present if an import statement OR package selector + // ('http' in 'http.Router'). Package *build.Package } @@ -53,11 +54,26 @@ func Godef(fset *token.FileSet, offset int, filename string, src []byte) (*Resul } return &Result{Package: pkg}, nil case ast.Expr: + result := func(obj *ast.Object) (*Result, error) { + p := types.DeclPos(obj) + r := &Result{Start: p, End: p + token.Pos(len(obj.Name))} + if imp, ok := obj.Decl.(*ast.ImportSpec); ok { + path, err := importPath(imp) + if err != nil { + return nil, err + } + pkg, err := build.Default.Import(path, filepath.Dir(fset.Position(p).Filename), build.FindOnly) + if err != nil { + return nil, fmt.Errorf("error finding import path for %s: %s", path, err) + } + r.Package = pkg + } + return r, nil + } importer := types.DefaultImporter(fset) // try local declarations only if obj, _ := types.ExprType(e, importer, fset); obj != nil { - p := types.DeclPos(obj) - return &Result{Start: p, End: p + token.Pos(len(obj.Name))}, nil + return result(obj) } // add declarations from other files in the local package and try again @@ -66,8 +82,7 @@ func Godef(fset *token.FileSet, offset int, filename string, src []byte) (*Resul log.Printf("parseLocalPackage error: %v\n", err) } if obj, _ := types.ExprType(e, importer, fset); obj != nil { - p := types.DeclPos(obj) - return &Result{Start: p, End: p + token.Pos(len(obj.Name))}, nil + return result(obj) } return nil, fmt.Errorf("no declaration found for %v", pretty{fset, e}) } diff --git a/langserver/langserver_test.go b/langserver/langserver_test.go index 7a017d75..d497ad17 100644 --- a/langserver/langserver_test.go +++ b/langserver/langserver_test.go @@ -40,6 +40,13 @@ func TestServer(t *testing.T) { "b.go": "package p; func B() { A() }", }, cases: lspTestCases{ + overrideGodefHover: map[string]string{ + //"a.go:1:9": "package p", // TODO(slimsag): sub-optimal "no declaration found for p" + "a.go:1:17": "func A()", + "a.go:1:23": "func A()", + "b.go:1:17": "func B()", + "b.go:1:23": "func A()", + }, wantHover: map[string]string{ "a.go:1:9": "package p", "a.go:1:17": "func A()", @@ -127,6 +134,12 @@ func TestServer(t *testing.T) { "a.go": "package p; type T struct { F string }", }, cases: lspTestCases{ + overrideGodefHover: map[string]string{ + // "a.go:1:28": "(T).F string", // TODO(sqs): see golang/hover.go; this is the output we want + "a.go:1:28": "struct field F string", + "a.go:1:17": `type T struct; struct{ F string }`, + }, + wantHover: map[string]string{ // "a.go:1:28": "(T).F string", // TODO(sqs): see golang/hover.go; this is the output we want "a.go:1:28": "struct field F string", @@ -171,6 +184,14 @@ func TestServer(t *testing.T) { "b_test.go": "package p; func Y() int { return X }", }, cases: lspTestCases{ + overrideGodefHover: map[string]string{ + "a.go:1:16": "var A int", + "x_test.go:1:40": "var X = p.A", + "x_test.go:1:46": "var A int", + "a_test.go:1:16": "var X = A", + "a_test.go:1:20": "var A int", + }, + wantHover: map[string]string{ "a.go:1:16": "var A int", "x_test.go:1:40": "var X int", @@ -226,6 +247,10 @@ func TestServer(t *testing.T) { "c/c.go": `package c; import "test/pkg/b"; var X = b.B;`, }, cases: lspTestCases{ + overrideGodefHover: map[string]string{ + "a_test.go:1:37": "var X = b.B", + "a_test.go:1:43": "var B int", + }, wantHover: map[string]string{ "a_test.go:1:37": "var X int", "a_test.go:1:43": "var B int", @@ -398,6 +423,10 @@ package main; import "test/pkg"; func B() { p.A(); B() }`, }, }, cases: lspTestCases{ + overrideGodefHover: map[string]string{ + "a.go:1:40": "func Println(a ...interface{}) (n int, err error); Println formats using the default formats for its operands and writes to standard output. Spaces are always added between operands and a newline is appended. It returns the number of bytes written and any write error encountered. \n\n", + // "a.go:1:53": "type int int", + }, wantHover: map[string]string{ "a.go:1:40": "func Println(a ...interface{}) (n int, err error)", // "a.go:1:53": "type int int", @@ -673,6 +702,10 @@ package main; import "test/pkg"; func B() { p.A(); B() }`, }, }, cases: lspTestCases{ + overrideGodefHover: map[string]string{ + "a.go:1:53": "func D1() dep2.D2", + "a.go:1:59": "struct field D2 int", + }, wantHover: map[string]string{ "a.go:1:53": "func D1() D2", "a.go:1:59": "struct field D2 int", @@ -789,6 +822,19 @@ type Header struct { `, }, cases: lspTestCases{ + overrideGodefHover: map[string]string{ + //"a.go:7:9": "package p; Package p is a package with lots of great things. \n\n", // TODO(slimsag): sub-optimal "no declaration found for p" + //"a.go:9:9": "", TODO: handle hovering on import statements (ast.BasicLit) + "a.go:12:5": "var logit = pkg2.X; logit is pkg2.X \n\n", + "a.go:12:13": "package pkg2 (\"test/pkg/vendor/github.com/a/pkg2\"); Package pkg2 shows dependencies. \n\nHow to \n\n```\nExample Code!\n\n```\n", + "a.go:12:18": "func X(); X does the unknown. \n\n", + "a.go:15:6": "type T struct; T is a struct. \n\n; struct {\n\t// F is a string field.\n\tF string\n\n\t// H is a header.\n\tH pkg2.Header\n}", + "a.go:17:2": "struct field F string; F is a string field. \n\n", + "a.go:20:2": "struct field H pkg2.Header; H is a header. \n\n", + "a.go:20:4": "package pkg2 (\"test/pkg/vendor/github.com/a/pkg2\"); Package pkg2 shows dependencies. \n\nHow to \n\n```\nExample Code!\n\n```\n", + "a.go:24:5": "var Foo string; Foo is the best string. \n\n", + "a.go:31:2": "var I2 = 3; I2 is an int \n\n", + }, wantHover: map[string]string{ "a.go:7:9": "package p; Package p is a package with lots of great things. \n\n", //"a.go:9:9": "", TODO: handle hovering on import statements (ast.BasicLit) @@ -972,7 +1018,7 @@ func dialServer(t testing.TB, addr string) *jsonrpc2.Conn { } type lspTestCases struct { - wantHover map[string]string + wantHover, overrideGodefHover map[string]string wantDefinition, overrideGodefDefinition map[string]string wantXDefinition map[string]string wantReferences map[string][]string @@ -1035,13 +1081,17 @@ func lspTests(t testing.TB, ctx context.Context, fs *AtomicFS, c *jsonrpc2.Conn, }) } - // Godef-based definition testing + // Godef-based definition & hover testing wantGodefDefinition := cases.overrideGodefDefinition if len(wantGodefDefinition) == 0 { wantGodefDefinition = cases.wantDefinition } + wantGodefHover := cases.overrideGodefHover + if len(wantGodefHover) == 0 { + wantGodefHover = cases.wantHover + } - if len(wantGodefDefinition) > 0 { + if len(wantGodefDefinition) > 0 || (len(wantGodefHover) > 0 && fs != nil) { UseBinaryPkgCache = true // Copy the VFS into a temp directory, which will be our $GOPATH. @@ -1071,6 +1121,10 @@ func lspTests(t testing.TB, ctx context.Context, fs *AtomicFS, c *jsonrpc2.Conn, } t.Logf("$ go install -v ...\n%s", out) + testOSToVFSPath = func(osPath string) string { + return strings.TrimPrefix(osPath, tmpDir) + } + // Run the tests. for pos, want := range wantGodefDefinition { if strings.HasPrefix(want, "/goroot") { @@ -1080,6 +1134,11 @@ func lspTests(t testing.TB, ctx context.Context, fs *AtomicFS, c *jsonrpc2.Conn, definitionTest(t, ctx, c, tmpRootPath, pos, want, tmpDir) }) } + for pos, want := range wantGodefHover { + tbRun(t, fmt.Sprintf("godef-hover-%s", strings.Replace(pos, "/", "-", -1)), func(t testing.TB) { + hoverTest(t, ctx, c, tmpRootPath, pos, want) + }) + } UseBinaryPkgCache = false }