Skip to content
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

feat: auto complete links #1295

Merged
merged 2 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
309 changes: 291 additions & 18 deletions lua/neorg/modules/core/completion/module.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ This module is an intermediary between Neorg and the completion engine of your c
module (this usually just involves setting the `engine` field in the [configuration](#configuration) section),
please read the corresponding wiki page for the engine you selected ([`nvim-cmp`](@core.integrations.nvim-cmp)
or [`nvim-compe`](@core.integrations.nvim-compe)) to complete setup.

Completions are provided in the following cases (examples in (), `|` represents the cursor location):
- TODO items (`- (|`)
- @ tags (`@|`)
- # tags (`#|`)
- file path links (`{:|`) provides workspace relative paths (`:$/workspace/relative/path:`)
- header links (`{*|`)
- fuzzy header links (`{#|`)
- footnotes (`{^|`)
- file path + header links (`{:path:*|`)
- file path + fuzzy header links (`{:path:#|`)
- file path + footnotes (`{:path:^|`)

Header completions will show only valid headers at the current level in the current or specified file. All
link completions are smart about closing `:` and `}`.
--]]

local neorg = require("neorg.core")
Expand All @@ -28,11 +43,192 @@ module.config.public = {
}

module.setup = function()
return { success = true, requires = { "core.integrations.treesitter" } }
return { success = true, requires = { "core.dirman", "core.integrations.treesitter" } }
end

