mirror of https://github.com/mickael-menu/zk
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.
480 lines
12 KiB
Go
480 lines
12 KiB
Go
package lsp
|
|
|
|
import (
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/mickael-menu/zk/adapter"
|
|
"github.com/mickael-menu/zk/adapter/sqlite"
|
|
"github.com/mickael-menu/zk/core/note"
|
|
"github.com/mickael-menu/zk/core/zk"
|
|
"github.com/mickael-menu/zk/util/errors"
|
|
"github.com/mickael-menu/zk/util/opt"
|
|
strutil "github.com/mickael-menu/zk/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
|
|
container *adapter.Container
|
|
documents map[protocol.DocumentUri]*document
|
|
}
|
|
|
|
// ServerOpts holds the options to create a new Server.
|
|
type ServerOpts struct {
|
|
Name string
|
|
Version string
|
|
LogFile opt.String
|
|
Container *adapter.Container
|
|
}
|
|
|
|
// NewServer creates a new Server instance.
|
|
func NewServer(opts ServerOpts) *Server {
|
|
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),
|
|
container: opts.Container,
|
|
documents: map[string]*document{},
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
server.container.OpenNotebook(workspace.folders)
|
|
|
|
// 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
|
|
|
|
zk, err := server.container.Zk()
|
|
if err == nil {
|
|
capabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental
|
|
capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{
|
|
ResolveProvider: boolPtr(true),
|
|
}
|
|
|
|
triggerChars := []string{"["}
|
|
|
|
// Setup tag completion trigger characters
|
|
if zk.Config.Format.Markdown.Hashtags {
|
|
triggerChars = append(triggerChars, "#")
|
|
}
|
|
if zk.Config.Format.Markdown.ColonTags {
|
|
triggerChars = append(triggerChars, ":")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
server.documents[params.TextDocument.URI] = &document{
|
|
URI: params.TextDocument.URI,
|
|
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
|
|
}
|
|
|
|
switch *triggerChar {
|
|
case "#", ":":
|
|
return server.buildTagCompletionList(*triggerChar)
|
|
|
|
case "[":
|
|
return server.buildLinkCompletionList(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
|
|
}
|
|
|
|
zk, err := server.container.Zk()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db, _, err := server.container.Database(false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var target string
|
|
err = db.WithTransaction(func(tx sqlite.Transaction) error {
|
|
finder := sqlite.NewNoteDAO(tx, server.container.Logger)
|
|
link, err := doc.DocumentLinkAt(params.Position)
|
|
if link == nil || err != nil {
|
|
return err
|
|
}
|
|
target, err = server.targetForHref(link.Href, zk.Path, finder)
|
|
return err
|
|
})
|
|
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 []protocol.DocumentLink{}, nil
|
|
}
|
|
|
|
zk, err := server.container.Zk()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db, _, err := server.container.Database(false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var documentLinks []protocol.DocumentLink
|
|
err = db.WithTransaction(func(tx sqlite.Transaction) error {
|
|
finder := sqlite.NewNoteDAO(tx, server.container.Logger)
|
|
links, err := doc.DocumentLinks()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, link := range links {
|
|
target, err := server.targetForHref(link.Href, zk.Path, finder)
|
|
if target == "" || err != nil {
|
|
continue
|
|
}
|
|
|
|
documentLinks = append(documentLinks, protocol.DocumentLink{
|
|
Range: link.Range,
|
|
Target: &target,
|
|
})
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return documentLinks, err
|
|
}
|
|
|
|
handler.TextDocumentDefinition = func(context *glsp.Context, params *protocol.DefinitionParams) (interface{}, error) {
|
|
doc, ok := server.documents[params.TextDocument.URI]
|
|
if !ok {
|
|
return []protocol.DocumentLink{}, nil
|
|
}
|
|
|
|
zk, err := server.container.Zk()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db, _, err := server.container.Database(false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var link *documentLink
|
|
var target string
|
|
err = db.WithTransaction(func(tx sqlite.Transaction) error {
|
|
finder := sqlite.NewNoteDAO(tx, server.container.Logger)
|
|
link, err = doc.DocumentLinkAt(params.Position)
|
|
if link == nil || err != nil {
|
|
return err
|
|
}
|
|
target, err = server.targetForHref(link.Href, zk.Path, finder)
|
|
return err
|
|
})
|
|
if link == nil || target == "" || err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if isTrue(clientCapabilities.TextDocument.Definition.LinkSupport) {
|
|
return protocol.LocationLink{
|
|
OriginSelectionRange: &link.Range,
|
|
TargetURI: target,
|
|
}, nil
|
|
} else {
|
|
return protocol.Location{
|
|
URI: target,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return server
|
|
}
|
|
|
|
// targetForHref returns the LSP documentUri for the note at the given HREF.
|
|
func (s *Server) targetForHref(href string, basePath string, finder note.Finder) (string, error) {
|
|
if strutil.IsURL(href) {
|
|
return href, nil
|
|
} else {
|
|
note, err := finder.FindByHref(href)
|
|
if err != nil {
|
|
s.server.Log.Errorf("findByHref(%s): %s", href, err.Error())
|
|
return "", err
|
|
}
|
|
if note == nil {
|
|
return "", nil
|
|
}
|
|
return "file://" + filepath.Join(basePath, 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(triggerChar string) ([]protocol.CompletionItem, error) {
|
|
zk, err := s.container.Zk()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db, _, err := s.container.Database(false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var tags []note.Collection
|
|
err = db.WithTransaction(func(tx sqlite.Transaction) error {
|
|
tags, err = sqlite.NewCollectionDAO(tx, s.container.Logger).FindAll(note.CollectionKindTag)
|
|
return err
|
|
})
|
|
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, zk.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 zk.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(params *protocol.CompletionParams) ([]protocol.CompletionItem, error) {
|
|
zk, err := s.container.Zk()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc, ok := s.documents[params.TextDocument.URI]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
|
|
db, _, err := s.container.Database(false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var notes []note.Match
|
|
err = db.WithTransaction(func(tx sqlite.Transaction) error {
|
|
finder := sqlite.NewNoteDAO(tx, s.container.Logger)
|
|
notes, err = finder.Find(note.FinderOpts{})
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var items []protocol.CompletionItem
|
|
for _, note := range notes {
|
|
items = append(items, protocol.CompletionItem{
|
|
Label: note.Title,
|
|
TextEdit: s.buildTextEditForLink(zk, note, doc, params.Position),
|
|
Documentation: protocol.MarkupContent{
|
|
Kind: protocol.MarkupKindMarkdown,
|
|
Value: note.RawContent,
|
|
},
|
|
})
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Server) buildTextEditForLink(zk *zk.Zk, note note.Match, document *document, pos protocol.Position) interface{} {
|
|
isWikiLink := (document.LookBehind(pos, 2) == "[[")
|
|
var text string
|
|
|
|
path := filepath.Join(zk.Path, note.Path)
|
|
documentPath := strings.TrimPrefix(document.URI, "file://")
|
|
path, err := filepath.Rel(filepath.Dir(documentPath), path)
|
|
if err != nil {
|
|
path = note.Path
|
|
}
|
|
ext := filepath.Ext(path)
|
|
path = strings.TrimSuffix(path, ext)
|
|
if isWikiLink {
|
|
text = path + "]]"
|
|
} else {
|
|
path = strings.ReplaceAll(url.PathEscape(path), "%2F", "/")
|
|
text = note.Title + "](" + path + ")"
|
|
}
|
|
|
|
return protocol.TextEdit{
|
|
Range: protocol.Range{
|
|
Start: pos,
|
|
End: pos,
|
|
},
|
|
NewText: text,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|