Publish LSP diagnostics for dead links and wiki-link titles (#42)

pull/43/head
Mickaël Menu 3 years ago committed by GitHub
parent e8cb1d8046
commit aa68199df8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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]

@ -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"
```

@ -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"
```

@ -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

@ -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
}

@ -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) {

@ -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.

@ -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)
}
}

@ -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"},

@ -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.

@ -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 {

Loading…
Cancel
Save