diff --git a/cmd/container.go b/adapter/container.go similarity index 99% rename from cmd/container.go rename to adapter/container.go index cac1f498..a45ac505 100644 --- a/cmd/container.go +++ b/adapter/container.go @@ -1,4 +1,4 @@ -package cmd +package adapter import ( "io" diff --git a/adapter/lsp/server.go b/adapter/lsp/server.go index 10002b18..86a473e4 100644 --- a/adapter/lsp/server.go +++ b/adapter/lsp/server.go @@ -1,8 +1,16 @@ package lsp import ( + "fmt" + "strings" + + "github.com/mickael-menu/zk/adapter" + "github.com/mickael-menu/zk/adapter/sqlite" + "github.com/mickael-menu/zk/core/note" + "github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/util/errors" "github.com/mickael-menu/zk/util/opt" + strutil "github.com/mickael-menu/zk/util/strings" "github.com/tliron/glsp" protocol "github.com/tliron/glsp/protocol_3_16" glspserv "github.com/tliron/glsp/server" @@ -12,32 +20,46 @@ import ( // Server holds the state of the Language Server. type Server struct { - server *glspserv.Server - initialized bool - clientCapabilities *protocol.ClientCapabilities + server *glspserv.Server + container *adapter.Container } // ServerOpts holds the options to create a new Server. type ServerOpts struct { - Name string - Version string - LogFile opt.String + Name string + Version string + LogFile opt.String + Container *adapter.Container } // NewServer creates a new Server instance. func NewServer(opts ServerOpts) *Server { - handler := protocol.Handler{} debug := !opts.LogFile.IsNull() - server := &Server{ - server: glspserv.NewServer(&handler, opts.Name, debug), - } - if debug { logging.Configure(10, opts.LogFile.Value) } + workspace := newWorkspace() + handler := protocol.Handler{} + server := &Server{ + server: glspserv.NewServer(&handler, opts.Name, debug), + container: opts.Container, + } + handler.Initialize = func(context *glsp.Context, params *protocol.InitializeParams) (interface{}, error) { - server.clientCapabilities = ¶ms.Capabilities + // clientCapabilities = ¶ms.Capabilities + + if len(params.WorkspaceFolders) > 0 { + for _, f := range params.WorkspaceFolders { + workspace.addFolder(f.URI) + } + } else if params.RootURI != nil { + workspace.addFolder(*params.RootURI) + } else if params.RootPath != nil { + workspace.addFolder(*params.RootPath) + } + + server.container.OpenNotebook(workspace.folders) // To see the logs with coc.nvim, run :CocCommand workspace.showOutput // https://github.com/neoclide/coc.nvim/wiki/Debug-language-server#using-output-channel @@ -46,13 +68,27 @@ func NewServer(opts ServerOpts) *Server { } capabilities := handler.CreateServerCapabilities() - capabilities.TextDocumentSync = protocol.TextDocumentSyncKindFull - capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{ - ResolveProvider: boolPtr(true), - } - capabilities.CompletionProvider = &protocol.CompletionOptions{ - ResolveProvider: boolPtr(true), - TriggerCharacters: []string{"#"}, + + zk, err := server.container.Zk() + if err == nil { + capabilities.TextDocumentSync = protocol.TextDocumentSyncKindFull + capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{ + ResolveProvider: boolPtr(true), + } + + triggerChars := []string{} + + // Setup tag completion trigger characters + if zk.Config.Format.Markdown.Hashtags { + triggerChars = append(triggerChars, "#") + } + if zk.Config.Format.Markdown.ColonTags { + triggerChars = append(triggerChars, ":") + } + + capabilities.CompletionProvider = &protocol.CompletionOptions{ + TriggerCharacters: triggerChars, + } } return protocol.InitializeResult{ @@ -65,7 +101,6 @@ func NewServer(opts ServerOpts) *Server { } handler.Initialized = func(context *glsp.Context, params *protocol.InitializedParams) error { - server.initialized = true return nil } @@ -79,7 +114,29 @@ func NewServer(opts ServerOpts) *Server { return nil } - // handler.TextDocumentCompletion = textDocumentCompletion + handler.WorkspaceDidChangeWorkspaceFolders = func(context *glsp.Context, params *protocol.DidChangeWorkspaceFoldersParams) error { + for _, f := range params.Event.Added { + workspace.addFolder(f.URI) + } + for _, f := range params.Event.Removed { + workspace.removeFolder(f.URI) + } + return nil + } + + handler.TextDocumentCompletion = func(context *glsp.Context, params *protocol.CompletionParams) (interface{}, error) { + triggerChar := params.Context.TriggerCharacter + if params.Context.TriggerKind != protocol.CompletionTriggerKindTriggerCharacter || triggerChar == nil { + return nil, nil + } + + switch *triggerChar { + case "#", ":": + return server.buildTagCompletionList(*triggerChar) + } + + return nil, nil + } return server } @@ -89,7 +146,59 @@ func (s *Server) Run() error { return errors.Wrap(s.server.RunStdio(), "lsp") } +func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.CompletionItem, error) { + zk, err := s.container.Zk() + if err != nil { + return nil, err + } + db, _, err := s.container.Database(false) + if err != nil { + return nil, err + } + + var tags []note.Collection + err = db.WithTransaction(func(tx sqlite.Transaction) error { + tags, err = sqlite.NewCollectionDAO(tx, s.container.Logger).FindAll(note.CollectionKindTag) + return err + }) + if err != nil { + return nil, err + } + + var items []protocol.CompletionItem + for _, tag := range tags { + items = append(items, protocol.CompletionItem{ + Label: tag.Name, + InsertText: s.buildInsertForTag(tag.Name, triggerChar, zk.Config), + Detail: stringPtr(fmt.Sprintf("%d %s", tag.NoteCount, strutil.Pluralize("note", tag.NoteCount))), + }) + } + + return items, nil +} + +func (s *Server) buildInsertForTag(name string, triggerChar string, config zk.Config) *string { + switch triggerChar { + case ":": + name += ":" + case "#": + if strings.Contains(name, " ") { + if config.Format.Markdown.MultiwordTags { + name += "#" + } else { + name = strings.ReplaceAll(name, " ", "\\ ") + } + } + } + return &name +} + func boolPtr(v bool) *bool { b := v return &b } + +func stringPtr(v string) *string { + s := v + return &s +} diff --git a/adapter/lsp/workspace.go b/adapter/lsp/workspace.go new file mode 100644 index 00000000..e7136139 --- /dev/null +++ b/adapter/lsp/workspace.go @@ -0,0 +1,28 @@ +package lsp + +import "strings" + +type workspace struct { + folders []string +} + +func newWorkspace() *workspace { + return &workspace{ + folders: []string{}, + } +} + +func (w *workspace) addFolder(folder string) { + folder = strings.TrimPrefix(folder, "file://") + w.folders = append(w.folders, folder) +} + +func (w *workspace) removeFolder(folder string) { + folder = strings.TrimPrefix(folder, "file://") + for i, f := range w.folders { + if f == folder { + w.folders = append(w.folders[:i], w.folders[i+1:]...) + break + } + } +} diff --git a/adapter/sqlite/collection_dao.go b/adapter/sqlite/collection_dao.go index 71a8d417..88660465 100644 --- a/adapter/sqlite/collection_dao.go +++ b/adapter/sqlite/collection_dao.go @@ -18,6 +18,7 @@ type CollectionDAO struct { // Prepared SQL statements createCollectionStmt *LazyStmt findCollectionStmt *LazyStmt + findAllCollectionsStmt *LazyStmt findAssociationStmt *LazyStmt createAssociationStmt *LazyStmt removeAssociationsStmt *LazyStmt @@ -42,6 +43,16 @@ func NewCollectionDAO(tx Transaction, logger util.Logger) *CollectionDAO { WHERE kind = ? AND name = ? `), + // Find all collections. + findAllCollectionsStmt: tx.PrepareLazy(` + SELECT c.name, COUNT(nc.id) as count + FROM collections c + LEFT JOIN notes_collections nc ON nc.collection_id = c.id + WHERE kind = ? + GROUP BY c.id + ORDER BY c.name + `), + // Returns whether a note and a collection are associated. findAssociationStmt: tx.PrepareLazy(` SELECT id FROM notes_collections @@ -77,6 +88,33 @@ func (d *CollectionDAO) FindOrCreate(kind note.CollectionKind, name string) (cor } } +func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, error) { + rows, err := d.findAllCollectionsStmt.Query(kind) + if err != nil { + return []note.Collection{}, err + } + defer rows.Close() + + collections := []note.Collection{} + + for rows.Next() { + var name string + var count int + err := rows.Scan(&name, &count) + if err != nil { + return collections, err + } + + collections = append(collections, note.Collection{ + Kind: kind, + Name: name, + NoteCount: count, + }) + } + + return collections, nil +} + func (d *CollectionDAO) findCollection(kind note.CollectionKind, name string) (core.CollectionId, error) { wrap := errors.Wrapperf("failed to get %s named %s", kind, name) diff --git a/adapter/sqlite/collection_dao_test.go b/adapter/sqlite/collection_dao_test.go index 22e3a325..4d161aff 100644 --- a/adapter/sqlite/collection_dao_test.go +++ b/adapter/sqlite/collection_dao_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/mickael-menu/zk/core" + "github.com/mickael-menu/zk/core/note" "github.com/mickael-menu/zk/util" "github.com/mickael-menu/zk/util/test/assert" ) @@ -32,6 +33,25 @@ func TestCollectionDAOFindOrCreate(t *testing.T) { }) } +func TestCollectionDaoFindAll(t *testing.T) { + testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) { + // Finds none + cs, err := dao.FindAll("missing") + assert.Nil(t, err) + assert.Equal(t, len(cs), 0) + + // Finds existing + cs, err = dao.FindAll("tag") + assert.Nil(t, err) + assert.Equal(t, cs, []note.Collection{ + {Kind: "tag", Name: "adventure", NoteCount: 2}, + {Kind: "tag", Name: "fantasy", NoteCount: 1}, + {Kind: "tag", Name: "fiction", NoteCount: 1}, + {Kind: "tag", Name: "history", NoteCount: 1}, + }) + }) +} + func TestCollectionDAOAssociate(t *testing.T) { testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) { // Returns existing association diff --git a/cmd/edit.go b/cmd/edit.go index 4a5026ef..407ac08d 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" + "github.com/mickael-menu/zk/adapter" "github.com/mickael-menu/zk/adapter/fzf" "github.com/mickael-menu/zk/adapter/sqlite" "github.com/mickael-menu/zk/core/note" @@ -17,7 +18,7 @@ type Edit struct { Filtering } -func (cmd *Edit) Run(container *Container) error { +func (cmd *Edit) Run(container *adapter.Container) error { zk, err := container.Zk() if err != nil { return err diff --git a/cmd/index.go b/cmd/index.go index c3a9a76b..44b1f0b1 100644 --- a/cmd/index.go +++ b/cmd/index.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + + "github.com/mickael-menu/zk/adapter" ) // Index indexes the content of all the notes in the notebook. @@ -14,7 +16,7 @@ func (cmd *Index) Help() string { return "You usually do not need to run `zk index` manually, as notes are indexed automatically when needed." } -func (cmd *Index) Run(container *Container) error { +func (cmd *Index) Run(container *adapter.Container) error { _, stats, err := container.Database(cmd.Force) if err != nil { return err diff --git a/cmd/list.go b/cmd/list.go index 4f67ccbb..198dc14f 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -5,6 +5,7 @@ import ( "io" "os" + "github.com/mickael-menu/zk/adapter" "github.com/mickael-menu/zk/adapter/fzf" "github.com/mickael-menu/zk/adapter/sqlite" "github.com/mickael-menu/zk/core/note" @@ -22,7 +23,7 @@ type List struct { Filtering } -func (cmd *List) Run(container *Container) error { +func (cmd *List) Run(container *adapter.Container) error { if cmd.Delimiter0 { cmd.Delimiter = "\x00" } diff --git a/cmd/lsp.go b/cmd/lsp.go index a6d906cb..58b8e985 100644 --- a/cmd/lsp.go +++ b/cmd/lsp.go @@ -1,18 +1,22 @@ package cmd import ( + "github.com/mickael-menu/zk/adapter" "github.com/mickael-menu/zk/adapter/lsp" "github.com/mickael-menu/zk/util/opt" ) // LSP starts a server implementing the Language Server Protocol. -type LSP struct{} +type LSP struct { + Log string `type:path placeholder:PATH help:"Absolute path to the log file"` +} -func (cmd *LSP) Run(container *Container) error { +func (cmd *LSP) Run(container *adapter.Container) error { server := lsp.NewServer(lsp.ServerOpts{ - Name: "zk", - Version: container.Version, - LogFile: opt.NewString("/tmp/zk-lsp.log"), + Name: "zk", + Version: container.Version, + LogFile: opt.NewNotEmptyString(cmd.Log), + Container: container, }) return server.Run() diff --git a/cmd/new.go b/cmd/new.go index 591cd0c8..87c4a901 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" + "github.com/mickael-menu/zk/adapter" "github.com/mickael-menu/zk/core/note" "github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/util/opt" @@ -29,7 +30,7 @@ func (cmd *New) ConfigOverrides() zk.ConfigOverrides { } } -func (cmd *New) Run(container *Container) error { +func (cmd *New) Run(container *adapter.Container) error { zk, err := container.Zk() if err != nil { return err diff --git a/core/note/parse.go b/core/note/parse.go index 15433d49..45be8726 100644 --- a/core/note/parse.go +++ b/core/note/parse.go @@ -44,6 +44,13 @@ type Parser interface { Parse(source string) (*Content, error) } +// Collection holds metadata about a note collection. +type Collection struct { + Kind CollectionKind + Name string + NoteCount int +} + // CollectionKind defines a kind of note collection, such as tags. type CollectionKind string diff --git a/main.go b/main.go index b09b6bea..1b2ccef8 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/alecthomas/kong" + "github.com/mickael-menu/zk/adapter" "github.com/mickael-menu/zk/cmd" "github.com/mickael-menu/zk/core/style" executil "github.com/mickael-menu/zk/util/exec" @@ -35,7 +36,7 @@ var cli struct { // NoInput is a flag preventing any user prompt when enabled. type NoInput bool -func (f NoInput) BeforeApply(container *cmd.Container) error { +func (f NoInput) BeforeApply(container *adapter.Container) error { container.Terminal.NoInput = true return nil } @@ -43,7 +44,7 @@ func (f NoInput) BeforeApply(container *cmd.Container) error { // ShowHelp is the default command run. It's equivalent to `zk --help`. type ShowHelp struct{} -func (cmd *ShowHelp) Run(container *cmd.Container) error { +func (cmd *ShowHelp) Run(container *adapter.Container) error { parser, err := kong.New(&cli, options(container)...) if err != nil { return err @@ -57,7 +58,7 @@ func (cmd *ShowHelp) Run(container *cmd.Container) error { func main() { // Create the dependency graph. - container, err := cmd.NewContainer(Version) + container, err := adapter.NewContainer(Version) fatalIfError(err) // Open the notebook if there's any. @@ -75,7 +76,7 @@ func main() { } } -func options(container *cmd.Container) []kong.Option { +func options(container *adapter.Container) []kong.Option { term := container.Terminal return []kong.Option{ kong.Bind(container), @@ -106,7 +107,7 @@ func fatalIfError(err error) { } // runAlias will execute a user alias if the command is one of them. -func runAlias(container *cmd.Container, args []string) (bool, error) { +func runAlias(container *adapter.Container, args []string) (bool, error) { if len(args) < 1 { return false, nil }