diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef1c4e..bcff917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added + +* [#144](https://github.com/mickael-menu/zk/issues/144) LSP auto-completion of YAML frontmatter tags. + ### Fixed * [#126](https://github.com/mickael-menu/zk/issues/126) Embedded image links shown as not found. diff --git a/internal/adapter/lsp/document.go b/internal/adapter/lsp/document.go index c38363e..bf32d4f 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" + strutil "github.com/mickael-menu/zk/internal/util/strings" "github.com/tliron/glsp" protocol "github.com/tliron/glsp/protocol_3_16" ) @@ -93,25 +94,13 @@ func (d *document) ApplyChanges(changes []interface{}) { d.lines = nil } -var nonEmptyString = regexp.MustCompile(`\S+`) - // WordAt returns the word found at the given location. -// Credit https://github.com/aca/neuron-language-server/blob/450a7cff71c14e291ee85ff8a0614fa9d4dd5145/utils.go#L13 func (d *document) WordAt(pos protocol.Position) string { line, ok := d.GetLine(int(pos.Line)) if !ok { return "" } - - charIdx := int(pos.Character) - wordIdxs := nonEmptyString.FindAllStringIndex(line, -1) - for _, wordIdx := range wordIdxs { - if wordIdx[0] <= charIdx && charIdx <= wordIdx[1] { - return line[wordIdx[0]:wordIdx[1]] - } - } - - return "" + return strutil.WordAt(line, int(pos.Character)) } // ContentAtRange returns the document text at given range. @@ -241,6 +230,28 @@ func (d *document) DocumentLinks() ([]documentLink, error) { return links, nil } +// IsTagPosition returns whether the given caret position is inside a tag (YAML frontmatter, #hashtag, etc.). +func (d *document) IsTagPosition(position protocol.Position, noteContentParser core.NoteContentParser) bool { + lines := strutil.CopyList(d.GetLines()) + lineIdx := int(position.Line) + charIdx := int(position.Character) + line := lines[lineIdx] + // https://github.com/mickael-menu/zk/issues/144#issuecomment-1006108485 + line = line[:charIdx] + "ZK_PLACEHOLDER" + line[charIdx:] + lines[lineIdx] = line + targetWord := strutil.WordAt(line, charIdx) + if targetWord == "" { + return false + } + + content := strings.Join(lines, "\n") + note, err := noteContentParser.ParseNoteContent(content) + if err != nil { + return false + } + return strutil.Contains(note.Tags, targetWord) +} + type documentLink struct { Href string Range protocol.Range diff --git a/internal/adapter/lsp/server.go b/internal/adapter/lsp/server.go index f3c83f6..d3fb817 100644 --- a/internal/adapter/lsp/server.go +++ b/internal/adapter/lsp/server.go @@ -22,23 +22,25 @@ import ( // Server holds the state of the Language Server. type Server struct { - server *glspserv.Server - notebooks *core.NotebookStore - documents *documentStore - templateLoader core.TemplateLoader - fs core.FileStorage - logger util.Logger + server *glspserv.Server + notebooks *core.NotebookStore + documents *documentStore + noteContentParser core.NoteContentParser + templateLoader core.TemplateLoader + 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 - TemplateLoader core.TemplateLoader - FS core.FileStorage + Name string + Version string + LogFile opt.String + Logger *util.ProxyLogger + Notebooks *core.NotebookStore + NoteContentParser core.NoteContentParser + TemplateLoader core.TemplateLoader + FS core.FileStorage } // NewServer creates a new Server instance. @@ -59,12 +61,13 @@ func NewServer(opts ServerOpts) *Server { } server := &Server{ - server: glspServer, - notebooks: opts.Notebooks, - documents: newDocumentStore(fs, opts.Logger), - templateLoader: opts.TemplateLoader, - fs: fs, - logger: opts.Logger, + server: glspServer, + notebooks: opts.Notebooks, + documents: newDocumentStore(fs, opts.Logger), + noteContentParser: opts.NoteContentParser, + templateLoader: opts.TemplateLoader, + fs: fs, + logger: opts.Logger, } var clientCapabilities protocol.ClientCapabilities @@ -178,8 +181,6 @@ func NewServer(opts ServerOpts) *Server { } handler.TextDocumentCompletion = func(context *glsp.Context, params *protocol.CompletionParams) (interface{}, error) { - // We don't use the context because clients might not send it. Instead, - // we'll look for trigger patterns in the document. doc, ok := server.documents.Get(params.TextDocument.URI) if !ok { return nil, nil @@ -190,28 +191,11 @@ func NewServer(opts ServerOpts) *Server { return nil, err } - switch doc.LookBehind(params.Position, 3) { - case "]((": - return server.buildLinkCompletionList(doc, notebook, params) - } - - switch doc.LookBehind(params.Position, 2) { - case "[[": - return server.buildLinkCompletionList(doc, notebook, params) - } - - switch doc.LookBehind(params.Position, 1) { - case "#": - if notebook.Config.Format.Markdown.Hashtags { - return server.buildTagCompletionList(notebook, "#") - } - case ":": - if notebook.Config.Format.Markdown.ColonTags { - return server.buildTagCompletionList(notebook, ":") - } + if params.Context != nil && params.Context.TriggerKind == protocol.CompletionTriggerKindInvoked { + return server.buildInvokedCompletionList(notebook, doc, params.Position) + } else { + return server.buildTriggerCompletionList(notebook, doc, params.Position) } - - return nil, nil } handler.CompletionItemResolve = func(context *glsp.Context, params *protocol.CompletionItem) (*protocol.CompletionItem, error) { @@ -649,7 +633,45 @@ func (s *Server) refreshDiagnosticsOfDocument(doc *document, notify glsp.NotifyF }() } -func (s *Server) buildTagCompletionList(notebook *core.Notebook, triggerChar string) ([]protocol.CompletionItem, error) { +// buildInvokedCompletionList builds the completion item response for a +// completion started automatically when typing an identifier, or manually. +func (s *Server) buildInvokedCompletionList(notebook *core.Notebook, doc *document, position protocol.Position) ([]protocol.CompletionItem, error) { + if !doc.IsTagPosition(position, s.noteContentParser) { + return nil, nil + } + return s.buildTagCompletionList(notebook, doc.WordAt(position)) +} + +// buildTriggerCompletionList builds the completion item response for a +// completion started with a trigger character. +func (s *Server) buildTriggerCompletionList(notebook *core.Notebook, doc *document, position protocol.Position) ([]protocol.CompletionItem, error) { + // We don't use the context because clients might not send it. Instead, + // we'll look for trigger patterns in the document. + switch doc.LookBehind(position, 3) { + case "]((": + return s.buildLinkCompletionList(notebook, doc, position) + } + + switch doc.LookBehind(position, 2) { + case "[[": + return s.buildLinkCompletionList(notebook, doc, position) + } + + switch doc.LookBehind(position, 1) { + case "#": + if notebook.Config.Format.Markdown.Hashtags { + return s.buildTagCompletionList(notebook, "#") + } + case ":": + if notebook.Config.Format.Markdown.ColonTags { + return s.buildTagCompletionList(notebook, ":") + } + } + + return nil, nil +} + +func (s *Server) buildTagCompletionList(notebook *core.Notebook, prefix string) ([]protocol.CompletionItem, error) { tags, err := notebook.FindCollections(core.CollectionKindTag, nil) if err != nil { return nil, err @@ -659,7 +681,7 @@ func (s *Server) buildTagCompletionList(notebook *core.Notebook, triggerChar str for _, tag := range tags { items = append(items, protocol.CompletionItem{ Label: tag.Name, - InsertText: s.buildInsertForTag(tag.Name, triggerChar, notebook.Config), + InsertText: s.buildInsertForTag(tag.Name, prefix, notebook.Config), Detail: stringPtr(fmt.Sprintf("%d %s", tag.NoteCount, strutil.Pluralize("note", tag.NoteCount))), }) } @@ -667,8 +689,8 @@ func (s *Server) buildTagCompletionList(notebook *core.Notebook, triggerChar str return items, nil } -func (s *Server) buildInsertForTag(name string, triggerChar string, config core.Config) *string { - switch triggerChar { +func (s *Server) buildInsertForTag(name string, prefix string, config core.Config) *string { + switch prefix { case ":": name += ":" case "#": @@ -683,8 +705,8 @@ func (s *Server) buildInsertForTag(name string, triggerChar string, config core. return &name } -func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, params *protocol.CompletionParams) ([]protocol.CompletionItem, error) { - linkFormatter, err := newLinkFormatter(doc, notebook, params) +func (s *Server) buildLinkCompletionList(notebook *core.Notebook, doc *document, position protocol.Position) ([]protocol.CompletionItem, error) { + linkFormatter, err := newLinkFormatter(notebook, doc, position) if err != nil { return nil, err } @@ -701,7 +723,7 @@ func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, var items []protocol.CompletionItem for _, note := range notes { - item, err := s.newCompletionItem(notebook, note, doc, params.Position, linkFormatter, templates) + item, err := s.newCompletionItem(notebook, note, doc, position, linkFormatter, templates) if err != nil { s.logger.Err(err) continue @@ -713,8 +735,8 @@ func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, return items, nil } -func newLinkFormatter(doc *document, notebook *core.Notebook, params *protocol.CompletionParams) (core.LinkFormatter, error) { - if doc.LookBehind(params.Position, 3) == "]((" { +func newLinkFormatter(notebook *core.Notebook, doc *document, position protocol.Position) (core.LinkFormatter, error) { + if doc.LookBehind(position, 3) == "]((" { return core.NewMarkdownLinkFormatter(notebook.Config.Format.Markdown, true) } else { return notebook.NewLinkFormatter() diff --git a/internal/cli/cmd/init.go b/internal/cli/cmd/init.go index 5dce176..dbc9e01 100644 --- a/internal/cli/cmd/init.go +++ b/internal/cli/cmd/init.go @@ -89,9 +89,9 @@ func startInitWizard() (core.InitOpts, error) { opts.WikiLinks = answers.WikiLink - opts.Hashtags = strings.InList(answers.Tags, hashtag) - opts.MultiwordTags = strings.InList(answers.Tags, multiwordTag) - opts.ColonTags = strings.InList(answers.Tags, colonTag) + opts.Hashtags = strings.Contains(answers.Tags, hashtag) + opts.MultiwordTags = strings.Contains(answers.Tags, multiwordTag) + opts.ColonTags = strings.Contains(answers.Tags, colonTag) return opts, nil } diff --git a/internal/cli/cmd/lsp.go b/internal/cli/cmd/lsp.go index 286773e..a0a4bda 100644 --- a/internal/cli/cmd/lsp.go +++ b/internal/cli/cmd/lsp.go @@ -13,13 +13,14 @@ type LSP struct { func (cmd *LSP) Run(container *cli.Container) error { server := lsp.NewServer(lsp.ServerOpts{ - Name: "zk", - Version: container.Version, - Logger: container.Logger, - LogFile: opt.NewNotEmptyString(cmd.Log), - Notebooks: container.Notebooks, - TemplateLoader: container.TemplateLoader, - FS: container.FS, + Name: "zk", + Version: container.Version, + Logger: container.Logger, + LogFile: opt.NewNotEmptyString(cmd.Log), + Notebooks: container.Notebooks, + NoteContentParser: container.NoteContentParser, + TemplateLoader: container.TemplateLoader, + FS: container.FS, }) return server.Run() diff --git a/internal/cli/container.go b/internal/cli/container.go index 6467f2c..bfe3397 100644 --- a/internal/cli/container.go +++ b/internal/cli/container.go @@ -34,6 +34,7 @@ type Container struct { Terminal *term.Terminal FS *fs.FileStorage TemplateLoader core.TemplateLoader + NoteContentParser core.NoteContentParser WorkingDir string Notebooks *core.NotebookStore currentNotebook *core.Notebook @@ -70,13 +71,23 @@ func NewContainer(version string) (*Container, error) { } } + noteContentParser := markdown.NewParser( + markdown.ParserOpts{ + HashtagEnabled: config.Format.Markdown.Hashtags, + MultiWordTagEnabled: config.Format.Markdown.MultiwordTags, + ColontagEnabled: config.Format.Markdown.ColonTags, + }, + logger, + ) + return &Container{ - Version: version, - Config: config, - Logger: logger, - Terminal: term, - FS: fs, - TemplateLoader: templateLoader, + Version: version, + Config: config, + Logger: logger, + Terminal: term, + FS: fs, + TemplateLoader: templateLoader, + NoteContentParser: noteContentParser, Notebooks: core.NewNotebookStore(config, core.NotebookStorePorts{ FS: fs, TemplateLoader: templateLoader, @@ -88,15 +99,8 @@ func NewContainer(version string) (*Container, error) { } notebook := core.NewNotebook(path, config, core.NotebookPorts{ - NoteIndex: sqlite.NewNoteIndex(db, logger), - NoteContentParser: markdown.NewParser( - markdown.ParserOpts{ - HashtagEnabled: config.Format.Markdown.Hashtags, - MultiWordTagEnabled: config.Format.Markdown.MultiwordTags, - ColontagEnabled: config.Format.Markdown.ColonTags, - }, - logger, - ), + NoteIndex: sqlite.NewNoteIndex(db, logger), + NoteContentParser: noteContentParser, TemplateLoaderFactory: func(language string) (core.TemplateLoader, error) { loader := handlebars.NewLoader(handlebars.LoaderOpts{ LookupPaths: []string{ diff --git a/internal/cli/filtering.go b/internal/cli/filtering.go index 43c2011..82eac95 100644 --- a/internal/cli/filtering.go +++ b/internal/cli/filtering.go @@ -48,7 +48,7 @@ func (f Filtering) ExpandNamedFilters(filters map[string]string, expandedFilters actualPaths := []string{} for _, path := range f.Path { - if filter, ok := filters[path]; ok && !strings.InList(expandedFilters, path) { + if filter, ok := filters[path]; ok && !strings.Contains(expandedFilters, path) { wrap := errors.Wrapperf("failed to expand named filter `%v`", path) var parsedFilter Filtering diff --git a/internal/util/strings/strings.go b/internal/util/strings/strings.go index 518ce96..0dd36c1 100644 --- a/internal/util/strings/strings.go +++ b/internal/util/strings/strings.go @@ -3,6 +3,7 @@ package strings import ( "bufio" "net/url" + "regexp" "strconv" "strings" ) @@ -108,16 +109,6 @@ func RemoveBlank(strs []string) []string { return res } -// InList returns whether the string is part of the given list of strings. -func InList(strings []string, s string) bool { - for _, c := range strings { - if c == s { - return true - } - } - return false -} - // Expand literal escaped whitespace characters in the given string to their // actual character. func ExpandWhitespaceLiterals(s string) string { @@ -136,3 +127,24 @@ func Contains(s []string, e string) bool { } return false } + +// WordAt returns the word found at the given character position. +// Credit https://github.com/aca/neuron-language-server/blob/450a7cff71c14e291ee85ff8a0614fa9d4dd5145/utils.go#L13 +func WordAt(str string, index int) string { + wordIdxs := wordRegex.FindAllStringIndex(str, -1) + for _, wordIdx := range wordIdxs { + if wordIdx[0] <= index && index <= wordIdx[1] { + return str[wordIdx[0]:wordIdx[1]] + } + } + + return "" +} + +var wordRegex = regexp.MustCompile(`[^ \t\n\f\r,;\[\]\"\']+`) + +func CopyList(list []string) []string { + out := make([]string, len(list)) + copy(out, list) + return out +} diff --git a/internal/util/strings/strings_test.go b/internal/util/strings/strings_test.go index f4ca2c5..6ac1b24 100644 --- a/internal/util/strings/strings_test.go +++ b/internal/util/strings/strings_test.go @@ -107,9 +107,18 @@ func TestRemoveBlank(t *testing.T) { test([]string{"One", "Two", " "}, []string{"One", "Two"}) } -func TestInList(t *testing.T) { +func TestExpandWhitespaceLiterals(t *testing.T) { + test := func(s string, expected string) { + assert.Equal(t, ExpandWhitespaceLiterals(s), expected) + } + + test(`nothing`, "nothing") + test(`newline\ntab\t`, "newline\ntab\t") +} + +func TestContains(t *testing.T) { test := func(items []string, s string, expected bool) { - assert.Equal(t, InList(items, s), expected) + assert.Equal(t, Contains(items, s), expected) } test([]string{}, "", false) @@ -120,11 +129,25 @@ func TestInList(t *testing.T) { test([]string{"one", "two"}, "three", false) } -func TestExpandWhitespaceLiterals(t *testing.T) { - test := func(s string, expected string) { - assert.Equal(t, ExpandWhitespaceLiterals(s), expected) +func TestWordAt(t *testing.T) { + test := func(s string, pos int, expected string) { + assert.Equal(t, WordAt(s, pos), expected) } - test(`nothing`, "nothing") - test(`newline\ntab\t`, "newline\ntab\t") + test("", 0, "") + test(" ", 2, "") + test("word", 2, "word") + test(" word ", 4, "word") + test("one two three", 4, "two") + test("one two three", 5, "two") + test("one two three", 7, "two") + test("one two-third three", 5, "two-third") + test("one two,three", 5, "two") + test("one two;three", 5, "two") + test("one [two] three", 5, "two") + test("one \"two\" three", 5, "two") + test("one 'two' three", 5, "two") + test("one\ntwo\nthree", 5, "two") + test("one\ttwo\tthree", 5, "two") + test("one @:~two three", 5, "@:~two") }