diff --git a/CHANGELOG.md b/CHANGELOG.md index e46878e..7e88f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. * [Editor integration through LSP](https://github.com/mickael-menu/zk/issues/22): * New code actions to create a note using the current selection as title. * Custom commands to [run `new` and `index` from your editor](docs/editors-integration.md#custom-commands). + * Diagnostics to [report dead links or wiki-link titles](docs/config-lsp.md). * Customize the format of `fzf`'s lines [with your own template](docs/tool-fzf.md). ```toml [tool] diff --git a/docs/config-lsp.md b/docs/config-lsp.md new file mode 100644 index 0000000..3ca0785 --- /dev/null +++ b/docs/config-lsp.md @@ -0,0 +1,27 @@ +# LSP configuration + +The `[lsp]` [configuration file](config.md) section provides settings to fine-tune the [LSP editors integration](editors-integration.md). + +## Diagnostics + +Use the `[lsp.diagnostics]` sub-section to configure how LSP diagnostics are reported to your editors. Each diagnostic setting can be: + +* An empty string or `none` to ignore this diagnostic. +* `hint`, `info`, `warning` or `error` to enable and set the severity of the diagnostic. + +| Setting | Default | Description | +|--------------|-----------|---------------------------------------------------------------------------| +| `wiki-title` | `"none"` | Report titles of wiki-links, which is useful if you use IDs for filenames | +| `dead-link` | `"error"` | Warn for dead links between notes | + +## Complete example + +```toml +[lsp] + +[lsp.diagnostics] +# Report titles of wiki-links as hints. +wiki-title = "hint" +# Warn for dead links between notes. +dead-link = "error" +``` \ No newline at end of file diff --git a/docs/config.md b/docs/config.md index 9b6718b..fd5c03a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -5,11 +5,12 @@ Each [notebook](notebook.md) contains a configuration file used to customize you * `[note]` sets the [note creation rules](config-note.md) * `[extra]` contains free [user variables](config-extra.md) which can be expanded in templates * `[group]` defines [note groups](config-group.md) with custom rules -* `[format]` configures the [note format settings](note-format.md), such as Markdown options. +* `[format]` configures the [note format settings](note-format.md), such as Markdown options * `[tool]` customizes interaction with external programs such as: * [your default editor](tool-editor.md) * [your default pager](tool-pager.md) * [`fzf`](tool-fzf.md) +* `[lsp]` setups the [Language Server Protocol settings](config-lsp.md) for [editors integration](editors-integration.md) * `[filter]` declares your [named filters](config-filter.md) * `[alias]` holds your [command aliases](config-alias.md) @@ -104,5 +105,13 @@ recent = "zk edit --sort created- --created-after 'last two weeks' --interactive # Show a random note. lucky = "zk list --quiet --format full --sort random --limit 1" -``` +# LSP (EDITOR INTEGRATION) +[lsp] + +[lsp.diagnostics] +# Report titles of wiki-links as hints. +wiki-title = "hint" +# Warn for dead links between notes. +dead-link = "error" +``` \ No newline at end of file diff --git a/docs/editors-integration.md b/docs/editors-integration.md index cce9271..3e26c4a 100644 --- a/docs/editors-integration.md +++ b/docs/editors-integration.md @@ -14,7 +14,10 @@ There are several extensions available to integrate `zk` in your favorite editor * Preview the content of a note when hovering a link. * Navigate in your notes by following internal links. * Create a new note using the current selection as title. +* Diagnostics for dead links and wiki-links titles. * [And more to come...](https://github.com/mickael-menu/zk/issues/22) + +You can configure some of these features in your notebook's [configuration file](config-lsp.md). ### Editor LSP configurations diff --git a/internal/adapter/lsp/document.go b/internal/adapter/lsp/document.go index 116130c..11ff2b4 100644 --- a/internal/adapter/lsp/document.go +++ b/internal/adapter/lsp/document.go @@ -8,6 +8,7 @@ import ( "github.com/mickael-menu/zk/internal/core" "github.com/mickael-menu/zk/internal/util" "github.com/mickael-menu/zk/internal/util/errors" + "github.com/tliron/glsp" protocol "github.com/tliron/glsp/protocol_3_16" ) @@ -26,22 +27,24 @@ func newDocumentStore(fs core.FileStorage, logger util.Logger) *documentStore { } } -func (s *documentStore) DidOpen(params protocol.DidOpenTextDocumentParams) error { +func (s *documentStore) DidOpen(params protocol.DidOpenTextDocumentParams, notify glsp.NotifyFunc) (*document, error) { langID := params.TextDocument.LanguageID if langID != "markdown" && langID != "vimwiki" { - return nil + return nil, nil } - path, err := s.normalizePath(params.TextDocument.URI) + uri := params.TextDocument.URI + path, err := s.normalizePath(uri) if err != nil { - return err + return nil, err } - s.documents[path] = &document{ + doc := &document{ + URI: uri, Path: path, Content: params.TextDocument.Text, } - - return nil + s.documents[path] = doc + return doc, nil } func (s *documentStore) Close(uri protocol.DocumentUri) { @@ -68,9 +71,11 @@ func (s *documentStore) normalizePath(pathOrUri string) (string, error) { // document represents an opened file. type document struct { - Path string - Content string - lines []string + URI protocol.DocumentUri + Path string + NeedsRefreshDiagnostics bool + Content string + lines []string } // ApplyChanges updates the content of the document from LSP textDocument/didChange events. @@ -190,7 +195,7 @@ func (d *document) DocumentLinks() ([]documentLink, error) { lines := d.GetLines() for lineIndex, line := range lines { - appendLink := func(href string, start, end int) { + appendLink := func(href string, start, end int, hasTitle bool) { if href == "" { return } @@ -207,6 +212,7 @@ func (d *document) DocumentLinks() ([]documentLink, error) { Character: protocol.UInteger(end), }, }, + HasTitle: hasTitle, }) } @@ -216,12 +222,13 @@ func (d *document) DocumentLinks() ([]documentLink, error) { if decodedHref, err := url.PathUnescape(href); err == nil { href = decodedHref } - appendLink(href, match[0], match[1]) + appendLink(href, match[0], match[1], true) } for _, match := range wikiLinkRegex.FindAllStringSubmatchIndex(line, -1) { href := line[match[2]:match[3]] - appendLink(href, match[0], match[1]) + hasTitle := match[4] != -1 + appendLink(href, match[0], match[1], hasTitle) } } @@ -231,4 +238,7 @@ func (d *document) DocumentLinks() ([]documentLink, error) { type documentLink struct { Href string Range protocol.Range + // HasTitle indicates whether this link has a title information. For + // example [[filename]] doesn't but [[filename|title]] does. + HasTitle bool } diff --git a/internal/adapter/lsp/server.go b/internal/adapter/lsp/server.go index d8df59c..00b67c7 100644 --- a/internal/adapter/lsp/server.go +++ b/internal/adapter/lsp/server.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "path/filepath" "strings" + "time" "github.com/mickael-menu/zk/internal/core" "github.com/mickael-menu/zk/internal/util" @@ -127,7 +128,14 @@ func NewServer(opts ServerOpts) *Server { } handler.TextDocumentDidOpen = func(context *glsp.Context, params *protocol.DidOpenTextDocumentParams) error { - return server.documents.DidOpen(*params) + doc, err := server.documents.DidOpen(*params, context.Notify) + if err != nil { + return err + } + if doc != nil { + server.refreshDiagnosticsOfDocument(doc, context.Notify, false) + } + return nil } handler.TextDocumentDidChange = func(context *glsp.Context, params *protocol.DidChangeTextDocumentParams) error { @@ -137,6 +145,7 @@ func NewServer(opts ServerOpts) *Server { } doc.ApplyChanges(params.ContentChanges) + server.refreshDiagnosticsOfDocument(doc, context.Notify, true) return nil } @@ -225,12 +234,12 @@ func NewServer(opts ServerOpts) *Server { return nil, err } - target, err := server.targetForHref(link.Href, doc, notebook) - if err != nil || target == "" || strutil.IsURL(target) { + target, err := server.noteForHref(link.Href, doc, notebook) + if err != nil || target == nil { return nil, err } - path, err := uriToPath(target) + path, err := uriToPath(target.URI) if err != nil { server.logger.Printf("unable to parse URI: %v", err) return nil, err @@ -268,14 +277,14 @@ func NewServer(opts ServerOpts) *Server { documentLinks := []protocol.DocumentLink{} for _, link := range links { - target, err := server.targetForHref(link.Href, doc, notebook) - if target == "" || err != nil { + target, err := server.noteForHref(link.Href, doc, notebook) + if target == nil || err != nil { continue } documentLinks = append(documentLinks, protocol.DocumentLink{ Range: link.Range, - Target: &target, + Target: &target.URI, }) } @@ -298,8 +307,8 @@ func NewServer(opts ServerOpts) *Server { return nil, err } - target, err := server.targetForHref(link.Href, doc, notebook) - if link == nil || target == "" || err != nil { + target, err := server.noteForHref(link.Href, doc, notebook) + if link == nil || target == nil || err != nil { return nil, err } @@ -308,11 +317,11 @@ func NewServer(opts ServerOpts) *Server { if false && isTrue(clientCapabilities.TextDocument.Definition.LinkSupport) { return protocol.LocationLink{ OriginSelectionRange: &link.Range, - TargetURI: target, + TargetURI: target.URI, }, nil } else { return protocol.Location{ - URI: target, + URI: target.URI, }, nil } } @@ -378,6 +387,11 @@ func NewServer(opts ServerOpts) *Server { return server } +// Run starts the Language Server in stdio mode. +func (s *Server) Run() error { + return errors.Wrap(s.server.RunStdio(), "lsp") +} + const cmdIndex = "zk.index" func (s *Server) executeCommandIndex(args []interface{}) (interface{}, error) { @@ -516,32 +530,105 @@ 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) { +// noteForHref returns the LSP documentUri for the note at the given HREF. +func (s *Server) noteForHref(href string, doc *document, notebook *core.Notebook) (*Note, 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) + return nil, nil + } + + path := filepath.Clean(filepath.Join(filepath.Dir(doc.Path), href)) + path, err := filepath.Rel(notebook.Path, path) + if err != nil { + return nil, 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 nil, err + } + if note == nil { + return nil, nil + } + joined_path := filepath.Join(notebook.Path, note.Path) + + return &Note{*note, pathToURI(joined_path)}, nil +} + +type Note struct { + core.MinimalNote + URI protocol.DocumentUri +} + +func (s *Server) refreshDiagnosticsOfDocument(doc *document, notify glsp.NotifyFunc, delay bool) { + if doc.NeedsRefreshDiagnostics { // Already refreshing + return + } + + notebook, err := s.notebookOf(doc) + if err != nil { + s.logger.Err(err) + return + } + + diagConfig := notebook.Config.LSP.Diagnostics + if diagConfig.WikiTitle == core.LSPDiagnosticNone && diagConfig.DeadLink == core.LSPDiagnosticNone { + // No diagnostic enabled. + return + } + + doc.NeedsRefreshDiagnostics = true + go func() { + if delay { + time.Sleep(1 * time.Second) } - note, err := notebook.FindByHref(path) + doc.NeedsRefreshDiagnostics = false + + diagnostics := []protocol.Diagnostic{} + links, err := doc.DocumentLinks() if err != nil { - s.logger.Printf("findByHref(%s): %s", href, err.Error()) - return "", err + s.logger.Err(err) + return } - if note == nil { - return "", nil + + for _, link := range links { + if strutil.IsURL(link.Href) { + continue + } + target, err := s.noteForHref(link.Href, doc, notebook) + if err != nil { + s.logger.Err(err) + continue + } + + var severity protocol.DiagnosticSeverity + var message string + if target == nil { + if diagConfig.DeadLink == core.LSPDiagnosticNone { + continue + } + severity = protocol.DiagnosticSeverity(diagConfig.DeadLink) + message = "not found" + } else { + if link.HasTitle || diagConfig.WikiTitle == core.LSPDiagnosticNone { + continue + } + severity = protocol.DiagnosticSeverity(diagConfig.WikiTitle) + message = target.Title + } + + diagnostics = append(diagnostics, protocol.Diagnostic{ + Range: link.Range, + Severity: &severity, + Source: stringPtr("zk"), + Message: message, + }) } - joined_path := filepath.Join(notebook.Path, note.Path) - return pathToURI(joined_path), nil - } -} -// Run starts the Language Server in stdio mode. -func (s *Server) Run() error { - return errors.Wrap(s.server.RunStdio(), "lsp") + go notify(protocol.ServerTextDocumentPublishDiagnostics, protocol.PublishDiagnosticsParams{ + URI: doc.URI, + Diagnostics: diagnostics, + }) + }() } func (s *Server) buildTagCompletionList(notebook *core.Notebook, triggerChar string) ([]protocol.CompletionItem, error) { diff --git a/internal/cli/container.go b/internal/cli/container.go index 43a4798..11f0b48 100644 --- a/internal/cli/container.go +++ b/internal/cli/container.go @@ -161,9 +161,9 @@ func globalConfigDir() string { // SetCurrentNotebook sets the first notebook found in the given search paths // as the current default one. -func (c *Container) SetCurrentNotebook(searchDirs []Dirs) { +func (c *Container) SetCurrentNotebook(searchDirs []Dirs) error { if len(searchDirs) == 0 { - return + return nil } for _, dirs := range searchDirs { @@ -176,9 +176,14 @@ func (c *Container) SetCurrentNotebook(searchDirs []Dirs) { c.Config = c.currentNotebook.Config // FIXME: Is there something to do to support multiple notebooks here? os.Setenv("ZK_NOTEBOOK_DIR", c.currentNotebook.Path) - return + } + // Report the error only if it's not the "notebook not found" one. + var errNotFound core.ErrNotebookNotFound + if !errors.As(c.currentNotebookErr, &errNotFound) { + return c.currentNotebookErr } } + return nil } // SetWorkingDir resets the current working directory. diff --git a/internal/core/config.go b/internal/core/config.go index bd308b2..0b6ebfa 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -15,6 +15,7 @@ type Config struct { Groups map[string]GroupConfig Format FormatConfig Tool ToolConfig + LSP LSPConfig Filters map[string]string Aliases map[string]string Extra map[string]string @@ -46,6 +47,12 @@ func NewDefaultConfig() Config { LinkDropExtension: true, }, }, + LSP: LSPConfig{ + Diagnostics: LSPDiagnosticConfig{ + WikiTitle: LSPDiagnosticNone, + DeadLink: LSPDiagnosticError, + }, + }, Filters: map[string]string{}, Aliases: map[string]string{}, Extra: map[string]string{}, @@ -124,6 +131,27 @@ type ToolConfig struct { FzfLine opt.String } +// LSPConfig holds the Language Server Protocol configuration. +type LSPConfig struct { + Diagnostics LSPDiagnosticConfig +} + +// LSPDiagnosticConfig holds the LSP diagnostics configuration. +type LSPDiagnosticConfig struct { + WikiTitle LSPDiagnosticSeverity + DeadLink LSPDiagnosticSeverity +} + +type LSPDiagnosticSeverity int + +const ( + LSPDiagnosticNone LSPDiagnosticSeverity = 0 + LSPDiagnosticError LSPDiagnosticSeverity = 1 + LSPDiagnosticWarning LSPDiagnosticSeverity = 2 + LSPDiagnosticInfo LSPDiagnosticSeverity = 3 + LSPDiagnosticHint LSPDiagnosticSeverity = 4 +) + // NoteConfig holds the user configuration used when generating new notes. type NoteConfig struct { // Handlebars template used when generating a new filename. @@ -184,12 +212,14 @@ func OpenConfig(path string, parentConfig Config, fs FileStorage) (Config, error // // The parentConfig will be used to inherit default config settings. func ParseConfig(content []byte, path string, parentConfig Config) (Config, error) { + wrap := errors.Wrapperf("failed to read config") + config := parentConfig var tomlConf tomlConfig err := toml.Unmarshal(content, &tomlConf) if err != nil { - return config, errors.Wrap(err, "failed to read config") + return config, wrap(err) } // Note @@ -275,6 +305,21 @@ func ParseConfig(content []byte, path string, parentConfig Config) (Config, erro config.Tool.FzfLine = opt.NewNotEmptyString(*tool.FzfLine) } + // LSP + lspDiags := tomlConf.LSP.Diagnostics + if lspDiags.WikiTitle != nil { + config.LSP.Diagnostics.WikiTitle, err = lspDiagnosticSeverityFromString(*lspDiags.WikiTitle) + if err != nil { + return config, wrap(err) + } + } + if lspDiags.DeadLink != nil { + config.LSP.Diagnostics.DeadLink, err = lspDiagnosticSeverityFromString(*lspDiags.DeadLink) + if err != nil { + return config, wrap(err) + } + } + // Filters if tomlConf.Filters != nil { for k, v := range tomlConf.Filters { @@ -345,6 +390,7 @@ type tomlConfig struct { Groups map[string]tomlGroupConfig `toml:"group"` Format tomlFormatConfig Tool tomlToolConfig + LSP tomlLSPConfig Extra map[string]string Filters map[string]string `toml:"filter"` Aliases map[string]string `toml:"alias"` @@ -387,6 +433,13 @@ type tomlToolConfig struct { FzfLine *string `toml:"fzf-line"` } +type tomlLSPConfig struct { + Diagnostics struct { + WikiTitle *string `toml:"wiki-title"` + DeadLink *string `toml:"dead-link"` + } +} + func charsetFromString(charset string) Charset { switch charset { case "alphanum": @@ -414,3 +467,20 @@ func caseFromString(c string) Case { return CaseLower } } + +func lspDiagnosticSeverityFromString(s string) (LSPDiagnosticSeverity, error) { + switch s { + case "", "none": + return LSPDiagnosticNone, nil + case "error": + return LSPDiagnosticError, nil + case "warning": + return LSPDiagnosticWarning, nil + case "info": + return LSPDiagnosticInfo, nil + case "hint": + return LSPDiagnosticHint, nil + default: + return LSPDiagnosticNone, fmt.Errorf("%s: unknown LSP diagnostic severity - may be none, hint, info, warning or error", s) + } +} diff --git a/internal/core/config_test.go b/internal/core/config_test.go index b8e2544..b8d3b5c 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -43,6 +43,12 @@ func TestParseDefaultConfig(t *testing.T) { FzfPreview: opt.NullString, FzfLine: opt.NullString, }, + LSP: LSPConfig{ + Diagnostics: LSPDiagnosticConfig{ + WikiTitle: LSPDiagnosticNone, + DeadLink: LSPDiagnosticError, + }, + }, Filters: make(map[string]string), Aliases: make(map[string]string), Extra: make(map[string]string), @@ -115,6 +121,10 @@ func TestParseComplete(t *testing.T) { [group."without path"] paths = [] + + [lsp.diagnostics] + wiki-title = "hint" + dead-link = "none" `), ".zk/config.toml", NewDefaultConfig()) assert.Nil(t, err) @@ -207,6 +217,12 @@ func TestParseComplete(t *testing.T) { FzfPreview: opt.NewString("bat {1}"), FzfLine: opt.NewString("{{title}}"), }, + LSP: LSPConfig{ + Diagnostics: LSPDiagnosticConfig{ + WikiTitle: LSPDiagnosticHint, + DeadLink: LSPDiagnosticNone, + }, + }, Filters: map[string]string{ "recents": "--created-after '2 weeks ago'", "journal": "journal --sort created", @@ -317,6 +333,12 @@ func TestParseMergesGroupConfig(t *testing.T) { LinkDropExtension: true, }, }, + LSP: LSPConfig{ + Diagnostics: LSPDiagnosticConfig{ + WikiTitle: LSPDiagnosticNone, + DeadLink: LSPDiagnosticError, + }, + }, Filters: make(map[string]string), Aliases: make(map[string]string), Extra: map[string]string{ @@ -401,6 +423,34 @@ func TestParseMarkdownLinkEncodePath(t *testing.T) { test("custom", false) } +func TestParseLSPDiagnosticsSeverity(t *testing.T) { + test := func(value string, expected LSPDiagnosticSeverity) { + toml := fmt.Sprintf(` + [lsp.diagnostics] + wiki-title = "%s" + dead-link = "%s" + `, value, value) + conf, err := ParseConfig([]byte(toml), ".zk/config.toml", NewDefaultConfig()) + assert.Nil(t, err) + assert.Equal(t, conf.LSP.Diagnostics.WikiTitle, expected) + assert.Equal(t, conf.LSP.Diagnostics.DeadLink, expected) + } + + test("", LSPDiagnosticNone) + test("none", LSPDiagnosticNone) + test("error", LSPDiagnosticError) + test("warning", LSPDiagnosticWarning) + test("info", LSPDiagnosticInfo) + test("hint", LSPDiagnosticHint) + + toml := ` + [lsp.diagnostics] + wiki-title = "foobar" + ` + _, err := ParseConfig([]byte(toml), ".zk/config.toml", NewDefaultConfig()) + assert.Err(t, err, "foobar: unknown LSP diagnostic severity - may be none, hint, info, warning or error") +} + func TestGroupConfigClone(t *testing.T) { original := GroupConfig{ Paths: []string{"original"}, diff --git a/internal/core/notebook_store.go b/internal/core/notebook_store.go index d23e58c..ce90cee 100644 --- a/internal/core/notebook_store.go +++ b/internal/core/notebook_store.go @@ -296,6 +296,22 @@ multiword-tags = false #fzf-preview = "bat -p --color always {-1}" +# LSP +# +# Configure basic editor integration for LSP-compatible editors. +# See https://github.com/mickael-menu/zk/blob/main/docs/editors-integration.md +# +[lsp] + +[lsp.diagnostics] +# Each diagnostic can have for value: none, hint, info, warning, error + +# Report titles of wiki-links as hints. +#wiki-title = "hint" +# Warn for dead links between notes. +dead-link = "error" + + # NAMED FILTERS # # A named filter is a set of note filtering options used frequently together. diff --git a/main.go b/main.go index b65f0e2..9e3d594 100644 --- a/main.go +++ b/main.go @@ -68,7 +68,8 @@ func main() { fatalIfError(err) searchDirs, err := notebookSearchDirs(dirs) fatalIfError(err) - container.SetCurrentNotebook(searchDirs) + err = container.SetCurrentNotebook(searchDirs) + fatalIfError(err) // Run the alias or command. if isAlias, err := runAlias(container, os.Args[1:]); isAlias {