module.private = {
engine = nil,

--- Get a list of all norg files in current workspace. Returns { workspace_path, norg_files }
--- @return table?
get_norg_files = function()
local dirman = neorg.modules.get_module("core.dirman")
if not dirman then
return nil
end

local current_workspace = dirman.get_current_workspace()
local norg_files = dirman.get_norg_files(current_workspace[1])
return { current_workspace[2], norg_files }
end,

--- Get the closing characters for a link completion
--- @param context table
--- @param colon boolean should there be a closing colon?
--- @return string "", ":", or ":}" depending on what's needed
get_closing_chars = function(context, colon)
local offset = 1
local closing_colon = ""
if colon then
closing_colon = ":"
if string.sub(context.full_line, context.char + offset, context.char + offset) == ":" then
closing_colon = ""
offset = 2
end
end

local closing_brace = "}"
if string.sub(context.full_line, context.char + offset, context.char + offset) == "}" then
closing_brace = ""
end

return closing_colon .. closing_brace
end,

--- Get the lines in a given norg file path.
--- @param file string file path, norg syntax accepted
--- @return table<string>
get_lines = function(file)
local dirutils = neorg.modules.get_module("core.dirman.utils")
if not dirutils then
return {}
end
local expanded = dirutils.expand_path(file, true)

local lines
if expanded then
if not string.match(expanded, "%.norg$") then
expanded = expanded .. ".norg"
end
local ok
ok, lines = pcall(vim.fn.readfile, expanded)
if not ok then
lines = {}
end
end
return lines
end,

--- Find linkable headers in the given file
--- @param file string file path, norg syntax is accepted
--- @param context table
--- @param heading_level number?
--- @return table<string>
find_headers = function(file, context, heading_level)
local leading_whitespace = " "
if context.before_char == " " then
leading_whitespace = ""
end

local closing_chars = module.private.get_closing_chars(context, false)
leading_whitespace = leading_whitespace or ""
local ret = {}

local lines = module.private.get_lines(file)
for _, line in ipairs(lines) do
local heading = { line:match("^%s*(%*+)%s+(.+)$") }
if not vim.tbl_isempty(heading) and (not heading_level or #heading[1] == heading_level) then
-- remove potential GTD status from link
local stripped_heading = string.gsub(heading[2], "^%(.%)%s?", "")
table.insert(ret, leading_whitespace .. stripped_heading .. closing_chars)
end
-- local marker_or_drawer = { line:match("^%s*(%|%|?%s+(.+))$") }
-- if not vim.tbl_isempty(marker_or_drawer) then
-- -- TODO: how do you link to these things
-- -- what even are they?
-- table.insert(ret, marker_or_drawer[2])
-- end
end

return ret
end,

--- Find footers in the given file
--- @param file string file path, norg syntax is accepted
--- @return table<string>
find_footnotes = function(file, context)
local ret = {}
local leading_whitespace = " "
if context.before_char == " " then
leading_whitespace = ""
end

local closing_chars = module.private.get_closing_chars(context, false)
leading_whitespace = leading_whitespace or ""
local lines = module.private.get_lines(file)
for _, line in ipairs(lines) do
local footnote = { line:match("^%s*%^%^? (.+)$") }
if not vim.tbl_isempty(footnote) then
table.insert(ret, leading_whitespace .. footnote[1] .. closing_chars)
end
end

return ret
end,

generate_file_links = function(context, _prev, _saved, _match)
local res = {}
local dirman = neorg.modules.get_module("core.dirman")
if not dirman then
return {}
end

local files = module.private.get_norg_files()
if not files or not files[2] then
return {}
end

local closing_chars = module.private.get_closing_chars(context, true)
for _, file in pairs(files[2]) do
assert(type(file) == "string")
local bufnr = dirman.get_file_bufnr(file)

if vim.api.nvim_get_current_buf() ~= bufnr then
-- using -6 to go to the end (-1) and remove '.norg' 5 more chars
local link = "{:$" .. file:sub(#files[1] + 1, -6) .. closing_chars
table.insert(res, link)
end
end

return res
end,

generate_local_heading_links = function(context, _prev, _saved, match)
local heading_level = match[2] and #match[2]
return module.private.find_headers(vim.api.nvim_buf_get_name(0), context, heading_level)
end,

generate_foreign_heading_links = function(context, _prev, _saved, match)
local file = match[1]
local heading_level = match[2] and #match[2]
if file then
return module.private.find_headers(file, context, heading_level)
end
return {}
end,

generate_local_footnote_links = function(context, _prev, _saved, _match)
return module.private.find_footnotes(vim.api.nvim_buf_get_name(0), context)
end,

generate_foreign_footnote_links = function(context, _prev, _saved, match)
if match[2] then
return module.private.find_footnotes(match[2], context)
end
return {}
end,

--- The node context for normal norg (ie. not in a code block)
normal_norg = function(current, previous)
-- If no previous node exists then try verifying the current node instead
if not previous then
return current and (current:type() ~= "translation_unit" or current:type() == "document") or false
end

-- If the previous node is not tag parameters or the tag name
-- (i.e. we are not inside of a tag) then show auto completions
return previous:type() ~= "tag_parameters" and previous:type() ~= "tag_name"
end,
}

module.load = function()
Expand Down Expand Up @@ -70,21 +266,12 @@ module.public = {

-- Define completions
completions = {
{ -- Create a new completion
{ -- Create a new completion (for `@|tags`)
-- Define the regex that should match in order to proceed
regex = "^%s*@(%w*)",

-- If regex can be matched, this item then gets verified via TreeSitter's AST
node = function(current, previous)
-- If no previous node exists then try verifying the current node instead
if not previous then
return current and (current:type() ~= "translation_unit" or current:type() == "document") or false
end

-- If the previous node is not tag parameters or the tag name
-- (i.e. we are not inside of a tag) then show autocompletions
return previous:type() ~= "tag_parameters" and previous:type() ~= "tag_name"
end,
node = module.private.normal_norg,

-- The actual elements to show if the above tests were true
complete = {
Expand Down Expand Up @@ -185,7 +372,7 @@ module.public = {
},
},
},
{
{ -- `#|tags`
regex = "^%s*%#(%w*)",

complete = {
Expand All @@ -203,7 +390,7 @@ module.public = {

descend = {},
},
{
{ -- `@|end` tags
regex = "^%s*@e?n?",
node = function(_, previous)
if not previous then
Expand All @@ -222,7 +409,7 @@ module.public = {
completion_start = "@",
},
},
{
{ -- TODO items `- (|)`
regex = "^%s*%-+%s+%(([x%*%s]?)",

complete = {
Expand All @@ -249,6 +436,92 @@ module.public = {
completion_start = "-",
},
},
{ -- links for file paths `{:|`
regex = "^.*{:([^:}]*)",

node = module.private.normal_norg,

complete = module.private.generate_file_links,

options = {
type = "File",
completion_start = "{",
},
},
{ -- links that have a file path, suggest any heading from the file `{:...:#|}`
regex = "^.*{:(.*):#[^}]*",

complete = module.private.generate_foreign_heading_links,

node = module.private.normal_norg,

options = {
type = "Reference",
completion_start = "#",
},
},
{ -- links that have a file path, suggest direct headings from the file `{:...:*|}`
regex = "^.*{:(.*):(%*+)[^}]*",

complete = module.private.generate_foreign_heading_links,

node = module.private.normal_norg,

options = {
type = "Reference",
completion_start = "*",
},
},
{ -- # links to headings in the current file `{#|}`
regex = "^.*{#[^}]*",

complete = module.private.generate_local_heading_links,

node = module.private.normal_norg,

options = {
type = "Reference",
completion_start = "#",
},
},
{ -- * links to headings in current file `{*|}`
regex = "^(.*){(%*+)[^}]*",
-- the first capture group is a nothing group so that match[2] is reliably the heading
-- level or nil if there's no heading level.

complete = module.private.generate_local_heading_links,

node = module.private.normal_norg,

options = {
type = "Reference",
completion_start = "*",
},
},
{ -- ^ footnote links in the current file `{^|}`
regex = "^(.*){%^[^}]*",

complete = module.private.generate_local_footnote_links,

node = module.private.normal_norg,

options = {
type = "Reference",
completion_start = "^",
},
},
{ -- ^ footnote links in another file `{:path:^|}`
regex = "^(.*){:(.*):%^[^}]*",

complete = module.private.generate_foreign_footnote_links,

node = module.private.normal_norg,

options = {
type = "Reference",
completion_start = "^",
},
},
},

--- Parses the public completion table and attempts to find all valid matches
Expand All @@ -267,13 +540,13 @@ module.public = {
-- If the completion data has a regex variable
if completion_data.regex then
-- Attempt to match the current line before the cursor with that regex
local match = context.line:match(saved .. completion_data.regex .. "$")
local match = { context.line:match(saved .. completion_data.regex .. "$") }

-- If our match was successful
if match then
if not vim.tbl_isempty(match) then
-- Construct a variable that will be returned on a successful match
local items = type(completion_data.complete) == "table" and completion_data.complete
or completion_data.complete(context, prev, saved)
or completion_data.complete(context, prev, saved, match)
local ret_completions = { items = items, options = completion_data.options or {} }

-- Set the match variable for the integration module
Expand Down
4 changes: 3 additions & 1 deletion lua/neorg/modules/core/integrations/nvim-cmp/module.lua
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ module.public = {
Property = module.private.cmp.lsp.CompletionItemKind.Property,
Format = module.private.cmp.lsp.CompletionItemKind.Property,
Embed = module.private.cmp.lsp.CompletionItemKind.Property,
Reference = module.private.cmp.lsp.CompletionItemKind.Reference,
File = module.private.cmp.lsp.CompletionItemKind.File,
}

module.private.source.new = function()
Expand Down Expand Up @@ -85,7 +87,7 @@ module.public = {
end

function module.private.source:get_trigger_characters()
return { "@", "-", "(", " ", "." }
return { "@", "-", "(", " ", ".", ":", "#", "*", "^" }
end

module.private.cmp.register_source("neorg", module.private.source)
Expand Down
Loading