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/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 new file mode 100644 index 000000000..e4c599610 --- /dev/null +++ b/docs/Completion.md @@ -0,0 +1,45 @@ +## Plugin Completion + +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: + +```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 +-- 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 + end, +}) +``` 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 98c9ad71f..9147053d1 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" @@ -486,7 +487,10 @@ M.new = function(id, callback) end end - local note = Note.create { id = id, template = Obsidian.opts.note.template } + local note = Note.create { + id = id, + template = Obsidian.opts.note.template, + } note:write() if callback then @@ -836,4 +840,17 @@ 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) + -- 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 + return M 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/commands/new.lua b/lua/obsidian/commands/new.lua index 2032148d7..f877dd14e 100644 --- a/lua/obsidian/commands/new.lua +++ b/lua/obsidian/commands/new.lua @@ -1,7 +1,6 @@ ---@param data obsidian.CommandArgs return function(data) - local id = data.args:len() > 0 and data.args - ---@diagnostic disable-next-line: param-type-mismatch + local id = data.args:len() > 0 and data.args or nil require("obsidian.actions").new(id, function(note) note:open { sync = true } end) 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 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/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/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/refs.lua b/lua/obsidian/completion/refs.lua index 354d63fb7..fe51c358a 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,21 +46,60 @@ 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) 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/base/new.lua b/lua/obsidian/completion/sources/base/new.lua deleted file mode 100644 index aa443160d..000000000 --- a/lua/obsidian/completion/sources/base/new.lua +++ /dev/null @@ -1,213 +0,0 @@ -local completion = require "obsidian.completion.refs" -local util = require "obsidian.util" -local Note = require "obsidian.note" -local Path = require "obsidian.path" - ----Used to track variables that are used between reusable method calls. This is required, because each ----call to the sources's completion hook won't create a new source object, but will reuse the same one. ----@class obsidian.completion.sources.base.NewNoteSourceCompletionContext ----@field completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@field request obsidian.completion.sources.base.Request ----@field search string|? ----@field insert_start integer|? ----@field insert_end integer|? -local NewNoteSourceCompletionContext = {} -NewNoteSourceCompletionContext.__index = NewNoteSourceCompletionContext - -NewNoteSourceCompletionContext.new = function() - return setmetatable({}, NewNoteSourceCompletionContext) -end - ----@class obsidian.completion.sources.base.NewNoteSourceBase ----@field incomplete_response table ----@field complete_response table -local NewNoteSourceBase = {} -NewNoteSourceBase.__index = NewNoteSourceBase - ----@return obsidian.completion.sources.base.NewNoteSourceBase -NewNoteSourceBase.new = function() - return setmetatable({}, NewNoteSourceBase) -end - -NewNoteSourceBase.get_trigger_characters = completion.get_trigger_characters - ----Sets up a new completion context that is used to pass around variables between completion source methods ----@param completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@param request obsidian.completion.sources.base.Request ----@return obsidian.completion.sources.base.NewNoteSourceCompletionContext -function NewNoteSourceBase.new_completion_context(_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 - - -- This request object will be used to determine the current cursor location and the text around it - completion_context.request = request - - return completion_context -end - ---- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods ----@param cc obsidian.completion.sources.base.NewNoteSourceCompletionContext -function NewNoteSourceBase:process_completion(cc) - if not self:can_complete_request(cc) then - return - end - - ---@type string|? - local block_link - cc.search, block_link = util.strip_block_links(cc.search) - - ---@type string|? - local anchor_link - cc.search, anchor_link = util.strip_anchor_links(cc.search) - - -- If block link is incomplete, do nothing. - if not block_link and vim.endswith(cc.search, "#^") then - cc.completion_resolve_callback(self.incomplete_response) - return - end - - -- If anchor link is incomplete, do nothing. - if not anchor_link and vim.endswith(cc.search, "#") then - cc.completion_resolve_callback(self.incomplete_response) - return - end - - -- Probably just a block/anchor link within current note. - if string.len(cc.search) == 0 then - cc.completion_resolve_callback(self.incomplete_response) - return - end - - -- Create a mock block. - ---@type obsidian.note.Block|? - local block - if block_link then - block = { block = "", id = util.standardize_block(block_link), line = 1 } - end - - -- Create a mock anchor. - ---@type obsidian.note.HeaderAnchor|? - local anchor - if anchor_link then - anchor = { anchor = anchor_link, header = string.sub(anchor_link, 2), level = 1, line = 1 } - end - - ---@type { label: string, note: obsidian.Note }[] - local new_notes_opts = {} - - local note = Note.create { id = cc.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 } - end - - -- Check for datetime macros. - for _, dt_offset in ipairs(util.resolve_date_macro(cc.search)) do - if dt_offset.cadence == "daily" then - note = require("obsidian.daily").daily { offset = dt_offset.offset } - if not note:exists() then - new_notes_opts[#new_notes_opts + 1] = { label = dt_offset.macro, note = note } - end - end - end - - -- Completion items. - local items = {} - - for _, new_note_opts in ipairs(new_notes_opts) do - local new_note = new_note_opts.note - - assert(new_note.path, "note without path") - - local label - if Obsidian.opts.link.style == "wiki" then - label = string.format("[[%s]] (create)", new_note_opts.label) - elseif Obsidian.opts.link.style == "markdown" then - label = string.format("[%s](…) (create)", new_note_opts.label) - elseif type(Obsidian.opts.link.style) == "function" then - label = Obsidian.opts.link.style { label = new_note_opts.label, path = "…" } .. " (create)" - else - error "not implemented" - end - - local new_text = new_note:format_link { - label = new_note_opts.label, - anchor = anchor, - block = block, - } - local documentation = { - kind = "markdown", - value = new_note:display_info { - label = "Create: " .. new_text, - }, - } - - items[#items + 1] = { - documentation = documentation, - sortText = new_note_opts.label, - filterText = completion.get_filter_text(new_note_opts.label), - label = label, - kind = vim.lsp.protocol.CompletionItemKind.Reference, - textEdit = { - newText = new_text, - range = { - start = { - line = cc.request.context.cursor.row - 1, - character = cc.insert_start, - }, - ["end"] = { - line = cc.request.context.cursor.row - 1, - character = cc.insert_end + 1, - }, - }, - }, - data = { - note = new_note, - }, - } - 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 -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/completion/sources/base/tags.lua b/lua/obsidian/completion/sources/base/tags.lua deleted file mode 100644 index ff3b17f2a..000000000 --- a/lua/obsidian/completion/sources/base/tags.lua +++ /dev/null @@ -1,130 +0,0 @@ -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 ----@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 - - -- This request object will be used to determine the current cursor location and the text around it - completion_context.request = request - - completion_context.root = api.resolve_workspace_dir() - - return completion_context -end - ---- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods ----@param cc obsidian.completion.sources.base.TagsSourceCompletionContext -function TagsSourceBase:process_completion(cc) - if not self:can_complete_request(cc) then - return - end - - search.find_tags_async(cc.search, function(tag_locs) - local tags = {} - for tag_loc in iter(tag_locs) do - tags[tag_loc.tag] = true - end - - local items = {} - for tag, _ in pairs(tags) do - -- Generate context-appropriate text - local insert_text, label_text - if cc.in_frontmatter then - -- Frontmatter: insert tag without # (YAML format) - insert_text = tag - label_text = "Tag: " .. tag - else - -- Document body: insert tag with # (Obsidian format) - insert_text = "#" .. tag - label_text = "Tag: #" .. tag - end - - -- Calculate the range to replace (the entire #tag pattern) - local cursor_before = cc.request.context.cursor_before_line - local hash_start = string.find(cursor_before, "#[^%s]*$") - local insert_start = hash_start and (hash_start - 1) or #cursor_before - local insert_end = #cursor_before - - items[#items + 1] = { - sortText = "#" .. tag, - label = label_text, - kind = vim.lsp.protocol.CompletionItemKind.Text, - textEdit = { - newText = insert_text, - range = { - ["start"] = { - line = cc.request.context.cursor.row - 1, - character = insert_start, - }, - ["end"] = { - line = cc.request.context.cursor.row - 1, - character = insert_end, - }, - }, - }, - data = { - bufnr = cc.request.context.bufnr, - in_frontmatter = cc.in_frontmatter, - line = cc.request.context.cursor.line, - tag = tag, - }, - } - end - - cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = items })) - end, { 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 -end - -return TagsSourceBase diff --git a/lua/obsidian/completion/sources/base/types.lua b/lua/obsidian/completion/sources/base/types.lua deleted file mode 100644 index ba22c0e7a..000000000 --- a/lua/obsidian/completion/sources/base/types.lua +++ /dev/null @@ -1,16 +0,0 @@ ----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 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/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/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/new.lua b/lua/obsidian/completion/sources/new.lua new file mode 100644 index 000000000..fd05a111c --- /dev/null +++ b/lua/obsidian/completion/sources/new.lua @@ -0,0 +1,164 @@ +local completion = require "obsidian.completion.refs" +local util = require "obsidian.util" +local Note = require "obsidian.note" + +local M = {} + +---@type lsp.CompletionList +local EMPTY_RESPONSE = { + isIncomplete = true, + items = {}, +} + +--- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods +---@param callback fun(resp: lsp.CompletionList) +---@param request obsidian.completion.Request +function M.process_completion(callback, request) + local can_complete, term, insert_start, insert_end = completion.can_complete(request) + + if (not can_complete) or (#term < Obsidian.opts.completion.min_chars) then + callback(EMPTY_RESPONSE) + return + end + + ---@cast term -nil + ---@cast insert_start -nil + ---@cast insert_end -nil + + term = util.lstrip_whitespace(term) + + ---@type string|? + local block_link + term, block_link = util.strip_block_links(term) + + ---@type string|? + local anchor_link + term, anchor_link = util.strip_anchor_links(term) + + -- If block link is incomplete, do nothing. + 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(term, "#") then + callback(EMPTY_RESPONSE) + return + end + + -- Probably just a block/anchor link within current note. + if string.len(term) == 0 then + callback(EMPTY_RESPONSE) + return + end + + -- Create a mock block. + ---@type obsidian.note.Block|? + local block + if block_link then + block = { block = "", id = util.standardize_block(block_link), line = 1 } + end + + -- Create a mock anchor. + ---@type obsidian.note.HeaderAnchor|? + local anchor + if anchor_link then + anchor = { anchor = anchor_link, header = string.sub(anchor_link, 2), level = 1, line = 1 } + end + + ---@type { label: string, note: obsidian.Note }[] + local new_notes_opts = {} + + 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 = term, note = note } + end + + -- Check for datetime macros. + 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 + new_notes_opts[#new_notes_opts + 1] = { label = dt_offset.macro, note = note } + end + end + end + + -- Completion items. + local items = {} + + for _, new_note_opts in ipairs(new_notes_opts) do + local new_note = new_note_opts.note + + assert(new_note.path, "note without path") + + local label + if Obsidian.opts.link.style == "wiki" then + label = string.format("[[%s]] (create)", new_note_opts.label) + elseif Obsidian.opts.link.style == "markdown" then + label = string.format("[%s](…) (create)", new_note_opts.label) + elseif type(Obsidian.opts.link.style) == "function" then + label = Obsidian.opts.link.style { label = new_note_opts.label, path = "…" } .. " (create)" + else + error "not implemented" + end + + local new_text = new_note:format_link { + label = new_note_opts.label, + anchor = anchor, + block = block, + } + local documentation = { + kind = "markdown", + value = new_note:display_info { + label = "Create: " .. new_text, + }, + } + + ---@type lsp.Range + local range = { + start = { + line = request.line, + character = insert_start, + }, + ["end"] = { + line = request.line, + character = insert_end, + }, + } + + ---@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.write_note", + title = "Obsidian write note", + arguments = { new_note }, + }, + -- 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 } -- + -- }, + textEdit = { + newText = new_text, + range = range, + }, + } + + items[#items + 1] = item + end + + callback { + isIncomplete = true, + items = items, + } +end + +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/completion/sources/base/refs.lua b/lua/obsidian/completion/sources/refs.lua similarity index 60% rename from lua/obsidian/completion/sources/base/refs.lua rename to lua/obsidian/completion/sources/refs.lua index 24829962e..d0f5bfced 100644 --- a/lua/obsidian/completion/sources/base/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,159 +16,57 @@ 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 +---@class obsidian.completion.sources.refs.context +---@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|? ---@field insert_end integer|? ---@field block_link string|? ---@field anchor_link string|? ----@field new_text_to_option table ----@field root obsidian.Path -local RefsSourceCompletionContext = {} -RefsSourceCompletionContext.__index = RefsSourceCompletionContext +---@field new_text_to_option table -RefsSourceCompletionContext.new = function() - return setmetatable({}, RefsSourceCompletionContext) -end - ----@class obsidian.completion.sources.base.RefsSourceBase ----@field incomplete_response table ----@field complete_response table -local RefsSourceBase = {} -RefsSourceBase.__index = RefsSourceBase - ----@return obsidian.completion.sources.base.RefsSourceBase -RefsSourceBase.new = function() - return setmetatable({}, RefsSourceBase) -end - -RefsSourceBase.get_trigger_characters = completion.get_trigger_characters - ----Sets up a new completion context that is used to pass around variables between completion source methods ----@param completion_resolve_callback (fun(self: any)) blink or nvim_cmp completion resolve callback ----@param request obsidian.completion.sources.base.Request ----@return obsidian.completion.sources.base.RefsSourceCompletionContext -function RefsSourceBase:new_completion_context(completion_resolve_callback, request) - local completion_context = RefsSourceCompletionContext.new() - - -- Sets up the completion callback, which will be called when the (possibly incomplete) completion items are ready - completion_context.completion_resolve_callback = completion_resolve_callback - - -- This request object will be used to determine the current cursor location and the text around it - completion_context.request = request - - completion_context.in_buffer_only = false - - completion_context.root = api.resolve_workspace_dir() - - return completion_context -end - ---- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function RefsSourceBase:process_completion(cc) - if not self:can_complete_request(cc) then - return - end - - self:strip_links(cc) - self:determine_buffer_only_search_scope(cc) +local M = {} - if cc.in_buffer_only then - local note = api.current_note(0, { collect_anchor_links = true, collect_blocks = true }) - if note then - self:process_search_results(cc, { note }) - else - cc.completion_resolve_callback(self.incomplete_response) - end - else - local search_opts = { - sort = false, - include_templates = false, - ignore_case = true, - } +---@type lsp.CompletionList +local EMPTY_RESPONSE = { + isIncomplete = true, + items = {}, +} - search.find_notes_async(cc.search, function(results) - self:process_search_results(cc, results) - end, { - dir = cc.root, - 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 +--- 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 -function RefsSourceBase:can_complete_request(cc) +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 not (can_complete and cc.search ~= nil and #cc.search >= Obsidian.opts.completion.min_chars) then - cc.completion_resolve_callback(self.incomplete_response) return false end return true end ----Collect matching block links. ----@param note obsidian.Note ----@param block_link string? ----@return obsidian.note.Block[]|? -function RefsSourceBase:collect_matching_blocks(note, block_link) - ---@type obsidian.note.Block[]|? - local matching_blocks - if block_link then - assert(note.blocks, "no block") - matching_blocks = {} - for block_id, block_data in pairs(note.blocks) do - if vim.startswith("#" .. block_id, block_link) then - table.insert(matching_blocks, block_data) - end - end - - if #matching_blocks == 0 then - -- Unmatched, create a mock one. - table.insert(matching_blocks, { id = util.standardize_block(block_link), line = 1 }) - end +--- 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 - - return matching_blocks -end - ----Collect matching anchor links. ----@param note obsidian.Note ----@param anchor_link string? ----@return obsidian.note.HeaderAnchor[]? -function RefsSourceBase:collect_matching_anchors(note, anchor_link) - ---@type obsidian.note.HeaderAnchor[]|? - local matching_anchors - if anchor_link then - assert(note.anchor_links, "no anchor link") - matching_anchors = {} - for anchor, anchor_data in pairs(note.anchor_links) do - if vim.startswith(anchor, anchor_link) then - table.insert(matching_anchors, anchor_data) - end - end - - if #matching_anchors == 0 then - -- Unmatched, create a mock one. - table.insert(matching_anchors, { anchor = anchor_link, header = string.sub(anchor_link, 2), level = 1, line = 1 }) - end + 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 RefsSourceBase:strip_links(cc) +---@param cc obsidian.completion.sources.refs.context +local function strip_links(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) @@ -184,102 +83,11 @@ function RefsSourceBase:strip_links(cc) end end ---- Determines whatever the in_buffer_only should be enabled ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext -function RefsSourceBase:determine_buffer_only_search_scope(cc) - if (cc.anchor_link or cc.block_link) and string.len(cc.search) == 0 then - -- Search over headers/blocks in current buffer only. - cc.in_buffer_only = true - end -end - ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext ----@param results obsidian.Note[] -function RefsSourceBase:process_search_results(cc, results) - 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) - - if cc.in_buffer_only then - self:update_completion_options(cc, nil, nil, matching_anchors, matching_blocks, note) - else - -- Collect all valid aliases for the note, including ID, title, and filename. - ---@type string[] - local aliases - if not cc.in_buffer_only then - aliases = util.tbl_unique { tostring(note.id), note:display_name(), unpack(note.aliases) } - end - - for _, alias in ipairs(aliases) do - self:update_completion_options(cc, alias, nil, matching_anchors, matching_blocks, note) - local alias_case_matched = util.match_case(cc.search, alias) - - if - alias_case_matched ~= nil - and alias_case_matched ~= alias - and not vim.list_contains(note.aliases, alias_case_matched) - and Obsidian.opts.completion.match_case - then - self:update_completion_options(cc, alias_case_matched, nil, matching_anchors, matching_blocks, note) - end - end - - if note.alt_alias ~= nil then - self:update_completion_options(cc, note:display_name(), note.alt_alias, matching_anchors, matching_blocks, note) - end - end - end - - for _, option in pairs(cc.new_text_to_option) do - -- TODO: need a better label, maybe just the note's display name? - ---@type string - local label - if 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.context.cursor.row - 1, - character = cc.insert_start, - }, - ["end"] = { - line = cc.request.context.cursor.row - 1, - character = cc.insert_end + 1, - }, - }, - }, - }) - end - - cc.completion_resolve_callback(vim.tbl_deep_extend("force", self.complete_response, { items = completion_items })) -end - ----@param cc obsidian.completion.sources.base.RefsSourceCompletionContext +---@param cc obsidian.completion.sources.refs.context ---@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) +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 @@ -306,7 +114,6 @@ function RefsSourceBase:update_completion_options(cc, label, alt_label, matching -- 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 } @@ -329,7 +136,6 @@ function RefsSourceBase:update_completion_options(cc, label, alt_label, matching } 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 @@ -349,7 +155,6 @@ function RefsSourceBase:update_completion_options(cc, label, alt_label, matching } 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 @@ -427,4 +232,128 @@ function RefsSourceBase:update_completion_options(cc, label, alt_label, matching end end -return RefsSourceBase +---@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, + }, + }, + }, + }) + 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/completion/sources/tags.lua b/lua/obsidian/completion/sources/tags.lua new file mode 100644 index 000000000..0ca37ed38 --- /dev/null +++ b/lua/obsidian/completion/sources/tags.lua @@ -0,0 +1,85 @@ +local completion = require "obsidian.completion.tags" +local search = require "obsidian.search" +local api = require "obsidian.api" + +local M = {} + +---@type lsp.CompletionList +local EMPTY_RESPONSE = { + isIncomplete = true, + items = {}, +} + +--- Runs a generalized version of the complete (nvim_cmp) or get_completions (blink) methods +---@param callback fun(resp: lsp.CompletionList) +---@param request obsidian.completion.Request +function M.process_completion(callback, request) + local can_complete, term, in_frontmatter = completion.can_complete(request) + + if not (can_complete and term ~= nil and #term >= Obsidian.opts.completion.min_chars) then + callback(EMPTY_RESPONSE) + return + end + + ---@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 + end + + local items = {} + for tag, count in pairs(tags) do + -- Generate context-appropriate text + local insert_text, label_text + if in_frontmatter then + -- Frontmatter: insert tag without # (YAML format) + insert_text = tag + label_text = "Tag: " .. tag + else + -- Document body: insert tag with # (Obsidian format) + insert_text = "#" .. tag + label_text = "Tag: #" .. tag + end + + -- Calculate the range to replace (the entire #tag pattern) + local cursor_before = request.cursor_before_line + local hash_start = string.find(cursor_before, "#[^%s]*$") + 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] = { + sortText = tag, + filterText = hash_start and "#" .. tag or tag, + label = label_text, + kind = vim.lsp.protocol.CompletionItemKind.Keyword, + documentation = { + kind = "markdown", + value = string.format("`#%s` — %d occurrence%s", tag, count, count == 1 and "" or "s"), + }, + textEdit = { + newText = insert_text, + range = { + ["start"] = { + line = request.line, + character = insert_start, + }, + ["end"] = { + line = request.line, + character = insert_end, + }, + }, + }, + } + end + + callback { + isIncomplete = true, + items = items, + } + end, { dir = api.resolve_workspace_dir() }) +end + +return M diff --git a/lua/obsidian/completion/tags.lua b/lua/obsidian/completion/tags.lua index 58ae991ef..080461137 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,39 +32,62 @@ local get_frontmatter_boundaries = function(bufnr) end end ----@return boolean, string|?, boolean|? -M.can_complete = function(request) - local search = M.find_tags_start(request.context.cursor_before_line) - if not search or string.len(search) == 0 then - return false +--- 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 - -- Check if we're inside frontmatter. - local in_frontmatter = false - local line = request.context.cursor.line - local frontmatter_start, frontmatter_end = get_frontmatter_boundaries(request.context.bufnr) - if - frontmatter_start ~= nil + -- 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 line = request.line + 1 -- 1-indexed + local frontmatter_start, frontmatter_end = get_frontmatter_boundaries(request.bufnr) + local in_frontmatter = frontmatter_start ~= nil and frontmatter_start <= (line + 1) and frontmatter_end ~= nil and line <= frontmatter_end - then - in_frontmatter = true - end - return true, search, in_frontmatter -end + -- 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 -M.get_trigger_characters = function() - return { "#" } -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 -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_/-]\\+" + return true, search, in_frontmatter end 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..b12bf424c 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) @@ -162,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/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..edb74e0bb 100644 --- a/lua/obsidian/lsp/handlers.lua +++ b/lua/obsidian/lsp/handlers.lua @@ -3,10 +3,12 @@ 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", ["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", } diff --git a/lua/obsidian/lsp/handlers/_definition.lua b/lua/obsidian/lsp/handlers/_definition.lua index f4511b18a..224a8f54f 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 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/lua/obsidian/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua new file mode 100644 index 000000000..b2f4cd39a --- /dev/null +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -0,0 +1,92 @@ +local Ref = require "obsidian.completion.sources.refs" +local Tag = require "obsidian.completion.sources.tags" +local NewNote = require "obsidian.completion.sources.new" + +---@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) + local uri = params.textDocument.uri + local bufnr = vim.uri_to_bufnr(uri) + + -- LSP position is 0-indexed line, 0-indexed character + local line = params.position.line + 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, 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 = character, + } +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) + + -- 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) + 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. + Ref.process_completion(on_source_done, request) + + -- Tags source. + Tag.process_completion(on_source_done, request) + + -- New note source (only if configured). + if Obsidian.opts.completion.create_new then + NewNote.process_completion(on_source_done, request) + end +end diff --git a/lua/obsidian/lsp/handlers/initialize.lua b/lua/obsidian/lsp/handlers/initialize.lua index 4d6729b93..417907581 100644 --- a/lua/obsidian/lsp/handlers/initialize.lua +++ b/lua/obsidian/lsp/handlers/initialize.lua @@ -20,6 +20,12 @@ local initializeResult = { documentSymbolProvider = true, workspaceSymbolProvider = true, codeActionProvider = true, + executeCommandProvider = { + commands = { "obsidian.write_note" }, + }, + completionProvider = { + resolveProvider = false, + }, workspace = { fileOperations = { didRename = { 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..45b60b9eb --- /dev/null +++ b/lua/obsidian/lsp/handlers/workspace_execute_command.lua @@ -0,0 +1,21 @@ +---Mostly not needed, but some lsp related plugins like nvim-cmp-lsp don't support vim.lsp.commands yet + +---@param params lsp.ExecuteCommandParams +---@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 + + local action = actions[command] + local args = params.arguments and params.arguments or {} + 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/lua/obsidian/lsp/init.lua b/lua/obsidian/lsp/init.lua index 9c12541e7..068ebc005 100644 --- a/lua/obsidian/lsp/init.lua +++ b/lua/obsidian/lsp/init.lua @@ -1,5 +1,6 @@ local lsp = {} local log = require "obsidian.log" +local lsp_util = require "obsidian.lsp.util" --- Start the lsp client --- @@ -20,6 +21,11 @@ lsp.start = function(buf) root_dir = tostring(Obsidian.dir), } + 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 }) if not client_id then 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 diff --git a/minimal.lua b/minimal.lua index d205f49db..8cd5abc5f 100644 --- a/minimal.lua +++ b/minimal.lua @@ -7,6 +7,8 @@ vim.o.conceallevel = 2 local cwd = vim.uv.cwd() +-- NOTE: if you want to try native lsp completion, see `:Obsidian help Completion` + local plugins = { { "obsidian-nvim/obsidian.nvim", @@ -35,6 +37,9 @@ local plugins = { -- **Choose your completion engine** -- { -- "hrsh7th/nvim-cmp", + -- dependencies = { + -- "hrsh7th/cmp-nvim-lsp", + -- }, -- config = function() -- local cmp = require "cmp" -- cmp.setup { @@ -42,17 +47,25 @@ local plugins = { -- [""] = cmp.mapping.abort(), -- [""] = cmp.mapping.confirm { select = true }, -- }, + -- sources = { + -- { name = "nvim_lsp" }, + -- }, -- } -- end, -- }, - { - "saghen/blink.cmp", - opts = { - fuzzy = { implementation = "lua" }, -- no need to build binary - }, - }, + -- { + -- "saghen/blink.cmp", + -- version = "1.*", + -- opts = {}, + -- }, + -- { + -- "nvim-mini/mini.nvim", + -- config = function() + -- require("mini.completion").setup {} + -- end, + -- }, } require("lazy.minit").repro { spec = plugins } -vim.cmd "checkhealth obsidian" +-- vim.cmd "checkhealth obsidian" 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..4a3d346dc --- /dev/null +++ b/tests/lsp/test_completion.lua @@ -0,0 +1,193 @@ +local h = dofile "tests/helpers.lua" +local T, child = h.child_vault() +local eq = MiniTest.expect.equality + +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() + local completion = require "obsidian.completion.refs" + + local before = "simple text [[foo" + local request = { + cursor_before_line = before, + cursor_after_line = "", + character = string.len(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 = "", + character = string.len(before), + } + + local can_complete, search, insert_start, insert_end, _ = completion.can_complete(request) + eq(true, can_complete) + eq("foo", search) + eq(16, insert_start) + eq(21, insert_end) +end + +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 }) + + run_completion(0, 4) + + 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 }) + + run_completion(0, 3) + + 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 }) + + 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 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 diff --git a/tests/test_completion.lua b/tests/test_completion.lua deleted file mode 100644 index 3c726057c..000000000 --- a/tests/test_completion.lua +++ /dev/null @@ -1,51 +0,0 @@ -local new_set, eq = MiniTest.new_set, MiniTest.expect.equality - -local T = new_set() - -T["completion"] = new_set() - -T["completion"]["refs"] = new_set() - -T["completion"]["refs"]["can_complete should handle wiki links with text"] = function() - local completion = require "obsidian.completion.refs" - - local before = "simple text [[foo" - local request = { - context = { - cursor_before_line = before, - cursor_after_line = "", - cursor = { - character = vim.fn.strchars(before), - }, - }, - } - - local can_complete, search, insert_start, insert_end, _ = completion.can_complete(request) - eq(true, can_complete) - eq("foo", search) - eq(12, insert_start) - eq(17, insert_end) -end - -T["completion"]["refs"]["can_complete should handle wiki links with preceding Unicode text"] = function() - local completion = require "obsidian.completion.refs" - - local before = "Unicode text ű [[foo" - local request = { - context = { - cursor_before_line = before, - cursor_after_line = "", - cursor = { - character = vim.fn.strchars(before), - }, - }, - } - - local can_complete, search, insert_start, insert_end, _ = completion.can_complete(request) - eq(true, can_complete) - eq("foo", search) - eq(15, insert_start) - eq(20, insert_end) -end - -return T