Skip to content

Builtin Obsidian LSP #52

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8f9cf5d
feat: obsidian_ls
neo451 Apr 11, 2025
7f29c66
feat: tags completion, better hover link recognition
neo451 Apr 12, 2025
9d3f924
initial references impl for tags and backlinks
neo451 Apr 12, 2025
d250ac7
pass the same client instance to all handlers
neo451 Apr 12, 2025
e85aaca
add workplace symbols as TOC and add handler signature
neo451 Apr 15, 2025
4a63a5a
use client:format_link to format completions
neo451 Apr 15, 2025
2eece7c
fix native completion for -, impl for documentLink
neo451 Apr 15, 2025
8215b18
completion supporting min_chars, and resolve, basic diagnostic
neo451 Apr 15, 2025
197e09d
add readfile util
neo451 Apr 15, 2025
5079a47
hover on tags, custom preview callback
neo451 Apr 16, 2025
a4ed94d
feat: implement toggle_checkbox as a server command
neo451 Apr 19, 2025
35bfbfd
fix: preview tags in completion
neo451 Apr 19, 2025
8ddaa58
fix: completion use TextEdits to be more accurate
neo451 Apr 19, 2025
f492596
feat: better interact with pickers
neo451 Apr 20, 2025
86e6a06
feat: use snippet marker to jump to anchor/block pos on accept
neo451 Apr 21, 2025
c1c4ebb
feat: initial try to do anchor links
neo451 Apr 22, 2025
e0944a5
feat: create note item
neo451 Apr 26, 2025
ed038fe
fix: start method takes a buf
neo451 May 2, 2025
e8ae8a5
feat: initial try at implementing rename
neo451 May 6, 2025
bbc3895
fix: cleaned up rename logic, ref replace working
neo451 May 6, 2025
fd4730a
feat: code action for all sub-commands
neo451 May 9, 2025
763dca7
fix: cleaning completion logic
neo451 May 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions lua/obsidian/client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions lua/obsidian/commands/__toc.lua
Original file line number Diff line number Diff line change
@@ -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
153 changes: 32 additions & 121 deletions lua/obsidian/commands/backlinks.lua
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
11 changes: 5 additions & 6 deletions lua/obsidian/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
})
Expand Down
40 changes: 40 additions & 0 deletions lua/obsidian/lsp/config.lua
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions lua/obsidian/lsp/diagnostic.lua
Original file line number Diff line number Diff line change
@@ -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
Loading