diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1e5006..8b77e429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `definition` will resolve and follow `[[#^block]]` and `[block](#^block)` links. - `definition` will resolve and follow `[[#header]]` and `[header](#header)` links. - A new `search` module to contain search related options. +- LSP based completion instead of source per plugin. ### Changed diff --git a/README.md b/README.md index fac59a9a..481a3956 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ _This plugin is not meant to replace Obsidian, but to complement it._ The Obsidi ## 🍴 About the fork -The original project has not been actively maintained for quite a while and with the ever-changing Neovim ecosystem, new widely used tools such as [blink.cmp](https://github.com/Saghen/blink.cmp) or [snacks.picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md) were not supported. +The original project has not been actively maintained for quite a while and with the ever-changing Neovim ecosystem, new widely used tools such as [snacks.picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md) were not supported. With bugs, issues and pull requests piling up, people from the community decided to fork and maintain the project. @@ -44,7 +44,7 @@ With bugs, issues and pull requests piling up, people from the community decided ## ⭐ Features -▶️ **Completion:** Ultra-fast, asynchronous autocompletion for note references and tags via [nvim-cmp](https://github.com/hrsh7th/nvim-cmp) or [blink.cmp](https://github.com/Saghen/blink.cmp) (triggered by typing `[[` for wiki links, `[` for markdown links, or `#` for tags) +▶️ **Completion:** Fast, asynchronous autocompletion for note references and tags via LSP completion (triggered by typing `[[` for wiki links, `[` for markdown links, or `#` for tags) 🏃 **Navigation:** Navigate throughout your vault via links, backlinks, tags and etc. @@ -137,11 +137,6 @@ There's one entry point user command for this plugin: `Obsidian` There's no required dependency, but there are a number of optional dependencies that enhance the obsidian.nvim experience. -**Completion:** - -- [blink.cmp](https://github.com/Saghen/blink.cmp) -- [nvim-cmp](https://github.com/hrsh7th/nvim-cmp) - **Pickers:** - [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) diff --git a/lua/obsidian/autocmds.lua b/lua/obsidian/autocmds.lua index aee46ff1..357e971f 100644 --- a/lua/obsidian/autocmds.lua +++ b/lua/obsidian/autocmds.lua @@ -55,13 +55,6 @@ vim.api.nvim_create_autocmd({ "BufEnter" }, { api.nav_link "prev" end, { buffer = true, desc = "Obsidian Previous Link" }) - -- Inject completion sources, providers to their plugin configurations - if opts.completion.nvim_cmp then - require("obsidian.completion.plugin_initializers.nvim_cmp").inject_sources(opts) - elseif opts.completion.blink then - require("obsidian.completion.plugin_initializers.blink").inject_sources(opts) - end - require("obsidian.lsp").start(ev.buf) exec_autocmds "ObsidianNoteEnter" diff --git a/lua/obsidian/completion/init.lua b/lua/obsidian/completion/init.lua deleted file mode 100644 index 4a7103e9..00000000 --- a/lua/obsidian/completion/init.lua +++ /dev/null @@ -1,6 +0,0 @@ -local M = { - refs = require "obsidian.completion.refs", - tags = require "obsidian.completion.tags", -} - -return M diff --git a/lua/obsidian/completion/plugin_initializers/blink.lua b/lua/obsidian/completion/plugin_initializers/blink.lua deleted file mode 100644 index c519c8b7..00000000 --- a/lua/obsidian/completion/plugin_initializers/blink.lua +++ /dev/null @@ -1,197 +0,0 @@ -local util = require "obsidian.util" -local obsidian = require "obsidian" - -local M = {} - -M.injected_once = false - -M.providers = { - { name = "obsidian", module = "obsidian.completion.sources.blink.refs" }, - { name = "obsidian_tags", module = "obsidian.completion.sources.blink.tags" }, -} - -local function add_provider(blink, provider_name, provider_module) - local add_source_provider = blink.add_source_provider or blink.add_provider - add_source_provider(provider_name, { - name = provider_name, - module = provider_module, - async = true, - opts = {}, - enabled = function() - -- Enable only in markdown buffers. - return vim.tbl_contains({ "markdown" }, vim.bo.filetype) - and vim.bo.buftype ~= "prompt" - and vim.b.completion ~= false - end, - }) -end - --- Ran once on the plugin startup -function M.register_providers() - local blink = require "blink.cmp" - - if Obsidian.opts.completion.create_new then - table.insert(M.providers, { name = "obsidian_new", module = "obsidian.completion.sources.blink.new" }) - end - - for _, provider in pairs(M.providers) do - add_provider(blink, provider.name, provider.module) - end -end - -local function add_element_to_list_if_not_exists(list, element) - if not vim.tbl_contains(list, element) then - table.insert(list, 1, element) - end -end - --- find workspaces of a path ----@param path string ----@return obsidian.Workspace -local function find_workspace(path) - return vim.iter(Obsidian.workspaces):find(function(ws) - return obsidian.api.path_is_note(path, ws) - end) -end - -local function should_return_if_not_in_workspace() - local current_file_path = vim.api.nvim_buf_get_name(0) - local buf_dir = vim.fs.dirname(current_file_path) - - local workspace = find_workspace(buf_dir) - return workspace ~= nil -end - -local function log_unexpected_type(config_path, unexpected_type, expected_type) - vim.notify( - "blink.cmp's `" - .. config_path - .. "` configuration appears to be an '" - .. unexpected_type - .. "' type, but it " - .. "should be '" - .. expected_type - .. "'. Obsidian won't update this configuration, and " - .. "completion won't work with blink.cmp", - vim.log.levels.ERROR - ) -end - ----Attempts to inject the Obsidian sources into per_filetype if that's what the user seems to use for markdown ----@param blink_sources_per_filetype table ----@return boolean true if it obsidian sources were injected into the sources.per_filetype -local function try_inject_blink_sources_into_per_filetype(blink_sources_per_filetype) - -- If the per_filetype is an empty object, then it's probably not utilized by the user - if vim.deep_equal(blink_sources_per_filetype, {}) then - return false - end - - local markdown_config = blink_sources_per_filetype["markdown"] - - -- If the markdown key is not used, then per_filetype it's probably not utilized by the user - if markdown_config == nil then - return false - end - - local markdown_config_type = type(markdown_config) - if markdown_config_type == "table" and util.islist(markdown_config) then - for _, provider in pairs(M.providers) do - add_element_to_list_if_not_exists(markdown_config, provider.name) - end - return true - elseif markdown_config_type == "function" then - local original_func = markdown_config - markdown_config = function() - local original_results = original_func() - - if should_return_if_not_in_workspace() then - return original_results - end - - for _, provider in pairs(M.providers) do - add_element_to_list_if_not_exists(original_results, provider.name) - end - return original_results - end - - -- Overwrite the original config function with the newly generated one - require("blink.cmp.config").sources.per_filetype["markdown"] = markdown_config - return true - else - log_unexpected_type( - ".sources.per_filetype['markdown']", - markdown_config_type, - "a list or a function that returns a list of sources" - ) - return true -- logged the error, returns as if this was successful to avoid further errors - end -end - ----Attempts to inject the Obsidian sources into default if that's what the user seems to use for markdown ----@param blink_sources_default (fun():string[])|(string[]) ----@return boolean true if it obsidian sources were injected into the sources.default -local function try_inject_blink_sources_into_default(blink_sources_default) - local blink_default_type = type(blink_sources_default) - if blink_default_type == "function" then - local original_func = blink_sources_default - blink_sources_default = function() - local original_results = original_func() - - if should_return_if_not_in_workspace() then - return original_results - end - - for _, provider in pairs(M.providers) do - add_element_to_list_if_not_exists(original_results, provider.name) - end - return original_results - end - - -- Overwrite the original config function with the newly generated one - require("blink.cmp.config").sources.default = blink_sources_default - return true - elseif blink_default_type == "table" and util.islist(blink_sources_default) then - for _, provider in pairs(M.providers) do - add_element_to_list_if_not_exists(blink_sources_default, provider.name) - end - - return true - elseif blink_default_type == "table" then - log_unexpected_type(".sources.default", blink_default_type, "a list") - return true -- logged the error, returns as if this was successful to avoid further errors - else - log_unexpected_type(".sources.default", blink_default_type, "a list or a function that returns a list") - return true -- logged the error, returns as if this was successful to avoid further errors - end -end - --- Triggered for each opened markdown buffer that's in a workspace. nvm_cmp had the capability to configure the sources --- per buffer, but blink.cmp doesn't have that capability. Instead, we have to inject the sources into the global --- configuration and set a boolean on the module to return early the next time this function is called. --- --- In-case the user used functions to configure their sources, the completion will properly work just for the markdown --- files that are in a workspace. Otherwise, the completion will work for all markdown files. -function M.inject_sources() - if M.injected_once then - return - end - - M.injected_once = true - - local blink_config = require "blink.cmp.config" - -- 'per_filetype' sources has priority over 'default' sources. - -- 'per_filetype' can be a table or a function which returns a table (["filetype"] = { "a", "b" }) - -- 'per_filetype' has the default value of {} (even if it's not configured by the user) - local blink_sources_per_filetype = blink_config.sources.per_filetype - if try_inject_blink_sources_into_per_filetype(blink_sources_per_filetype) then - return - end - - -- 'default' can be a list/array or a function which returns a list/array ({ "a", "b"}) - local blink_sources_default = blink_config.sources["default"] - if try_inject_blink_sources_into_default(blink_sources_default) then - return - end -end - -return M diff --git a/lua/obsidian/completion/plugin_initializers/nvim_cmp.lua b/lua/obsidian/completion/plugin_initializers/nvim_cmp.lua deleted file mode 100644 index f7344678..00000000 --- a/lua/obsidian/completion/plugin_initializers/nvim_cmp.lua +++ /dev/null @@ -1,34 +0,0 @@ -local M = {} - --- Ran once on the plugin startup -function M.register_sources() - local cmp = require "cmp" - - cmp.register_source("obsidian", require("obsidian.completion.sources.nvim_cmp.refs").new()) - cmp.register_source("obsidian_tags", require("obsidian.completion.sources.nvim_cmp.tags").new()) - if Obsidian.opts.completion.create_new then - cmp.register_source("obsidian_new", require("obsidian.completion.sources.nvim_cmp.new").new()) - end -end - --- Triggered for each opened markdown buffer that's in a workspace and configures nvim_cmp sources for the current buffer. -function M.inject_sources() - local cmp = require "cmp" - - local sources = { - { name = "obsidian" }, - { name = "obsidian_tags" }, - } - if Obsidian.opts.completion.create_new then - table.insert(sources, { name = "obsidian_new" }) - end - for _, source in pairs(cmp.get_config().sources) do - if source.name ~= "obsidian" and source.name ~= "obsidian_new" and source.name ~= "obsidian_tags" then - table.insert(sources, source) - end - end - ---@diagnostic disable-next-line: missing-fields - cmp.setup.buffer { sources = sources } -end - -return M diff --git a/lua/obsidian/completion/refs.lua b/lua/obsidian/completion/refs.lua deleted file mode 100644 index 9c7c5830..00000000 --- a/lua/obsidian/completion/refs.lua +++ /dev/null @@ -1,76 +0,0 @@ -local util = require "obsidian.util" - -local M = {} - ----@enum obsidian.completion.RefType -M.RefType = { - Wiki = 1, - Markdown = 2, -} - ----Backtrack through a string to find the first occurrence of '[['. ---- ----@param input string ----@return string|? input ----@return string|? search -local find_search_start = function(input) - for i = string.len(input), 1, -1 do - local substr = string.sub(input, i) - if vim.startswith(substr, "]") or vim.endswith(substr, "]") then - return nil - elseif vim.startswith(substr, "[[") then - return substr, string.sub(substr, 3) - elseif vim.startswith(substr, "[") and string.sub(input, i - 1, i - 1) ~= "[" then - return substr, string.sub(substr, 2) - end - end - return nil -end - ----Check if a completion request can/should be carried out. Returns a boolean ----and, if true, the search string and the column indices of where the completion ----items should be inserted. ---- ----@param request obsidian.completion.sources.base.Request ----@return boolean can_complete ----@return string|? search_string ----@return integer|? insert_start ----@return integer|? insert_end ----@return obsidian.completion.RefType|? ref_type -M.can_complete = function(request) - local input, search = find_search_start(request.context.cursor_before_line) - if input == nil or search == nil then - return false - elseif string.len(search) == 0 or util.is_whitespace(search) then - return false - end - - if vim.startswith(input, "[[") then - local suffix = string.sub(request.context.cursor_after_line, 1, 2) - local cursor_char = request.context.cursor.character - local insert_end_offset = suffix == "]]" and 1 or -1 - -- print("#input", #input, vim.fn.strchars(input)) - return true, search, cursor_char - vim.fn.strchars(input), cursor_char + 1 + insert_end_offset, M.RefType.Wiki - elseif vim.startswith(input, "[") then - local suffix = string.sub(request.context.cursor_after_line, 1, 1) - local cursor_char = request.context.cursor.character - local insert_end_offset = suffix == "]" and 0 or -1 - -- print("#input", #input, vim.fn.strchars(input)) - return true, search, cursor_char - vim.fn.strchars(input), cursor_char + 1 + insert_end_offset, M.RefType.Markdown - else - return false - end -end - -M.get_trigger_characters = function() - return { "[" } -end - -M.get_keyword_pattern = function() - -- Note that this is a vim pattern, not a Lua pattern. See ':help pattern'. - -- The enclosing [=[ ... ]=] is just a way to mark the boundary of a - -- string in Lua. - return [=[\%(^\|[^\[]\)\zs\[\{1,2}[^\]]\+\]\{,2}]=] -end - -return M diff --git a/lua/obsidian/completion/sources/base/new.lua b/lua/obsidian/completion/sources/base/new.lua deleted file mode 100644 index 67cb32ec..00000000 --- a/lua/obsidian/completion/sources/base/new.lua +++ /dev/null @@ -1,213 +0,0 @@ -local completion = require "obsidian.completion.refs" -local util = require "obsidian.util" -local LinkStyle = require("obsidian.config").LinkStyle -local Note = require "obsidian.note" -local Path = require "obsidian.path" - ----Used to track variables that are used between reusable method calls. This is required, because each ----call to the sources's completion hook won't create a new source object, but will reuse the same one. ----@class obsidian.completion.sources.base.NewNoteSourceCompletionContext ----@field completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@field request obsidian.completion.sources.base.Request ----@field search string|? ----@field insert_start integer|? ----@field insert_end integer|? ----@field ref_type obsidian.completion.RefType|? -local NewNoteSourceCompletionContext = {} -NewNoteSourceCompletionContext.__index = NewNoteSourceCompletionContext - -NewNoteSourceCompletionContext.new = function() - return setmetatable({}, NewNoteSourceCompletionContext) -end - ----@class obsidian.completion.sources.base.NewNoteSourceBase ----@field incomplete_response table ----@field complete_response table -local NewNoteSourceBase = {} -NewNoteSourceBase.__index = NewNoteSourceBase - ----@return obsidian.completion.sources.base.NewNoteSourceBase -NewNoteSourceBase.new = function() - return setmetatable({}, NewNoteSourceBase) -end - -NewNoteSourceBase.get_trigger_characters = completion.get_trigger_characters - ----Sets up a new completion context that is used to pass around variables between completion source methods ----@param completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@param request obsidian.completion.sources.base.Request ----@return obsidian.completion.sources.base.NewNoteSourceCompletionContext -function NewNoteSourceBase:new_completion_context(completion_resolve_callback, request) - local completion_context = NewNoteSourceCompletionContext.new() - - -- Sets up the completion callback, which will be called when the (possibly incomplete) completion items are ready - completion_context.completion_resolve_callback = completion_resolve_callback - - -- This request object will be used to determine the current cursor location and the text around it - completion_context.request = request - - return completion_context -end - ---- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods ----@param cc obsidian.completion.sources.base.NewNoteSourceCompletionContext -function NewNoteSourceBase:process_completion(cc) - if not self:can_complete_request(cc) then - return - end - - ---@type string|? - local block_link - cc.search, block_link = util.strip_block_links(cc.search) - - ---@type string|? - local anchor_link - cc.search, anchor_link = util.strip_anchor_links(cc.search) - - -- If block link is incomplete, do nothing. - if not block_link and vim.endswith(cc.search, "#^") then - cc.completion_resolve_callback(self.incomplete_response) - return - end - - -- If anchor link is incomplete, do nothing. - if not anchor_link and vim.endswith(cc.search, "#") then - cc.completion_resolve_callback(self.incomplete_response) - return - end - - -- Probably just a block/anchor link within current note. - if string.len(cc.search) == 0 then - cc.completion_resolve_callback(self.incomplete_response) - return - end - - -- Create a mock block. - ---@type obsidian.note.Block|? - local block - if block_link then - block = { block = "", id = util.standardize_block(block_link), line = 1 } - end - - -- Create a mock anchor. - ---@type obsidian.note.HeaderAnchor|? - local anchor - if anchor_link then - anchor = { anchor = anchor_link, header = string.sub(anchor_link, 2), level = 1, line = 1 } - end - - ---@type { label: string, note: obsidian.Note, template: string|? }[] - local new_notes_opts = {} - - local note = Note.create { title = cc.search } - if note.title and string.len(note.title) > 0 then - new_notes_opts[#new_notes_opts + 1] = { label = cc.search, note = note } - end - - -- Check for datetime macros. - for _, dt_offset in ipairs(util.resolve_date_macro(cc.search)) do - if dt_offset.cadence == "daily" then - note = require("obsidian.daily").daily(dt_offset.offset, { no_write = true }) - if not note:exists() then - new_notes_opts[#new_notes_opts + 1] = - { label = dt_offset.macro, note = note, template = Obsidian.opts.daily_notes.template } - end - end - end - - -- Completion items. - local items = {} - - for _, new_note_opts in ipairs(new_notes_opts) do - local new_note = new_note_opts.note - - assert(new_note.path, "note without path") - - ---@type obsidian.config.LinkStyle, string - local link_style, label - if cc.ref_type == completion.RefType.Wiki then - link_style = LinkStyle.wiki - label = string.format("[[%s]] (create)", new_note_opts.label) - elseif cc.ref_type == completion.RefType.Markdown then - link_style = LinkStyle.markdown - label = string.format("[%s](…) (create)", new_note_opts.label) - else - error "not implemented" - end - - local new_text = new_note:format_link { link_style = link_style, anchor = anchor, block = block } - local documentation = { - kind = "markdown", - value = new_note:display_info { - label = "Create: " .. new_text, - }, - } - - items[#items + 1] = { - documentation = documentation, - sortText = new_note_opts.label, - label = label, - kind = vim.lsp.protocol.CompletionItemKind.Reference, - textEdit = { - newText = new_text, - range = { - start = { - line = cc.request.context.cursor.row - 1, - character = cc.insert_start, - }, - ["end"] = { - line = cc.request.context.cursor.row - 1, - character = cc.insert_end + 1, - }, - }, - }, - data = { - note = new_note, - template = new_note_opts.template, - }, - } - end - - cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = items })) -end - ---- Returns whatever it's possible to complete the search and sets up the search related variables in cc ----@param cc obsidian.completion.sources.base.NewNoteSourceCompletionContext ----@return boolean success provides a chance to return early if the request didn't meet the requirements -function NewNoteSourceBase:can_complete_request(cc) - local can_complete - can_complete, cc.search, cc.insert_start, cc.insert_end, cc.ref_type = completion.can_complete(cc.request) - - if cc.search ~= nil then - cc.search = util.lstrip_whitespace(cc.search) - end - - if not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then - cc.completion_resolve_callback(self.incomplete_response) - return false - end - return true -end - ---- Runs a generalized version of the execute method ----@param item any ----@return table|? callback_return_value -function NewNoteSourceBase:process_execute(item) - local data = item.data - - if data == nil then - return nil - end - - -- Make sure `data.note` is actually an `obsidian.Note` object. If it gets serialized at some - -- point (seems to happen on Linux), it will lose its metatable. - if not Note.is_note_obj(data.note) then - data.note = setmetatable(data.note, Note) - data.note.path = setmetatable(data.note.path, Path) - end - - data.note:write { template = data.template } - return {} -end - -return NewNoteSourceBase diff --git a/lua/obsidian/completion/sources/base/refs.lua b/lua/obsidian/completion/sources/base/refs.lua deleted file mode 100644 index 5214a148..00000000 --- a/lua/obsidian/completion/sources/base/refs.lua +++ /dev/null @@ -1,382 +0,0 @@ -local completion = require "obsidian.completion.refs" -local LinkStyle = require("obsidian.config").LinkStyle -local util = require "obsidian.util" -local api = require "obsidian.api" -local search = require "obsidian.search" -local iter = vim.iter - ----Used to track variables that are used between reusable method calls. This is required, because each ----call to the sources's completion hook won't create a new source object, but will reuse the same one. ----@class obsidian.completion.sources.base.RefsSourceCompletionContext ----@field completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@field request obsidian.completion.sources.base.Request ----@field in_buffer_only boolean ----@field search string|? ----@field insert_start integer|? ----@field insert_end integer|? ----@field ref_type obsidian.completion.RefType|? ----@field block_link string|? ----@field anchor_link string|? ----@field new_text_to_option table -local RefsSourceCompletionContext = {} -RefsSourceCompletionContext.__index = RefsSourceCompletionContext - -RefsSourceCompletionContext.new = function() - return setmetatable({}, RefsSourceCompletionContext) -end - ----@class obsidian.completion.sources.base.RefsSourceBase ----@field incomplete_response table ----@field complete_response table -local RefsSourceBase = {} -RefsSourceBase.__index = RefsSourceBase - ----@return obsidian.completion.sources.base.RefsSourceBase -RefsSourceBase.new = function() - return setmetatable({}, RefsSourceBase) -end - -RefsSourceBase.get_trigger_characters = completion.get_trigger_characters - ----Sets up a new completion context that is used to pass around variables between completion source methods ----@param completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@param request obsidian.completion.sources.base.Request ----@return obsidian.completion.sources.base.RefsSourceCompletionContext -function RefsSourceBase:new_completion_context(completion_resolve_callback, request) - local completion_context = RefsSourceCompletionContext.new() - - -- Sets up the completion callback, which will be called when the (possibly incomplete) completion items are ready - completion_context.completion_resolve_callback = completion_resolve_callback - - -- This request object will be used to determine the current cursor location and the text around it - completion_context.request = request - - completion_context.in_buffer_only = false - - return completion_context -end - ---- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function RefsSourceBase:process_completion(cc) - if not self:can_complete_request(cc) then - return - end - - self:strip_links(cc) - self:determine_buffer_only_search_scope(cc) - - if cc.in_buffer_only then - local note = api.current_note(0, { collect_anchor_links = true, collect_blocks = true }) - if note then - self:process_search_results(cc, { note }) - else - cc.completion_resolve_callback(self.incomplete_response) - end - else - local search_opts = { - sort = false, - include_templates = false, - ignore_case = true, - } - - search.find_notes_async(cc.search, function(results) - self:process_search_results(cc, results) - end, { - search = search_opts, - notes = { collect_anchor_links = cc.anchor_link ~= nil, collect_blocks = cc.block_link ~= nil }, - }) - end -end - ---- Returns whatever it's possible to complete the search and sets up the search related variables in cc ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext ----@return boolean success provides a chance to return early if the request didn't meet the requirements -function RefsSourceBase:can_complete_request(cc) - local can_complete - can_complete, cc.search, cc.insert_start, cc.insert_end, cc.ref_type = completion.can_complete(cc.request) - - if not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then - cc.completion_resolve_callback(self.incomplete_response) - return false - end - - return true -end - ----Collect matching block links. ----@param note obsidian.Note ----@param block_link string? ----@return obsidian.note.Block[]|? -function RefsSourceBase:collect_matching_blocks(note, block_link) - ---@type obsidian.note.Block[]|? - local matching_blocks - if block_link then - assert(note.blocks, "no block") - matching_blocks = {} - for block_id, block_data in pairs(note.blocks) do - if vim.startswith("#" .. block_id, block_link) then - table.insert(matching_blocks, block_data) - end - end - - if #matching_blocks == 0 then - -- Unmatched, create a mock one. - table.insert(matching_blocks, { id = util.standardize_block(block_link), line = 1 }) - end - end - - return matching_blocks -end - ----Collect matching anchor links. ----@param note obsidian.Note ----@param anchor_link string? ----@return obsidian.note.HeaderAnchor[]? -function RefsSourceBase:collect_matching_anchors(note, anchor_link) - ---@type obsidian.note.HeaderAnchor[]|? - local matching_anchors - if anchor_link then - assert(note.anchor_links, "no anchor link") - matching_anchors = {} - for anchor, anchor_data in pairs(note.anchor_links) do - if vim.startswith(anchor, anchor_link) then - table.insert(matching_anchors, anchor_data) - end - end - - if #matching_anchors == 0 then - -- Unmatched, create a mock one. - table.insert(matching_anchors, { anchor = anchor_link, header = string.sub(anchor_link, 2), level = 1, line = 1 }) - end - end - - return matching_anchors -end - ---- Strips block and anchor links from the current search string ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function RefsSourceBase:strip_links(cc) - cc.search, cc.block_link = util.strip_block_links(cc.search) - cc.search, cc.anchor_link = util.strip_anchor_links(cc.search) - - -- If block link is incomplete, we'll match against all block links. - if not cc.block_link and vim.endswith(cc.search, "#^") then - cc.block_link = "#^" - cc.search = string.sub(cc.search, 1, -3) - end - - -- If anchor link is incomplete, we'll match against all anchor links. - if not cc.anchor_link and vim.endswith(cc.search, "#") then - cc.anchor_link = "#" - cc.search = string.sub(cc.search, 1, -2) - end -end - ---- Determines whatever the in_buffer_only should be enabled ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function RefsSourceBase:determine_buffer_only_search_scope(cc) - if (cc.anchor_link or cc.block_link) and string.len(cc.search) == 0 then - -- Search over headers/blocks in current buffer only. - cc.in_buffer_only = true - end -end - ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext ----@param results obsidian.Note[] -function RefsSourceBase:process_search_results(cc, results) - assert(cc, "no cc") - assert(results, "no results") - - local completion_items = {} - - cc.new_text_to_option = {} - - for note in iter(results) do - ---@cast note obsidian.Note - - local matching_blocks = self:collect_matching_blocks(note, cc.block_link) - local matching_anchors = self:collect_matching_anchors(note, cc.anchor_link) - - if cc.in_buffer_only then - self:update_completion_options(cc, nil, nil, matching_anchors, matching_blocks, note) - else - -- Collect all valid aliases for the note, including ID, title, and filename. - ---@type string[] - local aliases - if not cc.in_buffer_only then - aliases = util.tbl_unique { tostring(note.id), note:display_name(), unpack(note.aliases) } - if note.title ~= nil then - table.insert(aliases, note.title) - end - end - - for alias in iter(aliases) do - self:update_completion_options(cc, alias, nil, matching_anchors, matching_blocks, note) - local alias_case_matched = util.match_case(cc.search, alias) - - if - alias_case_matched ~= nil - and alias_case_matched ~= alias - and not vim.list_contains(note.aliases, alias_case_matched) - and Obsidian.opts.completion.match_case - then - self:update_completion_options(cc, alias_case_matched, nil, matching_anchors, matching_blocks, note) - end - end - - if note.alt_alias ~= nil then - self:update_completion_options(cc, note:display_name(), note.alt_alias, matching_anchors, matching_blocks, note) - end - end - end - - for _, option in pairs(cc.new_text_to_option) do - -- TODO: need a better label, maybe just the note's display name? - ---@type string - local label - if cc.ref_type == completion.RefType.Wiki then - label = string.format("[[%s]]", option.label) - elseif cc.ref_type == completion.RefType.Markdown then - label = string.format("[%s](…)", option.label) - else - error "not implemented" - end - - table.insert(completion_items, { - documentation = option.documentation, - sortText = option.sort_text, - label = label, - kind = vim.lsp.protocol.CompletionItemKind.Reference, - textEdit = { - newText = option.new_text, - range = { - ["start"] = { - line = cc.request.context.cursor.row - 1, - character = cc.insert_start, - }, - ["end"] = { - line = cc.request.context.cursor.row - 1, - character = cc.insert_end + 1, - }, - }, - }, - }) - end - - cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = completion_items })) -end - ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext ----@param label string|? ----@param alt_label string|? ----@param note obsidian.Note -function RefsSourceBase:update_completion_options(cc, label, alt_label, matching_anchors, matching_blocks, note) - ---@type { label: string|?, alt_label: string|?, anchor: obsidian.note.HeaderAnchor|?, block: obsidian.note.Block|? }[] - local new_options = {} - if matching_anchors ~= nil then - for anchor in iter(matching_anchors) do - table.insert(new_options, { label = label, alt_label = alt_label, anchor = anchor }) - end - elseif matching_blocks ~= nil then - for block in iter(matching_blocks) do - table.insert(new_options, { label = label, alt_label = alt_label, block = block }) - end - else - if label then - table.insert(new_options, { label = label, alt_label = alt_label }) - end - - -- Add all blocks and anchors, let cmp sort it out. - for _, anchor_data in pairs(note.anchor_links or {}) do - table.insert(new_options, { label = label, alt_label = alt_label, anchor = anchor_data }) - end - for _, block_data in pairs(note.blocks or {}) do - table.insert(new_options, { label = label, alt_label = alt_label, block = block_data }) - end - end - - -- De-duplicate options relative to their `new_text`. - for _, option in ipairs(new_options) do - ---@type obsidian.config.LinkStyle - local link_style - if cc.ref_type == completion.RefType.Wiki then - link_style = LinkStyle.wiki - elseif cc.ref_type == completion.RefType.Markdown then - link_style = LinkStyle.markdown - else - error "not implemented" - end - - ---@type string, string, string, table|? - local final_label, sort_text, new_text, documentation - if option.label then - new_text = - note:format_link { label = option.label, link_style = link_style, anchor = option.anchor, block = option.block } - - final_label = assert(option.alt_label or option.label, "no valid label") - if option.anchor then - final_label = final_label .. option.anchor.anchor - elseif option.block then - final_label = final_label .. "#" .. option.block.id - end - sort_text = final_label - - documentation = { - kind = "markdown", - value = note:display_info { - label = new_text, - anchor = option.anchor, - block = option.block, - }, - } - elseif option.anchor then - -- In buffer anchor link. - -- TODO: allow users to customize this? - if cc.ref_type == completion.RefType.Wiki then - new_text = "[[#" .. option.anchor.header .. "]]" - elseif cc.ref_type == completion.RefType.Markdown then - new_text = "[#" .. option.anchor.header .. "](" .. option.anchor.anchor .. ")" - else - error "not implemented" - end - - final_label = option.anchor.anchor - sort_text = final_label - - documentation = { - kind = "markdown", - value = string.format("`%s`", new_text), - } - elseif option.block then - -- In buffer block link. - -- TODO: allow users to customize this? - if cc.ref_type == completion.RefType.Wiki then - new_text = "[[#" .. option.block.id .. "]]" - elseif cc.ref_type == completion.RefType.Markdown then - new_text = "[#" .. option.block.id .. "](#" .. option.block.id .. ")" - else - error "not implemented" - end - - final_label = "#" .. option.block.id - sort_text = final_label - - documentation = { - kind = "markdown", - value = string.format("`%s`", new_text), - } - else - error "should not happen" - end - - if cc.new_text_to_option[new_text] then - cc.new_text_to_option[new_text].sort_text = cc.new_text_to_option[new_text].sort_text .. " " .. sort_text - else - cc.new_text_to_option[new_text] = - { label = final_label, new_text = new_text, sort_text = sort_text, documentation = documentation } - end - end -end - -return RefsSourceBase diff --git a/lua/obsidian/completion/sources/base/tags.lua b/lua/obsidian/completion/sources/base/tags.lua deleted file mode 100644 index fd413f3d..00000000 --- a/lua/obsidian/completion/sources/base/tags.lua +++ /dev/null @@ -1,126 +0,0 @@ -local completion = require "obsidian.completion.tags" -local iter = vim.iter -local search = require "obsidian.search" - ----Used to track variables that are used between reusable method calls. This is required, because each ----call to the sources's completion hook won't create a new source object, but will reuse the same one. ----@class obsidian.completion.sources.base.TagsSourceCompletionContext ----@field completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@field request obsidian.completion.sources.base.Request ----@field search string|? ----@field in_frontmatter boolean|? -local TagsSourceCompletionContext = {} -TagsSourceCompletionContext.__index = TagsSourceCompletionContext - -TagsSourceCompletionContext.new = function() - return setmetatable({}, TagsSourceCompletionContext) -end - ----@class obsidian.completion.sources.base.TagsSourceBase ----@field incomplete_response table ----@field complete_response table -local TagsSourceBase = {} -TagsSourceBase.__index = TagsSourceBase - ----@return obsidian.completion.sources.base.TagsSourceBase -TagsSourceBase.new = function() - return setmetatable({}, TagsSourceBase) -end - -TagsSourceBase.get_trigger_characters = completion.get_trigger_characters - ----Sets up a new completion context that is used to pass around variables between completion source methods ----@param completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@param request obsidian.completion.sources.base.Request ----@return obsidian.completion.sources.base.TagsSourceCompletionContext -function TagsSourceBase:new_completion_context(completion_resolve_callback, request) - local completion_context = TagsSourceCompletionContext.new() - - -- Sets up the completion callback, which will be called when the (possibly incomplete) completion items are ready - completion_context.completion_resolve_callback = completion_resolve_callback - - -- This request object will be used to determine the current cursor location and the text around it - completion_context.request = request - - return completion_context -end - ---- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods ----@param cc obsidian.completion.sources.base.TagsSourceCompletionContext -function TagsSourceBase:process_completion(cc) - if not self:can_complete_request(cc) then - return - end - - search.find_tags_async(cc.search, function(tag_locs) - local tags = {} - for tag_loc in iter(tag_locs) do - tags[tag_loc.tag] = true - end - - local items = {} - for tag, _ in pairs(tags) do - -- Generate context-appropriate text - local insert_text, label_text - if cc.in_frontmatter then - -- Frontmatter: insert tag without # (YAML format) - insert_text = tag - label_text = "Tag: " .. tag - else - -- Document body: insert tag with # (Obsidian format) - insert_text = "#" .. tag - label_text = "Tag: #" .. tag - end - - -- Calculate the range to replace (the entire #tag pattern) - local cursor_before = cc.request.context.cursor_before_line - local hash_start = string.find(cursor_before, "#[^%s]*$") - local insert_start = hash_start and (hash_start - 1) or #cursor_before - local insert_end = #cursor_before - - items[#items + 1] = { - sortText = "#" .. tag, - label = label_text, - kind = vim.lsp.protocol.CompletionItemKind.Text, - textEdit = { - newText = insert_text, - range = { - ["start"] = { - line = cc.request.context.cursor.row - 1, - character = insert_start, - }, - ["end"] = { - line = cc.request.context.cursor.row - 1, - character = insert_end, - }, - }, - }, - data = { - bufnr = cc.request.context.bufnr, - in_frontmatter = cc.in_frontmatter, - line = cc.request.context.cursor.line, - tag = tag, - }, - } - end - - cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = items })) - end) -end - ---- Returns whatever it's possible to complete the search and sets up the search related variables in cc ----@param cc obsidian.completion.sources.base.TagsSourceCompletionContext ----@return boolean success provides a chance to return early if the request didn't meet the requirements -function TagsSourceBase:can_complete_request(cc) - local can_complete - can_complete, cc.search, cc.in_frontmatter = completion.can_complete(cc.request) - - if not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then - cc.completion_resolve_callback(self.incomplete_response) - return false - end - - return true -end - -return TagsSourceBase diff --git a/lua/obsidian/completion/sources/base/types.lua b/lua/obsidian/completion/sources/base/types.lua deleted file mode 100644 index 16e1ce14..00000000 --- a/lua/obsidian/completion/sources/base/types.lua +++ /dev/null @@ -1,14 +0,0 @@ ----@class obsidian.completion.sources.base.Request.Context.Position ----@field public col integer ----@field public row integer - ----A request context class that partially matches cmp.Context to serve as a common interface for completion sources ----@class obsidian.completion.sources.base.Request.Context ----@field public bufnr integer ----@field public cursor obsidian.completion.sources.base.Request.Context.Position|lsp.Position ----@field public cursor_after_line string ----@field public cursor_before_line string - ----A request class that partially matches cmp.Request to serve as a common interface for completion sources ----@class obsidian.completion.sources.base.Request ----@field public context obsidian.completion.sources.base.Request.Context diff --git a/lua/obsidian/completion/sources/blink/new.lua b/lua/obsidian/completion/sources/blink/new.lua deleted file mode 100644 index f60259eb..00000000 --- a/lua/obsidian/completion/sources/blink/new.lua +++ /dev/null @@ -1,35 +0,0 @@ -local NewNoteSourceBase = require "obsidian.completion.sources.base.new" -local blink_util = require "obsidian.completion.sources.blink.util" - ----@class obsidian.completion.sources.blink.NewNoteSource : obsidian.completion.sources.base.NewNoteSourceBase -local NewNoteSource = {} -NewNoteSource.__index = NewNoteSource - -NewNoteSource.incomplete_response = blink_util.incomplete_response -NewNoteSource.complete_response = blink_util.complete_response - -function NewNoteSource.new() - return setmetatable(NewNoteSourceBase, NewNoteSource) -end - ----Implement the get_completions method of the completion provider ----@param context blink.cmp.Context ----@param resolve fun(self: blink.cmp.CompletionResponse): nil -function NewNoteSource:get_completions(context, resolve) - local request = blink_util.generate_completion_request_from_editor_state(context) - local cc = self:new_completion_context(resolve, request) - self:process_completion(cc) -end - ----Implements the execute method of the completion provider ----@param _ blink.cmp.Context ----@param item blink.cmp.CompletionItem ----@param callback fun(), ----@param default_implementation fun(context?: blink.cmp.Context, item?: blink.cmp.CompletionItem)): ((fun(): nil) | nil) -function NewNoteSource:execute(_, item, callback, default_implementation) - self:process_execute(item) - default_implementation() -- Ensure completion is still executed - callback() -- Required (as per blink documentation) -end - -return NewNoteSource diff --git a/lua/obsidian/completion/sources/blink/refs.lua b/lua/obsidian/completion/sources/blink/refs.lua deleted file mode 100644 index fdcea812..00000000 --- a/lua/obsidian/completion/sources/blink/refs.lua +++ /dev/null @@ -1,30 +0,0 @@ -local RefsSourceBase = require "obsidian.completion.sources.base.refs" -local blink_util = require "obsidian.completion.sources.blink.util" - ----@class obsidian.completion.sources.blink.CompletionItem ----@field label string ----@field new_text string ----@field sort_text string ----@field documentation table|? - ----@class obsidian.completion.sources.blink.RefsSource : obsidian.completion.sources.base.RefsSourceBase -local RefsSource = {} -RefsSource.__index = RefsSource - -RefsSource.incomplete_response = blink_util.incomplete_response -RefsSource.complete_response = blink_util.complete_response - -function RefsSource.new() - return setmetatable(RefsSourceBase, RefsSource) -end - ----Implement the get_completions method of the completion provider ----@param context blink.cmp.Context ----@param resolve fun(self: blink.cmp.CompletionResponse): nil -function RefsSource:get_completions(context, resolve) - local request = blink_util.generate_completion_request_from_editor_state(context) - local cc = self:new_completion_context(resolve, request) - self:process_completion(cc) -end - -return RefsSource diff --git a/lua/obsidian/completion/sources/blink/tags.lua b/lua/obsidian/completion/sources/blink/tags.lua deleted file mode 100644 index 6f7cdbd3..00000000 --- a/lua/obsidian/completion/sources/blink/tags.lua +++ /dev/null @@ -1,24 +0,0 @@ -local TagsSourceBase = require "obsidian.completion.sources.base.tags" -local blink_util = require "obsidian.completion.sources.blink.util" - ----@class obsidian.completion.sources.blink.TagsSource : obsidian.completion.sources.base.TagsSourceBase -local TagsSource = {} -TagsSource.__index = TagsSource - -TagsSource.incomplete_response = blink_util.incomplete_response -TagsSource.complete_response = blink_util.complete_response - -function TagsSource.new() - return setmetatable(TagsSourceBase, TagsSource) -end - ----Implements the get_completions method of the completion provider ----@param context blink.cmp.Context ----@param resolve fun(self: blink.cmp.CompletionResponse): nil -function TagsSource:get_completions(context, resolve) - local request = blink_util.generate_completion_request_from_editor_state(context) - local cc = self:new_completion_context(resolve, request) - self:process_completion(cc) -end - -return TagsSource diff --git a/lua/obsidian/completion/sources/blink/util.lua b/lua/obsidian/completion/sources/blink/util.lua deleted file mode 100644 index b2da48b1..00000000 --- a/lua/obsidian/completion/sources/blink/util.lua +++ /dev/null @@ -1,53 +0,0 @@ -local M = {} - ----Safe version of vim.str_utfindex ----@param text string ----@param vimindex integer|nil ----@return integer -local to_utfindex = function(text, vimindex) - vimindex = vimindex or #text + 1 - if vim.fn.has "nvim-0.11" == 1 then - return vim.str_utfindex(text, "utf-16", math.max(0, math.min(vimindex - 1, #text))) - end - return vim.str_utfindex(text, math.max(0, math.min(vimindex - 1, #text))) -end - ----Generates the completion request from a blink context ----@param context blink.cmp.Context ----@return obsidian.completion.sources.base.Request -M.generate_completion_request_from_editor_state = function(context) - local row = context.cursor[1] - local col = context.cursor[2] + 1 - local cursor_before_line = context.line:sub(1, col - 1) - local cursor_after_line = context.line:sub(col) - - local character = to_utfindex(context.line, col) - - return { - context = { - bufnr = context.bufnr, - cursor_before_line = cursor_before_line, - cursor_after_line = cursor_after_line, - cursor = { - row = row, - col = col, - line = row + 1, - character = character, - }, - }, - } -end - -M.incomplete_response = { - is_incomplete_forward = true, - is_incomplete_backward = true, - items = {}, -} - -M.complete_response = { - is_incomplete_forward = true, - is_incomplete_backward = false, - items = {}, -} - -return M diff --git a/lua/obsidian/completion/sources/nvim_cmp/new.lua b/lua/obsidian/completion/sources/nvim_cmp/new.lua deleted file mode 100644 index 77eb7bff..00000000 --- a/lua/obsidian/completion/sources/nvim_cmp/new.lua +++ /dev/null @@ -1,34 +0,0 @@ -local NewNoteSourceBase = require "obsidian.completion.sources.base.new" -local completion = require "obsidian.completion.refs" -local nvim_cmp_util = require "obsidian.completion.sources.nvim_cmp.util" - ----@class obsidian.completion.sources.nvim_cmp.NewNoteSource : obsidian.completion.sources.base.NewNoteSourceBase -local NewNoteSource = {} -NewNoteSource.__index = NewNoteSource - -NewNoteSource.new = function() - return setmetatable(NewNoteSourceBase, NewNoteSource) -end - -NewNoteSource.get_keyword_pattern = completion.get_keyword_pattern - -NewNoteSource.incomplete_response = nvim_cmp_util.incomplete_response -NewNoteSource.complete_response = nvim_cmp_util.complete_response - ----Invoke completion (required). ----@param request cmp.SourceCompletionApiParams ----@param callback fun(response: lsp.CompletionResponse|nil) -function NewNoteSource:complete(request, callback) - local cc = self:new_completion_context(callback, request) - self:process_completion(cc) -end - ----Creates a new note using the default template for the completion item. ----Executed after the item was selected. ----@param completion_item lsp.CompletionItem ----@param callback fun(completion_item: lsp.CompletionItem|nil) -function NewNoteSource:execute(completion_item, callback) - return callback(self:process_execute(completion_item)) -end - -return NewNoteSource diff --git a/lua/obsidian/completion/sources/nvim_cmp/refs.lua b/lua/obsidian/completion/sources/nvim_cmp/refs.lua deleted file mode 100644 index e76d4132..00000000 --- a/lua/obsidian/completion/sources/nvim_cmp/refs.lua +++ /dev/null @@ -1,30 +0,0 @@ -local RefsSourceBase = require "obsidian.completion.sources.base.refs" -local completion = require "obsidian.completion.refs" -local nvim_cmp_util = require "obsidian.completion.sources.nvim_cmp.util" - ----@class obsidian.completion.sources.nvim_cmp.CompletionItem ----@field label string ----@field new_text string ----@field sort_text string ----@field documentation table|? - ----@class obsidian.completion.sources.nvim_cmp.RefsSource : obsidian.completion.sources.base.RefsSourceBase -local RefsSource = {} -RefsSource.__index = RefsSource - -RefsSource.new = function() - return setmetatable(RefsSourceBase, RefsSource) -end - -RefsSource.get_keyword_pattern = completion.get_keyword_pattern - -RefsSource.incomplete_response = nvim_cmp_util.incomplete_response -RefsSource.complete_response = nvim_cmp_util.complete_response - ----@param request obsidian.completion.sources.base.Request -function RefsSource:complete(request, callback) - local cc = self:new_completion_context(callback, request) - self:process_completion(cc) -end - -return RefsSource diff --git a/lua/obsidian/completion/sources/nvim_cmp/tags.lua b/lua/obsidian/completion/sources/nvim_cmp/tags.lua deleted file mode 100644 index 4e22fa06..00000000 --- a/lua/obsidian/completion/sources/nvim_cmp/tags.lua +++ /dev/null @@ -1,23 +0,0 @@ -local TagsSourceBase = require "obsidian.completion.sources.base.tags" -local completion = require "obsidian.completion.tags" -local nvim_cmp_util = require "obsidian.completion.sources.nvim_cmp.util" - ----@class obsidian.completion.sources.nvim_cmp.TagsSource : obsidian.completion.sources.base.TagsSourceBase -local TagsSource = {} -TagsSource.__index = TagsSource - -TagsSource.new = function() - return setmetatable(TagsSourceBase, TagsSource) -end - -TagsSource.get_keyword_pattern = completion.get_keyword_pattern - -TagsSource.incomplete_response = nvim_cmp_util.incomplete_response -TagsSource.complete_response = nvim_cmp_util.complete_response - -function TagsSource:complete(request, callback) - local cc = self:new_completion_context(callback, request) - self:process_completion(cc) -end - -return TagsSource diff --git a/lua/obsidian/completion/sources/nvim_cmp/util.lua b/lua/obsidian/completion/sources/nvim_cmp/util.lua deleted file mode 100644 index 1c2e16bd..00000000 --- a/lua/obsidian/completion/sources/nvim_cmp/util.lua +++ /dev/null @@ -1,10 +0,0 @@ -local M = {} - -M.incomplete_response = { isIncomplete = true } - -M.complete_response = { - isIncomplete = true, - items = {}, -} - -return M diff --git a/lua/obsidian/completion/tags.lua b/lua/obsidian/completion/tags.lua deleted file mode 100644 index 58ae991e..00000000 --- a/lua/obsidian/completion/tags.lua +++ /dev/null @@ -1,66 +0,0 @@ -local Note = require "obsidian.note" -local Patterns = require("obsidian.search").Patterns - -local M = {} - ----@type { pattern: string, offset: integer }[] -local TAG_PATTERNS = { - { pattern = "[%s%(]#" .. Patterns.TagCharsOptional .. "$", offset = 2 }, - { pattern = "^#" .. Patterns.TagCharsOptional .. "$", offset = 1 }, -} - -M.find_tags_start = function(input) - for _, pattern in ipairs(TAG_PATTERNS) do - local match = string.match(input, pattern.pattern) - if match then - return string.sub(match, pattern.offset + 1) - end - end -end - ---- Find the boundaries of the YAML frontmatter within the buffer. ----@param bufnr integer ----@return integer|?, integer|? -local get_frontmatter_boundaries = function(bufnr) - local note = Note.from_buffer(bufnr) - if note.frontmatter_end_line ~= nil then - return 1, note.frontmatter_end_line - end -end - ----@return boolean, string|?, boolean|? -M.can_complete = function(request) - local search = M.find_tags_start(request.context.cursor_before_line) - if not search or string.len(search) == 0 then - return false - end - - -- Check if we're inside frontmatter. - local in_frontmatter = false - local line = request.context.cursor.line - local frontmatter_start, frontmatter_end = get_frontmatter_boundaries(request.context.bufnr) - if - frontmatter_start ~= nil - and frontmatter_start <= (line + 1) - and frontmatter_end ~= nil - and line <= frontmatter_end - then - in_frontmatter = true - end - - return true, search, in_frontmatter -end - -M.get_trigger_characters = function() - return { "#" } -end - -M.get_keyword_pattern = function() - -- Note that this is a vim pattern, not a Lua pattern. See ':help pattern'. - -- The enclosing [=[ ... ]=] is just a way to mark the boundary of a - -- string in Lua. - -- return [=[\%(^\|[^#]\)\zs#[a-zA-Z0-9_/-]\+]=] - return "#[a-zA-Z0-9_/-]\\+" -end - -return M diff --git a/lua/obsidian/config/default.lua b/lua/obsidian/config/default.lua index 8a19b8bc..b41076ab 100644 --- a/lua/obsidian/config/default.lua +++ b/lua/obsidian/config/default.lua @@ -76,22 +76,14 @@ return { ---@class obsidian.config.CompletionOpts --- - ---@field nvim_cmp? boolean - ---@field blink? boolean ---@field min_chars? integer ---@field match_case? boolean ---@field create_new? boolean - completion = (function() - local has_nvim_cmp, _ = pcall(require, "cmp") - local has_blink = pcall(require, "blink.cmp") - return { - nvim_cmp = has_nvim_cmp and not has_blink, - blink = has_blink, - min_chars = 2, - match_case = true, - create_new = true, - } - end)(), + completion = { + min_chars = 2, + match_case = true, + create_new = true, + }, ---@class obsidian.config.PickerNoteMappingOpts --- diff --git a/lua/obsidian/config/init.lua b/lua/obsidian/config/init.lua index a52e472d..72f5fba2 100644 --- a/lua/obsidian/config/init.lua +++ b/lua/obsidian/config/init.lua @@ -153,6 +153,16 @@ config.normalize = function(opts, defaults) ) end + if opts.completion ~= nil and opts.completion.blink ~= nil then + opts.completion.blink = nil + log.warn_once "The config option 'completion.blink' is deprecated, completion will work regardless of completion engine" + end + + if opts.completion ~= nil and opts.completion.nvim_cmp ~= nil then + opts.completion.nvim_cmp = nil + log.warn_once "The config option 'completion.nvim_cmp' is deprecated, completion will work regardless of completion engine" + end + if opts.detect_cwd ~= nil then opts.detect_cwd = nil log.warn_once( diff --git a/lua/obsidian/health.lua b/lua/obsidian/health.lua index 98884ac2..b4e5dd99 100644 --- a/lua/obsidian/health.lua +++ b/lua/obsidian/health.lua @@ -127,13 +127,6 @@ function M.check() "snacks.nvim", } - start "Completion" - - has_one_of { - "nvim-cmp", - "blink.cmp", - } - start "Dependencies" has_executable("rg", false) diff --git a/lua/obsidian/init.lua b/lua/obsidian/init.lua index e62bd4e6..a406f580 100644 --- a/lua/obsidian/init.lua +++ b/lua/obsidian/init.lua @@ -6,7 +6,6 @@ obsidian.api = require "obsidian.api" obsidian.async = require "obsidian.async" obsidian.Client = require "obsidian.client" obsidian.commands = require "obsidian.commands" -obsidian.completion = require "obsidian.completion" obsidian.config = require "obsidian.config" obsidian.log = require "obsidian.log" obsidian.img_paste = require "obsidian.img_paste" @@ -89,13 +88,6 @@ obsidian.setup = function(user_opts) require("obsidian.footer").start() end - -- Register completion sources, providers - if opts.completion.nvim_cmp then - require("obsidian.completion.plugin_initializers.nvim_cmp").register_sources() - elseif opts.completion.blink then - require("obsidian.completion.plugin_initializers.blink").register_providers() - end - -- Register autocmds for keymaps, options and custom callbacks require "obsidian.autocmds" diff --git a/lua/obsidian/lsp/handlers.lua b/lua/obsidian/lsp/handlers.lua index 4d22d557..4789b49c 100644 --- a/lua/obsidian/lsp/handlers.lua +++ b/lua/obsidian/lsp/handlers.lua @@ -7,6 +7,7 @@ return setmetatable({ [ms.textDocument_references] = require "obsidian.lsp.handlers.references", [ms.textDocument_definition] = require "obsidian.lsp.handlers.definition", [ms.textDocument_documentSymbol] = require "obsidian.lsp.handlers.document_symbol", + [ms.textDocument_completion] = require "obsidian.lsp.handlers.completion", }, { __index = function(_, _) return function() end diff --git a/lua/obsidian/lsp/handlers/_completion.lua b/lua/obsidian/lsp/handlers/_completion.lua new file mode 100644 index 00000000..b724b311 --- /dev/null +++ b/lua/obsidian/lsp/handlers/_completion.lua @@ -0,0 +1,82 @@ +local CmpType = { + ref = 1, + tag = 2, + -- heading = 3, + -- heading_all = 4, + -- block = 5, + -- block_all = 6, +} + +local RefPatterns = { + [CmpType.ref] = "[[", + [CmpType.tag] = "#", + -- [CmpType.heading] = "[[# ", + -- [CmpType.heading_all] = "[[## ", + -- [CmpType.block] = "[[^ ", + -- [CmpType.block_all] = "[[^^ ", +} + +---Backtrack through a string to find the first occurrence of '[['. +--- +---@param input string +---@return string|? input +---@return string|? search +local find_search_start = function(input) + for i = string.len(input), 1, -1 do + local substr = string.sub(input, i) + if vim.startswith(substr, "]") or vim.endswith(substr, "]") then + return nil + elseif vim.startswith(substr, "[[") then + return substr, string.sub(substr, 3) + -- elseif vim.startswith(substr, "[") and string.sub(input, i - 1, i - 1) ~= "[" then + -- return substr, string.sub(substr, 2) + end + end + return nil +end + +---Safe version of vim.str_utfindex +---@param text string +---@param vimindex integer|nil +---@return integer +local to_utfindex = function(text, vimindex) + vimindex = vimindex or #text + 1 + if vim.fn.has "nvim-0.11" == 1 then + return vim.str_utfindex(text, "utf-8", math.max(0, math.min(vimindex - 1, #text))) + end + ---@diagnostic disable-next-line: param-type-mismatch + return vim.str_utfindex(text, math.max(0, math.min(vimindex - 1, #text))) +end + +---@param line_text string +---@return integer? cmp_type +---@return string? prefix +---@return integer? st_character 0-indexed +local function get_cmp_type(line_text, col) + local cursor_before_line = line_text:sub(1, col - 1) + local input, search = find_search_start(line_text) + + local cursor_char = to_utfindex(line_text, col) + + local ref_start = cursor_char - vim.fn.strchars(input) + + return 1, search, ref_start + + -- print(input, search) + + -- for t, pattern in vim.spairs(RefPatterns) do -- spairs make sure ref is first + -- local st, ed = find(line_text, pattern, 1, true) + -- local st_character = to_utfindex(line_text, st) - 1 + -- + -- if st and ed then + -- local prefix = sub(line_text, ed + 1) + -- if vim.fn.strchars(prefix) >= Obsidian.opts.completion.min_chars then -- TODO: unicode + -- return t, prefix, st_character + -- end + -- end + -- end +end + +return { + get_cmp_type = get_cmp_type, +} diff --git a/lua/obsidian/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua new file mode 100644 index 00000000..9b6be816 --- /dev/null +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -0,0 +1,323 @@ +-- TODO: memoize? + +local obsidian = require "obsidian" +local util = obsidian.util +local Search = obsidian.search +local find, sub, lower = string.find, string.sub, string.lower + +local M = require "obsidian.lsp.handlers._completion" + +local CmpType = { + ref = 1, + tag = 2, + -- heading = 3, + -- heading_all = 4, + -- block = 5, + -- block_all = 6, +} + +local RefPatterns = { + [CmpType.ref] = "[[", + [CmpType.tag] = "#", + -- [CmpType.heading] = "[[# ", + -- [CmpType.heading_all] = "[[## ", + -- [CmpType.block] = "[[^ ", + -- [CmpType.block_all] = "[[^^ ", +} + +---Collect matching anchor links. +---@param note obsidian.Note +---@param anchor_link string? +---@return obsidian.note.HeaderAnchor[]? +local function collect_matching_anchors(note, anchor_link) + ---@type obsidian.note.HeaderAnchor[]|? + local matching_anchors + if anchor_link then + assert(note.anchor_links, "") + matching_anchors = {} + for anchor, anchor_data in pairs(note.anchor_links) do + if vim.startswith(anchor, anchor_link) then + table.insert(matching_anchors, anchor_data) + end + end + + if #matching_anchors == 0 then + -- Unmatched, create a mock one. + table.insert(matching_anchors, { anchor = anchor_link, header = string.sub(anchor_link, 2), level = 1, line = 1 }) + end + end + + return matching_anchors +end + +-- A more generic pure function, don't require label to exist +local function label_to_new_text(label) + local path = util.urlencode(label) .. ".md" + local opts = { label = label, path = path } + + local format_func + local style = Obsidian.opts.preferred_link_style + if style == "markdown" then + format_func = Obsidian.opts.markdown_link_func + elseif style == "wiki" then + format_func = Obsidian.opts.wiki_link_func + else + error "unimplemented link style" + end + return format_func(opts) +end + +---@param label string +---@param path string +---@param new_text string +---@param range lsp.Range +---@return lsp.CompletionItem +local function gen_ref_item(label, path, new_text, range) + return { + kind = 17, + label = label, + textEdit = { + range = range, + newText = new_text, + }, + labelDetails = { description = "Obsidian" }, + data = { + file = path, + kind = "ref", + }, + } +end + +local function gen_tag_item(tag) + return { + kind = 1, + label = tag, + insertText = tag, + labelDetails = { description = "ObsidianTag" }, + data = { kind = "tag" }, + } +end + +---@param label string +---@param range lsp.Range +---@return lsp.CompletionItem +local function gen_create_item(label, range) + return { + kind = 17, + label = label .. " (create)", + textEdit = { + range = range, + newText = label_to_new_text(label), + }, + labelDetails = { description = "Obsidian" }, + command = { + command = "create_note", + arguments = { label }, + }, + data = { + kind = "ref_create", -- TODO: resolve to a tooltip window + }, + } +end + +local function auto_accept(note, range) + local edit = { + documentChanges = { + { + textDocument = { + uri = vim.uri_from_fname(tostring(note.path)), + version = vim.NIL, + }, + edits = { + { + range = range, + newText = "[[" .. note.id .. "#", + }, + }, + }, + }, + } + vim.schedule(function() + vim.lsp.util.apply_workspace_edit(edit, "utf-8") + vim.api.nvim_win_set_cursor(0, { + range.start.line + 1, -- 0 index to 1 index + range["end"].character + 1, -- one char after + }) + end) +end + +local handle_bare_links = function(prefix, notes, range, handler) + local items = {} + + local auto = false + if vim.endswith(prefix, "#") then + prefix = sub(prefix, 0, -2) + auto = true + else + items[#items + 1] = gen_create_item(prefix, range) -- TODO: ? + end + + local pattern = vim.pesc(lower(prefix)) + + local note_lookup = {} + local queries = {} + local res_lookup = {} + + for _, note in ipairs(notes) do + if note.id then -- TODO: match case + note_lookup[note.id] = note + queries[#queries + 1] = note.id + end + end + + local matches = vim.fn.matchfuzzy(queries, pattern, { limit = 10 }) -- TODO: config? lower? + + if auto then + if not vim.tbl_isempty(matches) then + local note = note_lookup[matches[1]] + auto_accept(note, range) + end + else + for _, match in ipairs(matches) do + local note = note_lookup[match] + if not res_lookup[note] then + local link_text = note:format_link() + items[#items + 1] = gen_ref_item(note.id, note.path.filename, link_text, range) -- TODO: label -> id title fname? + res_lookup[note] = true + end + end + handler(nil, { items = items }) + end +end + +---@param partial string +---@param notes obsidian.Note[] +---@param anchor_link string +---@param callback function +local function handle_anchor_links(partial, notes, anchor_link, callback) + -- TODO: calc current_note once + -- TODO: handle two cases: + -- 1. typing partial note name, no completed text after cursor, insert the full link + -- 2. jumped to heading, only insert anchor + -- TODO: need to do more textEdit to insert additional #title to path so that app supports? + local items = {} + + local pattern = vim.pesc(lower(partial)) + + for _, note in ipairs(notes) do + local id = note.id + if id and find(lower(id), pattern) then + local note_anchors = collect_matching_anchors(note, anchor_link) + if not note_anchors then + return + end + for _, anchor in ipairs(note_anchors) do + items[#items + 1] = { + kind = 17, + label = anchor.header, + filterText = anchor.header, + insertText = anchor.header, + -- textEdit = { + -- range = { + -- start = { line = line_num, character = insert_start }, + -- ["end"] = { line = line_num, character = insert_end }, + -- }, + -- newText = insert_snippet_marker(insert_text, style), + -- }, + labelDetails = { description = "ObsidianAnchor" }, -- TODO: attach H1, H2 + data = { + file = note.path.filename, + kind = "anchor", + }, + } + end + end + callback(nil, { items = items }) + end +end + +-- local function handle_block_links() end + +local handlers = {} + +handlers[CmpType.tag] = function(partial, _, handler) + local items = {} + local tags = vim + .iter(Search.find_tags("", {})) + :map(function(match) + return match.tag + end) + :totable() + tags = util.tbl_unique(tags) + for _, tag in ipairs(tags) do + if tag and tag:lower():find(vim.pesc(partial:lower())) then + items[#items + 1] = gen_tag_item(tag) + end + end + handler(nil, { items = items }) +end + +handlers[CmpType.ref] = function(prefix, range, handler) + local anchor_link, block_link + prefix, anchor_link = util.strip_anchor_links(prefix) + prefix, block_link = util.strip_block_links(prefix) + + local search_opts = Search._defaults + search_opts.ignore_case = true + + local notes = Search.find_notes(prefix, { + search = search_opts, + notes = { collect_anchor_links = anchor_link ~= nil, collect_blocks = block_link ~= nil }, + }) + + if #notes == 0 then + return handler(nil, { items = {} }) + end + + if anchor_link then + handle_anchor_links(prefix, notes, anchor_link, handler) + -- elseif block_link then + -- handle_block_links(prefix, block_link, handler) + else + handle_bare_links(prefix, notes, range, handler) + end +end + +---Adpated from none-ls +---gets word to complete for use in completion sources +---@param params lsp.CompletionParams +---@return string word_to_complete +local get_word_to_complete = function(params) + local col = params.position.character + 1 + local line = vim.api.nvim_get_current_line() + local line_to_cursor = line:sub(1, col) + local regex = vim.regex "\\k*$" + + return line:sub(regex:match_str(line_to_cursor) + 1, col) +end + +-- TODO: search.find_heading +-- local function handle_heading() end +---@param params lsp.CompletionParams +---@param callback function +return function(params, callback, _) + local line_num = params.position.line -- 0-indexed + local cursor_col = params.position.character -- 0-indexed + + local line_text = vim.api.nvim_buf_get_lines(0, line_num, line_num + 1, false)[1] + local t, prefix, ref_start = M.get_cmp_type(line_text) + print(prefix, ref_start) + + callback = vim.schedule_wrap(callback) + + if not t then + return callback(nil, {}) + end + + local range = { + start = { line = line_num, character = ref_start }, + ["end"] = { line = line_num, character = cursor_col }, -- if auto parired + } + + handlers[t](prefix, range, callback) +end diff --git a/lua/obsidian/lsp/handlers/initialize.lua b/lua/obsidian/lsp/handlers/initialize.lua index 593062ee..4e6e3c10 100644 --- a/lua/obsidian/lsp/handlers/initialize.lua +++ b/lua/obsidian/lsp/handlers/initialize.lua @@ -1,3 +1,16 @@ +local chars = {} +for i = 32, 126 do + table.insert(chars, string.char(i)) +end + +local completion_options = { + triggerCharacters = chars, + resolveProvider = true, + completionItem = { + labelDetailsSupport = true, + }, +} + ---@type lsp.InitializeResult local initializeResult = { capabilities = { @@ -7,6 +20,7 @@ local initializeResult = { referencesProvider = true, definitionProvider = true, documentSymbolProvider = true, + completionProvider = completion_options, }, serverInfo = { name = "obsidian-ls", diff --git a/lua/obsidian/lsp/init.lua b/lua/obsidian/lsp/init.lua index 41ef9b5b..af5dcdac 100644 --- a/lua/obsidian/lsp/init.lua +++ b/lua/obsidian/lsp/init.lua @@ -35,6 +35,16 @@ lsp.start = function(buf) log.err("[obsidian-ls]: failed to start: " .. client_id) end + local has_blink = pcall(require, "blink.cmp") + local has_cmp = pcall(require, "cmp") + + if not (has_blink or has_cmp) and client_id then + vim.lsp.completion.enable(true, client_id, buf, { autotrigger = true }) + vim.bo[buf].omnifunc = "v:lua.vim.lsp.omnifunc" + vim.bo[buf].completeopt = "menu,menuone,noselect" + vim.bo[buf].iskeyword = "@,48-57,192-255" -- HACK: so that completion for note names with `-` in it works in native completion + end + return client_id end diff --git a/lua/obsidian/search/init.lua b/lua/obsidian/search/init.lua index b5a899f8..376276e5 100644 --- a/lua/obsidian/search/init.lua +++ b/lua/obsidian/search/init.lua @@ -407,7 +407,7 @@ M.find_notes = function(term, opts) opts = opts or {} opts.timeout = opts.timeout or 1000 return async.block_on(function(cb) - return M.find_notes_async(term, cb, { search = opts.search }) + return M.find_notes_async(term, cb, { search = opts.search, notes = opts.notes }) end, opts.timeout) end diff --git a/minimal.lua b/minimal.lua index d205f49d..3d95cee2 100644 --- a/minimal.lua +++ b/minimal.lua @@ -45,12 +45,17 @@ local plugins = { -- } -- end, -- }, - { - "saghen/blink.cmp", - opts = { - fuzzy = { implementation = "lua" }, -- no need to build binary - }, - }, + -- { + -- "saghen/blink.cmp", + -- build = "cargo build --release", + -- opts = { + -- cmdline = { enabled = false }, + -- completion = { + -- documentation = { auto_show = true }, + -- }, + -- -- fuzzy = { implementation = "lua" }, -- no need to build binary + -- }, + -- }, } require("lazy.minit").repro { spec = plugins } diff --git a/tests/helpers.lua b/tests/helpers.lua index 2664f2d6..c409938b 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -58,10 +58,6 @@ M.temp_vault = MiniTest.new_set { templates = { folder = "templates", }, - completion = { - blink = false, - nvim_cmp = false, - }, log_level = vim.log.levels.WARN, } diff --git a/tests/lsp/test_completion.lua b/tests/lsp/test_completion.lua new file mode 100644 index 00000000..92d8c6e7 --- /dev/null +++ b/tests/lsp/test_completion.lua @@ -0,0 +1,68 @@ +local eq = MiniTest.expect.equality +local h = dofile "tests/helpers.lua" + +local T, child = h.child_vault() + +local function request_completion() + return child.lsp.buf_request_sync( + 0, + "textDocument/completion", + child.lua_get "vim.lsp.util.make_position_params(0, 'utf-8')" + ) +end + +T["trigger with [["] = function() + local referencer = [==[ + +[[tar +]==] + + local root = child.Obsidian.dir + local referencer_path = root / "referencer.md" + h.write(referencer, referencer_path) + + local target_path = root / "target.md" + h.write("", target_path) + + child.cmd(string.format("edit %s", referencer_path)) + child.api.nvim_win_set_cursor(0, { 2, 0 }) + child.type_keys "A" + + local res = request_completion() + eq(1, #res) + local response = res[1] + local result = response.result + + eq(2, #result.items) + eq("tar (create)", result.items[1].label) + eq("target", result.items[2].label) +end + +T["trigger with #"] = function() + local referencer = [==[ +--- +tags: + - this/is/a/tag +--- + +]==] + + local root = child.Obsidian.dir + local referencer_path = root / "referencer.md" + h.write(referencer, referencer_path) + + child.cmd(string.format("edit %s", referencer_path)) + child.api.nvim_win_set_cursor(0, { 6, 0 }) + child.type_keys "I#thi" -- partial tag + + local res = request_completion() + + eq(1, #res) + local response = res[1] + local result = response.result + + eq(1, #result.items) + eq("this/is/a/tag", result.items[1].label) +end + +return T diff --git a/tests/lsp/test_completion_util.lua b/tests/lsp/test_completion_util.lua new file mode 100644 index 00000000..a3f455f6 --- /dev/null +++ b/tests/lsp/test_completion_util.lua @@ -0,0 +1,25 @@ +local eq = MiniTest.expect.equality +local h = dofile "tests/helpers.lua" +local M = require "obsidian.lsp.handlers._completion" + +local T, _ = h.child_vault() + +T["get_cmp_type"] = function() + -- 012345678 + -- ||||||||| + local t, prefix, ref_start = M.get_cmp_type("hello [[worl", 10) + + eq(t, 1) -- wiki + eq(prefix, "worl") + eq(ref_start, 6) + + -- 1 2 3456 + -- | | |||| + t, prefix, ref_start = M.get_cmp_type "你好 [[worl" + + eq(t, 1) -- wiki + eq(prefix, "worl") + eq(ref_start, 4) +end + +return T diff --git a/tests/test_completion.lua b/tests/test_completion.lua deleted file mode 100644 index 3c726057..00000000 --- a/tests/test_completion.lua +++ /dev/null @@ -1,51 +0,0 @@ -local new_set, eq = MiniTest.new_set, MiniTest.expect.equality - -local T = new_set() - -T["completion"] = new_set() - -T["completion"]["refs"] = new_set() - -T["completion"]["refs"]["can_complete should handle wiki links with text"] = function() - local completion = require "obsidian.completion.refs" - - local before = "simple text [[foo" - local request = { - context = { - cursor_before_line = before, - cursor_after_line = "", - cursor = { - character = vim.fn.strchars(before), - }, - }, - } - - local can_complete, search, insert_start, insert_end, _ = completion.can_complete(request) - eq(true, can_complete) - eq("foo", search) - eq(12, insert_start) - eq(17, insert_end) -end - -T["completion"]["refs"]["can_complete should handle wiki links with preceding Unicode text"] = function() - local completion = require "obsidian.completion.refs" - - local before = "Unicode text ű [[foo" - local request = { - context = { - cursor_before_line = before, - cursor_after_line = "", - cursor = { - character = vim.fn.strchars(before), - }, - }, - } - - local can_complete, search, insert_start, insert_end, _ = completion.can_complete(request) - eq(true, can_complete) - eq("foo", search) - eq(15, insert_start) - eq(20, insert_end) -end - -return T