You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
zk/internal/adapter/lsp/document.go

307 lines
8.3 KiB
Go

package lsp
import (
"net/url"
"path/filepath"
"regexp"
"strings"
"unicode/utf16"
"github.com/tliron/glsp"
protocol "github.com/tliron/glsp/protocol_3_16"
"github.com/zk-org/zk/internal/core"
"github.com/zk-org/zk/internal/util"
"github.com/zk-org/zk/internal/util/errors"
strutil "github.com/zk-org/zk/internal/util/strings"
)
// documentStore holds opened documents.
type documentStore struct {
documents map[string]*document
fs core.FileStorage
logger util.Logger
}
func newDocumentStore(fs core.FileStorage, logger util.Logger) *documentStore {
return &documentStore{
documents: map[string]*document{},
fs: fs,
logger: logger,
}
}
func (s *documentStore) DidOpen(params protocol.DidOpenTextDocumentParams, notify glsp.NotifyFunc) (*document, error) {
langID := params.TextDocument.LanguageID
if langID != "markdown" && langID != "vimwiki" && langID != "pandoc" {
return nil, nil
}
uri := params.TextDocument.URI
path, err := s.normalizePath(uri)
if err != nil {
return nil, err
}
doc := &document{
URI: uri,
Path: path,
Content: params.TextDocument.Text,
}
s.documents[path] = doc
return doc, nil
}
func (s *documentStore) Close(uri protocol.DocumentUri) {
delete(s.documents, uri)
}
func (s *documentStore) Get(pathOrURI string) (*document, bool) {
path, err := s.normalizePath(pathOrURI)
if err != nil {
s.logger.Err(err)
return nil, false
}
d, ok := s.documents[path]
return d, ok
}
func (s *documentStore) normalizePath(pathOrUri string) (string, error) {
path, err := uriToPath(pathOrUri)
if err != nil {
return "", errors.Wrapf(err, "unable to parse URI: %s", pathOrUri)
}
return s.fs.Canonical(path), nil
}
// document represents an opened file.
type document struct {
URI protocol.DocumentUri
Path string
NeedsRefreshDiagnostics bool
Content string
lines []string
}
// ApplyChanges updates the content of the document from LSP textDocument/didChange events.
func (d *document) ApplyChanges(changes []interface{}) {
for _, change := range changes {
switch c := change.(type) {
case protocol.TextDocumentContentChangeEvent:
startIndex, endIndex := c.Range.IndexesIn(d.Content)
d.Content = d.Content[:startIndex] + c.Text + d.Content[endIndex:]
case protocol.TextDocumentContentChangeEventWhole:
d.Content = c.Text
}
}
d.lines = nil
}
// WordAt returns the word found at the given location.
func (d *document) WordAt(pos protocol.Position) string {
line, ok := d.GetLine(int(pos.Line))
if !ok {
return ""
}
return strutil.WordAt(line, int(pos.Character))
}
// ContentAtRange returns the document text at given range.
func (d *document) ContentAtRange(rng protocol.Range) string {
startIndex, endIndex := rng.IndexesIn(d.Content)
return d.Content[startIndex:endIndex]
}
// GetLine returns the line at the given index.
func (d *document) GetLine(index int) (string, bool) {
lines := d.GetLines()
if index < 0 || index > len(lines) {
return "", false
}
return lines[index], true
}
// GetLines returns all the lines in the document.
func (d *document) GetLines() []string {
if d.lines == nil {
// We keep \r on purpose, to avoid messing up position conversions.
d.lines = strings.Split(d.Content, "\n")
}
return d.lines
}
// LookBehind returns the n characters before the given position, on the same line.
func (d *document) LookBehind(pos protocol.Position, length int) string {
line, ok := d.GetLine(int(pos.Line))
utf16Bytes := utf16.Encode([]rune(line))
if !ok {
return ""
}
charIdx := int(pos.Character)
if length > charIdx {
return string(utf16.Decode(utf16Bytes[0:charIdx]))
}
return string(utf16.Decode(utf16Bytes[(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))
utf16Bytes := utf16.Encode([]rune(line))
if !ok {
return ""
}
lineLength := len(utf16Bytes)
charIdx := int(pos.Character)
if lineLength <= charIdx+length {
return string(utf16.Decode(utf16Bytes[charIdx:]))
}
return string(utf16.Decode(utf16Bytes[charIdx:(charIdx + length)]))
}
var wikiLinkRegex = regexp.MustCompile(`\[?\[\[(.+?)(?: *\| *(.+?))?\]\]`)
var markdownLinkRegex = regexp.MustCompile(`\[([^\]]+?[^\\])\]\((.+?[^\\])\)`)
var fileURIregex = regexp.MustCompile(`file:///`)
// LinkFromRoot returns a Link to this document from the root of the given
// notebook.
func (d *document) LinkFromRoot(nb *core.Notebook) (*documentLink, error) {
href, err := nb.RelPath(d.Path)
if err != nil {
return nil, err
}
return &documentLink{
Href: href,
RelativeToDir: nb.Path,
}, nil
}
// DocumentLinkAt returns the internal or external link found in the document
// at the given position.
func (d *document) DocumentLinkAt(pos protocol.Position) (*documentLink, error) {
links, err := d.DocumentLinks()
if err != nil {
return nil, err
}
for _, link := range links {
if positionInRange(d.Content, link.Range, pos) {
return &link, nil
}
}
return nil, nil
}
// DocumentLinks returns all the internal and external links found in the
// document.
func (d *document) DocumentLinks() ([]documentLink, error) {
links := []documentLink{}
lines := d.GetLines()
for lineIndex, line := range lines {
appendLink := func(href string, start, end int, hasTitle bool, isWikiLink bool) {
if href == "" {
return
}
// Go regexes work with bytes, but the LSP client expects character indexes.
start = strutil.ByteIndexToRuneIndex(line, start)
end = strutil.ByteIndexToRuneIndex(line, end)
links = append(links, documentLink{
Href: href,
RelativeToDir: filepath.Dir(d.Path),
Range: protocol.Range{
Start: protocol.Position{
Line: protocol.UInteger(lineIndex),
Character: protocol.UInteger(start),
},
End: protocol.Position{
Line: protocol.UInteger(lineIndex),
Character: protocol.UInteger(end),
},
},
HasTitle: hasTitle,
IsWikiLink: isWikiLink,
})
}
// extract link paths from [title](path) patterns
// note: match[0:1] is the entire match, match[2:3] is the contents of
// brackets, match[4:5] is contents of parentheses
for _, match := range markdownLinkRegex.FindAllStringSubmatchIndex(line, -1) {
// Ignore embedded images ![title](file.png)
if match[0] > 0 && line[match[0]-1] == '!' {
continue
}
// ignore tripple dash file URIs [title](file:///foo.go)
if match[5]-match[4] >= 8 {
linkURL := line[match[4]:match[5]]
fileURIresult := linkURL[:8]
if fileURIregex.MatchString(fileURIresult) {
continue
}
}
href := line[match[4]:match[5]]
// Decode the href if it's percent-encoded
if decodedHref, err := url.PathUnescape(href); err == nil {
href = decodedHref
}
appendLink(href, match[0], match[1], false, false)
}
for _, match := range wikiLinkRegex.FindAllStringSubmatchIndex(line, -1) {
href := line[match[2]:match[3]]
hasTitle := match[4] != -1
appendLink(href, match[0], match[1], hasTitle, true)
}
}
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/zk-org/zk/issues/144#issuecomment-1006108485
line = line[:charIdx] + "ZK_PLACEHOLDER" + line[charIdx:]
lines[lineIdx] = line
targetWord := strutil.WordAt(line, charIdx)
if targetWord == "" {
return false
}
if string(targetWord[0]) == "#" {
targetWord = targetWord[1:]
}
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
RelativeToDir string
Range protocol.Range
// HasTitle indicates whether this link has a title information. For
// example [[filename]] doesn't but [[filename|title]] does.
HasTitle bool
// IsWikiLink indicates whether this link is a [[WikiLink]] instead of a
// regular Markdown link.
IsWikiLink bool
}