LSP auto-completion of YAML frontmatter tags (#146)

pull/147/head^2
Mickaël Menu 2 years ago committed by GitHub
parent 04a157f3be
commit 6ccbbe8613
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save