diff --git a/CHANGELOG.md b/CHANGELOG.md index 571277fe..6a885830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `makefile types` target to check types via lua-ls +- Added a builtin lsp to handle completion, hover, diagnostics and code actions ### Changed diff --git a/lua/obsidian/client.lua b/lua/obsidian/client.lua index 3e66a8e9..6a5aef14 100644 --- a/lua/obsidian/client.lua +++ b/lua/obsidian/client.lua @@ -2125,4 +2125,51 @@ Client.statusline = function(self) timer:start(0, 1000, vim.schedule_wrap(refresh)) end +--- Start the lsp client +--- +---@return integer +Client.lsp_start = function(self, buf) + local handlers = require "obsidian.lsp.handlers" + local has_blink, blink = pcall(require, "blink.cmp") + local has_cmp, cmp_lsp = pcall(require, "cmp_nvim_lsp") + + local capabilities + if has_blink then + capabilities = blink.get_lsp_capabilities({}, true) + elseif has_cmp then + capabilities = cmp_lsp.default_capabilities() + else + capabilities = vim.lsp.protocol.make_client_capabilities() + end + + local client_id = vim.lsp.start { + name = "obsidian-ls", + capabilities = capabilities, + cmd = function() + return { + request = function(method, params, handler, _) + handlers[method](self, params, handler, _) + end, + notify = function(method, params, handler, _) + handlers[method](self, params, handler, _) + end, + is_closing = function() end, + terminate = function() end, + } + end, + init_options = {}, + root_dir = tostring(self.dir), + } + assert(client_id, "failed to start obsidian_ls") + + if not (has_blink or has_cmp) then + vim.lsp.completion.enable(true, client_id, buf, { autotrigger = true }) + vim.bo[buf].omnifunc = "v:lua.vim.lsp.omnifunc" + vim.bo[buf].completeopt = "menu,menuone,noselect" + vim.bo[buf].iskeyword = "@,48-57,192-255" -- HACK: so that completion for note names with `-` in it works in native completion + end + + return client_id +end + return Client diff --git a/lua/obsidian/commands/__toc.lua b/lua/obsidian/commands/__toc.lua new file mode 100644 index 00000000..8127900a --- /dev/null +++ b/lua/obsidian/commands/__toc.lua @@ -0,0 +1,59 @@ +-- TODO: all pickers should do on_list callbacks + +local telescope_on_list = function(data) + local pickers = require "telescope.pickers" + local finders = require "telescope.finders" + pickers + .new({}, { + prompt_title = "Table of Contents", + finder = finders.new_table { + results = data.items, + entry_maker = function(value) + return { + value = value, + display = value.text, + path = value.filename, + ordinal = value.text, + } + end, + }, + }) + :find() +end + +local function mini_pick_on_list(data) + local ok, pick = pcall(require, "mini.pick") + if not ok then + vim.notify("no mini.pick found", 3) + return + end + + local items = data.items + for _, item in ipairs(data.items) do + item.path = item.filename + end + + pick.start { + source = { items = items }, + name = "Table of Contents", + show = pick.default_show, + choose = pick.default_choose, + } +end + +---@param client obsidian.Client +---@param _ CommandArgs +return function(client, _) + local picker_name = tostring(client:picker()) + if picker_name == "TelescopePicker()" then + vim.lsp.buf.document_symbol { on_list = telescope_on_list } + elseif picker_name == "SnacksPicker()" then + require("snacks.picker").lsp_symbols() + elseif picker_name == "FzfPicker()" then + require("fzf-lua").lsp_document_symbols() + elseif picker_name == "MiniPicker()" then + vim.lsp.buf.document_symbol { on_list = mini_pick_on_list } + else + vim.lsp.buf.document_symbol { loclist = false } + end +end diff --git a/lua/obsidian/commands/backlinks.lua b/lua/obsidian/commands/backlinks.lua index 30a3ca5f..358e1c98 100644 --- a/lua/obsidian/commands/backlinks.lua +++ b/lua/obsidian/commands/backlinks.lua @@ -1,56 +1,25 @@ -local util = require "obsidian.util" local log = require "obsidian.log" -local RefTypes = require("obsidian.search").RefTypes ----@param client obsidian.Client ----@param picker obsidian.Picker ----@param note obsidian.Note ----@param opts { anchor: string|?, block: string|? }|? -local function collect_backlinks(client, picker, note, opts) - opts = opts or {} - - client:find_backlinks_async(note, function(backlinks) - if vim.tbl_isempty(backlinks) then - if opts.anchor then - log.info("No backlinks found for anchor '%s' in note '%s'", opts.anchor, note.id) - elseif opts.block then - log.info("No backlinks found for block '%s' in note '%s'", opts.block, note.id) - else - log.info("No backlinks found for note '%s'", note.id) - end - return - end - - local entries = {} - for _, matches in ipairs(backlinks) do - for _, match in ipairs(matches.matches) do - entries[#entries + 1] = { - value = { path = matches.path, line = match.line }, - filename = tostring(matches.path), - lnum = match.line, - } - end - end - - ---@type string - local prompt_title - if opts.anchor then - prompt_title = string.format("Backlinks to '%s%s'", note.id, opts.anchor) - elseif opts.block then - prompt_title = string.format("Backlinks to '%s#%s'", note.id, util.standardize_block(opts.block)) - else - prompt_title = string.format("Backlinks to '%s'", note.id) - end - - vim.schedule(function() - picker:pick(entries, { - prompt_title = prompt_title, - callback = function(value) - util.open_buffer(value.path, { line = value.line }) +local telescope_on_list = function(data) + local pickers = require "telescope.pickers" + local finders = require "telescope.finders" + pickers + .new({}, { + prompt_title = "References", + finder = finders.new_table { + results = data.items, + entry_maker = function(value) + return { + value = value, + display = value.text, + path = value.filename, + ordinal = value.text, + lnum = value.lnum, + } end, - }) - end) - end, { search = { sort = true }, anchor = opts.anchor, block = opts.block }) + }, + }) + :find() end ---@param client obsidian.Client @@ -60,77 +29,19 @@ return function(client) log.err "No picker configured" return end - - local location, _, ref_type = util.parse_cursor_link { include_block_ids = true } - - if - location ~= nil - and ref_type ~= RefTypes.NakedUrl - and ref_type ~= RefTypes.FileUrl - and ref_type ~= RefTypes.BlockID - then - -- Remove block links from the end if there are any. - -- TODO: handle block links. - ---@type string|? - local block_link - location, block_link = util.strip_block_links(location) - - -- Remove anchor links from the end if there are any. - ---@type string|? - local anchor_link - location, anchor_link = util.strip_anchor_links(location) - - -- Assume 'location' is current buffer path if empty, like for TOCs. - if string.len(location) == 0 then - location = vim.api.nvim_buf_get_name(0) - end - - local opts = { anchor = anchor_link, block = block_link } - - client:resolve_note_async(location, function(...) - ---@type obsidian.Note[] - local notes = { ... } - - if #notes == 0 then - log.err("No notes matching '%s'", location) - return - elseif #notes == 1 then - return collect_backlinks(client, picker, notes[1], opts) - else - return vim.schedule(function() - picker:pick_note(notes, { - prompt_title = "Select note", - callback = function(note) - collect_backlinks(client, picker, note, opts) - end, - }) - end) - end - end) + local picker_name = tostring(picker) + + if picker_name == "TelescopePicker()" then + vim.lsp.buf.references({ + includeDeclaration = false, + }, { on_list = telescope_on_list }) + elseif picker_name == "SnacksPicker()" then + require("snacks.picker").lsp_symbols() + elseif picker_name == "FzfPicker()" then + require("fzf-lua").lsp_document_symbols() + elseif picker_name == "MiniPicker()" then + -- vim.lsp.buf.document_symbol { on_list = mini_pick_on_list } else - ---@type { anchor: string|?, block: string|? } - local opts = {} - ---@type obsidian.note.LoadOpts - local load_opts = {} - - if ref_type == RefTypes.BlockID then - opts.block = location - else - load_opts.collect_anchor_links = true - end - - local note = client:current_note(0, load_opts) - - -- Check if cursor is on a header, if so, use that anchor. - local header_match = util.parse_header(vim.api.nvim_get_current_line()) - if header_match then - opts.anchor = header_match.anchor - end - - if note == nil then - log.err "Current buffer does not appear to be a note inside the vault" - else - collect_backlinks(client, picker, note, opts) - end + vim.lsp.buf.document_symbol { loclist = false } end end diff --git a/lua/obsidian/init.lua b/lua/obsidian/init.lua index 669ea56f..399cb023 100644 --- a/lua/obsidian/init.lua +++ b/lua/obsidian/init.lua @@ -144,11 +144,10 @@ obsidian.setup = function(opts) vim.keymap.set("n", mapping_keys, mapping_config.action, mapping_config.opts) 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() + local client_id = client:lsp_start(ev.buf) + + if not (pcall(require, "blink.cmp") or pcall(require, "cmp")) then + vim.lsp.completion.enable(true, client_id, ev.buf, { autotrigger = true }) end local win = vim.api.nvim_get_current_win() @@ -164,7 +163,7 @@ obsidian.setup = function(opts) -- Run enter-note callback. client.callback_manager:enter_note(function() - return obsidian.Note.from_buffer(ev.bufnr) + return obsidian.Note.from_buffer(ev.buf) end) end, }) diff --git a/lua/obsidian/lsp/config.lua b/lua/obsidian/lsp/config.lua new file mode 100644 index 00000000..6c014e2d --- /dev/null +++ b/lua/obsidian/lsp/config.lua @@ -0,0 +1,40 @@ +-- TODO:eventaully move to config + +local defualt = { + actions = {}, + complete = true, + checkboxs = { + ---@type "- [ ] " | "* [ ] " | "+ [ ] " | "1. [ ] " | "1) [ ] " + style = "- [ ] ", + }, + preview = { + tag = function(tag_locs, params) + return ([[Tag used in %d notes]]):format(#tag_locs) + end, + note = function(notes, params) + for i, note in ipairs(notes) do + if vim.uri_from_fname(note.path.filename) == params.textDocument.uri then + table.remove(notes, i) + end + end + local note = notes[1] + return note.path:read() + end, + }, + -- option to only show first few links on hover, and completion doc +} + +local cmds = require "obsidian.commands" + +-- TODO: make context aware +for _, cmd in ipairs(vim.tbl_keys(cmds.commands)) do + defualt.actions[cmd] = { + title = cmd, + command = cmd, + fn = function() + vim.cmd.Obsidian(cmd) + end, + } +end + +return defualt diff --git a/lua/obsidian/lsp/diagnostic.lua b/lua/obsidian/lsp/diagnostic.lua new file mode 100644 index 00000000..adef7795 --- /dev/null +++ b/lua/obsidian/lsp/diagnostic.lua @@ -0,0 +1,32 @@ +local util = require "obsidian.lsp.util" + +local ns = vim.api.nvim_create_namespace "obsidian-ls.diagnostics" + +-- IDEAD: inject markdownlint like none-ls +-- https://github.com/nvimtools/none-ls.nvim/blob/main/lua/null-ls/builtins/diagnostics/markdownlint.lua +-- https://github.com/nvimtools/none-ls.nvim/blob/main/lua/null-ls/builtins/diagnostics/markdownlint_cli2.lua + +return function(client, params) + local uri = params.textDocument.uri + local buf = vim.uri_to_bufnr(uri) + local diagnostics = {} + + local client_id = assert(vim.lsp.get_clients({ name = "obsidian-ls" })[1]) + + local links = util.get_links(client, buf) + + for _, link in ipairs(links) do + if link.target == "error" then + table.insert(diagnostics, { + lnum = link.range.start.line, + col = link.range.start.character, + severity = vim.lsp.protocol.DiagnosticSeverity.Warning, + message = "This is an error", + source = "obsidian-ls", + code = "ERROR", + }) + end + end + + vim.diagnostic.set(ns, buf, diagnostics, { client_id = client_id }) +end diff --git a/lua/obsidian/lsp/handlers.lua b/lua/obsidian/lsp/handlers.lua new file mode 100644 index 00000000..d4893ba9 --- /dev/null +++ b/lua/obsidian/lsp/handlers.lua @@ -0,0 +1,23 @@ +local ms = vim.lsp.protocol.Methods + +return setmetatable({ + [ms.initialize] = require "obsidian.lsp.handlers.initialize", + [ms.textDocument_completion] = require "obsidian.lsp.handlers.completion", + [ms.completionItem_resolve] = require "obsidian.lsp.handlers.completion_resolve", + [ms.textDocument_hover] = require "obsidian.lsp.handlers.hover", + [ms.workspace_diagnostic] = require "obsidian.lsp.handlers.workplace_diagnostics", + [ms.textDocument_rename] = require "obsidian.lsp.handlers.rename", + [ms.textDocument_references] = require "obsidian.lsp.handlers.references", + [ms.textDocument_documentSymbol] = require "obsidian.lsp.handlers.document_symbol", + [ms.textDocument_documentLink] = require "obsidian.lsp.handlers.document_link", + [ms.textDocument_didOpen] = require "obsidian.lsp.handlers.did_open", + [ms.textDocument_didChange] = require "obsidian.lsp.handlers.did_change", + [ms.initialized] = require "obsidian.lsp.handlers.initialized", + [ms.workspace_executeCommand] = require "obsidian.lsp.handlers.execute_command", + [ms.textDocument_codeAction] = require "obsidian.lsp.handlers.code_action", +}, { + __index = function(_, k) + vim.notify("obsidian_ls does not support method " .. k .. " yet", 3) + return function() end + end, +}) diff --git a/lua/obsidian/lsp/handlers/completion.lua b/lua/obsidian/lsp/handlers/completion.lua new file mode 100644 index 00000000..77c7559f --- /dev/null +++ b/lua/obsidian/lsp/handlers/completion.lua @@ -0,0 +1,298 @@ +-- TODO: completion for anchor, blocks +-- TODO: memoize? + +local util = require "obsidian.util" +local find, sub, lower = string.find, string.sub, string.lower +local ref_trigger_pattern = { + wiki = "[[", + markdown = "[", +} + +-- TODO: remove +local state = { + ---@type obsidian.Note + current_note = nil, +} + +-- TODO: +local function insert_snippet_marker(text, style) + if style == "markdown" then + local pos = text:find "]" + local a, b = sub(text, 1, pos - 1), sub(text, pos) + return a .. "$1" .. b + end +end + +---Collect matching anchor links. +---@param note obsidian.Note +---@param anchor_link string? +---@return obsidian.note.HeaderAnchor[]? +local function collect_matching_anchors(note, anchor_link) + ---@type obsidian.note.HeaderAnchor[]|? + local matching_anchors + if anchor_link then + assert(note.anchor_links) + matching_anchors = {} + for anchor, anchor_data in pairs(note.anchor_links) do + if vim.startswith(anchor, anchor_link) then + table.insert(matching_anchors, anchor_data) + end + end + + if #matching_anchors == 0 then + -- Unmatched, create a mock one. + table.insert(matching_anchors, { anchor = anchor_link, header = string.sub(anchor_link, 2), level = 1, line = 1 }) + end + end + + return matching_anchors +end + +-- A more generic pure function, don't require label to exist +local function format_link(label, format_func) + local path = util.urlencode(label) .. ".md" + local opts = { label = label, path = path } + return format_func(opts) +end + +---@param label string +---@param path string +---@param new_text string +---@param range lsp.Range +---@return lsp.CompletionItem +local function gen_ref_item(label, path, new_text, range, style, is_snippet) + return { + kind = 17, + label = label, + filterText = label, + insertTextFormat = 2, -- is snippet TODO: extract to config option + textEdit = { + range = range, + newText = new_text, + -- insert_snippet_marker(new_text, style), + }, + labelDetails = { description = "Obsidian" }, + data = { + file = path, + kind = "ref", + }, + } +end + +---@param label string +---@param range lsp.Range +---@param format_func function +---@return lsp.CompletionItem +local function gen_create_item(label, range, format_func) + return { + kind = 17, + label = label .. " (create)", + filterText = label, + textEdit = { + range = range, + newText = format_link(label, format_func), + }, + labelDetails = { description = "Obsidian" }, + command = { -- runs after accept + command = "create_note", + arguments = { label }, + }, + data = { + kind = "ref_create", -- TODO: resolve to a tooltip window + }, + } +end + +local handle_bare_links = function(client, partial, range, handler, format_func) + local pattern = vim.pesc(lower(partial)) + + local function match(title) + return title and find(lower(title), pattern) + end + + local style = client.opts.preferred_link_style + + client:find_notes_async(partial, function(notes) + local items = {} + for _, note in ipairs(notes or {}) do + local title = note.title + if match(title) then + local link_text = client:format_link(note) + items[#items + 1] = gen_ref_item(note.title, note.path.filename, link_text, range, style) + end + end + items[#items + 1] = gen_create_item(partial, range, format_func) + handler(nil, { items = items }) + end) +end + +---@param client obsidian.Client +local function handle_ref(client, partial, ref_start, cursor_col, line_num, handler) + ---@type string|? + local anchor_link + partial, anchor_link = util.strip_anchor_links(partial) + local style = client.opts.preferred_link_style + + local range = { + start = { line = line_num, character = ref_start }, + ["end"] = { line = line_num, character = cursor_col }, -- if auto parired + } + + local format_func + if style == "markdown" then + format_func = client.opts.markdown_link_func + else + format_func = client.opts.wiki_link_func + end + + if not anchor_link then + handle_bare_links(client, partial, range, handler, format_func) + else + local Note = require "obsidian.note" + -- state.current_note = state.current_note or client:find_notes(partial)[2] + -- TODO: calc current_note once + -- TODO: handle two cases: + -- 1. typing partial note name, no completeed text after cursor, insert the full link + -- 2. jumped to heading, only insert anchor + -- TODO: need to do more textEdit to insert additional #title to path so that app supports? + local items = {} + client:find_notes_async( + partial, + vim.schedule_wrap(function(notes) + for _, note in ipairs(notes) do + local title = note.title + local pattern = vim.pesc(lower(partial)) + if title and find(lower(title), pattern) then + local note2 = Note.from_file(note.path.filename, { collect_anchor_links = true }) + + local note_anchors = collect_matching_anchors(note2, anchor_link) + if not note_anchors then + return + end + for _, anchor in ipairs(note_anchors) do + items[#items + 1] = { + kind = 17, + label = anchor.header, + filterText = anchor.header, + insertText = anchor.header, + -- insertTextFormat = 2, -- is snippet + -- textEdit = { + -- range = { + -- start = { line = line_num, character = insert_start }, + -- ["end"] = { line = line_num, character = insert_end }, + -- }, + -- newText = insert_snippet_marker(insert_text, style), + -- }, + labelDetails = { description = "ObsidianAnchor" }, + data = { + file = note.path.filename, + kind = "anchor", + }, + } + end + end + handler(nil, { items = items }) + end + end) + ) + end +end + +local function calc_tag_item(tag) + return { + kind = "File", + label = tag, + filterText = tag, + insertText = tag, + labelDetails = { description = "ObsidianTag" }, + data = { kind = "tag" }, + } +end + +local function handle_tag(client, partial, handler) + local items = {} + client:list_tags_async( + partial, + vim.schedule_wrap(function(tags) + for _, tag in ipairs(tags) do + if tag and tag:lower():find(vim.pesc(partial:lower())) then + items[#items + 1] = calc_tag_item(tag) + end + end + handler(nil, { items = items }) + end) + ) +end + +local function handle_heading(client) + -- TODO: client:find_headings_async + -- client:find_ +end + +-- util.BLOCK_PATTERN = "%^[%w%d][%w%d-]*" +local anchor_trigger_pattern = { + markdown = "%[%S+#(%w*)", +} + +local heading_trigger_pattern = "[##" + +local CmpType = { + ref = 1, + tag = 2, + anchor = 3, +} + +---@param text string +---@param style obsidian.config.LinkStyle +---@param min_char integer +---@return integer? +---@return string? +---@return integer? +local function get_type(text, style, min_char) + local ref_start = find(text, ref_trigger_pattern[style], 1, true) + local tag_start = find(text, "#", 1, true) + -- local heading_start = find(text, heading_trigger_pattern, 1, true) + + if ref_start then + local partial = sub(text, ref_start + #ref_trigger_pattern[style]) + if #partial >= min_char then + return CmpType.ref, partial, ref_start + end + elseif tag_start then + local partial = sub(text, tag_start + 1) + if #partial >= min_char then + return CmpType.tag, partial, tag_start + end + -- elseif heading_start then + -- local partial = sub(text, heading_start + #heading_trigger_pattern) + -- if #partial >= min_char then + -- return CmpType.anchor, partial, heading_start + -- end + end +end + +---@param client obsidian.Client +---@param params table +---@param handler function +return function(client, params, handler, _) + local link_style = client.opts.preferred_link_style + local min_chars = client.opts.completion.min_chars + + local uri = params.textDocument.uri + local line_num = params.position.line + local char_num = params.position.character + + local buf = vim.uri_to_bufnr(uri) + local line_text = vim.api.nvim_buf_get_lines(buf, line_num, line_num + 1, false)[1] + local text_before = sub(line_text, 1, char_num) + local t, partial, start = get_type(text_before, link_style, min_chars) + + if t == CmpType.ref then + handle_ref(client, partial, start - 1, char_num, line_num, handler) + elseif t == CmpType.tag then + handle_tag(client, partial, handler) + elseif t == CmpType.anchor then + else + return + end +end diff --git a/lua/obsidian/lsp/handlers/completion_resolve.lua b/lua/obsidian/lsp/handlers/completion_resolve.lua new file mode 100644 index 00000000..b48f22b6 --- /dev/null +++ b/lua/obsidian/lsp/handlers/completion_resolve.lua @@ -0,0 +1,28 @@ +local util = require "obsidian.lsp.util" + +---@param client obsidian.Client +---@param params table +---@param handler function +return function(client, params, handler, _) + local kind = params.data.kind + + if kind == "ref" then + local content = util.read_file(params.data.file) + if content then + params.documentation = { + value = content, + kind = "markdown", + } + handler(nil, params) + else + vim.notify("No notes found", 3) + end + elseif kind == "tag" then + local content = util.preview_tag_sync(client, params, params.label) + params.documentation = { + value = content, + kind = "markdown", + } + handler(nil, params) + end +end diff --git a/lua/obsidian/lsp/handlers/did_change.lua b/lua/obsidian/lsp/handlers/did_change.lua new file mode 100644 index 00000000..bdb600d0 --- /dev/null +++ b/lua/obsidian/lsp/handlers/did_change.lua @@ -0,0 +1,8 @@ +local diagnostic = require "obsidian.lsp.diagnostic" + +---@param client obsidian.Client +---@param params table +---@param handler function +return function(client, params, handler, _) + diagnostic(client, params) +end diff --git a/lua/obsidian/lsp/handlers/did_open.lua b/lua/obsidian/lsp/handlers/did_open.lua new file mode 100644 index 00000000..bdb600d0 --- /dev/null +++ b/lua/obsidian/lsp/handlers/did_open.lua @@ -0,0 +1,8 @@ +local diagnostic = require "obsidian.lsp.diagnostic" + +---@param client obsidian.Client +---@param params table +---@param handler function +return function(client, params, handler, _) + diagnostic(client, params) +end diff --git a/lua/obsidian/lsp/handlers/document_link.lua b/lua/obsidian/lsp/handlers/document_link.lua new file mode 100644 index 00000000..5c6a9e8d --- /dev/null +++ b/lua/obsidian/lsp/handlers/document_link.lua @@ -0,0 +1,10 @@ +local util = require "obsidian.lsp.util" + +---@param client obsidian.Client +---@param params table +---@param handler function +return function(client, params, handler, _) + local bufnr = vim.uri_to_bufnr(params.textDocument.uri) + local links = util.get_links(client, bufnr) + handler(nil, links) +end diff --git a/lua/obsidian/lsp/handlers/document_symbol.lua b/lua/obsidian/lsp/handlers/document_symbol.lua new file mode 100644 index 00000000..37484216 --- /dev/null +++ b/lua/obsidian/lsp/handlers/document_symbol.lua @@ -0,0 +1,10 @@ +local util = require "obsidian.lsp.util" + +---@param client obsidian.Client +---@param params table +---@param handler function +return function(client, params, handler) + local bufnr = vim.uri_to_bufnr(params.textDocument.uri) + local symbols = util.get_headings(bufnr) + handler(nil, symbols) +end diff --git a/lua/obsidian/lsp/handlers/execute_command.lua b/lua/obsidian/lsp/handlers/execute_command.lua new file mode 100644 index 00000000..728e9ac2 --- /dev/null +++ b/lua/obsidian/lsp/handlers/execute_command.lua @@ -0,0 +1,12 @@ +local Config = require "obsidian.lsp.config" + +---@param client obsidian.Client +---@param params table +return function(client, params) + local cmd = params.command + + Config.actions[cmd].fn() + + -- return require("obsidian.lsp.handlers.commands." .. cmd)(client, params) + -- return require "obsidian.lsp.handlers.commands.createNote"(client, params) +end diff --git a/lua/obsidian/lsp/handlers/hover.lua b/lua/obsidian/lsp/handlers/hover.lua new file mode 100644 index 00000000..58f09be2 --- /dev/null +++ b/lua/obsidian/lsp/handlers/hover.lua @@ -0,0 +1,34 @@ +local util = require "obsidian.util" +local lsp_util = require "obsidian.lsp.util" + +--- TODO: tag hover should also work on frontmatter + +---@param client obsidian.Client +---@param params table +---@param handler function +return function(client, params, handler, _) + local cursor_ref = util.parse_cursor_link() -- TODO: use title to validate if note is right + local cursor_tag = util.cursor_tag() + if cursor_ref then + lsp_util.preview_ref(client, params, cursor_ref, function(content) + if content then + handler(nil, { + contents = content, + }) + vim.notify("No note found", 3) + end + end) + elseif cursor_tag then + lsp_util.preview_tag(client, params, cursor_tag, function(content) + if content then + handler(nil, { + contents = content, + }) + else + vim.notify("No tag found", 3) + end + end) + else + vim.notify("No note or tag found", 3) + end +end diff --git a/lua/obsidian/lsp/handlers/initialize.lua b/lua/obsidian/lsp/handlers/initialize.lua new file mode 100644 index 00000000..b80badcd --- /dev/null +++ b/lua/obsidian/lsp/handlers/initialize.lua @@ -0,0 +1,65 @@ +local Config = require "obsidian.lsp.config" + +local completion_options + +-- TODO: what about non-english chars, or emojis? +local chars = {} +for i = 32, 126 do + table.insert(chars, string.char(i)) +end + +if Config.complete then + completion_options = { + triggerCharacters = chars, + resolveProvider = true, + completionItem = { + labelDetailsSupport = true, + }, + } +else + completion_options = false +end + +local initializeResult = { + capabilities = { + codeActionProvider = true, + hoverProvider = true, + definitionProvider = true, + implementationProvider = true, + declarationProvider = true, + documentLinkProvider = true, + -- TODO: Add diagnostic support + diagnosticProvider = { + interFileDependencies = false, + workspaceDiagnostics = true, + }, + typeDefinitionProvider = true, + renameProvider = true, + referencesProvider = true, + documentSymbolProvider = true, + executeCommandProvider = { + commands = { + "toggleCheckbox", + "createNote", + }, + }, + completionProvider = completion_options, + textDocumentSync = { + openClose = true, + change = 1, + }, + }, + serverInfo = { + name = "obsidian-ls", + version = "1.0.0", + }, +} + +---@param client obsidian.Client +---@param params table +---@param handler function +return function(client, params, handler, _) + vim.list_extend(initializeResult.capabilities.executeCommandProvider.commands, vim.tbl_keys(Config.actions)) + + return handler(nil, initializeResult, params.context) +end diff --git a/lua/obsidian/lsp/handlers/initialized.lua b/lua/obsidian/lsp/handlers/initialized.lua new file mode 100644 index 00000000..2c94bec2 --- /dev/null +++ b/lua/obsidian/lsp/handlers/initialized.lua @@ -0,0 +1,6 @@ +---@param obsidian_client obsidian.Client +---@param params table +---@param handler function +return function(obsidian_client, params, handler, _) + return +end diff --git a/lua/obsidian/lsp/handlers/references.lua b/lua/obsidian/lsp/handlers/references.lua new file mode 100644 index 00000000..fb4f0cbc --- /dev/null +++ b/lua/obsidian/lsp/handlers/references.lua @@ -0,0 +1,82 @@ +---@diagnostic disable: missing-fields + +local util = require "obsidian.util" +local Note = require "obsidian.note" + +-- TODO: references for anchor, blocks + +---@param obsidian_client obsidian.Client +---@param _ table +---@param handler function +return function(obsidian_client, _, handler, _) + local tag = util.cursor_tag(nil, nil) + if tag then + obsidian_client:find_tags_async(tag, function(tag_locations) + local entries = {} + for _, tag_loc in ipairs(tag_locations) do + if tag_loc.tag == tag or vim.startswith(tag_loc.tag, tag .. "/") then + local line = tag_loc.line - 1 -- lsp wants zero-indexed + local tag_start = (tag_loc.tag_start or 1) - 1 + entries[#entries + 1] = { + uri = vim.uri_from_fname(tostring(tag_loc.path)), + range = { + start = { line = line, character = tag_start }, + ["end"] = { line = line, character = tag_start }, + }, + } + end + end + if vim.tbl_isempty(entries) then + vim.notify("Tag not found", 3) + return + end + handler(nil, entries) + end, { search = { sort = true } }) + end + + local buf = vim.api.nvim_get_current_buf() + + local note = Note.from_buffer(buf) + + obsidian_client:find_backlinks_async( + note, + vim.schedule_wrap(function(backlinks) + -- if vim.tbl_isempty(backlinks) then + -- if opts.anchor then + -- log.info("No backlinks found for anchor '%s' in note '%s'", opts.anchor, note.id) + -- elseif opts.block then + -- log.info("No backlinks found for block '%s' in note '%s'", opts.block, note.id) + -- else + -- log.info("No backlinks found for note '%s'", note.id) + -- end + -- return + -- end + -- + + local entries = {} + for _, matches in ipairs(backlinks) do + for _, match in ipairs(matches.matches) do + entries[#entries + 1] = { + uri = vim.uri_from_fname(tostring(matches.path)), + range = { + start = { line = match.line, character = 1 }, + ["end"] = { line = match.line, character = 1 }, + }, + } + end + end + + handler(nil, entries) + + -- ---@type string + -- local prompt_title + -- if opts.anchor then + -- prompt_title = string.format("Backlinks to '%s%s'", note.id, opts.anchor) + -- elseif opts.block then + -- prompt_title = string.format("Backlinks to '%s#%s'", note.id, util.standardize_block(opts.block)) + -- else + -- prompt_title = string.format("Backlinks to '%s'", note.id) + -- end + end) + ) +end diff --git a/lua/obsidian/lsp/handlers/rename.lua b/lua/obsidian/lsp/handlers/rename.lua new file mode 100644 index 00000000..2d4b75fe --- /dev/null +++ b/lua/obsidian/lsp/handlers/rename.lua @@ -0,0 +1,122 @@ +local lsp = vim.lsp +local Path = require "obsidian.path" +local Note = require "obsidian.note" +local search = require "obsidian.search" + +---@param old_uri string +---@param new_uri string +local function rename_file(old_uri, new_uri) + ---@type lsp.WorkspaceEdit + local edit = { + documentChanges = { + { + kind = "rename", + oldUri = old_uri, + newUri = new_uri, + }, + }, + } + + lsp.util.apply_workspace_edit(edit, "utf-8") +end + +-- Search notes on disk for any references to `cur_note_id`. +-- We look for the following forms of references: +-- * '[[cur_note_id]]' +-- * '[[cur_note_id|ALIAS]]' +-- * '[[cur_note_id\|ALIAS]]' (a wiki link within a table) +-- * '[ALIAS](cur_note_id)' +-- And all of the above with relative paths (from the vault root) to the note instead of just the note ID, +-- with and without the ".md" suffix. +-- Another possible form is [[ALIAS]], but we don't change the note's aliases when renaming +-- so those links will still be valid. +local ref_patterns = { + "[[%s]]", -- wiki + "[[%s|", -- wiki with alias + "[[%s\\|", -- wiki link within a table + "[[%s#", -- wiki with heading + "](%s)", -- markdown + "](%s#", -- markdown with heading +} + +---@param client obsidian.Client +---@param params table +local function rename_current_note(client, params) + local new_note_id = params.newName + local uri = params.textDocument.uri + local current_file = vim.uri_to_fname(uri) + local dirname = vim.fs.dirname(current_file) + + local new_path = vim.fs.joinpath(dirname, new_note_id) .. ".md" + local new_note_path = Path.new(new_path) + + local cur_note_bufnr = vim.uri_to_bufnr(uri) + local cur_note_path = Path.buffer(cur_note_bufnr) + local cur_note = Note.from_file(cur_note_path) + local cur_note_id = tostring(cur_note.id) + + local cur_note_rel_path = tostring(client:vault_relative_path(cur_note_path, { strict = true })) + local new_note_rel_path = tostring(client:vault_relative_path(new_note_path, { strict = true })) + + local replace_lookup = {} + + for _, pat in ipairs(ref_patterns) do + replace_lookup[pat:format(cur_note_id)] = pat:format(new_note_id) + replace_lookup[pat:format(cur_note_rel_path)] = pat:format(new_note_rel_path) + replace_lookup[pat:format(cur_note_rel_path:sub(1, -4))] = pat:format(new_note_rel_path:sub(1, -4)) + end + + local reference_forms = vim.tbl_keys(replace_lookup) + + search.search_async( + client.dir, + reference_forms, + search.SearchOpts.from_tbl { fixed_strings = true, max_count_per_file = 1 }, + vim.schedule_wrap(function(match) + local file = match.path.text + local line = match.line_number - 1 + local start, _end = match.submatches[1].start, match.submatches[1]["end"] + local matched = match.submatches[1].match.text + local edit = { + documentChanges = { + { + textDocument = { + uri = vim.uri_from_fname(file), + }, + edits = { + { + range = { + start = { line = line, character = start }, + ["end"] = { line = line, character = _end }, + }, + newText = replace_lookup[matched], + }, + }, + }, + }, + } + lsp.util.apply_workspace_edit(edit, "utf-8") + end), + function(_) + -- TODO: conclude the rename + -- all_tasks_submitted = true + end + ) + rename_file(uri, vim.uri_from_fname(new_path)) + + local note = client:current_note() + note.id = new_note_id +end + +local function rename_note_at_cursor(params) end + +---@param client obsidian.Client +---@param params table +return function(client, params, _, _) + local position = params.position + + -- TODO: check if cursor on link + rename_current_note(client, params) + + -- require "obsidian.commands.rename"(obsidian_client, { args = params.newName }) +end diff --git a/lua/obsidian/lsp/handlers/workplace_diagnostics.lua b/lua/obsidian/lsp/handlers/workplace_diagnostics.lua new file mode 100644 index 00000000..7de4a52b --- /dev/null +++ b/lua/obsidian/lsp/handlers/workplace_diagnostics.lua @@ -0,0 +1,7 @@ +---@param obsidian_client obsidian.Client +---@param params table +---@param handler function +return function(obsidian_client, params, handler, _) + print "diagnosticing workspace" + return +end diff --git a/lua/obsidian/lsp/util.lua b/lua/obsidian/lsp/util.lua new file mode 100644 index 00000000..d25777a9 --- /dev/null +++ b/lua/obsidian/lsp/util.lua @@ -0,0 +1,187 @@ +local M = {} +local ts = vim.treesitter +local server_config = require "obsidian.lsp.config" + +M.preview_tag = function(client, _, term, cb) + client:find_tags_async( + term, + vim.schedule_wrap(function(tag_locs) + cb(server_config.preview.tag(tag_locs, _)) + end) + ) +end + +M.preview_tag_sync = function(client, _, term) + local tag_locs = client:find_tags(term, { timeout = 4000 }) + + return server_config.preview.tag(tag_locs, _) +end + +-- TODO: if term is file path then just read the file +M.preview_ref = function(client, params, term, cb) + client:find_notes_async( + term, + vim.schedule_wrap(function(notes) + cb(server_config.preview.note(notes, params)) + end) + ) +end + +local heading_query = [[ + (setext_heading + heading_content: (_) @h1 + (setext_h1_underline)) + (setext_heading + heading_content: (_) @h2 + (setext_h2_underline)) + (atx_heading + (atx_h1_marker) + heading_content: (_) @h1) + (atx_heading + (atx_h2_marker) + heading_content: (_) @h2) + (atx_heading + (atx_h3_marker) + heading_content: (_) @h3) + (atx_heading + (atx_h4_marker) + heading_content: (_) @h4) + (atx_heading + (atx_h5_marker) + heading_content: (_) @h5) + (atx_heading + (atx_h6_marker) + heading_content: (_) @h6) + ]] + +local link_query = [[ +( + (inline_link + (link_text) @text + (link_destination) @url + ) @link + ) +( + (reference_link + (link_text) @text + (link_reference) @url + ) @link + ) +]] + +---Extract headings from buffer +---@param bufnr integer +---@return lsp.DocumentSymbol[] +M.get_headings = function(bufnr) + local lang = ts.language.get_lang(vim.bo[bufnr].filetype) + if not lang then + return {} + end + local parser = assert(ts.get_parser(bufnr, lang, { error = false })) + local query = ts.query.parse(lang, heading_query) + local root = parser:parse()[1]:root() + local headings = {} + for id, node, _, _ in query:iter_captures(root, bufnr) do + local text = string.rep("#", id) .. " " .. ts.get_node_text(node, bufnr) + local row, _ = node:start() + headings[#headings + 1] = { + kind = 15, + range = { + start = { line = row, character = 1 }, + ["end"] = { line = row, character = 1 }, + }, + selectionRange = { + start = { line = row, character = 1 }, + ["end"] = { line = row, character = 1 }, + }, + name = text, + } + end + return headings +end + +---@param client obsidian.Client +---@param bufnr integer +---@param lsp.DocumentLink[] +M.get_links = function(client, bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + local links = {} + local lang = ts.language.get_lang(vim.bo[bufnr].filetype) + + -- Tree-sitter part for Markdown links + if lang then + local parser = ts.get_parser(bufnr, lang) + if parser then + local query_success, query = pcall(ts.query.parse, lang, link_query) + + if query_success and query then + local root = parser:parse()[1]:root() + for _, match, _ in query:iter_matches(root, bufnr) do + local link_info = {} + for id, node in pairs(match) do + local capture_name = query.captures[id] + if capture_name == "text" then + link_info.text = ts.get_node_text(node, bufnr) + elseif capture_name == "url" then + link_info.url = ts.get_node_text(node, bufnr) + elseif capture_name == "link" then + local start_row, start_col = node:start() + link_info.line = start_row + link_info.column = start_col + end + end + if link_info.text and link_info.url then + table.insert(links, { + text = link_info.text, + url = link_info.url, + line = link_info.line, + column = link_info.column, + type = "markdown", + }) + end + end + end + end + end + + -- Regex part for wiki-style links [[...]] + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for line_idx, line in ipairs(lines) do + local col = 0 + while true do + local start, end_ = line:find("%[%[.-%]%]", col) + if not start then + break + end + + local content = line:sub(start + 2, end_ - 2) + local target, text = content:match "^([^|]*)%|?(.*)$" + text = text ~= "" and text or target + + table.insert(links, { + kind = 15, + target = target, -- TODO: resolve to path + range = { + start = { line = line_idx - 1, character = 1 }, + ["end"] = { line = line_idx - 1, character = 1 }, + }, + }) + + col = end_ + 1 + end + end + + return links +end + +-- TODO: make async in the future? + +function M.read_file(file) + local fd = assert(io.open(file, "r")) + ---@type string + local data = fd:read "*a" + fd:close() + return data +end + +return M diff --git a/lua/obsidian/path.lua b/lua/obsidian/path.lua index 92c526e6..c3c0d0ac 100644 --- a/lua/obsidian/path.lua +++ b/lua/obsidian/path.lua @@ -601,4 +601,14 @@ Path.unlink = function(self, opts) end end +--- Read the file +Path.read = function(self, _) + local file = tostring(self) + local fd = assert(io.open(file, "r")) + ---@type string + local data = fd:read "*a" + fd:close() + return data +end + return Path