From 746340034c7e66e6ed7484b91d62414c0ce1683a Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Wed, 11 Mar 2026 18:12:53 +0000 Subject: [PATCH 01/30] feat: lsp completion! --- lua/obsidian/autocmds.lua | 7 - lua/obsidian/completion/init.lua | 6 - .../plugin_initializers/nvim_cmp.lua | 34 ----- lua/obsidian/completion/sources/base/refs.lua | 72 +++++----- lua/obsidian/completion/sources/blink/new.lua | 35 ----- .../completion/sources/blink/tags.lua | 24 ---- .../completion/sources/blink/util.lua | 43 ------ .../completion/sources/nvim_cmp/new.lua | 34 ----- .../completion/sources/nvim_cmp/refs.lua | 30 ---- .../completion/sources/nvim_cmp/tags.lua | 23 --- .../completion/sources/nvim_cmp/util.lua | 10 -- lua/obsidian/config/default.lua | 18 +-- lua/obsidian/config/init.lua | 18 +++ lua/obsidian/health.lua | 7 - lua/obsidian/init.lua | 8 -- lua/obsidian/lsp/handlers.lua | 2 + lua/obsidian/lsp/handlers/completion.lua | 131 ++++++++++++++++++ lua/obsidian/lsp/handlers/execute_command.lua | 26 ++++ lua/obsidian/lsp/handlers/initialize.lua | 7 + lua/obsidian/lsp/init.lua | 15 ++ tests/helpers.lua | 4 - tests/lsp/test_completion.lua | 103 ++++++++++++++ 22 files changed, 345 insertions(+), 312 deletions(-) delete mode 100644 lua/obsidian/completion/init.lua delete mode 100644 lua/obsidian/completion/plugin_initializers/nvim_cmp.lua delete mode 100644 lua/obsidian/completion/sources/blink/new.lua delete mode 100644 lua/obsidian/completion/sources/blink/tags.lua delete mode 100644 lua/obsidian/completion/sources/blink/util.lua delete mode 100644 lua/obsidian/completion/sources/nvim_cmp/new.lua delete mode 100644 lua/obsidian/completion/sources/nvim_cmp/refs.lua delete mode 100644 lua/obsidian/completion/sources/nvim_cmp/tags.lua delete mode 100644 lua/obsidian/completion/sources/nvim_cmp/util.lua create mode 100644 lua/obsidian/lsp/handlers/completion.lua create mode 100644 lua/obsidian/lsp/handlers/execute_command.lua create mode 100644 tests/lsp/test_completion.lua diff --git a/lua/obsidian/autocmds.lua b/lua/obsidian/autocmds.lua index e6a4e4ddf..cfcd3b53d 100644 --- a/lua/obsidian/autocmds.lua +++ b/lua/obsidian/autocmds.lua @@ -59,13 +59,6 @@ local function bufenter_callback(ev) end, { buffer = true, desc = "Obsidian Previous Link" }) end - -- Inject completion sources, providers to their plugin configurations - if opts.completion.nvim_cmp then - require("obsidian.completion.plugin_initializers.nvim_cmp").inject_sources() - elseif opts.completion.blink then - require("obsidian.completion.plugin_initializers.blink").inject_sources() - end - require("obsidian.lsp").start(ev.buf) if opts.footer.enabled then diff --git a/lua/obsidian/completion/init.lua b/lua/obsidian/completion/init.lua deleted file mode 100644 index 4a7103e9f..000000000 --- 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/nvim_cmp.lua b/lua/obsidian/completion/plugin_initializers/nvim_cmp.lua deleted file mode 100644 index f73446780..000000000 --- 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/sources/base/refs.lua b/lua/obsidian/completion/sources/base/refs.lua index 24829962e..8d8169d24 100644 --- a/lua/obsidian/completion/sources/base/refs.lua +++ b/lua/obsidian/completion/sources/base/refs.lua @@ -26,50 +26,47 @@ local search = require "obsidian.search" ---@field anchor_link string|? ---@field new_text_to_option table ---@field root obsidian.Path -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 +local M = { + incomplete_response = { isIncomplete = true }, + complete_response = { isIncomplete = true, items = {} }, +} + +M.__index = M ---@return obsidian.completion.sources.base.RefsSourceBase -RefsSourceBase.new = function() - return setmetatable({}, RefsSourceBase) +M.new = function() + return setmetatable({}, M) end -RefsSourceBase.get_trigger_characters = completion.get_trigger_characters +M.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() +function M.new_completion_context(_self, completion_resolve_callback, request) + local cc = {} -- 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 + cc.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 - - completion_context.root = api.resolve_workspace_dir() + cc.request = request + cc.in_buffer_only = false + cc.root = api.resolve_workspace_dir() + cc.new_text_to_option = {} - return completion_context + return cc 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 +function M:process_completion(cc) + if not self:can_complete_request(cc) or not cc.search then return end @@ -103,7 +100,7 @@ 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) +function M:can_complete_request(cc) local can_complete can_complete, cc.search, cc.insert_start, cc.insert_end = completion.can_complete(cc.request) @@ -119,7 +116,7 @@ end ---@param note obsidian.Note ---@param block_link string? ---@return obsidian.note.Block[]|? -function RefsSourceBase:collect_matching_blocks(note, block_link) +function M.collect_matching_blocks(note, block_link) ---@type obsidian.note.Block[]|? local matching_blocks if block_link then @@ -144,7 +141,7 @@ end ---@param note obsidian.Note ---@param anchor_link string? ---@return obsidian.note.HeaderAnchor[]? -function RefsSourceBase:collect_matching_anchors(note, anchor_link) +function M.collect_matching_anchors(note, anchor_link) ---@type obsidian.note.HeaderAnchor[]|? local matching_anchors if anchor_link then @@ -167,7 +164,10 @@ end --- Strips block and anchor links from the current search string ---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function RefsSourceBase:strip_links(cc) +function M.strip_links(_self, cc) + if not cc.search then + return + end cc.search, cc.block_link = util.strip_block_links(cc.search) cc.search, cc.anchor_link = util.strip_anchor_links(cc.search) @@ -186,7 +186,10 @@ 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) +function M.determine_buffer_only_search_scope(_self, cc) + if not cc.search then + return + end 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 @@ -195,16 +198,17 @@ end ---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext ---@param results obsidian.Note[] -function RefsSourceBase:process_search_results(cc, results) +function M:process_search_results(cc, results) + if not cc.search then + return + end local completion_items = {} - cc.new_text_to_option = {} - for _, note in ipairs(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) + local matching_blocks = M.collect_matching_blocks(note, cc.block_link) + local matching_anchors = M.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) @@ -279,7 +283,7 @@ end ---@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) +function M.update_completion_options(_self, 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 @@ -427,4 +431,4 @@ function RefsSourceBase:update_completion_options(cc, label, alt_label, matching end end -return RefsSourceBase +return M diff --git a/lua/obsidian/completion/sources/blink/new.lua b/lua/obsidian/completion/sources/blink/new.lua deleted file mode 100644 index f60259eb4..000000000 --- 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/tags.lua b/lua/obsidian/completion/sources/blink/tags.lua deleted file mode 100644 index 6f7cdbd35..000000000 --- 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 26615351f..000000000 --- a/lua/obsidian/completion/sources/blink/util.lua +++ /dev/null @@ -1,43 +0,0 @@ -local M = {} - ----Generates the completion request from a blink context. ---- ----blink.cmp gets cursor from nvim_win_get_cursor (byte offset) and applies ----textEdits via vim.lsp.util.apply_text_edits with 'utf-8' encoding, so all ----positions are in bytes. We use byte offsets throughout to stay consistent. ----@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] - -- context.cursor[2] is a 0-indexed byte offset from nvim_win_get_cursor - local byte_col = context.cursor[2] - local cursor_before_line = context.line:sub(1, byte_col) - local cursor_after_line = context.line:sub(byte_col + 1) - - return { - context = { - bufnr = context.bufnr, - cursor_before_line = cursor_before_line, - cursor_after_line = cursor_after_line, - cursor = { - row = row, - line = row, - character = byte_col, - }, - }, - } -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 77eb7bffa..000000000 --- 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 e76d4132e..000000000 --- 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 4e22fa064..000000000 --- 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 1c2e16bd6..000000000 --- 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/config/default.lua b/lua/obsidian/config/default.lua index 730d8866e..673fa6f78 100644 --- a/lua/obsidian/config/default.lua +++ b/lua/obsidian/config/default.lua @@ -126,22 +126,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 d11755147..fab8132dd 100644 --- a/lua/obsidian/config/init.lua +++ b/lua/obsidian/config/init.lua @@ -122,6 +122,24 @@ config.normalize = function(opts, defaults) deprecate("preferred_link_style", "link.style", "3.18") end + if opts.completion ~= nil and opts.completion.nvim_cmp ~= nil then + opts.completion.nvim_cmp = nil + deprecate( + "completion.nvim_cmp", + "removing it from your config. Completion is now provided via the built-in obsidian-ls LSP server", + "4.0" + ) + end + + if opts.completion ~= nil and opts.completion.blink ~= nil then + opts.completion.blink = nil + deprecate( + "completion.blink", + "removing it from your config. Completion is now provided via the built-in obsidian-ls LSP server", + "4.0" + ) + end + if opts.completion ~= nil and opts.completion.new_notes_location ~= nil then opts.new_notes_location = opts.completion.new_notes_location opts.completion.new_notes_location = nil diff --git a/lua/obsidian/health.lua b/lua/obsidian/health.lua index 8e530394c..d747a4914 100644 --- a/lua/obsidian/health.lua +++ b/lua/obsidian/health.lua @@ -140,13 +140,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 a978c7332..b7cc77bf7 100644 --- a/lua/obsidian/init.lua +++ b/lua/obsidian/init.lua @@ -8,7 +8,6 @@ obsidian.code_action = require "obsidian.lsp.handlers._code_action" 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 = log obsidian.img_paste = require "obsidian.img_paste" @@ -84,13 +83,6 @@ obsidian.setup = function(user_opts) obsidian.commands.install_legacy() 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 a428bb29f..e6529d508 100644 --- a/lua/obsidian/lsp/handlers.lua +++ b/lua/obsidian/lsp/handlers.lua @@ -9,4 +9,6 @@ return { ["textDocument/definition"] = require "obsidian.lsp.handlers.definition", ["textDocument/documentSymbol"] = require "obsidian.lsp.handlers.document_symbol", ["textDocument/codeAction"] = require "obsidian.lsp.handlers.code_action", + ["textDocument/completion"] = require "obsidian.lsp.handlers.completion", + ["workspace/executeCommand"] = require "obsidian.lsp.handlers.execute_command", } diff --git a/lua/obsidian/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua new file mode 100644 index 000000000..75995c3b1 --- /dev/null +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -0,0 +1,131 @@ +local RefsSourceBase = require "obsidian.completion.sources.base.refs" +local TagsSourceBase = require "obsidian.completion.sources.base.tags" +local NewNoteSourceBase = require "obsidian.completion.sources.base.new" + +--- LSP-standard response shapes. +local incomplete_response = { isIncomplete = true } +local complete_response = { isIncomplete = true, items = {} } + +--- Build a base-class Request from LSP CompletionParams. +--- +--- The base classes expect: +--- request.context.bufnr (integer) +--- request.context.cursor_before_line (string) +--- request.context.cursor_after_line (string) +--- request.context.cursor.row (1-indexed line) +--- request.context.cursor.col (1-indexed byte column) +--- request.context.cursor.line (1-indexed line, used by tags for frontmatter) +--- request.context.cursor.character (0-indexed UTF-8 offset, used by refs.can_complete) +--- +---@param params lsp.CompletionParams +---@return obsidian.completion.sources.base.Request +local function build_request(params) + local uri = params.textDocument.uri + local bufnr = vim.uri_to_bufnr(uri) + + -- LSP position is 0-indexed line, 0-indexed character (UTF-16 by default, but + -- obsidian-ls advertises utf-8 offset encoding). + local lsp_line = params.position.line + local lsp_char = params.position.character + + -- Fetch the full line text from the buffer. + local lines = vim.api.nvim_buf_get_lines(bufnr, lsp_line, lsp_line + 1, false) + local line_text = lines[1] or "" + + -- Convert 0-indexed character to 1-indexed byte column. + local col = lsp_char + 1 + + local cursor_before_line = line_text:sub(1, col - 1) + local cursor_after_line = line_text:sub(col) + + return { + context = { + bufnr = bufnr, + cursor_before_line = cursor_before_line, + cursor_after_line = cursor_after_line, + cursor = { + row = lsp_line + 1, -- 1-indexed + col = col, -- 1-indexed byte + line = lsp_line + 1, -- 1-indexed, used by tags for frontmatter detection + character = lsp_char, -- 0-indexed, used by refs.can_complete + }, + }, + } +end + +--- Instantiate a source with LSP-standard response fields. +---@generic T +---@param Source { new: fun(): T } +---@return T +local function make_source(Source) + local source = Source.new() + source.incomplete_response = incomplete_response + source.complete_response = complete_response + return source +end + +--- Merge two LSP CompletionList tables. +---@param a lsp.CompletionList +---@param b lsp.CompletionList +---@return lsp.CompletionList +local function merge_results(a, b) + local items = {} + for _, item in ipairs(a.items or {}) do + items[#items + 1] = item + end + for _, item in ipairs(b.items or {}) do + items[#items + 1] = item + end + return { + isIncomplete = a.isIncomplete or b.isIncomplete, + items = items, + } +end + +---@param params lsp.CompletionParams +---@param callback fun(err: any, result: lsp.CompletionList) +return function(params, callback, _) + local request = build_request(params) + + -- We'll collect results from up to 3 sources (refs, tags, new note) and merge + -- them before calling the LSP callback. Because each source is async, we use + -- a simple counter to know when all have finished. + local pending = 0 + local merged = { isIncomplete = true, items = {} } + + local function on_source_done(result) + if result and result.items then + merged = merge_results(merged, result) + end + pending = pending - 1 + if pending == 0 then + callback(nil, merged) + end + end + + -- Refs source. + local refs_source = make_source(RefsSourceBase) + pending = pending + 1 + local refs_cc = refs_source:new_completion_context(on_source_done, request) + refs_source:process_completion(refs_cc) + + -- Tags source. + local tags_source = make_source(TagsSourceBase) + pending = pending + 1 + local tags_cc = tags_source:new_completion_context(on_source_done, request) + tags_source:process_completion(tags_cc) + + -- New note source (only if configured). + if Obsidian.opts.completion.create_new then + local new_source = make_source(NewNoteSourceBase) + pending = pending + 1 + local new_cc = new_source:new_completion_context(on_source_done, request) + new_source:process_completion(new_cc) + end + + -- If no sources were started (shouldn't happen, but guard against it), + -- return an empty result immediately. + if pending == 0 then + callback(nil, merged) + end +end diff --git a/lua/obsidian/lsp/handlers/execute_command.lua b/lua/obsidian/lsp/handlers/execute_command.lua new file mode 100644 index 000000000..62ce36de8 --- /dev/null +++ b/lua/obsidian/lsp/handlers/execute_command.lua @@ -0,0 +1,26 @@ +local NewNoteSourceBase = require "obsidian.completion.sources.base.new" +local log = require "obsidian.log" + +---@param params lsp.ExecuteCommandParams +---@param callback fun(err: any, result: any) +return function(params, callback, _) + if params.command == "obsidian.create_note" then + local args = params.arguments or {} + local item = args[1] + if not item then + callback("obsidian.create_note: missing item argument", nil) + return + end + + local source = NewNoteSourceBase.new() + local ok, err = pcall(source.process_execute, source, item) + if ok then + callback(nil, {}) + else + log.err("obsidian.create_note failed: " .. tostring(err)) + callback(tostring(err), nil) + end + else + callback("Unknown command: " .. tostring(params.command), nil) + end +end diff --git a/lua/obsidian/lsp/handlers/initialize.lua b/lua/obsidian/lsp/handlers/initialize.lua index 4d6729b93..bb9e51eb6 100644 --- a/lua/obsidian/lsp/handlers/initialize.lua +++ b/lua/obsidian/lsp/handlers/initialize.lua @@ -20,6 +20,13 @@ local initializeResult = { documentSymbolProvider = true, workspaceSymbolProvider = true, codeActionProvider = true, + completionProvider = { + triggerCharacters = { "[", "#" }, + resolveProvider = false, + }, + executeCommandProvider = { + commands = { "obsidian.create_note" }, + }, workspace = { fileOperations = { didRename = { diff --git a/lua/obsidian/lsp/init.lua b/lua/obsidian/lsp/init.lua index 9c12541e7..f49df7a72 100644 --- a/lua/obsidian/lsp/init.lua +++ b/lua/obsidian/lsp/init.lua @@ -1,6 +1,21 @@ local lsp = {} local log = require "obsidian.log" +--- Check if a third-party completion engine (blink.cmp or nvim-cmp) is available. +--- Both engines auto-consume LSP completion from attached language servers. +---@return boolean +local function has_completion_engine() + local has_blink = pcall(require, "blink.cmp") + if has_blink then + return true + end + local has_cmp = pcall(require, "cmp") + if has_cmp then + return true + end + return false +end + --- Start the lsp client --- ---@param buf integer diff --git a/tests/helpers.lua b/tests/helpers.lua index 72f9655a0..9ba40b328 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 000000000..a118cbae0 --- /dev/null +++ b/tests/lsp/test_completion.lua @@ -0,0 +1,103 @@ +local h = dofile "tests/helpers.lua" +local T, child = h.child_vault() +local eq = MiniTest.expect.equality + +T["completion"] = MiniTest.new_set() + +T["completion"]["returns items for wiki link trigger"] = function() + h.mock_vault_contents(child.Obsidian.dir, { + ["test.md"] = "[[ta", + ["target.md"] = [==[ +--- +id: target +aliases: [] +tags: [] +--- +Target note content +]==], + }) + + child.cmd("edit " .. tostring(child.Obsidian.dir / "test.md")) + child.api.nvim_win_set_cursor(0, { 1, 4 }) + + child.lua [[ + local handler = require "obsidian.lsp.handlers.completion" + handler({ + textDocument = { uri = vim.uri_from_bufnr(0) }, + position = { line = 0, character = 4 }, + }, function(err, res) + _G._test_result = res + end) + ]] + vim.uv.sleep(100) + + local result = child.lua_get [[_G._test_result]] + eq("table", type(result)) + eq(true, result.isIncomplete) + + -- Should find "target" note. + local found = false + for _, item in ipairs(result.items or {}) do + if item.label and item.label:find "target" then + found = true + break + end + end + eq(true, found) +end + +T["completion"]["returns items for tag trigger"] = function() + h.mock_vault_contents(child.Obsidian.dir, { + ["test.md"] = "#ta", + ["tagged.md"] = [==[ +--- +id: tagged +aliases: [] +tags: + - task +--- +]==], + }) + + child.cmd("edit " .. tostring(child.Obsidian.dir / "test.md")) + child.api.nvim_win_set_cursor(0, { 1, 3 }) + + child.lua [[ + local handler = require "obsidian.lsp.handlers.completion" + handler({ + textDocument = { uri = vim.uri_from_bufnr(0) }, + position = { line = 0, character = 3 }, + }, function(err, res) + _G._test_result = res + end) + ]] + vim.uv.sleep(100) + + local result = child.lua_get [[_G._test_result]] + eq("table", type(result)) +end + +T["completion"]["isIncomplete is true"] = function() + h.mock_vault_contents(child.Obsidian.dir, { + ["test.md"] = "[[fo", + }) + + child.cmd("edit " .. tostring(child.Obsidian.dir / "test.md")) + child.api.nvim_win_set_cursor(0, { 1, 4 }) + + child.lua [[ + local handler = require "obsidian.lsp.handlers.completion" + handler({ + textDocument = { uri = vim.uri_from_bufnr(0) }, + position = { line = 0, character = 4 }, + }, function(err, res) + _G._test_result = res + end) + ]] + vim.uv.sleep(100) + + local is_incomplete = child.lua_get [[_G._test_result and _G._test_result.isIncomplete]] + eq(true, is_incomplete) +end + +return T From 26ed6451cfae49b81ebdfac983a7edfbd1c7724a Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Thu, 12 Mar 2026 00:08:02 +0000 Subject: [PATCH 02/30] fix: simplify request handler --- lua/obsidian/lsp/handlers/_rename.lua | 4 +++- tests/lsp/test_rename.lua | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/obsidian/lsp/handlers/_rename.lua b/lua/obsidian/lsp/handlers/_rename.lua index 0eb0b540a..bdc6d503e 100644 --- a/lua/obsidian/lsp/handlers/_rename.lua +++ b/lua/obsidian/lsp/handlers/_rename.lua @@ -185,7 +185,9 @@ M.rename = function(note, new_name, callback, opts) -- so that file with renamed refs are displaying correctly for _, buf in ipairs(buf_list) do - vim.bo[buf].filetype = "markdown" + if vim.api.nvim_buf_is_valid(buf) then + vim.bo[buf].filetype = "markdown" + end end note.id = new_name diff --git a/tests/lsp/test_rename.lua b/tests/lsp/test_rename.lua index 5c9ec85cb..195a1a04a 100644 --- a/tests/lsp/test_rename.lua +++ b/tests/lsp/test_rename.lua @@ -62,6 +62,7 @@ end child.cmd("edit " .. files[target]) child.lua [[vim.lsp.buf.rename("target", {})]] + flush() eq("Identical name", child.lua_get "msg") end @@ -80,6 +81,7 @@ end child.cmd("edit " .. files[target]) child.lua [[vim.lsp.buf.rename("existing", {})]] + flush() eq("Note with same name exists", child.lua_get "msg") end From 42b5358f49a4a0589e1763cb3c9ad4d2aa833407 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Fri, 3 Apr 2026 22:47:38 +0100 Subject: [PATCH 03/30] wip: partly auto triggers --- lua/obsidian/lsp/handlers/initialize.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lua/obsidian/lsp/handlers/initialize.lua b/lua/obsidian/lsp/handlers/initialize.lua index bb9e51eb6..7b6e2ea98 100644 --- a/lua/obsidian/lsp/handlers/initialize.lua +++ b/lua/obsidian/lsp/handlers/initialize.lua @@ -9,6 +9,12 @@ local function send_progress(dispatchers, kind, title, percentage) }) end +local chars = { "[", "#" } + +for i = 32, 126 do + table.insert(chars, string.char(i)) +end + ---@type lsp.InitializeResult local initializeResult = { capabilities = { @@ -21,7 +27,7 @@ local initializeResult = { workspaceSymbolProvider = true, codeActionProvider = true, completionProvider = { - triggerCharacters = { "[", "#" }, + triggerCharacters = chars, resolveProvider = false, }, executeCommandProvider = { From d21523e6cd8b26754150d7ea43795f538bdf0986 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Thu, 9 Apr 2026 19:13:00 +0100 Subject: [PATCH 04/30] refactor: use client commands to create new --- lua/obsidian/completion/sources/base/new.lua | 32 ++++++------------- lua/obsidian/lsp/handlers.lua | 1 - lua/obsidian/lsp/handlers/execute_command.lua | 26 --------------- lua/obsidian/lsp/handlers/initialize.lua | 3 -- 4 files changed, 9 insertions(+), 53 deletions(-) delete mode 100644 lua/obsidian/lsp/handlers/execute_command.lua diff --git a/lua/obsidian/completion/sources/base/new.lua b/lua/obsidian/completion/sources/base/new.lua index aa443160d..6bef7c4ec 100644 --- a/lua/obsidian/completion/sources/base/new.lua +++ b/lua/obsidian/completion/sources/base/new.lua @@ -1,7 +1,6 @@ local completion = require "obsidian.completion.refs" local util = require "obsidian.util" 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. @@ -143,12 +142,18 @@ function NewNoteSourceBase:process_completion(cc) }, } - items[#items + 1] = { + ---@type lsp.CompletionItem + local item = { documentation = documentation, sortText = new_note_opts.label, filterText = completion.get_filter_text(new_note_opts.label), label = label, kind = vim.lsp.protocol.CompletionItemKind.Reference, + command = { + command = "obsidian.new", + title = "Obsidian new", + arguments = { new_note.id }, + }, textEdit = { newText = new_text, range = { @@ -166,6 +171,8 @@ function NewNoteSourceBase:process_completion(cc) note = new_note, }, } + + items[#items + 1] = item end cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = items })) @@ -189,25 +196,4 @@ function NewNoteSourceBase:can_complete_request(cc) return true end ---- Runs a generalized version of the execute method ----@param item any ----@return table|? callback_return_value -function NewNoteSourceBase.process_execute(_self, 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() - return {} -end - return NewNoteSourceBase diff --git a/lua/obsidian/lsp/handlers.lua b/lua/obsidian/lsp/handlers.lua index e6529d508..9795aec2e 100644 --- a/lua/obsidian/lsp/handlers.lua +++ b/lua/obsidian/lsp/handlers.lua @@ -10,5 +10,4 @@ return { ["textDocument/documentSymbol"] = require "obsidian.lsp.handlers.document_symbol", ["textDocument/codeAction"] = require "obsidian.lsp.handlers.code_action", ["textDocument/completion"] = require "obsidian.lsp.handlers.completion", - ["workspace/executeCommand"] = require "obsidian.lsp.handlers.execute_command", } diff --git a/lua/obsidian/lsp/handlers/execute_command.lua b/lua/obsidian/lsp/handlers/execute_command.lua deleted file mode 100644 index 62ce36de8..000000000 --- a/lua/obsidian/lsp/handlers/execute_command.lua +++ /dev/null @@ -1,26 +0,0 @@ -local NewNoteSourceBase = require "obsidian.completion.sources.base.new" -local log = require "obsidian.log" - ----@param params lsp.ExecuteCommandParams ----@param callback fun(err: any, result: any) -return function(params, callback, _) - if params.command == "obsidian.create_note" then - local args = params.arguments or {} - local item = args[1] - if not item then - callback("obsidian.create_note: missing item argument", nil) - return - end - - local source = NewNoteSourceBase.new() - local ok, err = pcall(source.process_execute, source, item) - if ok then - callback(nil, {}) - else - log.err("obsidian.create_note failed: " .. tostring(err)) - callback(tostring(err), nil) - end - else - callback("Unknown command: " .. tostring(params.command), nil) - end -end diff --git a/lua/obsidian/lsp/handlers/initialize.lua b/lua/obsidian/lsp/handlers/initialize.lua index 7b6e2ea98..4d6baec49 100644 --- a/lua/obsidian/lsp/handlers/initialize.lua +++ b/lua/obsidian/lsp/handlers/initialize.lua @@ -30,9 +30,6 @@ local initializeResult = { triggerCharacters = chars, resolveProvider = false, }, - executeCommandProvider = { - commands = { "obsidian.create_note" }, - }, workspace = { fileOperations = { didRename = { From dd25822c20c47fda37795b279cfb594ddffa2d07 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Tue, 14 Apr 2026 22:10:31 +0100 Subject: [PATCH 05/30] refactor: massively simplify code --- lua/obsidian/completion/refs.lua | 19 +-- lua/obsidian/completion/sources/base/new.lua | 128 ++++++++---------- lua/obsidian/completion/sources/base/refs.lua | 108 ++++++--------- lua/obsidian/completion/sources/base/tags.lua | 99 +++++--------- .../completion/sources/base/types.lua | 21 +-- .../completion/sources/blink/refs.lua | 24 ---- lua/obsidian/completion/tags.lua | 18 +-- lua/obsidian/lsp/handlers/completion.lua | 66 +++------ tests/lsp/test_completion.lua | 41 ++++++ tests/test_completion.lua | 46 ------- 10 files changed, 210 insertions(+), 360 deletions(-) delete mode 100644 lua/obsidian/completion/sources/blink/refs.lua diff --git a/lua/obsidian/completion/refs.lua b/lua/obsidian/completion/refs.lua index 354d63fb7..ccf34167a 100644 --- a/lua/obsidian/completion/refs.lua +++ b/lua/obsidian/completion/refs.lua @@ -23,13 +23,13 @@ end ---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 +---@param request obsidian.completion.Request ---@return boolean can_complete ---@return string|? search_string ---@return integer|? insert_start ---@return integer|? insert_end M.can_complete = function(request) - local input, search = find_search_start(request.context.cursor_before_line) + local input, search = find_search_start(request.cursor_before_line) if input == nil or search == nil then return false elseif string.len(search) == 0 or util.is_whitespace(search) then @@ -37,8 +37,8 @@ M.can_complete = function(request) 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 suffix = string.sub(request.cursor_after_line, 1, 2) + local cursor_char = request.character local insert_end_offset = suffix == "]]" and 1 or -1 return true, search, cursor_char - string.len(input), cursor_char + 1 + insert_end_offset else @@ -46,17 +46,6 @@ M.can_complete = function(request) 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 - ---@param label string ---@return string M.get_filter_text = function(label) diff --git a/lua/obsidian/completion/sources/base/new.lua b/lua/obsidian/completion/sources/base/new.lua index 6bef7c4ec..ea39b57bb 100644 --- a/lua/obsidian/completion/sources/base/new.lua +++ b/lua/obsidian/completion/sources/base/new.lua @@ -4,52 +4,48 @@ local Note = require "obsidian.note" ---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 +---@class obsidian.completion.NewNoteSourceCompletionContext +---@field completion_resolve_callback fun(resp: lsp.CompletionList) +---@field request obsidian.completion.Request ---@field search string|? ---@field insert_start integer|? ---@field insert_end integer|? -local NewNoteSourceCompletionContext = {} -NewNoteSourceCompletionContext.__index = NewNoteSourceCompletionContext -NewNoteSourceCompletionContext.new = function() - return setmetatable({}, NewNoteSourceCompletionContext) -end - ----@class obsidian.completion.sources.base.NewNoteSourceBase +---@class obsidian.completion.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 +local M = { + incomplete_response = { isIncomplete = true }, + complete_response = { isIncomplete = true, items = {} }, +} ----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(_self, 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 +--- Returns whatever it's possible to complete the search and sets up the search related variables in cc +---@param cc obsidian.completion.NewNoteSourceCompletionContext +---@return boolean success provides a chance to return early if the request didn't meet the requirements +local function can_complete_request(cc) + local can_complete + can_complete, cc.search, cc.insert_start, cc.insert_end = completion.can_complete(cc.request) - -- This request object will be used to determine the current cursor location and the text around it - completion_context.request = request + if cc.search ~= nil then + cc.search = util.lstrip_whitespace(cc.search) + end - return completion_context + if not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then + return false + end + return true 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 +---@param completion_resolve_callback fun(resp: lsp.CompletionList) +---@param request obsidian.completion.Request +function M.process_completion(completion_resolve_callback, request) + local cc = { + completion_resolve_callback = completion_resolve_callback, + request = request, + } + if not can_complete_request(cc) then + cc.completion_resolve_callback(M.incomplete_response) return end @@ -63,19 +59,19 @@ function NewNoteSourceBase:process_completion(cc) -- If block link is incomplete, do nothing. if not block_link and vim.endswith(cc.search, "#^") then - cc.completion_resolve_callback(self.incomplete_response) + cc.completion_resolve_callback(M.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) + cc.completion_resolve_callback(M.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) + cc.completion_resolve_callback(M.incomplete_response) return end @@ -142,6 +138,21 @@ function NewNoteSourceBase:process_completion(cc) }, } + ---@cast cc.insert_end -nil + ---@cast cc.insert_start -nil + + ---@type lsp.Range + local range = { + start = { + line = cc.request.line, + character = cc.insert_start, + }, + ["end"] = { + line = cc.request.line, + character = cc.insert_end + 1, + }, + } + ---@type lsp.CompletionItem local item = { documentation = documentation, @@ -152,48 +163,23 @@ function NewNoteSourceBase:process_completion(cc) command = { command = "obsidian.new", title = "Obsidian new", - arguments = { new_note.id }, + arguments = { new_note.id, new_note_opts.template }, }, + -- command = { + -- command = "obsidian.new_from_template", + -- title = "Obsidian new_from_template", + -- arguments = { new_note.id, new_note_opts.template } -- for [[new_note@template future expansion + -- }, 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, + range = range, }, } items[#items + 1] = item 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 = 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 + cc.completion_resolve_callback(vim.tbl_deep_extend("force", M.complete_response, { items = items })) end -return NewNoteSourceBase +return M diff --git a/lua/obsidian/completion/sources/base/refs.lua b/lua/obsidian/completion/sources/base/refs.lua index 8d8169d24..4ae5910c8 100644 --- a/lua/obsidian/completion/sources/base/refs.lua +++ b/lua/obsidian/completion/sources/base/refs.lua @@ -16,8 +16,8 @@ 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.RefsSourceCompletionContext ----@field completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@field request obsidian.completion.sources.base.Request +---@field completion_resolve_callback fun(resp: lsp.CompletionList) +---@field request obsidian.completion.Request ---@field in_buffer_only boolean ---@field search string|? ---@field insert_start integer|? @@ -25,7 +25,6 @@ local search = require "obsidian.search" ---@field block_link string|? ---@field anchor_link string|? ---@field new_text_to_option table ----@field root obsidian.Path ---@class obsidian.completion.sources.base.RefsSourceBase ---@field incomplete_response table @@ -35,50 +34,44 @@ local M = { complete_response = { isIncomplete = true, items = {} }, } -M.__index = M - ----@return obsidian.completion.sources.base.RefsSourceBase -M.new = function() - return setmetatable({}, M) -end - -M.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 M.new_completion_context(_self, completion_resolve_callback, request) - local cc = {} - - -- Sets up the completion callback, which will be called when the (possibly incomplete) completion items are ready - cc.completion_resolve_callback = completion_resolve_callback +--- 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 +local function can_complete_request(cc) + local can_complete + can_complete, cc.search, cc.insert_start, cc.insert_end = completion.can_complete(cc.request) - -- This request object will be used to determine the current cursor location and the text around it - cc.request = request - cc.in_buffer_only = false - cc.root = api.resolve_workspace_dir() - cc.new_text_to_option = {} + if not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then + return false + end - return cc + return true end ---- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function M:process_completion(cc) - if not self:can_complete_request(cc) or not cc.search then +---@param completion_resolve_callback function +---@param request obsidian.completion.Request +function M.process_completion(completion_resolve_callback, request) + local cc = { + completion_resolve_callback = completion_resolve_callback, + request = request, + in_buffer_only = false, + new_text_to_option = {}, + } + + if not can_complete_request(cc) or not cc.search then + cc.completion_resolve_callback(M.incomplete_response) return end - self:strip_links(cc) - self:determine_buffer_only_search_scope(cc) + M.strip_links(cc) + M.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 }) + M.process_search_results(cc, { note }) else - cc.completion_resolve_callback(self.incomplete_response) + cc.completion_resolve_callback(M.incomplete_response) end else local search_opts = { @@ -88,30 +81,15 @@ function M:process_completion(cc) } search.find_notes_async(cc.search, function(results) - self:process_search_results(cc, results) + M.process_search_results(cc, results) end, { - dir = cc.root, + dir = api.resolve_workspace_dir(), 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 M:can_complete_request(cc) - local can_complete - can_complete, cc.search, cc.insert_start, cc.insert_end = 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? @@ -164,7 +142,7 @@ end --- Strips block and anchor links from the current search string ---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function M.strip_links(_self, cc) +function M.strip_links(cc) if not cc.search then return end @@ -186,7 +164,7 @@ end --- Determines whatever the in_buffer_only should be enabled ---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function M.determine_buffer_only_search_scope(_self, cc) +function M.determine_buffer_only_search_scope(cc) if not cc.search then return end @@ -198,7 +176,7 @@ end ---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext ---@param results obsidian.Note[] -function M:process_search_results(cc, results) +function M.process_search_results(cc, results) if not cc.search then return end @@ -211,7 +189,7 @@ function M:process_search_results(cc, results) local matching_anchors = M.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) + M.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[] @@ -221,7 +199,7 @@ function M:process_search_results(cc, results) end for _, alias in ipairs(aliases) do - self:update_completion_options(cc, alias, nil, matching_anchors, matching_blocks, note) + M.update_completion_options(cc, alias, nil, matching_anchors, matching_blocks, note) local alias_case_matched = util.match_case(cc.search, alias) if @@ -230,12 +208,12 @@ function M:process_search_results(cc, results) 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) + M.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) + M.update_completion_options(cc, note:display_name(), note.alt_alias, matching_anchors, matching_blocks, note) end end end @@ -254,6 +232,8 @@ function M:process_search_results(cc, results) error "not implemented" end + ---@cast cc.insert_end -nil + table.insert(completion_items, { documentation = option.documentation, sortText = option.sort_text, @@ -264,11 +244,11 @@ function M:process_search_results(cc, results) newText = option.new_text, range = { ["start"] = { - line = cc.request.context.cursor.row - 1, + line = cc.request.line, character = cc.insert_start, }, ["end"] = { - line = cc.request.context.cursor.row - 1, + line = cc.request.line, character = cc.insert_end + 1, }, }, @@ -276,14 +256,16 @@ function M:process_search_results(cc, results) }) end - cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = completion_items })) + cc.completion_resolve_callback(vim.tbl_deep_extend("force", M.complete_response, { items = completion_items })) end +-- TODO: localize + ---@param cc obsidian.completion.sources.base.RefsSourceCompletionContext ---@param label string|? ---@param alt_label string|? ---@param note obsidian.Note -function M.update_completion_options(_self, cc, label, alt_label, matching_anchors, matching_blocks, note) +function M.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 diff --git a/lua/obsidian/completion/sources/base/tags.lua b/lua/obsidian/completion/sources/base/tags.lua index ff3b17f2a..673f02db8 100644 --- a/lua/obsidian/completion/sources/base/tags.lua +++ b/lua/obsidian/completion/sources/base/tags.lua @@ -1,64 +1,54 @@ local completion = require "obsidian.completion.tags" -local iter = vim.iter local search = require "obsidian.search" local api = require "obsidian.api" ---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 +---@class obsidian.completion.TagsSourceCompletionContext +---@field completion_resolve_callback fun(resp: lsp.CompletionList) +---@field request obsidian.completion.Request ---@field search string|? ---@field in_frontmatter boolean|? ----@field root obsidian.Path -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(_self, 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 +local M = { + incomplete_response = { isIncomplete = true }, + complete_response = { isIncomplete = true, items = {} }, +} - -- This request object will be used to determine the current cursor location and the text around it - completion_context.request = request +--- Returns whatever it's possible to complete the search and sets up the search related variables in cc +---@param cc obsidian.completion.TagsSourceCompletionContext +---@return boolean success provides a chance to return early if the request didn't meet the requirements +local function can_complete_request(cc) + local can_complete + can_complete, cc.search, cc.in_frontmatter = completion.can_complete(cc.request) - completion_context.root = api.resolve_workspace_dir() + if not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then + return false + end - return completion_context + return true 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 +---@param completion_resolve_callback fun(resp: lsp.CompletionList) +---@param request obsidian.completion.Request +function M.process_completion(completion_resolve_callback, request) + local cc = { + completion_resolve_callback = completion_resolve_callback, + request = request, + } + + if not can_complete_request(cc) then + cc.completion_resolve_callback(M.incomplete_response) return end search.find_tags_async(cc.search, function(tag_locs) local tags = {} - for tag_loc in iter(tag_locs) do + for tag_loc in vim.iter(tag_locs) do tags[tag_loc.tag] = true end @@ -77,7 +67,7 @@ function TagsSourceBase:process_completion(cc) end -- Calculate the range to replace (the entire #tag pattern) - local cursor_before = cc.request.context.cursor_before_line + local cursor_before = cc.request.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 @@ -90,41 +80,20 @@ function TagsSourceBase:process_completion(cc) newText = insert_text, range = { ["start"] = { - line = cc.request.context.cursor.row - 1, + line = cc.request.line, character = insert_start, }, ["end"] = { - line = cc.request.context.cursor.row - 1, + line = cc.request.line, 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, { dir = cc.root }) -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 + cc.completion_resolve_callback(vim.tbl_deep_extend("force", M.complete_response, { items = items })) + end, { dir = api.resolve_workspace_dir() }) end -return TagsSourceBase +return M diff --git a/lua/obsidian/completion/sources/base/types.lua b/lua/obsidian/completion/sources/base/types.lua index ba22c0e7a..adae0e756 100644 --- a/lua/obsidian/completion/sources/base/types.lua +++ b/lua/obsidian/completion/sources/base/types.lua @@ -1,16 +1,7 @@ ----Cursor position within a completion request. ----@class obsidian.completion.sources.base.Request.Context.Cursor ----@field public row integer 1-indexed line number ----@field public line integer 1-indexed line number (same as row, used by tags for frontmatter) ----@field public character integer 0-indexed byte offset into the line (utf-8) - ----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.Cursor ----@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 +---@class obsidian.completion.Request +---@field bufnr integer +---@field cursor_after_line string +---@field cursor_before_line string +---@field line integer 1-indexed line number (same as row, used by tags for frontmatter) +---@field character integer 0-indexed byte offset into the line (utf-8) diff --git a/lua/obsidian/completion/sources/blink/refs.lua b/lua/obsidian/completion/sources/blink/refs.lua deleted file mode 100644 index 5b87b77e1..000000000 --- a/lua/obsidian/completion/sources/blink/refs.lua +++ /dev/null @@ -1,24 +0,0 @@ -local RefsSourceBase = require "obsidian.completion.sources.base.refs" -local blink_util = require "obsidian.completion.sources.blink.util" - ----@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/tags.lua b/lua/obsidian/completion/tags.lua index 58ae991ef..50608e573 100644 --- a/lua/obsidian/completion/tags.lua +++ b/lua/obsidian/completion/tags.lua @@ -30,15 +30,15 @@ end ---@return boolean, string|?, boolean|? M.can_complete = function(request) - local search = M.find_tags_start(request.context.cursor_before_line) + local search = M.find_tags_start(request.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) + local line = request.line + 1 -- 1-indexed + local frontmatter_start, frontmatter_end = get_frontmatter_boundaries(request.bufnr) if frontmatter_start ~= nil and frontmatter_start <= (line + 1) @@ -51,16 +51,4 @@ M.can_complete = function(request) 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/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua index 75995c3b1..6bad9b759 100644 --- a/lua/obsidian/lsp/handlers/completion.lua +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -1,24 +1,18 @@ -local RefsSourceBase = require "obsidian.completion.sources.base.refs" -local TagsSourceBase = require "obsidian.completion.sources.base.tags" -local NewNoteSourceBase = require "obsidian.completion.sources.base.new" - ---- LSP-standard response shapes. -local incomplete_response = { isIncomplete = true } -local complete_response = { isIncomplete = true, items = {} } +local Ref = require "obsidian.completion.sources.base.refs" +local Tag = require "obsidian.completion.sources.base.tags" +local NewNote = require "obsidian.completion.sources.base.new" --- Build a base-class Request from LSP CompletionParams. --- --- The base classes expect: ---- request.context.bufnr (integer) ---- request.context.cursor_before_line (string) ---- request.context.cursor_after_line (string) ---- request.context.cursor.row (1-indexed line) ---- request.context.cursor.col (1-indexed byte column) ---- request.context.cursor.line (1-indexed line, used by tags for frontmatter) ---- request.context.cursor.character (0-indexed UTF-8 offset, used by refs.can_complete) +--- request.bufnr (integer) +--- request.cursor_before_line (string) +--- request.cursor_after_line (string) +--- request.line (0-indexed line, used by tags for frontmatter) +--- request.character (0-indexed UTF-8 offset, used by refs.can_complete) --- ---@param params lsp.CompletionParams ----@return obsidian.completion.sources.base.Request +---@return obsidian.completion.Request local function build_request(params) local uri = params.textDocument.uri local bufnr = vim.uri_to_bufnr(uri) @@ -39,31 +33,14 @@ local function build_request(params) local cursor_after_line = line_text:sub(col) return { - context = { - bufnr = bufnr, - cursor_before_line = cursor_before_line, - cursor_after_line = cursor_after_line, - cursor = { - row = lsp_line + 1, -- 1-indexed - col = col, -- 1-indexed byte - line = lsp_line + 1, -- 1-indexed, used by tags for frontmatter detection - character = lsp_char, -- 0-indexed, used by refs.can_complete - }, - }, + bufnr = bufnr, + cursor_before_line = cursor_before_line, + cursor_after_line = cursor_after_line, + line = lsp_line, + character = lsp_char, -- 0-indexed, used by refs.can_complete } end ---- Instantiate a source with LSP-standard response fields. ----@generic T ----@param Source { new: fun(): T } ----@return T -local function make_source(Source) - local source = Source.new() - source.incomplete_response = incomplete_response - source.complete_response = complete_response - return source -end - --- Merge two LSP CompletionList tables. ---@param a lsp.CompletionList ---@param b lsp.CompletionList @@ -104,23 +81,20 @@ return function(params, callback, _) end -- Refs source. - local refs_source = make_source(RefsSourceBase) + -- local refs_source = RefsSourceBase:new() pending = pending + 1 - local refs_cc = refs_source:new_completion_context(on_source_done, request) - refs_source:process_completion(refs_cc) + -- local refs_cc = Ref.new_completion_context( + Ref.process_completion(on_source_done, request) -- Tags source. - local tags_source = make_source(TagsSourceBase) + -- local tags_source = TagsSourceBase:new() pending = pending + 1 - local tags_cc = tags_source:new_completion_context(on_source_done, request) - tags_source:process_completion(tags_cc) + Tag.process_completion(on_source_done, request) -- New note source (only if configured). if Obsidian.opts.completion.create_new then - local new_source = make_source(NewNoteSourceBase) pending = pending + 1 - local new_cc = new_source:new_completion_context(on_source_done, request) - new_source:process_completion(new_cc) + NewNote.process_completion(on_source_done, request) end -- If no sources were started (shouldn't happen, but guard against it), diff --git a/tests/lsp/test_completion.lua b/tests/lsp/test_completion.lua index a118cbae0..fd330b712 100644 --- a/tests/lsp/test_completion.lua +++ b/tests/lsp/test_completion.lua @@ -2,6 +2,47 @@ local h = dofile "tests/helpers.lua" local T, child = h.child_vault() local eq = MiniTest.expect.equality +-- TODO: better test helpers +T["refs"] = MiniTest.new_set() + +T["refs"]["can_complete should handle wiki links with text"] = function() + local completion = require "obsidian.completion.refs" + + local before = "simple text [[foo" + local request = { + 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["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 = { + 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 + T["completion"] = MiniTest.new_set() T["completion"]["returns items for wiki link trigger"] = function() diff --git a/tests/test_completion.lua b/tests/test_completion.lua index 3c726057c..9e20ae562 100644 --- a/tests/test_completion.lua +++ b/tests/test_completion.lua @@ -2,50 +2,4 @@ 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 From c6895f23c87ed29a8245da05c162d357e516222c Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Tue, 14 Apr 2026 22:19:40 +0100 Subject: [PATCH 06/30] further --- .../completion/plugin_initializers/blink.lua | 188 ------------------ lua/obsidian/completion/refs.lua | 7 + .../completion/sources/{base => }/new.lua | 11 +- .../completion/sources/{base => }/refs.lua | 0 .../completion/sources/{base => }/tags.lua | 0 .../completion/sources/{base => }/types.lua | 0 lua/obsidian/lsp/handlers/completion.lua | 6 +- tests/test_completion.lua | 5 - 8 files changed, 13 insertions(+), 204 deletions(-) delete mode 100644 lua/obsidian/completion/plugin_initializers/blink.lua rename lua/obsidian/completion/sources/{base => }/new.lua (94%) rename lua/obsidian/completion/sources/{base => }/refs.lua (100%) rename lua/obsidian/completion/sources/{base => }/tags.lua (100%) rename lua/obsidian/completion/sources/{base => }/types.lua (100%) delete mode 100644 tests/test_completion.lua diff --git a/lua/obsidian/completion/plugin_initializers/blink.lua b/lua/obsidian/completion/plugin_initializers/blink.lua deleted file mode 100644 index 358bcb67a..000000000 --- a/lua/obsidian/completion/plugin_initializers/blink.lua +++ /dev/null @@ -1,188 +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 - -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 = obsidian.api.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" 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/refs.lua b/lua/obsidian/completion/refs.lua index ccf34167a..b05a1328a 100644 --- a/lua/obsidian/completion/refs.lua +++ b/lua/obsidian/completion/refs.lua @@ -52,4 +52,11 @@ M.get_filter_text = function(label) return "[[" .. label end +---@type lsp.CompletionList +M.incomplete_response = { isIncomplete = true, items = {} } + +-- TODO: +---@type lsp.CompletionList +M.complete_response = { isIncomplete = false, items = {} } + return M diff --git a/lua/obsidian/completion/sources/base/new.lua b/lua/obsidian/completion/sources/new.lua similarity index 94% rename from lua/obsidian/completion/sources/base/new.lua rename to lua/obsidian/completion/sources/new.lua index ea39b57bb..07444b488 100644 --- a/lua/obsidian/completion/sources/base/new.lua +++ b/lua/obsidian/completion/sources/new.lua @@ -11,13 +11,7 @@ local Note = require "obsidian.note" ---@field insert_start integer|? ---@field insert_end integer|? ----@class obsidian.completion.NewNoteSourceBase ----@field incomplete_response table ----@field complete_response table -local M = { - incomplete_response = { isIncomplete = true }, - complete_response = { isIncomplete = true, items = {} }, -} +local M = {} --- Returns whatever it's possible to complete the search and sets up the search related variables in cc ---@param cc obsidian.completion.NewNoteSourceCompletionContext @@ -179,7 +173,8 @@ function M.process_completion(completion_resolve_callback, request) items[#items + 1] = item end - cc.completion_resolve_callback(vim.tbl_deep_extend("force", M.complete_response, { items = items })) + local completion_list = vim.tbl_deep_extend("force", completion.complete_response, { items = items }) + cc.completion_resolve_callback(completion_list) end return M diff --git a/lua/obsidian/completion/sources/base/refs.lua b/lua/obsidian/completion/sources/refs.lua similarity index 100% rename from lua/obsidian/completion/sources/base/refs.lua rename to lua/obsidian/completion/sources/refs.lua diff --git a/lua/obsidian/completion/sources/base/tags.lua b/lua/obsidian/completion/sources/tags.lua similarity index 100% rename from lua/obsidian/completion/sources/base/tags.lua rename to lua/obsidian/completion/sources/tags.lua diff --git a/lua/obsidian/completion/sources/base/types.lua b/lua/obsidian/completion/sources/types.lua similarity index 100% rename from lua/obsidian/completion/sources/base/types.lua rename to lua/obsidian/completion/sources/types.lua diff --git a/lua/obsidian/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua index 6bad9b759..0845881db 100644 --- a/lua/obsidian/lsp/handlers/completion.lua +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -1,6 +1,6 @@ -local Ref = require "obsidian.completion.sources.base.refs" -local Tag = require "obsidian.completion.sources.base.tags" -local NewNote = require "obsidian.completion.sources.base.new" +local Ref = require "obsidian.completion.sources.refs" +local Tag = require "obsidian.completion.sources.tags" +local NewNote = require "obsidian.completion.sources.new" --- Build a base-class Request from LSP CompletionParams. --- diff --git a/tests/test_completion.lua b/tests/test_completion.lua deleted file mode 100644 index 9e20ae562..000000000 --- a/tests/test_completion.lua +++ /dev/null @@ -1,5 +0,0 @@ -local new_set, eq = MiniTest.new_set, MiniTest.expect.equality - -local T = new_set() - -return T From 1a3c9bef5548f284e374d5bc9d19ce7a52390661 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Tue, 14 Apr 2026 22:25:32 +0100 Subject: [PATCH 07/30] fix: test --- tests/lsp/test_completion.lua | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/lsp/test_completion.lua b/tests/lsp/test_completion.lua index fd330b712..2e9459535 100644 --- a/tests/lsp/test_completion.lua +++ b/tests/lsp/test_completion.lua @@ -12,9 +12,7 @@ T["refs"]["can_complete should handle wiki links with text"] = function() local request = { cursor_before_line = before, cursor_after_line = "", - cursor = { - character = vim.fn.strchars(before), - }, + character = string.len(before), } local can_complete, search, insert_start, insert_end, _ = completion.can_complete(request) @@ -31,16 +29,14 @@ T["refs"]["can_complete should handle wiki links with preceding Unicode text"] = local request = { cursor_before_line = before, cursor_after_line = "", - cursor = { - character = vim.fn.strchars(before), - }, + character = string.len(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) + eq(16, insert_start) + eq(21, insert_end) end T["completion"] = MiniTest.new_set() From b98773a1659e8f685e4f70abe71745c9305ee27d Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Tue, 14 Apr 2026 22:35:33 +0100 Subject: [PATCH 08/30] better names --- lua/obsidian/completion/tags.lua | 5 +++++ lua/obsidian/lsp/handlers/completion.lua | 23 ++++++----------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/lua/obsidian/completion/tags.lua b/lua/obsidian/completion/tags.lua index 50608e573..af8183092 100644 --- a/lua/obsidian/completion/tags.lua +++ b/lua/obsidian/completion/tags.lua @@ -3,12 +3,16 @@ local Patterns = require("obsidian.search").Patterns local M = {} +-- TODO: use proper unicode match + ---@type { pattern: string, offset: integer }[] local TAG_PATTERNS = { { pattern = "[%s%(]#" .. Patterns.TagCharsOptional .. "$", offset = 2 }, { pattern = "^#" .. Patterns.TagCharsOptional .. "$", offset = 1 }, } +---@param input string +---@return string? M.find_tags_start = function(input) for _, pattern in ipairs(TAG_PATTERNS) do local match = string.match(input, pattern.pattern) @@ -28,6 +32,7 @@ local get_frontmatter_boundaries = function(bufnr) end end +---@param request obsidian.completion.Request ---@return boolean, string|?, boolean|? M.can_complete = function(request) local search = M.find_tags_start(request.cursor_before_line) diff --git a/lua/obsidian/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua index 0845881db..3065abad6 100644 --- a/lua/obsidian/lsp/handlers/completion.lua +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -4,39 +4,28 @@ local NewNote = require "obsidian.completion.sources.new" --- Build a base-class Request from LSP CompletionParams. --- ---- The base classes expect: ---- request.bufnr (integer) ---- request.cursor_before_line (string) ---- request.cursor_after_line (string) ---- request.line (0-indexed line, used by tags for frontmatter) ---- request.character (0-indexed UTF-8 offset, used by refs.can_complete) ---- ---@param params lsp.CompletionParams ---@return obsidian.completion.Request local function build_request(params) local uri = params.textDocument.uri local bufnr = vim.uri_to_bufnr(uri) - -- LSP position is 0-indexed line, 0-indexed character (UTF-16 by default, but - -- obsidian-ls advertises utf-8 offset encoding). - local lsp_line = params.position.line + -- LSP position is 0-indexed line, 0-indexed character + local line = params.position.line local lsp_char = params.position.character -- Fetch the full line text from the buffer. - local lines = vim.api.nvim_buf_get_lines(bufnr, lsp_line, lsp_line + 1, false) + local lines = vim.api.nvim_buf_get_lines(bufnr, line, line + 1, false) local line_text = lines[1] or "" - -- Convert 0-indexed character to 1-indexed byte column. - local col = lsp_char + 1 - - local cursor_before_line = line_text:sub(1, col - 1) - local cursor_after_line = line_text:sub(col) + local cursor_before_line = line_text:sub(1, line - 1) + local cursor_after_line = line_text:sub(line) return { bufnr = bufnr, cursor_before_line = cursor_before_line, cursor_after_line = cursor_after_line, - line = lsp_line, + line = line, character = lsp_char, -- 0-indexed, used by refs.can_complete } end From c49c75b134a3758d706039ad65e9d9af03c0d888 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Tue, 14 Apr 2026 23:01:50 +0100 Subject: [PATCH 09/30] refactor --- lua/obsidian/completion/refs.lua | 7 ------- lua/obsidian/completion/sources/new.lua | 20 ++++++++++++++------ lua/obsidian/completion/sources/refs.lua | 21 ++++++++++++--------- lua/obsidian/completion/sources/tags.lua | 21 ++++++++++++--------- lua/obsidian/completion/sources/types.lua | 7 ------- lua/obsidian/lsp/handlers/completion.lua | 9 +++++++-- 6 files changed, 45 insertions(+), 40 deletions(-) delete mode 100644 lua/obsidian/completion/sources/types.lua diff --git a/lua/obsidian/completion/refs.lua b/lua/obsidian/completion/refs.lua index b05a1328a..ccf34167a 100644 --- a/lua/obsidian/completion/refs.lua +++ b/lua/obsidian/completion/refs.lua @@ -52,11 +52,4 @@ M.get_filter_text = function(label) return "[[" .. label end ----@type lsp.CompletionList -M.incomplete_response = { isIncomplete = true, items = {} } - --- TODO: ----@type lsp.CompletionList -M.complete_response = { isIncomplete = false, items = {} } - return M diff --git a/lua/obsidian/completion/sources/new.lua b/lua/obsidian/completion/sources/new.lua index 07444b488..8ebc99c18 100644 --- a/lua/obsidian/completion/sources/new.lua +++ b/lua/obsidian/completion/sources/new.lua @@ -13,6 +13,12 @@ local Note = require "obsidian.note" local M = {} +---@type lsp.CompletionList +local EMPTY_RESPONSE = { + isIncomplete = true, + items = {}, +} + --- Returns whatever it's possible to complete the search and sets up the search related variables in cc ---@param cc obsidian.completion.NewNoteSourceCompletionContext ---@return boolean success provides a chance to return early if the request didn't meet the requirements @@ -39,7 +45,7 @@ function M.process_completion(completion_resolve_callback, request) request = request, } if not can_complete_request(cc) then - cc.completion_resolve_callback(M.incomplete_response) + cc.completion_resolve_callback(EMPTY_RESPONSE) return end @@ -53,19 +59,19 @@ function M.process_completion(completion_resolve_callback, request) -- If block link is incomplete, do nothing. if not block_link and vim.endswith(cc.search, "#^") then - cc.completion_resolve_callback(M.incomplete_response) + cc.completion_resolve_callback(EMPTY_RESPONSE) return end -- If anchor link is incomplete, do nothing. if not anchor_link and vim.endswith(cc.search, "#") then - cc.completion_resolve_callback(M.incomplete_response) + cc.completion_resolve_callback(EMPTY_RESPONSE) return end -- Probably just a block/anchor link within current note. if string.len(cc.search) == 0 then - cc.completion_resolve_callback(M.incomplete_response) + cc.completion_resolve_callback(EMPTY_RESPONSE) return end @@ -173,8 +179,10 @@ function M.process_completion(completion_resolve_callback, request) items[#items + 1] = item end - local completion_list = vim.tbl_deep_extend("force", completion.complete_response, { items = items }) - cc.completion_resolve_callback(completion_list) + cc.completion_resolve_callback { + isIncomplete = true, + items = items, + } end return M diff --git a/lua/obsidian/completion/sources/refs.lua b/lua/obsidian/completion/sources/refs.lua index 4ae5910c8..beacb87ee 100644 --- a/lua/obsidian/completion/sources/refs.lua +++ b/lua/obsidian/completion/sources/refs.lua @@ -26,12 +26,12 @@ local search = require "obsidian.search" ---@field anchor_link string|? ---@field new_text_to_option table ----@class obsidian.completion.sources.base.RefsSourceBase ----@field incomplete_response table ----@field complete_response table -local M = { - incomplete_response = { isIncomplete = true }, - complete_response = { isIncomplete = true, items = {} }, +local M = {} + +---@type lsp.CompletionList +local EMPTY_RESPONSE = { + isIncomplete = true, + items = {}, } --- Returns whatever it's possible to complete the search and sets up the search related variables in cc @@ -59,7 +59,7 @@ function M.process_completion(completion_resolve_callback, request) } if not can_complete_request(cc) or not cc.search then - cc.completion_resolve_callback(M.incomplete_response) + cc.completion_resolve_callback(EMPTY_RESPONSE) return end @@ -71,7 +71,7 @@ function M.process_completion(completion_resolve_callback, request) if note then M.process_search_results(cc, { note }) else - cc.completion_resolve_callback(M.incomplete_response) + cc.completion_resolve_callback(EMPTY_RESPONSE) end else local search_opts = { @@ -256,7 +256,10 @@ function M.process_search_results(cc, results) }) end - cc.completion_resolve_callback(vim.tbl_deep_extend("force", M.complete_response, { items = completion_items })) + cc.completion_resolve_callback { + isIncomplete = true, + items = completion_items, + } end -- TODO: localize diff --git a/lua/obsidian/completion/sources/tags.lua b/lua/obsidian/completion/sources/tags.lua index 673f02db8..e50d3a0ff 100644 --- a/lua/obsidian/completion/sources/tags.lua +++ b/lua/obsidian/completion/sources/tags.lua @@ -10,12 +10,12 @@ local api = require "obsidian.api" ---@field search string|? ---@field in_frontmatter boolean|? ----@class obsidian.completion.sources.base.TagsSourceBase ----@field incomplete_response table ----@field complete_response table -local M = { - incomplete_response = { isIncomplete = true }, - complete_response = { isIncomplete = true, items = {} }, +local M = {} + +---@type lsp.CompletionList +local EMPTY_RESPONSE = { + isIncomplete = true, + items = {}, } --- Returns whatever it's possible to complete the search and sets up the search related variables in cc @@ -42,13 +42,13 @@ function M.process_completion(completion_resolve_callback, request) } if not can_complete_request(cc) then - cc.completion_resolve_callback(M.incomplete_response) + cc.completion_resolve_callback(EMPTY_RESPONSE) return end search.find_tags_async(cc.search, function(tag_locs) local tags = {} - for tag_loc in vim.iter(tag_locs) do + for _, tag_loc in ipairs(tag_locs) do tags[tag_loc.tag] = true end @@ -92,7 +92,10 @@ function M.process_completion(completion_resolve_callback, request) } end - cc.completion_resolve_callback(vim.tbl_deep_extend("force", M.complete_response, { items = items })) + cc.completion_resolve_callback { + isIncomplete = true, + items = items, + } end, { dir = api.resolve_workspace_dir() }) end diff --git a/lua/obsidian/completion/sources/types.lua b/lua/obsidian/completion/sources/types.lua deleted file mode 100644 index adae0e756..000000000 --- a/lua/obsidian/completion/sources/types.lua +++ /dev/null @@ -1,7 +0,0 @@ ----A request class that partially matches cmp.Request to serve as a common interface for completion sources ----@class obsidian.completion.Request ----@field bufnr integer ----@field cursor_after_line string ----@field cursor_before_line string ----@field line integer 1-indexed line number (same as row, used by tags for frontmatter) ----@field character integer 0-indexed byte offset into the line (utf-8) diff --git a/lua/obsidian/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua index 3065abad6..4130779d2 100644 --- a/lua/obsidian/lsp/handlers/completion.lua +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -2,8 +2,13 @@ local Ref = require "obsidian.completion.sources.refs" local Tag = require "obsidian.completion.sources.tags" local NewNote = require "obsidian.completion.sources.new" ---- Build a base-class Request from LSP CompletionParams. ---- +---@class obsidian.completion.Request +---@field bufnr integer +---@field cursor_after_line string +---@field cursor_before_line string +---@field line integer 0-indexed line number +---@field character integer 0-indexed byte offset into the line (utf-8) + ---@param params lsp.CompletionParams ---@return obsidian.completion.Request local function build_request(params) From 963f8b449efb2304d8787016bc2cbc791d0817d0 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Tue, 14 Apr 2026 23:07:41 +0100 Subject: [PATCH 10/30] fix: mis use col --- lua/obsidian/lsp/handlers/completion.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/obsidian/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua index 4130779d2..cd9d2d9cc 100644 --- a/lua/obsidian/lsp/handlers/completion.lua +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -23,8 +23,8 @@ local function build_request(params) local lines = vim.api.nvim_buf_get_lines(bufnr, line, line + 1, false) local line_text = lines[1] or "" - local cursor_before_line = line_text:sub(1, line - 1) - local cursor_after_line = line_text:sub(line) + local cursor_before_line = line_text:sub(1, lsp_char) + local cursor_after_line = line_text:sub(lsp_char + 1) return { bufnr = bufnr, From e1019639d6724385f585f9a784a064e9a5169b5e Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 00:17:17 +0100 Subject: [PATCH 11/30] fix: tags finally working, no race conidtion --- lua/obsidian/completion/sources/tags.lua | 3 ++- lua/obsidian/lsp/handlers/completion.lua | 33 ++++++++++-------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/lua/obsidian/completion/sources/tags.lua b/lua/obsidian/completion/sources/tags.lua index e50d3a0ff..c1128eba7 100644 --- a/lua/obsidian/completion/sources/tags.lua +++ b/lua/obsidian/completion/sources/tags.lua @@ -73,7 +73,8 @@ function M.process_completion(completion_resolve_callback, request) local insert_end = #cursor_before items[#items + 1] = { - sortText = "#" .. tag, + sortText = tag, + filterText = "#" .. tag, label = label_text, kind = vim.lsp.protocol.CompletionItemKind.Text, textEdit = { diff --git a/lua/obsidian/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua index cd9d2d9cc..b2f4cd39a 100644 --- a/lua/obsidian/lsp/handlers/completion.lua +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -17,21 +17,21 @@ local function build_request(params) -- LSP position is 0-indexed line, 0-indexed character local line = params.position.line - local lsp_char = params.position.character + local character = params.position.character -- Fetch the full line text from the buffer. local lines = vim.api.nvim_buf_get_lines(bufnr, line, line + 1, false) local line_text = lines[1] or "" - local cursor_before_line = line_text:sub(1, lsp_char) - local cursor_after_line = line_text:sub(lsp_char + 1) + local cursor_before_line = line_text:sub(1, character) + local cursor_after_line = line_text:sub(character + 1) return { bufnr = bufnr, cursor_before_line = cursor_before_line, cursor_after_line = cursor_after_line, line = line, - character = lsp_char, -- 0-indexed, used by refs.can_complete + character = character, } end @@ -58,10 +58,15 @@ end return function(params, callback, _) local request = build_request(params) - -- We'll collect results from up to 3 sources (refs, tags, new note) and merge - -- them before calling the LSP callback. Because each source is async, we use - -- a simple counter to know when all have finished. - local pending = 0 + -- Collect results from up to 3 sources and merge before calling the LSP callback. + -- IMPORTANT: all pending counts must be set before starting any source, because + -- sources that can't complete call back synchronously, which would fire the final + -- callback before remaining sources are even registered. + local pending = 2 -- refs + tags always run + if Obsidian.opts.completion.create_new then + pending = pending + 1 + end + local merged = { isIncomplete = true, items = {} } local function on_source_done(result) @@ -75,25 +80,13 @@ return function(params, callback, _) end -- Refs source. - -- local refs_source = RefsSourceBase:new() - pending = pending + 1 - -- local refs_cc = Ref.new_completion_context( Ref.process_completion(on_source_done, request) -- Tags source. - -- local tags_source = TagsSourceBase:new() - pending = pending + 1 Tag.process_completion(on_source_done, request) -- New note source (only if configured). if Obsidian.opts.completion.create_new then - pending = pending + 1 NewNote.process_completion(on_source_done, request) end - - -- If no sources were started (shouldn't happen, but guard against it), - -- return an empty result immediately. - if pending == 0 then - callback(nil, merged) - end end From 5e6df522f4a16cc7245cff8a5d3ad3699041e7ac Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 00:22:28 +0100 Subject: [PATCH 12/30] feat: tag number of occurances --- lua/obsidian/completion/sources/tags.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lua/obsidian/completion/sources/tags.lua b/lua/obsidian/completion/sources/tags.lua index c1128eba7..ed0de26a3 100644 --- a/lua/obsidian/completion/sources/tags.lua +++ b/lua/obsidian/completion/sources/tags.lua @@ -49,11 +49,11 @@ function M.process_completion(completion_resolve_callback, request) search.find_tags_async(cc.search, function(tag_locs) local tags = {} for _, tag_loc in ipairs(tag_locs) do - tags[tag_loc.tag] = true + tags[tag_loc.tag] = (tags[tag_loc.tag] or 0) + 1 end local items = {} - for tag, _ in pairs(tags) do + for tag, count in pairs(tags) do -- Generate context-appropriate text local insert_text, label_text if cc.in_frontmatter then @@ -77,6 +77,10 @@ function M.process_completion(completion_resolve_callback, request) filterText = "#" .. tag, label = label_text, kind = vim.lsp.protocol.CompletionItemKind.Text, + documentation = { + kind = "markdown", + value = string.format("`#%s` — %d occurrence%s", tag, count, count == 1 and "" or "s"), + }, textEdit = { newText = insert_text, range = { From 81347b87e9875ecb4628887e8e8fe734743ddca2 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 00:25:56 +0100 Subject: [PATCH 13/30] keyword tags --- lua/obsidian/completion/sources/tags.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/obsidian/completion/sources/tags.lua b/lua/obsidian/completion/sources/tags.lua index ed0de26a3..01c075fd7 100644 --- a/lua/obsidian/completion/sources/tags.lua +++ b/lua/obsidian/completion/sources/tags.lua @@ -76,7 +76,7 @@ function M.process_completion(completion_resolve_callback, request) sortText = tag, filterText = "#" .. tag, label = label_text, - kind = vim.lsp.protocol.CompletionItemKind.Text, + kind = vim.lsp.protocol.CompletionItemKind.Keyword, documentation = { kind = "markdown", value = string.format("`#%s` — %d occurrence%s", tag, count, count == 1 and "" or "s"), From 4068d1227590c7e333df7fa988a941f67dbf3587 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 00:44:19 +0100 Subject: [PATCH 14/30] inline --- lua/obsidian/completion/sources/new.lua | 84 +++++++++---------------- 1 file changed, 30 insertions(+), 54 deletions(-) diff --git a/lua/obsidian/completion/sources/new.lua b/lua/obsidian/completion/sources/new.lua index 8ebc99c18..8dd957726 100644 --- a/lua/obsidian/completion/sources/new.lua +++ b/lua/obsidian/completion/sources/new.lua @@ -2,15 +2,6 @@ local completion = require "obsidian.completion.refs" local util = require "obsidian.util" local Note = require "obsidian.note" ----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.NewNoteSourceCompletionContext ----@field completion_resolve_callback fun(resp: lsp.CompletionList) ----@field request obsidian.completion.Request ----@field search string|? ----@field insert_start integer|? ----@field insert_end integer|? - local M = {} ---@type lsp.CompletionList @@ -19,59 +10,46 @@ local EMPTY_RESPONSE = { items = {}, } ---- Returns whatever it's possible to complete the search and sets up the search related variables in cc ----@param cc obsidian.completion.NewNoteSourceCompletionContext ----@return boolean success provides a chance to return early if the request didn't meet the requirements -local function can_complete_request(cc) - local can_complete - can_complete, cc.search, cc.insert_start, cc.insert_end = 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 - return false - end - return true -end - --- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods ----@param completion_resolve_callback fun(resp: lsp.CompletionList) +---@param callback fun(resp: lsp.CompletionList) ---@param request obsidian.completion.Request -function M.process_completion(completion_resolve_callback, request) - local cc = { - completion_resolve_callback = completion_resolve_callback, - request = request, - } - if not can_complete_request(cc) then - cc.completion_resolve_callback(EMPTY_RESPONSE) +function M.process_completion(callback, request) + local can_complete, search, insert_start, insert_end = completion.can_complete(request) + + if (not can_complete) or (#search >= Obsidian.opts.completion.min_chars) then + callback(EMPTY_RESPONSE) return end + ---@cast search -nil + ---@cast insert_start -nil + ---@cast insert_end -nil + + search = util.lstrip_whitespace(search) + ---@type string|? local block_link - cc.search, block_link = util.strip_block_links(cc.search) + search, block_link = util.strip_block_links(search) ---@type string|? local anchor_link - cc.search, anchor_link = util.strip_anchor_links(cc.search) + search, anchor_link = util.strip_anchor_links(search) -- If block link is incomplete, do nothing. - if not block_link and vim.endswith(cc.search, "#^") then - cc.completion_resolve_callback(EMPTY_RESPONSE) + if not block_link and vim.endswith(search, "#^") then + callback(EMPTY_RESPONSE) return end -- If anchor link is incomplete, do nothing. - if not anchor_link and vim.endswith(cc.search, "#") then - cc.completion_resolve_callback(EMPTY_RESPONSE) + if not anchor_link and vim.endswith(search, "#") then + callback(EMPTY_RESPONSE) return end -- Probably just a block/anchor link within current note. - if string.len(cc.search) == 0 then - cc.completion_resolve_callback(EMPTY_RESPONSE) + if string.len(search) == 0 then + callback(EMPTY_RESPONSE) return end @@ -92,13 +70,13 @@ function M.process_completion(completion_resolve_callback, request) ---@type { label: string, note: obsidian.Note }[] local new_notes_opts = {} - local note = Note.create { id = cc.search, template = Obsidian.opts.note.template } + local note = Note.create { id = search, template = Obsidian.opts.note.template } if note.id and string.len(note.id) > 0 then - new_notes_opts[#new_notes_opts + 1] = { label = cc.search, note = note } + new_notes_opts[#new_notes_opts + 1] = { label = search, note = note } end -- Check for datetime macros. - for _, dt_offset in ipairs(util.resolve_date_macro(cc.search)) do + for _, dt_offset in ipairs(util.resolve_date_macro(search)) do if dt_offset.cadence == "daily" then note = require("obsidian.daily").daily { offset = dt_offset.offset } if not note:exists() then @@ -138,18 +116,15 @@ function M.process_completion(completion_resolve_callback, request) }, } - ---@cast cc.insert_end -nil - ---@cast cc.insert_start -nil - ---@type lsp.Range local range = { start = { - line = cc.request.line, - character = cc.insert_start, + line = request.line, + character = insert_start, }, ["end"] = { - line = cc.request.line, - character = cc.insert_end + 1, + line = request.line, + character = insert_end + 1, }, } @@ -165,10 +140,11 @@ function M.process_completion(completion_resolve_callback, request) title = "Obsidian new", arguments = { new_note.id, new_note_opts.template }, }, + -- NOTE: for [[new_note@template future expansion -- command = { -- command = "obsidian.new_from_template", -- title = "Obsidian new_from_template", - -- arguments = { new_note.id, new_note_opts.template } -- for [[new_note@template future expansion + -- arguments = { new_note.id, new_note_opts.template } -- -- }, textEdit = { newText = new_text, @@ -179,7 +155,7 @@ function M.process_completion(completion_resolve_callback, request) items[#items + 1] = item end - cc.completion_resolve_callback { + callback { isIncomplete = true, items = items, } From 70b5dfa69d29a3f530afcf9487fd8116814d0c3c Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 00:51:33 +0100 Subject: [PATCH 15/30] tags inline --- lua/obsidian/completion/sources/tags.lua | 49 +++++++----------------- 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/lua/obsidian/completion/sources/tags.lua b/lua/obsidian/completion/sources/tags.lua index 01c075fd7..392ef0bc8 100644 --- a/lua/obsidian/completion/sources/tags.lua +++ b/lua/obsidian/completion/sources/tags.lua @@ -2,14 +2,6 @@ local completion = require "obsidian.completion.tags" local search = require "obsidian.search" local api = require "obsidian.api" ----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.TagsSourceCompletionContext ----@field completion_resolve_callback fun(resp: lsp.CompletionList) ----@field request obsidian.completion.Request ----@field search string|? ----@field in_frontmatter boolean|? - local M = {} ---@type lsp.CompletionList @@ -18,35 +10,20 @@ local EMPTY_RESPONSE = { items = {}, } ---- Returns whatever it's possible to complete the search and sets up the search related variables in cc ----@param cc obsidian.completion.TagsSourceCompletionContext ----@return boolean success provides a chance to return early if the request didn't meet the requirements -local function 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 - return false - end - - return true -end - --- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods ----@param completion_resolve_callback fun(resp: lsp.CompletionList) +---@param callback fun(resp: lsp.CompletionList) ---@param request obsidian.completion.Request -function M.process_completion(completion_resolve_callback, request) - local cc = { - completion_resolve_callback = completion_resolve_callback, - request = request, - } +function M.process_completion(callback, request) + local can_complete, term, in_frontmatter = completion.can_complete(request) - if not can_complete_request(cc) then - cc.completion_resolve_callback(EMPTY_RESPONSE) + if not (can_complete and term ~= nil and #term >= Obsidian.opts.completion.min_chars) then + callback(EMPTY_RESPONSE) return end - search.find_tags_async(cc.search, function(tag_locs) + ---@cast term -nil + + search.find_tags_async(term, function(tag_locs) local tags = {} for _, tag_loc in ipairs(tag_locs) do tags[tag_loc.tag] = (tags[tag_loc.tag] or 0) + 1 @@ -56,7 +33,7 @@ function M.process_completion(completion_resolve_callback, request) for tag, count in pairs(tags) do -- Generate context-appropriate text local insert_text, label_text - if cc.in_frontmatter then + if in_frontmatter then -- Frontmatter: insert tag without # (YAML format) insert_text = tag label_text = "Tag: " .. tag @@ -67,7 +44,7 @@ function M.process_completion(completion_resolve_callback, request) end -- Calculate the range to replace (the entire #tag pattern) - local cursor_before = cc.request.cursor_before_line + local cursor_before = request.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 @@ -85,11 +62,11 @@ function M.process_completion(completion_resolve_callback, request) newText = insert_text, range = { ["start"] = { - line = cc.request.line, + line = request.line, character = insert_start, }, ["end"] = { - line = cc.request.line, + line = request.line, character = insert_end, }, }, @@ -97,7 +74,7 @@ function M.process_completion(completion_resolve_callback, request) } end - cc.completion_resolve_callback { + callback { isIncomplete = true, items = items, } From 84e91bd3ebb9b32a68a3f881564b39eb06719b75 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 01:16:10 +0100 Subject: [PATCH 16/30] frontmatter tags! --- CHANGELOG.md | 2 + lua/obsidian/completion/sources/tags.lua | 3 +- lua/obsidian/completion/tags.lua | 56 +++++++++++++++++++----- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f48ec5e..26ba00c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - folders will open a file picker - see docs at - `Note` class can carry a `template` field. +- LSP completion replaces completion plugin based completion. +- Frontmatter tag completion. ### Removed diff --git a/lua/obsidian/completion/sources/tags.lua b/lua/obsidian/completion/sources/tags.lua index 392ef0bc8..c67f3c878 100644 --- a/lua/obsidian/completion/sources/tags.lua +++ b/lua/obsidian/completion/sources/tags.lua @@ -46,7 +46,8 @@ function M.process_completion(callback, request) -- Calculate the range to replace (the entire #tag pattern) local cursor_before = request.cursor_before_line local hash_start = string.find(cursor_before, "#[^%s]*$") - local insert_start = hash_start and (hash_start - 1) or #cursor_before + local _, dash_end = string.find(cursor_before, "%-%s") + local insert_start = hash_start and (hash_start - 1) or dash_end local insert_end = #cursor_before items[#items + 1] = { diff --git a/lua/obsidian/completion/tags.lua b/lua/obsidian/completion/tags.lua index af8183092..080461137 100644 --- a/lua/obsidian/completion/tags.lua +++ b/lua/obsidian/completion/tags.lua @@ -32,25 +32,59 @@ local get_frontmatter_boundaries = function(bufnr) end end +--- Check if cursor line is a YAML list item under the `tags:` key in frontmatter. +--- Scans backwards from cursor to find the parent key. +---@param bufnr integer +---@param cursor_line integer 1-indexed +---@param cursor_before_line string +---@return boolean is_tags_item +---@return string search_term +local function in_frontmatter_tags_list(bufnr, cursor_line, cursor_before_line) + -- Check if current line looks like a YAML list item: " - something" or " - " + local item_text = cursor_before_line:match "^%s+-%s+(.*)" or cursor_before_line:match "^%s+-%s*$" and "" + if item_text == nil then + return false, "" + end + + -- Scan backwards to find the parent key + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, cursor_line - 1, false) + for i = #lines, 1, -1 do + local l = lines[i] + -- Match a YAML key with no value (start of block sequence), e.g. "tags:" + local key = l:match "^(%w[%w_-]*):%s*$" + if key then + return key == "tags", item_text + end + -- If we hit a line that's not a list item or empty, stop + if not l:match "^%s+%-" and not l:match "^%s*$" then + break + end + end + return false, "" +end + ---@param request obsidian.completion.Request ---@return boolean, string|?, boolean|? M.can_complete = function(request) - local search = M.find_tags_start(request.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.line + 1 -- 1-indexed local frontmatter_start, frontmatter_end = get_frontmatter_boundaries(request.bufnr) - if - frontmatter_start ~= nil + local in_frontmatter = frontmatter_start ~= nil and frontmatter_start <= (line + 1) and frontmatter_end ~= nil and line <= frontmatter_end - then - in_frontmatter = true + + -- In frontmatter, check for tags list item trigger (no # needed) + if in_frontmatter then + local is_tags, term = in_frontmatter_tags_list(request.bufnr, line, request.cursor_before_line) + if is_tags then + return true, term, true + end + end + + -- Standard #tag trigger + local search = M.find_tags_start(request.cursor_before_line) + if not search or string.len(search) == 0 then + return false end return true, search, in_frontmatter From d1d2ca3c3ff40e072f5d714aa3118247209a9200 Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 01:50:00 +0100 Subject: [PATCH 17/30] mini.completion! --- lua/obsidian/completion/sources/new.lua | 24 ++++++++++++------------ lua/obsidian/lsp/init.lua | 11 ++--------- minimal.lua | 16 ++++++++++------ 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/lua/obsidian/completion/sources/new.lua b/lua/obsidian/completion/sources/new.lua index 8dd957726..9f3ebc29a 100644 --- a/lua/obsidian/completion/sources/new.lua +++ b/lua/obsidian/completion/sources/new.lua @@ -14,41 +14,41 @@ local EMPTY_RESPONSE = { ---@param callback fun(resp: lsp.CompletionList) ---@param request obsidian.completion.Request function M.process_completion(callback, request) - local can_complete, search, insert_start, insert_end = completion.can_complete(request) + local can_complete, term, insert_start, insert_end = completion.can_complete(request) - if (not can_complete) or (#search >= Obsidian.opts.completion.min_chars) then + if (not can_complete) or (#term < Obsidian.opts.completion.min_chars) then callback(EMPTY_RESPONSE) return end - ---@cast search -nil + ---@cast term -nil ---@cast insert_start -nil ---@cast insert_end -nil - search = util.lstrip_whitespace(search) + term = util.lstrip_whitespace(term) ---@type string|? local block_link - search, block_link = util.strip_block_links(search) + term, block_link = util.strip_block_links(term) ---@type string|? local anchor_link - search, anchor_link = util.strip_anchor_links(search) + term, anchor_link = util.strip_anchor_links(term) -- If block link is incomplete, do nothing. - if not block_link and vim.endswith(search, "#^") then + if not block_link and vim.endswith(term, "#^") then callback(EMPTY_RESPONSE) return end -- If anchor link is incomplete, do nothing. - if not anchor_link and vim.endswith(search, "#") then + if not anchor_link and vim.endswith(term, "#") then callback(EMPTY_RESPONSE) return end -- Probably just a block/anchor link within current note. - if string.len(search) == 0 then + if string.len(term) == 0 then callback(EMPTY_RESPONSE) return end @@ -70,13 +70,13 @@ function M.process_completion(callback, request) ---@type { label: string, note: obsidian.Note }[] local new_notes_opts = {} - local note = Note.create { id = search, template = Obsidian.opts.note.template } + local note = Note.create { id = term, template = Obsidian.opts.note.template } if note.id and string.len(note.id) > 0 then - new_notes_opts[#new_notes_opts + 1] = { label = search, note = note } + new_notes_opts[#new_notes_opts + 1] = { label = term, note = note } end -- Check for datetime macros. - for _, dt_offset in ipairs(util.resolve_date_macro(search)) do + for _, dt_offset in ipairs(util.resolve_date_macro(term)) do if dt_offset.cadence == "daily" then note = require("obsidian.daily").daily { offset = dt_offset.offset } if not note:exists() then diff --git a/lua/obsidian/lsp/init.lua b/lua/obsidian/lsp/init.lua index f49df7a72..b5bbff164 100644 --- a/lua/obsidian/lsp/init.lua +++ b/lua/obsidian/lsp/init.lua @@ -1,19 +1,12 @@ local lsp = {} local log = require "obsidian.log" ---- Check if a third-party completion engine (blink.cmp or nvim-cmp) is available. ---- Both engines auto-consume LSP completion from attached language servers. ---@return boolean local function has_completion_engine() local has_blink = pcall(require, "blink.cmp") - if has_blink then - return true - end local has_cmp = pcall(require, "cmp") - if has_cmp then - return true - end - return false + local has_mini = pcall(require, "mini.completion") + return has_blink or has_cmp or has_mini end --- Start the lsp client diff --git a/minimal.lua b/minimal.lua index d205f49db..0c6474732 100644 --- a/minimal.lua +++ b/minimal.lua @@ -45,12 +45,16 @@ local plugins = { -- } -- end, -- }, - { - "saghen/blink.cmp", - opts = { - fuzzy = { implementation = "lua" }, -- no need to build binary - }, - }, + -- { + -- "saghen/blink.cmp", + -- opts = { + -- fuzzy = { implementation = "lua" }, -- no need to build binary + -- }, + -- }, + -- { + -- "nvim-mini/mini.completion", + -- opts = {}, + -- }, } require("lazy.minit").repro { spec = plugins } From 1d67b20ddaa5be01de874e0c373e016f6e05523c Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 13:16:57 +0100 Subject: [PATCH 18/30] doc --- docs/Completion.md | 38 ++++++++++++++++++++++++++++++++++++++ lua/obsidian/lsp/init.lua | 8 -------- minimal.lua | 2 -- 3 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 docs/Completion.md diff --git a/docs/Completion.md b/docs/Completion.md new file mode 100644 index 000000000..60d97549a --- /dev/null +++ b/docs/Completion.md @@ -0,0 +1,38 @@ +## Plugin Completion + +This plugin provide plugin-agnostic completion via in-process LSP, you only need to make sure you are triggering LSP completions in markdown buffers. + +For blink.cmp, if you have a dedicated `per_filetype` config for markdown, lsp completion will not attach, use: + +```lua + +require("blink.cmp").setup { + sources = { + -- NOTE: no need if you don't have custom markdown stuff + per_filetype = { + markdown = { + "lsp", -- NOTE: explicitly enable lsp + -- inherit_defaults = true, -- NOTE: if your defaults include lsp + "dictionary", + }, + }, + }, +} +``` + +## Neovim Native Completion + +To use completions without completion plugin, put this anywhere in your config before an obsidian buffer loads: + +```lua +vim.api.nvim_create_autocmd("LspAttach", { + callback = function(ev) + local buf = ev.buf + local client = vim.lsp.get_client_by_id(ev.data.client_id) + if client and client.name == "obsidian-ls" then + vim.bo[buf].completeopt = "menuone,noselect,fuzzy,nosort" -- noselect to make sure no accidentally accept and create new notes, others are not strictly necessary, adjust to your taste, see `:h completeopt' + vim.lsp.completion.enable(true, client.id, buf, { autotrigger = true }) + end + end, +}) +``` diff --git a/lua/obsidian/lsp/init.lua b/lua/obsidian/lsp/init.lua index b5bbff164..9c12541e7 100644 --- a/lua/obsidian/lsp/init.lua +++ b/lua/obsidian/lsp/init.lua @@ -1,14 +1,6 @@ local lsp = {} local log = require "obsidian.log" ----@return boolean -local function has_completion_engine() - local has_blink = pcall(require, "blink.cmp") - local has_cmp = pcall(require, "cmp") - local has_mini = pcall(require, "mini.completion") - return has_blink or has_cmp or has_mini -end - --- Start the lsp client --- ---@param buf integer diff --git a/minimal.lua b/minimal.lua index 0c6474732..d1549fdc3 100644 --- a/minimal.lua +++ b/minimal.lua @@ -58,5 +58,3 @@ local plugins = { } require("lazy.minit").repro { spec = plugins } - -vim.cmd "checkhealth obsidian" From 5947fe4a27db9a15de1756078d5e8b645b0f855d Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 17:32:29 +0100 Subject: [PATCH 19/30] support workspace commands since nvim-cmp don't work with vim.commands --- lua/obsidian/completion/sources/tags.lua | 2 +- lua/obsidian/lsp/handlers.lua | 1 + lua/obsidian/lsp/handlers/initialize.lua | 10 +++------- .../lsp/handlers/workspace_execute_command.lua | 12 ++++++++++++ 4 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 lua/obsidian/lsp/handlers/workspace_execute_command.lua diff --git a/lua/obsidian/completion/sources/tags.lua b/lua/obsidian/completion/sources/tags.lua index c67f3c878..0ca37ed38 100644 --- a/lua/obsidian/completion/sources/tags.lua +++ b/lua/obsidian/completion/sources/tags.lua @@ -52,7 +52,7 @@ function M.process_completion(callback, request) items[#items + 1] = { sortText = tag, - filterText = "#" .. tag, + filterText = hash_start and "#" .. tag or tag, label = label_text, kind = vim.lsp.protocol.CompletionItemKind.Keyword, documentation = { diff --git a/lua/obsidian/lsp/handlers.lua b/lua/obsidian/lsp/handlers.lua index 9795aec2e..edb74e0bb 100644 --- a/lua/obsidian/lsp/handlers.lua +++ b/lua/obsidian/lsp/handlers.lua @@ -3,6 +3,7 @@ return { ["initialize"] = require "obsidian.lsp.handlers.initialize", ["workspace/didRenameFiles"] = require "obsidian.lsp.handlers.did_rename_files", ["workspace/symbol"] = require "obsidian.lsp.handlers.workspace_symbol", + ["workspace/executeCommand"] = require "obsidian.lsp.handlers.workspace_execute_command", ["textDocument/rename"] = require "obsidian.lsp.handlers.rename", ["textDocument/prepareRename"] = require "obsidian.lsp.handlers.prepare_rename", ["textDocument/references"] = require "obsidian.lsp.handlers.references", diff --git a/lua/obsidian/lsp/handlers/initialize.lua b/lua/obsidian/lsp/handlers/initialize.lua index 4d6baec49..38c91fc01 100644 --- a/lua/obsidian/lsp/handlers/initialize.lua +++ b/lua/obsidian/lsp/handlers/initialize.lua @@ -9,12 +9,6 @@ local function send_progress(dispatchers, kind, title, percentage) }) end -local chars = { "[", "#" } - -for i = 32, 126 do - table.insert(chars, string.char(i)) -end - ---@type lsp.InitializeResult local initializeResult = { capabilities = { @@ -26,8 +20,10 @@ local initializeResult = { documentSymbolProvider = true, workspaceSymbolProvider = true, codeActionProvider = true, + executeCommandProvider = { + commands = { "obsidian.new" }, + }, completionProvider = { - triggerCharacters = chars, resolveProvider = false, }, workspace = { diff --git a/lua/obsidian/lsp/handlers/workspace_execute_command.lua b/lua/obsidian/lsp/handlers/workspace_execute_command.lua new file mode 100644 index 000000000..66067c7e0 --- /dev/null +++ b/lua/obsidian/lsp/handlers/workspace_execute_command.lua @@ -0,0 +1,12 @@ +---Mostly not needed, but some lsp related plugins like nvim-cmp-lsp don't support vim.lsp.commmands yet + +---@param params lsp.ExecuteCommandParams +return function(params) + local command = params.command:gsub("obsidian%.", "") + local actions = require "obsidian.actions" + ---@diagnostic disable-next-line: param-type-mismatch + local action = vim.schedule_wrap(actions[command]) + local args = params.arguments and params.arguments or {} + ---@diagnostic disable-next-line: param-type-mismatch + pcall(action, unpack(args)) +end From 9f7d9625898cf62c32226681142eb49ab6dbed7a Mon Sep 17 00:00:00 2001 From: neo451 <412444506@qq.com> Date: Wed, 15 Apr 2026 19:24:48 +0100 Subject: [PATCH 20/30] minimal.lua --- minimal.lua | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/minimal.lua b/minimal.lua index d1549fdc3..7848bed69 100644 --- a/minimal.lua +++ b/minimal.lua @@ -7,6 +7,23 @@ vim.o.conceallevel = 2 local cwd = vim.uv.cwd() +local chars = {} +for i = 32, 126 do + table.insert(chars, string.char(i)) +end + +vim.api.nvim_create_autocmd("LspAttach", { + callback = function(ev) + local client = vim.lsp.get_client_by_id(ev.data.client_id) + + if client and client.name == "obsidian-ls" then + client.server_capabilities.completionProvider.triggerCharacters = chars + -- vim.bo[ev.buf].completeopt = "menuone,noselect,fuzzy,nosort" -- noselect to make sure no accidentally accept and create new notes, others are not necessary + -- vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) + end + end, +}) + local plugins = { { "obsidian-nvim/obsidian.nvim", @@ -35,6 +52,9 @@ local plugins = { -- **Choose your completion engine** -- { -- "hrsh7th/nvim-cmp", + -- dependencies = { + -- "hrsh7th/cmp-nvim-lsp", + -- }, -- config = function() -- local cmp = require "cmp" -- cmp.setup { @@ -42,6 +62,9 @@ local plugins = { -- [""] = cmp.mapping.abort(), -- [""] = cmp.mapping.confirm { select = true }, -- }, + -- sources = { + -- { name = "nvim_lsp" }, + -- }, -- } -- end, -- }, @@ -52,9 +75,13 @@ local plugins = { -- }, -- }, -- { - -- "nvim-mini/mini.completion", - -- opts = {}, + -- "nvim-mini/mini.nvim", + -- config = function() + -- require("mini.completion").setup {} + -- end, -- }, } require("lazy.minit").repro { spec = plugins } + +-- vim.cmd "checkhealth obsidian" From b1c302964fbfeb448588a4d3fb19c1b35eeb68f0 Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Fri, 15 May 2026 15:25:33 +0100 Subject: [PATCH 21/30] typo --- lua/obsidian/lsp/handlers/workspace_execute_command.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/obsidian/lsp/handlers/workspace_execute_command.lua b/lua/obsidian/lsp/handlers/workspace_execute_command.lua index 66067c7e0..890efd0c9 100644 --- a/lua/obsidian/lsp/handlers/workspace_execute_command.lua +++ b/lua/obsidian/lsp/handlers/workspace_execute_command.lua @@ -1,4 +1,4 @@ ----Mostly not needed, but some lsp related plugins like nvim-cmp-lsp don't support vim.lsp.commmands yet +---Mostly not needed, but some lsp related plugins like nvim-cmp-lsp don't support vim.lsp.commands yet ---@param params lsp.ExecuteCommandParams return function(params) From c536e91d1b17872f5e45b1eb0c8c160ddf38d185 Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Fri, 15 May 2026 19:33:37 +0100 Subject: [PATCH 22/30] fix: workspace command return, proper docs and minimal.lua --- docs/Completion.md | 7 +++++++ lua/obsidian/completion/sources/new.lua | 2 +- .../lsp/handlers/workspace_execute_command.lua | 15 +++++++++++++-- minimal.lua | 5 +++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/Completion.md b/docs/Completion.md index 60d97549a..7fd3e5a2c 100644 --- a/docs/Completion.md +++ b/docs/Completion.md @@ -25,11 +25,18 @@ require("blink.cmp").setup { To use completions without completion plugin, put this anywhere in your config before an obsidian buffer loads: ```lua +-- HACK: to trigger on every ascii char, unicode will work when the upstream neovim completion respects isIncomplete field properly +local chars = {} +for i = 32, 126 do + table.insert(chars, string.char(i)) +end + vim.api.nvim_create_autocmd("LspAttach", { callback = function(ev) local buf = ev.buf local client = vim.lsp.get_client_by_id(ev.data.client_id) if client and client.name == "obsidian-ls" then + client.server_capabilities.completionProvider.triggerCharacters = chars -- HACK: vim.bo[buf].completeopt = "menuone,noselect,fuzzy,nosort" -- noselect to make sure no accidentally accept and create new notes, others are not strictly necessary, adjust to your taste, see `:h completeopt' vim.lsp.completion.enable(true, client.id, buf, { autotrigger = true }) end diff --git a/lua/obsidian/completion/sources/new.lua b/lua/obsidian/completion/sources/new.lua index 9f3ebc29a..e7f8a5b79 100644 --- a/lua/obsidian/completion/sources/new.lua +++ b/lua/obsidian/completion/sources/new.lua @@ -138,7 +138,7 @@ function M.process_completion(callback, request) command = { command = "obsidian.new", title = "Obsidian new", - arguments = { new_note.id, new_note_opts.template }, + arguments = { new_note.id }, }, -- NOTE: for [[new_note@template future expansion -- command = { diff --git a/lua/obsidian/lsp/handlers/workspace_execute_command.lua b/lua/obsidian/lsp/handlers/workspace_execute_command.lua index 890efd0c9..7f21c00dc 100644 --- a/lua/obsidian/lsp/handlers/workspace_execute_command.lua +++ b/lua/obsidian/lsp/handlers/workspace_execute_command.lua @@ -1,12 +1,23 @@ ---Mostly not needed, but some lsp related plugins like nvim-cmp-lsp don't support vim.lsp.commands yet ---@param params lsp.ExecuteCommandParams -return function(params) +---@param callback fun(err: lsp.ResponseError?, result: any) +return function(params, callback) local command = params.command:gsub("obsidian%.", "") local actions = require "obsidian.actions" + if type(actions[command]) ~= "function" then + callback({ code = -32601, message = "command not found: " .. params.command }, nil) + return + end + ---@diagnostic disable-next-line: param-type-mismatch local action = vim.schedule_wrap(actions[command]) local args = params.arguments and params.arguments or {} ---@diagnostic disable-next-line: param-type-mismatch - pcall(action, unpack(args)) + local ok, err = pcall(action, unpack(args)) + if ok then + callback(nil, nil) + else + callback({ code = -32603, message = "command failed: " .. tostring(err) }, nil) + end end diff --git a/minimal.lua b/minimal.lua index 7848bed69..5094d8efd 100644 --- a/minimal.lua +++ b/minimal.lua @@ -7,6 +7,7 @@ vim.o.conceallevel = 2 local cwd = vim.uv.cwd() +-- HACK: for native neovim completion and mini.completion local chars = {} for i = 32, 126 do table.insert(chars, string.char(i)) @@ -18,8 +19,8 @@ vim.api.nvim_create_autocmd("LspAttach", { if client and client.name == "obsidian-ls" then client.server_capabilities.completionProvider.triggerCharacters = chars - -- vim.bo[ev.buf].completeopt = "menuone,noselect,fuzzy,nosort" -- noselect to make sure no accidentally accept and create new notes, others are not necessary - -- vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) + vim.bo[ev.buf].completeopt = "menuone,noselect,fuzzy,nosort" -- noselect to make sure no accidentally accept and create new notes, others are not necessary + vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) end end, }) From 15bc0edd1e5b1c4139e759cab9561af60698d3cd Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Fri, 15 May 2026 20:13:18 +0100 Subject: [PATCH 23/30] add migration notice for existing configs --- lua/obsidian/lsp/init.lua | 62 +++++++++++++++++++++++++++++++++++++++ minimal.lua | 9 ++++-- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/lua/obsidian/lsp/init.lua b/lua/obsidian/lsp/init.lua index 9c12541e7..f048c9e21 100644 --- a/lua/obsidian/lsp/init.lua +++ b/lua/obsidian/lsp/init.lua @@ -1,6 +1,66 @@ local lsp = {} local log = require "obsidian.log" +local function check_completion_availability() + if pcall(require, "blink.cmp") then + local blink_markdown_config = require("blink.cmp.config").sources.per_filetype["markdown"] + if not blink_markdown_config then + return + end + if type(blink_markdown_config) == "function" then + blink_markdown_config = blink_markdown_config() + end + local configured = vim.tbl_contains(blink_markdown_config, "lsp") or blink_markdown_config.inherit_defaults + if not configured then + log.warn [[This plugin has migrated to in process lsp completion, blink.cmp config for markdown buffer is not properly configured, add +```lua +require("blink.cmp").setup({ + per_filetype = { + markdown = { + inherit_defaults = true, + -- or + "lsp" + }, + }, +}) +``` + ]] + end + elseif pcall(require, "cmp") then + if not pcall(require, "cmp_nvim_lsp") then + log.warn [[This plugin has migrated to in process lsp completion, for your nvim-cmp setup you need cmp-nvim-lsp plugin]] + return + end + local cmp_config = require "cmp.config" + local ft_conf = cmp_config.filetypes["markdown"] + local sources = (ft_conf and ft_conf.sources) or (cmp_config.global and cmp_config.global.sources) or {} + local configured = false + for _, src in ipairs(sources) do + if src.name == "nvim_lsp" then + configured = true + break + end + end + if not configured then + log.warn [[This plugin has migrated to in process lsp completion, nvim-cmp source `nvim_lsp` is not configured for markdown buffers, add +```lua +require("cmp").setup({ + sources = { + { name = "nvim_lsp" }, + }, +}) +-- or per-filetype: +require("cmp").setup.filetype("markdown", { + sources = { + { name = "nvim_lsp" }, + }, +}) +``` + ]] + end + end +end + --- Start the lsp client --- ---@param buf integer @@ -20,6 +80,8 @@ lsp.start = function(buf) root_dir = tostring(Obsidian.dir), } + check_completion_availability() + local client_id = vim.lsp.start(lsp_config, { bufnr = buf, silent = false }) if not client_id then diff --git a/minimal.lua b/minimal.lua index 5094d8efd..543925ba8 100644 --- a/minimal.lua +++ b/minimal.lua @@ -7,7 +7,7 @@ vim.o.conceallevel = 2 local cwd = vim.uv.cwd() --- HACK: for native neovim completion and mini.completion +-- HACK: enable for native neovim completion and mini.completion local chars = {} for i = 32, 126 do table.insert(chars, string.char(i)) @@ -64,9 +64,14 @@ local plugins = { -- [""] = cmp.mapping.confirm { select = true }, -- }, -- sources = { - -- { name = "nvim_lsp" }, + -- -- { name = "nvim_lsp" }, -- }, -- } + -- require("cmp").setup.filetype("markdown", { + -- sources = { + -- { name = "nvim_lsp" }, + -- }, + -- }) -- end, -- }, -- { From fcd848cc5110ff7ad604f62dfb8f35fead242e02 Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Fri, 15 May 2026 21:52:10 +0100 Subject: [PATCH 24/30] fix #678 --- lua/obsidian/actions.lua | 9 +++++++-- lua/obsidian/commands/new.lua | 5 ++--- lua/obsidian/completion/sources/new.lua | 2 +- lua/obsidian/lsp/handlers/_definition.lua | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lua/obsidian/actions.lua b/lua/obsidian/actions.lua index 98c9ad71f..343a3ab86 100644 --- a/lua/obsidian/actions.lua +++ b/lua/obsidian/actions.lua @@ -475,8 +475,9 @@ end --- this function never opens it. --- ---@param id string|? +---@param title string|? ---@param callback fun(note: obsidian.Note)|? -M.new = function(id, callback) +M.new = function(id, title, callback) if not id then id = api.input("Enter id or path (optional): ", { completion = "file" }) if not id then @@ -486,7 +487,11 @@ M.new = function(id, callback) end end - local note = Note.create { id = id, template = Obsidian.opts.note.template } + local note = Note.create { + id = id, + title = title, + template = Obsidian.opts.note.template, -- TODO: maybe unneed when creating, or set as a field that note carries + } note:write() if callback then diff --git a/lua/obsidian/commands/new.lua b/lua/obsidian/commands/new.lua index 2032148d7..2a7ad77ea 100644 --- a/lua/obsidian/commands/new.lua +++ b/lua/obsidian/commands/new.lua @@ -1,8 +1,7 @@ ---@param data obsidian.CommandArgs return function(data) - local id = data.args:len() > 0 and data.args - ---@diagnostic disable-next-line: param-type-mismatch - require("obsidian.actions").new(id, function(note) + local id = data.args:len() > 0 and data.args or nil + require("obsidian.actions").new(id, nil, function(note) note:open { sync = true } end) end diff --git a/lua/obsidian/completion/sources/new.lua b/lua/obsidian/completion/sources/new.lua index e7f8a5b79..10a549450 100644 --- a/lua/obsidian/completion/sources/new.lua +++ b/lua/obsidian/completion/sources/new.lua @@ -138,7 +138,7 @@ function M.process_completion(callback, request) command = { command = "obsidian.new", title = "Obsidian new", - arguments = { new_note.id }, + arguments = { new_note.id, new_note_opts.label }, }, -- NOTE: for [[new_note@template future expansion -- command = { diff --git a/lua/obsidian/lsp/handlers/_definition.lua b/lua/obsidian/lsp/handlers/_definition.lua index f4511b18a..8f61c6697 100644 --- a/lua/obsidian/lsp/handlers/_definition.lua +++ b/lua/obsidian/lsp/handlers/_definition.lua @@ -3,7 +3,7 @@ local search = obsidian.search local util = obsidian.util local log = obsidian.log local api = obsidian.api -local actions = obsidian.actions +local actions = require "obsidian.actions" local function open_uri(uri, scheme) if vim.list_contains(Obsidian.opts.open.schemes, scheme) then @@ -49,7 +49,7 @@ local function create_new_note(location, callback, opts) local confirm = api.confirm(("Create new note '%s'?"):format(location), format_options) if confirm == "Yes" then - actions.new(location, function(note) + actions.new(location, opts.label, function(note) update_link(note) callback { note:_location() } end) From 9e57c5acb7b298b35a319edb36414d588289a6d9 Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Sat, 23 May 2026 02:38:22 +0100 Subject: [PATCH 25/30] fix: don't open note on completion accept --- lua/obsidian/commands/new_from_template.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/obsidian/commands/new_from_template.lua b/lua/obsidian/commands/new_from_template.lua index 2e8530d2f..1602660c9 100644 --- a/lua/obsidian/commands/new_from_template.lua +++ b/lua/obsidian/commands/new_from_template.lua @@ -3,6 +3,6 @@ return function(data) local id = table.concat(data.fargs, " ", 1, #data.fargs - 1) local template = data.fargs[#data.fargs] require("obsidian.actions").new_from_template(id, template, function(note) - note:open() + note:open { sync = true } end) end From ab82ee5eaa195cdc3ba15f5e6618d9c5eba0aec6 Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Sat, 23 May 2026 22:31:22 +0100 Subject: [PATCH 26/30] fix: use proper write note for completion new source --- lua/obsidian/actions.lua | 7 +++++++ lua/obsidian/completion/sources/new.lua | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lua/obsidian/actions.lua b/lua/obsidian/actions.lua index 343a3ab86..312d4914c 100644 --- a/lua/obsidian/actions.lua +++ b/lua/obsidian/actions.lua @@ -841,4 +841,11 @@ M.merge_note = function(dst_note) end end +--- write note to disk, for lsp completion create note +--- +---@param note obsidian.Note +M.write_note = function(note) + note:write() +end + return M diff --git a/lua/obsidian/completion/sources/new.lua b/lua/obsidian/completion/sources/new.lua index 10a549450..64ed3c6c2 100644 --- a/lua/obsidian/completion/sources/new.lua +++ b/lua/obsidian/completion/sources/new.lua @@ -136,9 +136,9 @@ function M.process_completion(callback, request) label = label, kind = vim.lsp.protocol.CompletionItemKind.Reference, command = { - command = "obsidian.new", - title = "Obsidian new", - arguments = { new_note.id, new_note_opts.label }, + command = "obsidian.write_note", + title = "Obsidian write note", + arguments = { new_note }, }, -- NOTE: for [[new_note@template future expansion -- command = { From 5911eff9c2a34942d48b7682117ddcb86d65aa86 Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Sat, 23 May 2026 23:49:50 +0100 Subject: [PATCH 27/30] better tests, better refs.lua --- lua/obsidian/actions.lua | 11 +- lua/obsidian/commands/new.lua | 2 +- lua/obsidian/completion/refs.lua | 50 +++ lua/obsidian/completion/sources/refs.lua | 346 ++++++++---------- lua/obsidian/lsp/handlers/_definition.lua | 2 +- lua/obsidian/lsp/handlers/initialize.lua | 2 +- .../handlers/workspace_execute_command.lua | 4 +- minimal.lua | 46 +-- tests/lsp/test_completion.lua | 115 ++++-- 9 files changed, 312 insertions(+), 266 deletions(-) diff --git a/lua/obsidian/actions.lua b/lua/obsidian/actions.lua index 312d4914c..e348ecadb 100644 --- a/lua/obsidian/actions.lua +++ b/lua/obsidian/actions.lua @@ -3,6 +3,7 @@ local api = require "obsidian.api" local log = require "obsidian.log" local util = require "obsidian.util" local Note = require "obsidian.note" +local Path = require "obsidian.path" local attachment = require "obsidian.attachment" local picker = require "obsidian.picker" @@ -475,9 +476,8 @@ end --- this function never opens it. --- ---@param id string|? ----@param title string|? ---@param callback fun(note: obsidian.Note)|? -M.new = function(id, title, callback) +M.new = function(id, callback) if not id then id = api.input("Enter id or path (optional): ", { completion = "file" }) if not id then @@ -489,7 +489,6 @@ M.new = function(id, title, callback) local note = Note.create { id = id, - title = title, template = Obsidian.opts.note.template, -- TODO: maybe unneed when creating, or set as a field that note carries } note:write() @@ -845,6 +844,12 @@ end --- ---@param note obsidian.Note M.write_note = function(note) + -- Make sure `note` is actually an `obsidian.Note` object. + -- If it gets serialized by server commands, it will lose its metatable. + if not Note.is_note_obj(note) then + note = setmetatable(note, Note) + note.path = setmetatable(note.path, Path) + end note:write() end diff --git a/lua/obsidian/commands/new.lua b/lua/obsidian/commands/new.lua index 2a7ad77ea..f877dd14e 100644 --- a/lua/obsidian/commands/new.lua +++ b/lua/obsidian/commands/new.lua @@ -1,7 +1,7 @@ ---@param data obsidian.CommandArgs return function(data) local id = data.args:len() > 0 and data.args or nil - require("obsidian.actions").new(id, nil, function(note) + require("obsidian.actions").new(id, function(note) note:open { sync = true } end) end diff --git a/lua/obsidian/completion/refs.lua b/lua/obsidian/completion/refs.lua index ccf34167a..fe51c358a 100644 --- a/lua/obsidian/completion/refs.lua +++ b/lua/obsidian/completion/refs.lua @@ -52,4 +52,54 @@ M.get_filter_text = function(label) return "[[" .. label end +---Collect matching block links. +---@param note obsidian.Note +---@param block_link string? +---@return obsidian.note.Block[]|? +function M.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 M.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 + return M diff --git a/lua/obsidian/completion/sources/refs.lua b/lua/obsidian/completion/sources/refs.lua index beacb87ee..369896e40 100644 --- a/lua/obsidian/completion/sources/refs.lua +++ b/lua/obsidian/completion/sources/refs.lua @@ -1,12 +1,13 @@ +--- TODO: make more declarative local completion = require "obsidian.completion.refs" local util = require "obsidian.util" local api = require "obsidian.api" local search = require "obsidian.search" ----@class obsidian.completion.CompletionItemOptions ----@field label string +---@class obsidian.completion.sources.refs.options +---@field label string|? ---@field new_text string ----@field sort_text string +---@field sort_text string|? ---@field documentation table|? ---@field note obsidian.Note|? ---@field anchor obsidian.note.HeaderAnchor|? @@ -15,7 +16,7 @@ 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.RefsSourceCompletionContext +---@class obsidian.completion.sources.refs.context ---@field completion_resolve_callback fun(resp: lsp.CompletionList) ---@field request obsidian.completion.Request ---@field in_buffer_only boolean @@ -24,7 +25,7 @@ local search = require "obsidian.search" ---@field insert_end integer|? ---@field block_link string|? ---@field anchor_link string|? ----@field new_text_to_option table +---@field new_text_to_option table local M = {} @@ -34,8 +35,8 @@ local EMPTY_RESPONSE = { items = {}, } ---- 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 +--- Returns whether it's possible to complete the search and sets up the search related variables in cc +---@param cc obsidian.completion.sources.refs.context ---@return boolean success provides a chance to return early if the request didn't meet the requirements local function can_complete_request(cc) local can_complete @@ -48,101 +49,21 @@ local function can_complete_request(cc) return true end ----@param completion_resolve_callback function ----@param request obsidian.completion.Request -function M.process_completion(completion_resolve_callback, request) - local cc = { - completion_resolve_callback = completion_resolve_callback, - request = request, - in_buffer_only = false, - new_text_to_option = {}, - } - - if not can_complete_request(cc) or not cc.search then - cc.completion_resolve_callback(EMPTY_RESPONSE) +--- Determines whatever the in_buffer_only should be enabled +---@param cc obsidian.completion.sources.refs.context +local function determine_buffer_only_search_scope(cc) + if not cc.search then return end - - M.strip_links(cc) - M.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 - M.process_search_results(cc, { note }) - else - cc.completion_resolve_callback(EMPTY_RESPONSE) - end - else - local search_opts = { - sort = false, - include_templates = false, - ignore_case = true, - } - - search.find_notes_async(cc.search, function(results) - M.process_search_results(cc, results) - end, { - dir = api.resolve_workspace_dir(), - search = search_opts, - notes = { collect_anchor_links = cc.anchor_link ~= nil, collect_blocks = cc.block_link ~= nil }, - }) - end -end - ----Collect matching block links. ----@param note obsidian.Note ----@param block_link string? ----@return obsidian.note.Block[]|? -function M.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 M.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 + 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 - - return matching_anchors end --- Strips block and anchor links from the current search string ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function M.strip_links(cc) +---@param cc obsidian.completion.sources.refs.context +local function strip_links(cc) if not cc.search then return end @@ -162,113 +83,11 @@ function M.strip_links(cc) end end ---- Determines whatever the in_buffer_only should be enabled ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function M.determine_buffer_only_search_scope(cc) - if not cc.search then - return - end - 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 M.process_search_results(cc, results) - if not cc.search then - return - end - local completion_items = {} - - for _, note in ipairs(results) do - ---@cast note obsidian.Note - - local matching_blocks = M.collect_matching_blocks(note, cc.block_link) - local matching_anchors = M.collect_matching_anchors(note, cc.anchor_link) - - if cc.in_buffer_only then - M.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) } - end - - for _, alias in ipairs(aliases) do - M.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 - M.update_completion_options(cc, alias_case_matched, nil, matching_anchors, matching_blocks, note) - end - end - - if note.alt_alias ~= nil then - M.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 Obsidian.opts.link.style == "wiki" then - label = string.format("[[%s]]", option.label) - elseif Obsidian.opts.link.style == "markdown" then - label = string.format("[%s](…)", option.label) - elseif type(Obsidian.opts.link.style) == "function" then - label = Obsidian.opts.link.style { label = option.label or "", path = "" } - else - error "not implemented" - end - - ---@cast cc.insert_end -nil - - table.insert(completion_items, { - documentation = option.documentation, - sortText = option.sort_text, - filterText = completion.get_filter_text(option.label), - label = label, - kind = vim.lsp.protocol.CompletionItemKind.Reference, - textEdit = { - newText = option.new_text, - range = { - ["start"] = { - line = cc.request.line, - character = cc.insert_start, - }, - ["end"] = { - line = cc.request.line, - character = cc.insert_end + 1, - }, - }, - }, - }) - end - - cc.completion_resolve_callback { - isIncomplete = true, - items = completion_items, - } -end - --- TODO: localize - ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext +---@param cc obsidian.completion.sources.refs.context ---@param label string|? ---@param alt_label string|? ---@param note obsidian.Note -function M.update_completion_options(cc, label, alt_label, matching_anchors, matching_blocks, note) +local function 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 @@ -295,7 +114,6 @@ function M.update_completion_options(cc, label, alt_label, matching_anchors, mat -- De-duplicate options relative to their `new_text`. for _, option in ipairs(new_options) do - ---@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, anchor = option.anchor, block = option.block } @@ -318,7 +136,6 @@ function M.update_completion_options(cc, label, alt_label, matching_anchors, mat } elseif option.anchor then -- In buffer anchor link. - -- TODO: allow users to customize this? if Obsidian.opts.link.style == "wiki" then new_text = "[[#" .. option.anchor.header .. "]]" elseif Obsidian.opts.link.style == "markdown" then @@ -338,7 +155,6 @@ function M.update_completion_options(cc, label, alt_label, matching_anchors, mat } elseif option.block then -- In buffer block link. - -- TODO: allow users to customize this? if Obsidian.opts.link.style == "wiki" then new_text = "[[#" .. option.block.id .. "]]" elseif Obsidian.opts.link.style == "markdown" then @@ -416,4 +232,128 @@ function M.update_completion_options(cc, label, alt_label, matching_anchors, mat end end +---@param cc obsidian.completion.sources.refs.context +---@param results obsidian.Note[] +local function process_search_results(cc, results) + if not cc.search then + return + end + local completion_items = {} + + for _, note in ipairs(results) do + ---@cast note obsidian.Note + + local matching_blocks = completion.collect_matching_blocks(note, cc.block_link) + local matching_anchors = completion.collect_matching_anchors(note, cc.anchor_link) + + if cc.in_buffer_only then + update_completion_options(cc, nil, nil, matching_anchors, matching_blocks, note) + else + -- Collect all valid aliases for the note, including ID, title, and filename. + local aliases = util.tbl_unique { tostring(note.id), note:display_name(), unpack(note.aliases) } + + for _, alias in ipairs(aliases) do + 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 + update_completion_options(cc, alias_case_matched, nil, matching_anchors, matching_blocks, note) + end + end + + if note.alt_alias ~= nil then + 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 Obsidian.opts.link.style == "wiki" then + label = string.format("[[%s]]", option.label) + elseif Obsidian.opts.link.style == "markdown" then + label = string.format("[%s](…)", option.label) + elseif type(Obsidian.opts.link.style) == "function" then + label = Obsidian.opts.link.style { label = option.label or "", path = "" } + else + error "not implemented" + end + + table.insert(completion_items, { + documentation = option.documentation, + sortText = option.sort_text, + filterText = completion.get_filter_text(option.label), + label = label, + kind = vim.lsp.protocol.CompletionItemKind.Reference, + textEdit = { + newText = option.new_text, + range = { + ["start"] = { + line = cc.request.line, + character = cc.insert_start, + }, + ["end"] = { + line = cc.request.line, + character = cc.insert_end + 1, + }, + }, + }, + }) + end + + cc.completion_resolve_callback { + isIncomplete = true, + items = completion_items, + } +end + +---@param completion_resolve_callback function +---@param request obsidian.completion.Request +function M.process_completion(completion_resolve_callback, request) + local cc = { + completion_resolve_callback = completion_resolve_callback, + request = request, + in_buffer_only = false, + new_text_to_option = {}, + } + + if not can_complete_request(cc) or not cc.search then + cc.completion_resolve_callback(EMPTY_RESPONSE) + return + end + + strip_links(cc) + 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 + process_search_results(cc, { note }) + else + cc.completion_resolve_callback(EMPTY_RESPONSE) + end + else + local search_opts = { + sort = false, + include_templates = false, + ignore_case = true, + } + + search.find_notes_async(cc.search, function(results) + process_search_results(cc, results) + end, { + dir = api.resolve_workspace_dir(), + search = search_opts, + notes = { collect_anchor_links = cc.anchor_link ~= nil, collect_blocks = cc.block_link ~= nil }, + }) + end +end + return M diff --git a/lua/obsidian/lsp/handlers/_definition.lua b/lua/obsidian/lsp/handlers/_definition.lua index 8f61c6697..224a8f54f 100644 --- a/lua/obsidian/lsp/handlers/_definition.lua +++ b/lua/obsidian/lsp/handlers/_definition.lua @@ -49,7 +49,7 @@ local function create_new_note(location, callback, opts) local confirm = api.confirm(("Create new note '%s'?"):format(location), format_options) if confirm == "Yes" then - actions.new(location, opts.label, function(note) + actions.new(location, function(note) update_link(note) callback { note:_location() } end) diff --git a/lua/obsidian/lsp/handlers/initialize.lua b/lua/obsidian/lsp/handlers/initialize.lua index 38c91fc01..417907581 100644 --- a/lua/obsidian/lsp/handlers/initialize.lua +++ b/lua/obsidian/lsp/handlers/initialize.lua @@ -21,7 +21,7 @@ local initializeResult = { workspaceSymbolProvider = true, codeActionProvider = true, executeCommandProvider = { - commands = { "obsidian.new" }, + commands = { "obsidian.write_note" }, }, completionProvider = { resolveProvider = false, diff --git a/lua/obsidian/lsp/handlers/workspace_execute_command.lua b/lua/obsidian/lsp/handlers/workspace_execute_command.lua index 7f21c00dc..45b60b9eb 100644 --- a/lua/obsidian/lsp/handlers/workspace_execute_command.lua +++ b/lua/obsidian/lsp/handlers/workspace_execute_command.lua @@ -10,10 +10,8 @@ return function(params, callback) return end - ---@diagnostic disable-next-line: param-type-mismatch - local action = vim.schedule_wrap(actions[command]) + local action = actions[command] local args = params.arguments and params.arguments or {} - ---@diagnostic disable-next-line: param-type-mismatch local ok, err = pcall(action, unpack(args)) if ok then callback(nil, nil) diff --git a/minimal.lua b/minimal.lua index 543925ba8..92a25ff03 100644 --- a/minimal.lua +++ b/minimal.lua @@ -51,29 +51,29 @@ local plugins = { -- "echasnovski/mini.pick", -- **Choose your completion engine** - -- { - -- "hrsh7th/nvim-cmp", - -- dependencies = { - -- "hrsh7th/cmp-nvim-lsp", - -- }, - -- config = function() - -- local cmp = require "cmp" - -- cmp.setup { - -- mapping = cmp.mapping.preset.insert { - -- [""] = cmp.mapping.abort(), - -- [""] = cmp.mapping.confirm { select = true }, - -- }, - -- sources = { - -- -- { name = "nvim_lsp" }, - -- }, - -- } - -- require("cmp").setup.filetype("markdown", { - -- sources = { - -- { name = "nvim_lsp" }, - -- }, - -- }) - -- end, - -- }, + { + "hrsh7th/nvim-cmp", + dependencies = { + "hrsh7th/cmp-nvim-lsp", + }, + config = function() + local cmp = require "cmp" + cmp.setup { + mapping = cmp.mapping.preset.insert { + [""] = cmp.mapping.abort(), + [""] = cmp.mapping.confirm { select = true }, + }, + sources = { + -- { name = "nvim_lsp" }, + }, + } + require("cmp").setup.filetype("markdown", { + sources = { + { name = "nvim_lsp" }, + }, + }) + end, + }, -- { -- "saghen/blink.cmp", -- opts = { diff --git a/tests/lsp/test_completion.lua b/tests/lsp/test_completion.lua index 2e9459535..4a3d346dc 100644 --- a/tests/lsp/test_completion.lua +++ b/tests/lsp/test_completion.lua @@ -2,7 +2,26 @@ local h = dofile "tests/helpers.lua" local T, child = h.child_vault() local eq = MiniTest.expect.equality --- TODO: better test helpers +local function run_completion(line, character) + child.lua(string.format( + [[ + _G._test_result = nil + local done = false + local handler = require "obsidian.lsp.handlers.completion" + handler({ + textDocument = { uri = vim.uri_from_bufnr(0) }, + position = { line = %d, character = %d }, + }, function(_, res) + _G._test_result = res + done = true + end) + vim.wait(2000, function() return done end, 10) + ]], + line, + character + )) +end + T["refs"] = MiniTest.new_set() T["refs"]["can_complete should handle wiki links with text"] = function() @@ -57,16 +76,7 @@ Target note content child.cmd("edit " .. tostring(child.Obsidian.dir / "test.md")) child.api.nvim_win_set_cursor(0, { 1, 4 }) - child.lua [[ - local handler = require "obsidian.lsp.handlers.completion" - handler({ - textDocument = { uri = vim.uri_from_bufnr(0) }, - position = { line = 0, character = 4 }, - }, function(err, res) - _G._test_result = res - end) - ]] - vim.uv.sleep(100) + run_completion(0, 4) local result = child.lua_get [[_G._test_result]] eq("table", type(result)) @@ -99,16 +109,7 @@ tags: child.cmd("edit " .. tostring(child.Obsidian.dir / "test.md")) child.api.nvim_win_set_cursor(0, { 1, 3 }) - child.lua [[ - local handler = require "obsidian.lsp.handlers.completion" - handler({ - textDocument = { uri = vim.uri_from_bufnr(0) }, - position = { line = 0, character = 3 }, - }, function(err, res) - _G._test_result = res - end) - ]] - vim.uv.sleep(100) + run_completion(0, 3) local result = child.lua_get [[_G._test_result]] eq("table", type(result)) @@ -122,19 +123,71 @@ T["completion"]["isIncomplete is true"] = function() child.cmd("edit " .. tostring(child.Obsidian.dir / "test.md")) child.api.nvim_win_set_cursor(0, { 1, 4 }) - child.lua [[ - local handler = require "obsidian.lsp.handlers.completion" - handler({ - textDocument = { uri = vim.uri_from_bufnr(0) }, - position = { line = 0, character = 4 }, - }, function(err, res) - _G._test_result = res - end) - ]] - vim.uv.sleep(100) + run_completion(0, 4) local is_incomplete = child.lua_get [[_G._test_result and _G._test_result.isIncomplete]] eq(true, is_incomplete) end +T["completion"]["completes tag inside frontmatter tags: list"] = function() + h.mock_vault_contents(child.Obsidian.dir, { + ["test.md"] = "---\ntags:\n - ta\n---\n", + ["tagged.md"] = [==[ +--- +id: tagged +tags: + - task +--- +]==], + }) + + child.cmd("edit " .. tostring(child.Obsidian.dir / "test.md")) + -- Line 3 (1-indexed) " - ta", cursor after "ta" at byte 6. + child.api.nvim_win_set_cursor(0, { 3, 6 }) + + run_completion(2, 6) + + local result = child.lua_get [[_G._test_result]] + eq("table", type(result)) + + -- Frontmatter form: newText is bare tag (no '#'). + local found = false + for _, item in ipairs(result.items or {}) do + if item.textEdit and item.textEdit.newText == "task" then + found = true + break + end + end + eq(true, found) +end + +T["completion"]["create_new emits write_note command that writes file"] = function() + h.mock_vault_contents(child.Obsidian.dir, { + ["test.md"] = "[[brandnewnote", + }) + + child.cmd("edit " .. tostring(child.Obsidian.dir / "test.md")) + child.api.nvim_win_set_cursor(0, { 1, 14 }) + + run_completion(0, 14) + + child.lua [[ + _G._note_path = nil + _G._has_create = false + for _, item in ipairs((_G._test_result or {}).items or {}) do + if item.command and item.command.command == "obsidian.write_note" then + _G._has_create = true + local note = item.command.arguments[1] + require("obsidian.actions").write_note(note) + _G._note_path = tostring(note.path) + break + end + end + ]] + eq(true, child.lua_get [[_G._has_create]]) + local note_path = child.lua_get [[_G._note_path]] + eq("string", type(note_path)) + eq(1, vim.fn.filereadable(note_path)) +end + return T From dd45da57fa4d42b3c14af4baafe94ad018538aeb Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Sun, 24 May 2026 00:03:42 +0100 Subject: [PATCH 28/30] fix dup doc in minimal.lua --- minimal.lua | 64 ++++++++++++++++++----------------------------------- 1 file changed, 21 insertions(+), 43 deletions(-) diff --git a/minimal.lua b/minimal.lua index 92a25ff03..8cd5abc5f 100644 --- a/minimal.lua +++ b/minimal.lua @@ -7,23 +7,7 @@ vim.o.conceallevel = 2 local cwd = vim.uv.cwd() --- HACK: enable for native neovim completion and mini.completion -local chars = {} -for i = 32, 126 do - table.insert(chars, string.char(i)) -end - -vim.api.nvim_create_autocmd("LspAttach", { - callback = function(ev) - local client = vim.lsp.get_client_by_id(ev.data.client_id) - - if client and client.name == "obsidian-ls" then - client.server_capabilities.completionProvider.triggerCharacters = chars - vim.bo[ev.buf].completeopt = "menuone,noselect,fuzzy,nosort" -- noselect to make sure no accidentally accept and create new notes, others are not necessary - vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) - end - end, -}) +-- NOTE: if you want to try native lsp completion, see `:Obsidian help Completion` local plugins = { { @@ -51,34 +35,28 @@ local plugins = { -- "echasnovski/mini.pick", -- **Choose your completion engine** - { - "hrsh7th/nvim-cmp", - dependencies = { - "hrsh7th/cmp-nvim-lsp", - }, - config = function() - local cmp = require "cmp" - cmp.setup { - mapping = cmp.mapping.preset.insert { - [""] = cmp.mapping.abort(), - [""] = cmp.mapping.confirm { select = true }, - }, - sources = { - -- { name = "nvim_lsp" }, - }, - } - require("cmp").setup.filetype("markdown", { - sources = { - { name = "nvim_lsp" }, - }, - }) - end, - }, -- { - -- "saghen/blink.cmp", - -- opts = { - -- fuzzy = { implementation = "lua" }, -- no need to build binary + -- "hrsh7th/nvim-cmp", + -- dependencies = { + -- "hrsh7th/cmp-nvim-lsp", -- }, + -- config = function() + -- local cmp = require "cmp" + -- cmp.setup { + -- mapping = cmp.mapping.preset.insert { + -- [""] = cmp.mapping.abort(), + -- [""] = cmp.mapping.confirm { select = true }, + -- }, + -- sources = { + -- { name = "nvim_lsp" }, + -- }, + -- } + -- end, + -- }, + -- { + -- "saghen/blink.cmp", + -- version = "1.*", + -- opts = {}, -- }, -- { -- "nvim-mini/mini.nvim", From 1d2aee5b0dd902578f6565c46b8b8412e6d32903 Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Sun, 24 May 2026 01:29:25 +0100 Subject: [PATCH 29/30] fix: off by one in links --- lua/obsidian/completion/sources/new.lua | 2 +- lua/obsidian/completion/sources/refs.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/obsidian/completion/sources/new.lua b/lua/obsidian/completion/sources/new.lua index 64ed3c6c2..fd05a111c 100644 --- a/lua/obsidian/completion/sources/new.lua +++ b/lua/obsidian/completion/sources/new.lua @@ -124,7 +124,7 @@ function M.process_completion(callback, request) }, ["end"] = { line = request.line, - character = insert_end + 1, + character = insert_end, }, } diff --git a/lua/obsidian/completion/sources/refs.lua b/lua/obsidian/completion/sources/refs.lua index 369896e40..d0f5bfced 100644 --- a/lua/obsidian/completion/sources/refs.lua +++ b/lua/obsidian/completion/sources/refs.lua @@ -301,7 +301,7 @@ local function process_search_results(cc, results) }, ["end"] = { line = cc.request.line, - character = cc.insert_end + 1, + character = cc.insert_end, }, }, }, From 7b305fd49977fbfefdb96f64f4ee23b28dfbd71b Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Sun, 24 May 2026 18:20:47 +0100 Subject: [PATCH 30/30] fix: nits and proper docs --- README.md | 36 ++++++++++----------- docs/Completion.md | 6 ++-- docs/LSP-Progress.md | 2 +- lua/obsidian/actions.lua | 2 +- lua/obsidian/health.lua | 7 +++- lua/obsidian/lsp/init.lua | 66 +++---------------------------------- lua/obsidian/lsp/util.lua | 68 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 101 insertions(+), 86 deletions(-) create mode 100644 lua/obsidian/lsp/util.lua diff --git a/README.md b/README.md index d20952e7b..e88b8075f 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The original project has not been actively maintained for quite a while and with ## ⭐ 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 and markdown links, `#` for tags) +▶️ **Completion:** Ultra-fast, asynchronous autocompletion for note references and tags via in-process LSP (triggered by typing `[[` for wiki and markdown links, `#` for tags) 🏃 **Navigation:** Navigate throughout your vault via links, backlinks, tags and etc. @@ -157,33 +157,31 @@ 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) -- [fzf-lua](https://github.com/ibhagwan/fzf-lua) -- [mini.pick](https://github.com/echasnovski/mini.pick) -- [snacks.picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md) +- **Pickers:** + - [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) + - [fzf-lua](https://github.com/ibhagwan/fzf-lua) + - [mini.pick](https://github.com/echasnovski/mini.pick) + - [snacks.picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md) To use a specific picker, set `picker.name` in your config, e.g.: + ```lua -require"obsidian".setup { - picker = { - name = "snacks.picker", -- use snacks picker +require("obsidian").setup { + picker = { + name = "snacks.picker", -- use snacks picker -- name = "telescope.nvim", -- or telescope -- name = "fzf-lua", -- or fzf-lua -- name = "mini.pick", -- or mini.pick -}} + }, +} ``` -**Image viewing:** +- **Image viewing:** + - [snacks.image](https://github.com/folke/snacks.nvim/blob/main/docs/image.md) + - See [Images](https://github.com/obsidian-nvim/obsidian.nvim/wiki/Images) for configuration. -- [snacks.image](https://github.com/folke/snacks.nvim/blob/main/docs/image.md) -- See [Images](https://github.com/obsidian-nvim/obsidian.nvim/wiki/Images) for configuration. +- **Completion** + - See [Completion](https://github.com/obsidian-nvim/obsidian.nvim/wiki/Completion) ## 📥 Installation diff --git a/docs/Completion.md b/docs/Completion.md index 7fd3e5a2c..e4c599610 100644 --- a/docs/Completion.md +++ b/docs/Completion.md @@ -1,8 +1,8 @@ ## Plugin Completion -This plugin provide plugin-agnostic completion via in-process LSP, you only need to make sure you are triggering LSP completions in markdown buffers. +This plugin provides plugin-agnostic completion via in-process LSP, you only need to make sure you are triggering LSP completions in markdown buffers. -For blink.cmp, if you have a dedicated `per_filetype` config for markdown, lsp completion will not attach, use: +For blink.cmp, if you have a dedicated `per_filetype` config for markdown, LSP completion will not attach, use: ```lua @@ -25,7 +25,7 @@ require("blink.cmp").setup { To use completions without completion plugin, put this anywhere in your config before an obsidian buffer loads: ```lua --- HACK: to trigger on every ascii char, unicode will work when the upstream neovim completion respects isIncomplete field properly +-- HACK: to trigger on every ASCII char, unicode will work when the upstream neovim completion respects isIncomplete field properly local chars = {} for i = 32, 126 do table.insert(chars, string.char(i)) diff --git a/docs/LSP-Progress.md b/docs/LSP-Progress.md index 443a4fe82..03803fffd 100644 --- a/docs/LSP-Progress.md +++ b/docs/LSP-Progress.md @@ -15,8 +15,8 @@ Tracking implementation status of [LSP 3.17](https://microsoft.github.io/languag - [x] Rename (`textDocument/rename`) - rename notes and update all references across the vault - [x] Prepare Rename (`textDocument/prepareRename`) - [x] Code Action (`textDocument/codeAction`) +- [x] Completion Proposals (`textDocument/completion`) - [ ] Hover (`textDocument/hover`) -- [ ] Completion Proposals (`textDocument/completion`) - currently handled outside LSP via nvim-cmp/blink.cmp - [ ] Completion Item Resolve (`completionItem/resolve`) - [ ] Publish Diagnostics (`textDocument/publishDiagnostics`) - [ ] Code Action Resolve (`codeAction/resolve`) diff --git a/lua/obsidian/actions.lua b/lua/obsidian/actions.lua index e348ecadb..9147053d1 100644 --- a/lua/obsidian/actions.lua +++ b/lua/obsidian/actions.lua @@ -489,7 +489,7 @@ M.new = function(id, callback) local note = Note.create { id = id, - template = Obsidian.opts.note.template, -- TODO: maybe unneed when creating, or set as a field that note carries + template = Obsidian.opts.note.template, } note:write() diff --git a/lua/obsidian/health.lua b/lua/obsidian/health.lua index d747a4914..b12bf424c 100644 --- a/lua/obsidian/health.lua +++ b/lua/obsidian/health.lua @@ -155,11 +155,16 @@ function M.check() end start "Sync" - has_one_of_executable { "ob", sync_client.cmd, } + + local warning = require("obsidian.lsp.util").check_completion_availability() + if warning then + start "Completion" + warn_f(warning) + end end return M diff --git a/lua/obsidian/lsp/init.lua b/lua/obsidian/lsp/init.lua index f048c9e21..068ebc005 100644 --- a/lua/obsidian/lsp/init.lua +++ b/lua/obsidian/lsp/init.lua @@ -1,65 +1,6 @@ local lsp = {} local log = require "obsidian.log" - -local function check_completion_availability() - if pcall(require, "blink.cmp") then - local blink_markdown_config = require("blink.cmp.config").sources.per_filetype["markdown"] - if not blink_markdown_config then - return - end - if type(blink_markdown_config) == "function" then - blink_markdown_config = blink_markdown_config() - end - local configured = vim.tbl_contains(blink_markdown_config, "lsp") or blink_markdown_config.inherit_defaults - if not configured then - log.warn [[This plugin has migrated to in process lsp completion, blink.cmp config for markdown buffer is not properly configured, add -```lua -require("blink.cmp").setup({ - per_filetype = { - markdown = { - inherit_defaults = true, - -- or - "lsp" - }, - }, -}) -``` - ]] - end - elseif pcall(require, "cmp") then - if not pcall(require, "cmp_nvim_lsp") then - log.warn [[This plugin has migrated to in process lsp completion, for your nvim-cmp setup you need cmp-nvim-lsp plugin]] - return - end - local cmp_config = require "cmp.config" - local ft_conf = cmp_config.filetypes["markdown"] - local sources = (ft_conf and ft_conf.sources) or (cmp_config.global and cmp_config.global.sources) or {} - local configured = false - for _, src in ipairs(sources) do - if src.name == "nvim_lsp" then - configured = true - break - end - end - if not configured then - log.warn [[This plugin has migrated to in process lsp completion, nvim-cmp source `nvim_lsp` is not configured for markdown buffers, add -```lua -require("cmp").setup({ - sources = { - { name = "nvim_lsp" }, - }, -}) --- or per-filetype: -require("cmp").setup.filetype("markdown", { - sources = { - { name = "nvim_lsp" }, - }, -}) -``` - ]] - end - end -end +local lsp_util = require "obsidian.lsp.util" --- Start the lsp client --- @@ -80,7 +21,10 @@ lsp.start = function(buf) root_dir = tostring(Obsidian.dir), } - check_completion_availability() + local warning = lsp_util.check_completion_availability() + if warning then + log.warn_once(warning) + end local client_id = vim.lsp.start(lsp_config, { bufnr = buf, silent = false }) diff --git a/lua/obsidian/lsp/util.lua b/lua/obsidian/lsp/util.lua new file mode 100644 index 000000000..ff40f9612 --- /dev/null +++ b/lua/obsidian/lsp/util.lua @@ -0,0 +1,68 @@ +local M = {} + +---@return string|? +M.check_completion_availability = function() + if pcall(require, "blink.cmp") then + local blink_config = require("blink.cmp.config").sources.default + local blink_markdown_config = require("blink.cmp.config").sources.per_filetype["markdown"] + if not blink_markdown_config then + return + end + if type(blink_markdown_config) == "function" then + blink_markdown_config = blink_markdown_config() + end + if type(blink_config) == "function" then + blink_config = blink_config() + end + local configured = vim.tbl_contains(blink_markdown_config, "lsp") + or (blink_markdown_config.inherit_defaults and vim.tbl_contains(blink_config, "lsp")) + if not configured then + return [[This plugin has migrated to in process lsp completion, blink.cmp config for markdown buffer is not properly configured, add +```lua +require("blink.cmp").setup({ + per_filetype = { + markdown = { + "lsp" + -- or if "lsp" in defaults + inherit_defaults = true, + }, + }, +}) +``` +]] + end + elseif pcall(require, "cmp") then + if not pcall(require, "cmp_nvim_lsp") then + return [[This plugin has migrated to in process lsp completion, for your nvim-cmp setup you need cmp-nvim-lsp plugin]] + end + local cmp_config = require "cmp.config" + local ft_conf = cmp_config.filetypes["markdown"] + local sources = (ft_conf and ft_conf.sources) or (cmp_config.global and cmp_config.global.sources) or {} + local configured = false + for _, src in ipairs(sources) do + if src.name == "nvim_lsp" then + configured = true + break + end + end + if not configured then + return [[This plugin has migrated to in process lsp completion, nvim-cmp source `nvim_lsp` is not configured for markdown buffers, add +```lua +require("cmp").setup({ + sources = { + { name = "nvim_lsp" }, + }, +}) +-- or per-filetype: +require("cmp").setup.filetype("markdown", { + sources = { + { name = "nvim_lsp" }, + }, +}) +``` +]] + end + end +end + +return M