From 66473cdf882927a01a0c3d7bd63584ebb8cc20fd Mon Sep 17 00:00:00 2001 From: Hariom Verma Date: Fri, 22 Sep 2023 06:48:57 +0530 Subject: [PATCH] Handle code completion and hover --- internal/lsp/completion.go | 251 +++++++++++++++++++++++++++++++++++++ internal/lsp/hover.go | 97 ++++++++++++++ internal/lsp/server.go | 6 + internal/lsp/util.go | 68 ++++++++++ 4 files changed, 422 insertions(+) create mode 100644 internal/lsp/completion.go create mode 100644 internal/lsp/hover.go diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go new file mode 100644 index 0000000..2b917c7 --- /dev/null +++ b/internal/lsp/completion.go @@ -0,0 +1,251 @@ +package lsp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" +) + +type CompletionStore struct { + time time.Time + + pkgs []*Package +} + +func (cs *CompletionStore) lookupPkg(pkg string) *Package { + for _, p := range cs.pkgs { + if p.Name == pkg { + return p + } + } + return nil +} + +func (cs *CompletionStore) lookupSymbol(pkg, symbol string) *Symbol { + for _, p := range cs.pkgs { + if p.Name == pkg { + for _, s := range p.Symbols { + if s.Name == symbol { + return s + } + } + } + } + return nil +} + +func (cs *CompletionStore) lookupSymbolByImports(symbol string, imports []*ast.ImportSpec) *Symbol { + for _, spec := range imports { + value := spec.Path.Value + + value = value[1 : len(value)-1] // remove quotes + value = value[strings.LastIndex(value, "/")+1:] // get last part + + s := cs.lookupSymbol(value, symbol) + if s != nil { + return s + } + } + + return nil +} + +type Package struct { + Name string + ImportPath string + Symbols []*Symbol +} + +type Symbol struct { + Name string + Doc string + Signature string + Kind string +} + +func (s Symbol) String() string { + return fmt.Sprintf("```gno\n%s\n```\n\n%s", s.Signature, s.Doc) +} + +func (s *server) Completion(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + var params protocol.CompletionParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return sendParseError(ctx, reply, err) + } + + uri := params.TextDocument.URI + file, ok := s.snapshot.Get(uri.Filename()) + if !ok { + return reply(ctx, nil, errors.New("snapshot not found")) + } + + items := []protocol.CompletionItem{} + + token, err := file.TokenAt(params.Position) + if err != nil { + return reply(ctx, nil, err) + } + text := strings.TrimSuffix(strings.TrimSpace(token.Text), ".") + slog.Info("completion", "text", text) + + // TODO: + // pgf, err := file.ParseGno(ctx) + // path, e := astutil.PathEnclosingInterval(pgf.File, 13, 8) + + pkg := s.completionStore.lookupPkg(text) + if pkg != nil { + for _, s := range pkg.Symbols { + items = append(items, protocol.CompletionItem{ + Label: s.Name, + InsertText: s.Name, + Kind: symbolToKind(s.Kind), + Detail: s.Signature, + Documentation: s.Doc, + }) + } + } + + return reply(ctx, items, err) +} + +func InitCompletionStore(dirs []string) *CompletionStore { + pkgs := []*Package{} + + if len(dirs) == 0 { + return &CompletionStore{ + pkgs: pkgs, + time: time.Now(), + } + } + + pkgDirs, err := ListGnoPackages(dirs) + if err != nil { + panic(err) + } + + for _, p := range pkgDirs { + files, err := ListGnoFiles(p) + if err != nil { + panic(err) + } + symbols := []*Symbol{} + for _, file := range files { + symbols = append(symbols, getSymbols(file)...) + } + // convert to import path: + // get path relative to dir, and convert separators to slashes. + ip := strings.ReplaceAll( + strings.TrimPrefix(p, p+string(filepath.Separator)), + string(filepath.Separator), "/", + ) + + pkgs = append(pkgs, &Package{ + Name: filepath.Base(p), + ImportPath: ip, + Symbols: symbols, + }) + } + + return &CompletionStore{ + pkgs: pkgs, + time: time.Now(), + } +} + +func getSymbols(fname string) []*Symbol { + var symbols []*Symbol + + // Create a FileSet to work with. + fset := token.NewFileSet() + + // Parse the file and create an AST. + file, err := parser.ParseFile(fset, fname, nil, parser.ParseComments) + if err != nil { + panic(err) + } + + bsrc, err := os.ReadFile(fname) + if err != nil { + panic(err) + } + text := string(bsrc) + + // Trim AST to exported declarations only. + ast.FileExports(file) + + ast.Inspect(file, func(n ast.Node) bool { + var found *Symbol + + switch n.(type) { + case *ast.FuncDecl: + found = function(n, text) + case *ast.GenDecl: + found = declaration(n, text) + } + + if found != nil { + symbols = append(symbols, found) + } + + return true + }) + + return symbols +} + +func declaration(n ast.Node, source string) *Symbol { + sym, _ := n.(*ast.GenDecl) + + for _, spec := range sym.Specs { + switch t := spec.(type) { + case *ast.TypeSpec: + return &Symbol{ + Name: t.Name.Name, + Doc: sym.Doc.Text(), + Signature: strings.Split(source[t.Pos()-1:t.End()-1], " {")[0], + Kind: typeName(*t), + } + } + } + + return nil +} + +func function(n ast.Node, source string) *Symbol { + sym, _ := n.(*ast.FuncDecl) + return &Symbol{ + Name: sym.Name.Name, + Doc: sym.Doc.Text(), + Signature: strings.Split(source[sym.Pos()-1:sym.End()-1], " {")[0], + Kind: "func", + } +} + +func typeName(t ast.TypeSpec) string { + switch t.Type.(type) { + case *ast.StructType: + return "struct" + case *ast.InterfaceType: + return "interface" + case *ast.ArrayType: + return "array" + case *ast.MapType: + return "map" + case *ast.ChanType: + return "chan" + default: + return "type" + } +} diff --git a/internal/lsp/hover.go b/internal/lsp/hover.go new file mode 100644 index 0000000..4acbd39 --- /dev/null +++ b/internal/lsp/hover.go @@ -0,0 +1,97 @@ +package lsp + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "strings" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" +) + +type HoveredToken struct { + Text string + Start int + End int +} + +func (s *server) Hover(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + var params protocol.DefinitionParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return sendParseError(ctx, reply, err) + } + + uri := params.TextDocument.URI + file, ok := s.snapshot.Get(uri.Filename()) + if !ok { + return reply(ctx, nil, errors.New("snapshot not found")) + } + + offset := file.PositionToOffset(params.Position) + // tokedf := pgf.FileSet.AddFile(doc.Path, -1, len(doc.Content)) + // target := tokedf.Pos(offset) + + slog.Info("hover", "offset", offset) + pgf, err := file.ParseGno(ctx) + if err != nil { + reply(ctx, nil, errors.New("cannot parse gno file")) + } + for _, spec := range pgf.File.Imports { + slog.Info("hover", "spec", spec.Path.Value, "pos", spec.Path.Pos(), "end", spec.Path.End()) + if int(spec.Path.Pos()) <= offset && offset <= int(spec.Path.End()) { + // TODO: handle hover for imports + slog.Info("hover", "import", spec.Path.Value) + return reply(ctx, nil, nil) + } + } + + token, err := file.TokenAt(params.Position) + if err != nil { + return reply(ctx, protocol.Hover{}, err) + } + text := strings.TrimSpace(token.Text) + + // FIXME: Use the AST package to do this + get type of token. + // + // This is just a quick PoC to get something working. + + // strings.Split(p.Body, + text = strings.Split(text, "(")[0] + + text = strings.TrimSuffix(text, ",") + text = strings.TrimSuffix(text, ")") + + // *mux.Request + text = strings.TrimPrefix(text, "*") + + slog.Info("hover", "pkg", len(s.completionStore.pkgs)) + + parts := strings.Split(text, ".") + if len(parts) == 2 { + pkg := parts[0] + sym := parts[1] + + slog.Info("hover", "pkg", pkg, "sym", sym) + found := s.completionStore.lookupSymbol(pkg, sym) + if found == nil && pgf.File != nil { + found = s.completionStore.lookupSymbolByImports(sym, pgf.File.Imports) + } + + if found != nil { + return reply(ctx, protocol.Hover{ + Contents: protocol.MarkupContent{ + Kind: protocol.Markdown, + Value: found.String(), + }, + Range: posToRange( + int(params.Position.Line), + []int{token.Start, token.End}, + ), + }, nil) + } + } + + return reply(ctx, nil, err) +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 10e13f1..a01486e 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -20,6 +20,7 @@ type server struct { env *env.Env snapshot *Snapshot + completionStore *CompletionStore formatOpt tools.FormattingOption } @@ -36,6 +37,7 @@ func BuildServerHandler(conn jsonrpc2.Conn, env *env.Env) jsonrpc2.Handler { env: env, snapshot: NewSnapshot(), + completionStore: InitCompletionStore(dirs), formatOpt: tools.Gofumpt, } @@ -63,6 +65,10 @@ func (s *server) ServerHandler(ctx context.Context, reply jsonrpc2.Replier, req return s.DidSave(ctx, reply, req) case "textDocument/formatting": return s.Formatting(ctx, reply, req) + case "textDocument/hover": + return s.Hover(ctx, reply, req) + case "textDocument/completion": + return s.Completion(ctx, reply, req) default: return jsonrpc2.MethodNotFoundHandler(ctx, reply, req) } diff --git a/internal/lsp/util.go b/internal/lsp/util.go index e21cbe8..14fadd8 100644 --- a/internal/lsp/util.go +++ b/internal/lsp/util.go @@ -11,6 +11,54 @@ import ( "go.lsp.dev/protocol" ) +func ListGnoPackages(paths []string) ([]string, error) { + res := []string{} + for _, path := range paths { + visited := map[string]bool{} + err := filepath.WalkDir(path, func(curpath string, f fs.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("%s: walk dir: %w", path, err) + } + if f.IsDir() { + return nil // skip + } + if filepath.Ext(curpath) != ".gno" { + return nil + } + parentDir := filepath.Dir(curpath) + if _, found := visited[parentDir]; found { + return nil + } + visited[parentDir] = true + res = append(res, parentDir) + return nil + }) + if err != nil { + return nil, err + } + } + return res, nil +} + +func ListGnoFiles(path string) ([]string, error) { + var files []string + entries, err := os.ReadDir(path) + if err != nil { + return nil, err + } + + for _, e := range entries { + if e.IsDir() { + continue + } + if !strings.HasSuffix(e.Name(), ".gno") { + continue + } + files = append(files, filepath.Join(path, e.Name())) + } + return files, nil +} + // GoToGnoFileName return gno file name from generated go file // If not a generated go file, return unchanged fname func GoToGnoFileName(fname string) string { @@ -101,3 +149,23 @@ func posToRange(line int, span []int) *protocol.Range { } } +func symbolToKind(symbol string) protocol.CompletionItemKind { + switch symbol { + case "const": + return protocol.CompletionItemKindConstant + case "func": + return protocol.CompletionItemKindFunction + case "type": + return protocol.CompletionItemKindClass + case "var": + return protocol.CompletionItemKindVariable + case "struct": + return protocol.CompletionItemKindStruct + case "interface": + return protocol.CompletionItemKindInterface + case "package": + return protocol.CompletionItemKindModule + default: + return protocol.CompletionItemKindValue + } +}