diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e8c48..18eb772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file. ### Added +* An experimental Language Server for LSP-compatible editors: + * Auto-complete Markdown links with `[[` (setup wiki-links in the [note formats configuration](docs/note-format.md)) + * Auto-complete [hashtags and colon-separated tags](docs/tags.md). + * Preview the content of a note when hovering a link. + * Navigate in your notes by following internal links. + * [And more to come...](https://github.com/mickael-menu/zk/issues/22) + * See [the documentation](docs/editors-integration.md) for configuration samples. * Pair `--match` with `--exact-match` / `-e` to search for (case insensitive) exact occurrences in your notes. * This can be useful when looking for terms including special characters, such as `[[name]]`. * Generating links to notes. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9d0abea --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +VERSION := `git describe --tags --match v[0-9]* 2> /dev/null` + +all: macos linux + rm -f zk + +macos: + rm -f zk && ./go build && zip -r "zk-${VERSION}-macos-`uname -m`.zip" zk + +linux: + rm -f zk && docker run --rm -v "${PWD}":/usr/src/zk -w /usr/src/zk mickaelmenu/zk-xcompile:linux-i386 /bin/bash -c './go build' && tar -zcvf "zk-${VERSION}-linux-i386.tar.gz" zk + rm -f zk && docker run --rm -v "${PWD}":/usr/src/zk -w /usr/src/zk mickaelmenu/zk-xcompile:linux-amd64 /bin/bash -c './go build' && tar -zcvf "zk-${VERSION}-linux-amd64.tar.gz" zk + rm -f zk && docker run --rm -v "${PWD}":/usr/src/zk -w /usr/src/zk mickaelmenu/zk-xcompile:linux-arm64 /bin/bash -c './go build' && tar -zcvf "zk-${VERSION}-linux-arm64.tar.gz" zk + +clean: + rm -rf zk* diff --git a/README.md b/README.md index 4177941..307be7d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ * [Creating notes from templates](docs/note-creation.md) * [Advanced search and filtering capabilities](docs/note-filtering.md) including [tags](docs/tags.md), links and mentions -* [Interactive browser](docs/tool-fzf), powered by `fzf` +* [Integration with your favorite editors](docs/editors-integration.md): + * [`zk.nvim`](https://github.com/megalithic/zk.nvim) for Neovim 0.5+, maintained by [Seth Messer](https://github.com/megalithic) + * [`zk-vscode`](https://github.com/mickael-menu/zk-vscode) for Visual Studio Code + * [Any LSP-compatible editor](docs/editors-integration.md) +* [Interactive browser](docs/tool-fzf.md), powered by `fzf` * [Git-style command aliases](docs/config-alias.md) and [named filters](docs/config-filter.md) * [Made with automation in mind](docs/automation.md) * [Notebook housekeeping](docs/notebook-housekeeping.md) diff --git a/docs/editors-integration.md b/docs/editors-integration.md new file mode 100644 index 0000000..7e54cdd --- /dev/null +++ b/docs/editors-integration.md @@ -0,0 +1,86 @@ +# Editors integration + +There are several extensions available to integrate `zk` in your favorite editor: + +* [`zk.nvim`](https://github.com/megalithic/zk.nvim) for Neovim 0.5+, maintained by [Seth Messer](https://github.com/megalithic) +* [`zk-vscode`](https://github.com/mickael-menu/zk-vscode) for Visual Studio Code + +## Language Server Protocol + +`zk` ships with a [Language Server](https://microsoft.github.io/language-server-protocol/overviews/lsp/overview/) to provide basic support for any LSP-compatible editor. The currently supported features are: + +* Auto-complete Markdown links with `[[` (setup wiki-links in the [note formats configuration](note-format.md)) +* Auto-complete [hashtags and colon-separated tags](tags.md). +* Preview the content of a note when hovering a link. +* Navigate in your notes by following internal links. +* [And more to come...](https://github.com/mickael-menu/zk/issues/22) + +To start the Language Server, use the `zk lsp` command. Refer to the following sections for editor-specific examples. [Feel free to share the configuration for your editor](https://github.com/mickael-menu/zk/issues/22). + +### Vim and Neovim + +#### Vim and Neovim 0.4 + +With [`coc.nvim`](https://github.com/neoclide/coc.nvim), run `:CocConfig` and add the following in the settings file: + +```jsonc +{ + // Important, otherwise link completion containing spaces and other special characters won't work. + "suggest.invalidInsertCharacters": [], + + "languageserver": { + "zk": { + "command": "zk", + "args": ["lsp"], + "trace.server": "messages", + "filetypes": ["markdown"] + }, + } +} +``` + +#### Neovim 0.5 built-in LSP client + +Using [`nvim-lspconfig`](https://github.com/neovim/nvim-lspconfig): + +```lua +local lspconfig = require('lspconfig') +local configs = require('lspconfig/configs') + +configs.zk = { + default_config = { + cmd = {'zk', 'lsp'}, + filetypes = {'markdown'}, + root_dir = function() + return vim.loop.cwd() + end, + settings = {} + }; +} + +lspconfig.zk.setup({ on_attach = function(client, buffer) + -- Add keybindings here, see https://github.com/neovim/nvim-lspconfig#keybindings-and-completion +end }) +``` + +### Sublime Text + +Install the [Sublime LSP](https://github.com/sublimelsp/LSP) package, then run the **Preferences: LSP Settings** command. Add the following to the settings file: + +```jsonc +{ + "clients": { + "zk": { + "enabled": true, + "command": ["zk", "lsp"], + "languageId": "markdown", + "scopes": [ "source.markdown" ], + "syntaxes": [ "Packages/MarkdownEditing/Markdown.sublime-syntax" ] + } + } +} +``` + +### Visual Studio Code + +Install the [`zk-vscode`](https://marketplace.visualstudio.com/items?itemName=mickael-menu.zk-vscode) extension from the Marketplace. diff --git a/go.mod b/go.mod index 5e94d1a..1815e60 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/mickael-menu/zk go 1.15 +replace github.com/tliron/glsp => github.com/mickael-menu/glsp v0.1.0 + require ( github.com/AlecAivazis/survey/v2 v2.2.7 github.com/alecthomas/kong v0.2.16-0.20210209082517-405b2f4fd9a4 diff --git a/go.sum b/go.sum index 63d5596..0417816 100644 --- a/go.sum +++ b/go.sum @@ -369,6 +369,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182aff github.com/maxbrunsfeld/counterfeiter/v6 v6.2.2/go.mod h1:eD9eIE7cdwcMi9rYluz88Jz2VyhSmden33/aXg4oVIY= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mickael-menu/glsp v0.1.0 h1:we6mTssWXxGPVeEcTpCW8AOpdCuUXwUZ6Q2UiYVnCOw= +github.com/mickael-menu/glsp v0.1.0/go.mod h1:ouzTGvQteTU4hdsG+32vIx0if7E9CzMa64d7tYJJ91g= github.com/mickael-menu/pretty v0.2.3 h1:AXi5WcBuWxwQV6iY/GhmCFpaoboQO2SLtzfujrn7dv0= github.com/mickael-menu/pretty v0.2.3/go.mod h1:gupeWUSWoo3KX7BItIuouLgTqQLlmRylpaPdIK6IqLk= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= @@ -510,8 +512,6 @@ github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0 github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/go-naturaldate v1.3.0 h1:OgJIPkR/Jk4bFMBLbxZ8w+QUxwjqSvzd9x+yXocY4RI= github.com/tj/go-naturaldate v1.3.0/go.mod h1:rpUbjivDKiS1BlfMGc2qUKNZ/yxgthOfmytQs8d8hKk= -github.com/tliron/glsp v0.0.0-20210308190902-c7ec7df19257 h1:EIMeclnZjLgYIUs06pWOo+wIuOji9Q4Qz0MaU3va198= -github.com/tliron/glsp v0.0.0-20210308190902-c7ec7df19257/go.mod h1:ouzTGvQteTU4hdsG+32vIx0if7E9CzMa64d7tYJJ91g= github.com/tliron/kutil v0.1.22 h1:VnwZ6YlTao2ISmm9wdv8CnVy5BjROBPpG65qIRc1LtE= github.com/tliron/kutil v0.1.22/go.mod h1:HkG4xQS2/BHI8EO9WfdOwnlUil7NhY/wmiV7U1uwEYw= github.com/tliron/yamlkeys v1.3.5/go.mod h1:8kJ1A/1s3p/I3MQUAbtv72dPEyQGoh0ZkQp0UAkABBo= diff --git a/internal/adapter/lsp/document.go b/internal/adapter/lsp/document.go index c195e2b..b829666 100644 --- a/internal/adapter/lsp/document.go +++ b/internal/adapter/lsp/document.go @@ -1,6 +1,7 @@ package lsp import ( + "net/url" "regexp" "strings" @@ -84,6 +85,21 @@ func (d *document) LookBehind(pos protocol.Position, length int) string { return line[(charIdx - length):charIdx] } +// LookForward returns the n characters after the given position, on the same line. +func (d *document) LookForward(pos protocol.Position, length int) string { + line, ok := d.GetLine(int(pos.Line)) + if !ok { + return "" + } + + lineLength := len(line) + charIdx := int(pos.Character) + if lineLength <= charIdx+length { + return line[charIdx:] + } + return line[charIdx:(charIdx + length)] +} + var wikiLinkRegex = regexp.MustCompile(`\[?\[\[(.+?)(?:\|(.+?))?\]\]`) var markdownLinkRegex = regexp.MustCompile(`\[([^\]]+?[^\\])\]\((.+?[^\\])\)`) @@ -134,6 +150,10 @@ func (d *document) DocumentLinks() ([]documentLink, error) { for _, match := range markdownLinkRegex.FindAllStringSubmatchIndex(line, -1) { href := line[match[4]:match[5]] + // Valid Markdown links are percent-encoded. + if decodedHref, err := url.PathUnescape(href); err == nil { + href = decodedHref + } appendLink(href, match[0], match[1]) } diff --git a/internal/adapter/lsp/server.go b/internal/adapter/lsp/server.go index a0825dc..8ff8e7e 100644 --- a/internal/adapter/lsp/server.go +++ b/internal/adapter/lsp/server.go @@ -85,7 +85,12 @@ func NewServer(opts ServerOpts) *Server { capabilities := handler.CreateServerCapabilities() capabilities.HoverProvider = true - capabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental + change := protocol.TextDocumentSyncKindIncremental + capabilities.TextDocumentSync = protocol.TextDocumentSyncOptions{ + OpenClose: boolPtr(true), + Change: &change, + Save: boolPtr(true), + } capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{ ResolveProvider: boolPtr(true), } @@ -94,6 +99,7 @@ func NewServer(opts ServerOpts) *Server { capabilities.CompletionProvider = &protocol.CompletionOptions{ TriggerCharacters: triggerChars, + ResolveProvider: boolPtr(true), } capabilities.DefinitionProvider = boolPtr(true) @@ -201,6 +207,21 @@ func NewServer(opts ServerOpts) *Server { return nil, nil } + handler.CompletionItemResolve = func(context *glsp.Context, params *protocol.CompletionItem) (*protocol.CompletionItem, error) { + if path, ok := params.Data.(string); ok { + content, err := ioutil.ReadFile(path) + if err != nil { + return params, err + } + params.Documentation = protocol.MarkupContent{ + Kind: protocol.MarkupKindMarkdown, + Value: string(content), + } + } + + return params, nil + } + handler.TextDocumentHover = func(context *glsp.Context, params *protocol.HoverParams) (*protocol.Hover, error) { doc, ok := server.documents[params.TextDocument.URI] if !ok { @@ -377,41 +398,64 @@ func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, return nil, err } - notes, err := notebook.FindNotes(core.NoteFindOpts{}) + notes, err := notebook.FindMinimalNotes(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) + item, err := s.newCompletionItem(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)) + s.logger.Err(err) 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, - }, - }) + items = append(items, item) } return items, nil } -func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.ContextualNote, document *document, pos protocol.Position, linkFormatter core.LinkFormatter) (interface{}, error) { +func (s *Server) newCompletionItem(notebook *core.Notebook, note core.MinimalNote, doc *document, pos protocol.Position, linkFormatter core.LinkFormatter) (item protocol.CompletionItem, err error) { + kind := protocol.CompletionItemKindReference + item.Kind = &kind + item.Data = filepath.Join(notebook.Path, note.Path) + + if note.Title != "" { + item.Label = note.Title + } else { + item.Label = note.Path + } + + // Add the path to the filter text to be able to complete by it. + item.FilterText = stringPtr(item.Label + " " + note.Path) + + item.TextEdit, err = s.newTextEditForLink(notebook, note, doc, pos, linkFormatter) + if err != nil { + err = errors.Wrapf(err, "failed to build TextEdit for note at %s", note.Path) + return + } + + addTextEdits := []protocol.TextEdit{} + + // Some LSP clients (e.g. VSCode) don't support deleting the trigger + // characters with the main TextEdit. So let's add an additional + // TextEdit for that. + addTextEdits = append(addTextEdits, protocol.TextEdit{ + NewText: "", + Range: rangeFromPosition(pos, -2, 0), + }) + + item.AdditionalTextEdits = addTextEdits + + return item, nil +} + +func (s *Server) newTextEditForLink(notebook *core.Notebook, note core.MinimalNote, doc *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) + path, err := filepath.Rel(filepath.Dir(doc.Path), path) if err != nil { path = note.Path } @@ -421,16 +465,16 @@ func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.Context return nil, err } - // Overwrite [[ trigger - start := pos - start.Character -= 2 + // Some LSP clients (e.g. VSCode) auto-pair brackets, so we need to + // remove the closing ]] after the completion. + endOffset := 0 + if doc.LookForward(pos, 2) == "]]" { + endOffset = 2 + } return protocol.TextEdit{ - Range: protocol.Range{ - Start: start, - End: pos, - }, NewText: link, + Range: rangeFromPosition(pos, 0, endOffset), }, nil } @@ -440,6 +484,23 @@ func positionInRange(content string, rng protocol.Range, pos protocol.Position) return i >= start && i <= end } +func rangeFromPosition(pos protocol.Position, startOffset, endOffset int) protocol.Range { + offsetPos := func(offset int) protocol.Position { + newPos := pos + if offset < 0 { + newPos.Character -= uint32(-offset) + } else { + newPos.Character += uint32(offset) + } + return newPos + } + + return protocol.Range{ + Start: offsetPos(startOffset), + End: offsetPos(endOffset), + } +} + func boolPtr(v bool) *bool { b := v return &b diff --git a/internal/cli/cmd/lsp.go b/internal/cli/cmd/lsp.go index d2181bf..aa0b492 100644 --- a/internal/cli/cmd/lsp.go +++ b/internal/cli/cmd/lsp.go @@ -8,7 +8,7 @@ import ( // LSP starts a server implementing the Language Server Protocol. type LSP struct { - Log string `type:path placeholder:PATH help:"Absolute path to the log file"` + Log string `hidden type:path placeholder:PATH help:"Absolute path to the log file"` } func (cmd *LSP) Run(container *cli.Container) error {