You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
zk/internal/adapter/lsp/server.go

460 lines
12 KiB
Go

package lsp
import (
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/errors"
"github.com/mickael-menu/zk/internal/util/opt"
strutil "github.com/mickael-menu/zk/internal/util/strings"
"github.com/tliron/glsp"
protocol "github.com/tliron/glsp/protocol_3_16"
glspserv "github.com/tliron/glsp/server"
"github.com/tliron/kutil/logging"
_ "github.com/tliron/kutil/logging/simple"
)
// Server holds the state of the Language Server.
type Server struct {
server *glspserv.Server
notebooks *core.NotebookStore
documents map[protocol.DocumentUri]*document
fs core.FileStorage
logger util.Logger
}
// ServerOpts holds the options to create a new Server.
type ServerOpts struct {
Name string
Version string
LogFile opt.String
Logger *util.ProxyLogger
Notebooks *core.NotebookStore
FS core.FileStorage
}
// NewServer creates a new Server instance.
func NewServer(opts ServerOpts) *Server {
fs := opts.FS
debug := !opts.LogFile.IsNull()
if debug {
logging.Configure(10, opts.LogFile.Value)
}
workspace := newWorkspace()
handler := protocol.Handler{}
server := &Server{
server: glspserv.NewServer(&handler, opts.Name, debug),
notebooks: opts.Notebooks,
documents: map[string]*document{},
fs: fs,
}
// Redirect zk's logger to GLSP's to avoid breaking the JSON-RPC protocol
// with unwanted output.
if opts.Logger != nil {
opts.Logger.Logger = newGlspLogger(server.server.Log)
server.logger = opts.Logger
}
var clientCapabilities protocol.ClientCapabilities
handler.Initialize = func(context *glsp.Context, params *protocol.InitializeParams) (interface{}, error) {
clientCapabilities = params.Capabilities
if len(params.WorkspaceFolders) > 0 {
for _, f := range params.WorkspaceFolders {
workspace.addFolder(f.URI)
}
} else if params.RootURI != nil {
workspace.addFolder(*params.RootURI)
} else if params.RootPath != nil {
workspace.addFolder(*params.RootPath)
}
// To see the logs with coc.nvim, run :CocCommand workspace.showOutput
// https://github.com/neoclide/coc.nvim/wiki/Debug-language-server#using-output-channel
if params.Trace != nil {
protocol.SetTraceValue(*params.Trace)
}
capabilities := handler.CreateServerCapabilities()
capabilities.HoverProvider = true
capabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental
capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{
ResolveProvider: boolPtr(true),
}
triggerChars := []string{"[", "#", ":"}
capabilities.CompletionProvider = &protocol.CompletionOptions{
TriggerCharacters: triggerChars,
}
capabilities.DefinitionProvider = boolPtr(true)
return protocol.InitializeResult{
Capabilities: capabilities,
ServerInfo: &protocol.InitializeResultServerInfo{
Name: opts.Name,
Version: &opts.Version,
},
}, nil
}
handler.Initialized = func(context *glsp.Context, params *protocol.InitializedParams) error {
return nil
}
handler.Shutdown = func(context *glsp.Context) error {
protocol.SetTraceValue(protocol.TraceValueOff)
return nil
}
handler.SetTrace = func(context *glsp.Context, params *protocol.SetTraceParams) error {
protocol.SetTraceValue(params.Value)
return nil
}
handler.WorkspaceDidChangeWorkspaceFolders = func(context *glsp.Context, params *protocol.DidChangeWorkspaceFoldersParams) error {
for _, f := range params.Event.Added {
workspace.addFolder(f.URI)
}
for _, f := range params.Event.Removed {
workspace.removeFolder(f.URI)
}
return nil
}
handler.TextDocumentDidOpen = func(context *glsp.Context, params *protocol.DidOpenTextDocumentParams) error {
langID := params.TextDocument.LanguageID
if langID != "markdown" && langID != "vimwiki" {
return nil
}
path := fs.Canonical(strings.TrimPrefix(params.TextDocument.URI, "file://"))
server.documents[params.TextDocument.URI] = &document{
Path: path,
Content: params.TextDocument.Text,
Log: server.server.Log,
}
return nil
}
handler.TextDocumentDidChange = func(context *glsp.Context, params *protocol.DidChangeTextDocumentParams) error {
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
return nil
}
doc.ApplyChanges(params.ContentChanges)
return nil
}
handler.TextDocumentDidClose = func(context *glsp.Context, params *protocol.DidCloseTextDocumentParams) error {
delete(server.documents, params.TextDocument.URI)
return nil
}
handler.TextDocumentDidSave = func(context *glsp.Context, params *protocol.DidSaveTextDocumentParams) error {
return nil
}
handler.TextDocumentCompletion = func(context *glsp.Context, params *protocol.CompletionParams) (interface{}, error) {
triggerChar := params.Context.TriggerCharacter
if params.Context.TriggerKind != protocol.CompletionTriggerKindTriggerCharacter || triggerChar == nil {
return nil, nil
}
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
return nil, nil
}
notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
switch *triggerChar {
case "#":
if notebook.Config.Format.Markdown.Hashtags {
return server.buildTagCompletionList(notebook, "#")
}
case ":":
if notebook.Config.Format.Markdown.ColonTags {
return server.buildTagCompletionList(notebook, ":")
}
case "[":
if doc.LookBehind(params.Position, 2) == "[[" {
return server.buildLinkCompletionList(doc, notebook, params)
}
}
return nil, nil
}
handler.TextDocumentHover = func(context *glsp.Context, params *protocol.HoverParams) (*protocol.Hover, error) {
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
return nil, nil
}
link, err := doc.DocumentLinkAt(params.Position)
if link == nil || err != nil {
return nil, err
}
notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
target, err := server.targetForHref(link.Href, doc, notebook)
if err != nil || target == "" || strutil.IsURL(target) {
return nil, err
}
target = strings.TrimPrefix(target, "file://")
contents, err := ioutil.ReadFile(target)
if err != nil {
return nil, err
}
return &protocol.Hover{
Contents: protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: string(contents),
},
}, nil
}
handler.TextDocumentDocumentLink = func(context *glsp.Context, params *protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) {
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
return nil, nil
}
links, err := doc.DocumentLinks()
if err != nil {
return nil, err
}
notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
documentLinks := []protocol.DocumentLink{}
for _, link := range links {
target, err := server.targetForHref(link.Href, doc, notebook)
if target == "" || err != nil {
continue
}
documentLinks = append(documentLinks, protocol.DocumentLink{
Range: link.Range,
Target: &target,
})
}
return documentLinks, err
}
handler.TextDocumentDefinition = func(context *glsp.Context, params *protocol.DefinitionParams) (interface{}, error) {
doc, ok := server.documents[params.TextDocument.URI]
if !ok {
return nil, nil
}
link, err := doc.DocumentLinkAt(params.Position)
if link == nil || err != nil {
return nil, err
}
notebook, err := server.notebookOf(doc)
if err != nil {
return nil, err
}
target, err := server.targetForHref(link.Href, doc, notebook)
if link == nil || target == "" || err != nil {
return nil, err
}
// FIXME: Waiting for https://github.com/tliron/glsp/pull/3 to be
// merged before using LocationLink.
if false && isTrue(clientCapabilities.TextDocument.Definition.LinkSupport) {
return protocol.LocationLink{
OriginSelectionRange: &link.Range,
TargetURI: target,
}, nil
} else {
return protocol.Location{
URI: target,
}, nil
}
}
return server
}
func (s *Server) notebookOf(doc *document) (*core.Notebook, error) {
return s.notebooks.Open(doc.Path)
}
// targetForHref returns the LSP documentUri for the note at the given HREF.
func (s *Server) targetForHref(href string, doc *document, notebook *core.Notebook) (string, error) {
if strutil.IsURL(href) {
return href, nil
} else {
path := filepath.Clean(filepath.Join(filepath.Dir(doc.Path), href))
path, err := filepath.Rel(notebook.Path, path)
if err != nil {
return "", errors.Wrapf(err, "failed to resolve href: %s", href)
}
note, err := notebook.FindByHref(path)
if err != nil {
s.logger.Printf("findByHref(%s): %s", href, err.Error())
return "", err
}
if note == nil {
return "", nil
}
return "file://" + filepath.Join(notebook.Path, note.Path), nil
}
}
// Run starts the Language Server in stdio mode.
func (s *Server) Run() error {
return errors.Wrap(s.server.RunStdio(), "lsp")
}
func (s *Server) buildTagCompletionList(notebook *core.Notebook, triggerChar string) ([]protocol.CompletionItem, error) {
tags, err := notebook.FindCollections(core.CollectionKindTag)
if err != nil {
return nil, err
}
var items []protocol.CompletionItem
for _, tag := range tags {
items = append(items, protocol.CompletionItem{
Label: tag.Name,
InsertText: s.buildInsertForTag(tag.Name, triggerChar, notebook.Config),
Detail: stringPtr(fmt.Sprintf("%d %s", tag.NoteCount, strutil.Pluralize("note", tag.NoteCount))),
})
}
return items, nil
}
func (s *Server) buildInsertForTag(name string, triggerChar string, config core.Config) *string {
switch triggerChar {
case ":":
name += ":"
case "#":
if strings.Contains(name, " ") {
if config.Format.Markdown.MultiwordTags {
name += "#"
} else {
name = strings.ReplaceAll(name, " ", "\\ ")
}
}
}
return &name
}
func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, params *protocol.CompletionParams) ([]protocol.CompletionItem, error) {
linkFormatter, err := notebook.NewLinkFormatter()
if err != nil {
return nil, err
}
notes, err := notebook.FindNotes(core.NoteFindOpts{})
if err != nil {
return nil, err
}
var items []protocol.CompletionItem
for _, note := range notes {
textEdit, err := s.buildTextEditForLink(notebook, note, doc, params.Position, linkFormatter)
if err != nil {
s.logger.Err(errors.Wrapf(err, "failed to build TextEdit for note at %s", note.Path))
continue
}
label := note.Title
if label == "" {
label = note.Path
}
items = append(items, protocol.CompletionItem{
Label: label,
TextEdit: textEdit,
Documentation: protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: note.RawContent,
},
})
}
return items, nil
}
func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.ContextualNote, document *document, pos protocol.Position, linkFormatter core.LinkFormatter) (interface{}, error) {
path := filepath.Join(notebook.Path, note.Path)
path = s.fs.Canonical(path)
path, err := filepath.Rel(filepath.Dir(document.Path), path)
if err != nil {
path = note.Path
}
link, err := linkFormatter(path, note.Title)
if err != nil {
return nil, err
}
// Overwrite [[ trigger
start := pos
start.Character -= 2
return protocol.TextEdit{
Range: protocol.Range{
Start: start,
End: pos,
},
NewText: link,
}, nil
}
func positionInRange(content string, rng protocol.Range, pos protocol.Position) bool {
start, end := rng.IndexesIn(content)
i := pos.IndexIn(content)
return i >= start && i <= end
}
func boolPtr(v bool) *bool {
b := v
return &b
}
func isTrue(v *bool) bool {
return v != nil && *v == true
}
func isFalse(v *bool) bool {
return v == nil || *v == false
}
func stringPtr(v string) *string {
s := v
return &s
}