Skip to content

Commit bd12dac

Browse files
authored
feat: auto complete links (#1295)
closes #752
1 parent 7cf5382 commit bd12dac

File tree

2 files changed

+294
-19
lines changed

2 files changed

+294
-19
lines changed

lua/neorg/modules/core/completion/module.lua

+291-18
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ This module is an intermediary between Neorg and the completion engine of your c
88
module (this usually just involves setting the `engine` field in the [configuration](#configuration) section),
99
please read the corresponding wiki page for the engine you selected ([`nvim-cmp`](@core.integrations.nvim-cmp)
1010
or [`nvim-compe`](@core.integrations.nvim-compe)) to complete setup.
11+
12+
Completions are provided in the following cases (examples in (), `|` represents the cursor location):
13+
- TODO items (`- (|`)
14+
- @ tags (`@|`)
15+
- # tags (`#|`)
16+
- file path links (`{:|`) provides workspace relative paths (`:$/workspace/relative/path:`)
17+
- header links (`{*|`)
18+
- fuzzy header links (`{#|`)
19+
- footnotes (`{^|`)
20+
- file path + header links (`{:path:*|`)
21+
- file path + fuzzy header links (`{:path:#|`)
22+
- file path + footnotes (`{:path:^|`)
23+
24+
Header completions will show only valid headers at the current level in the current or specified file. All
25+
link completions are smart about closing `:` and `}`.
1126
--]]
1227

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

3045
module.setup = function()
31-
return { success = true, requires = { "core.integrations.treesitter" } }
46+
return { success = true, requires = { "core.dirman", "core.integrations.treesitter" } }
3247
end
3348

3449
module.private = {
3550
engine = nil,
51+
52+
--- Get a list of all norg files in current workspace. Returns { workspace_path, norg_files }
53+
--- @return table?
54+
get_norg_files = function()
55+
local dirman = neorg.modules.get_module("core.dirman")
56+
if not dirman then
57+
return nil
58+
end
59+
60+
local current_workspace = dirman.get_current_workspace()
61+
local norg_files = dirman.get_norg_files(current_workspace[1])
62+
return { current_workspace[2], norg_files }
63+
end,
64+
65+
--- Get the closing characters for a link completion
66+
--- @param context table
67+
--- @param colon boolean should there be a closing colon?
68+
--- @return string "", ":", or ":}" depending on what's needed
69+
get_closing_chars = function(context, colon)
70+
local offset = 1
71+
local closing_colon = ""
72+
if colon then
73+
closing_colon = ":"
74+
if string.sub(context.full_line, context.char + offset, context.char + offset) == ":" then
75+
closing_colon = ""
76+
offset = 2
77+
end
78+
end
79+
80+
local closing_brace = "}"
81+
if string.sub(context.full_line, context.char + offset, context.char + offset) == "}" then
82+
closing_brace = ""
83+
end
84+
85+
return closing_colon .. closing_brace
86+
end,
87+
88+
--- Get the lines in a given norg file path.
89+
--- @param file string file path, norg syntax accepted
90+
--- @return table<string>
91+
get_lines = function(file)
92+
local dirutils = neorg.modules.get_module("core.dirman.utils")
93+
if not dirutils then
94+
return {}
95+
end
96+
local expanded = dirutils.expand_path(file, true)
97+
98+
local lines
99+
if expanded then
100+
if not string.match(expanded, "%.norg$") then
101+
expanded = expanded .. ".norg"
102+
end
103+
local ok
104+
ok, lines = pcall(vim.fn.readfile, expanded)
105+
if not ok then
106+
lines = {}
107+
end
108+
end
109+
return lines
110+
end,
111+
112+
--- Find linkable headers in the given file
113+
--- @param file string file path, norg syntax is accepted
114+
--- @param context table
115+
--- @param heading_level number?
116+
--- @return table<string>
117+
find_headers = function(file, context, heading_level)
118+
local leading_whitespace = " "
119+
if context.before_char == " " then
120+
leading_whitespace = ""
121+
end
122+
123+
local closing_chars = module.private.get_closing_chars(context, false)
124+
leading_whitespace = leading_whitespace or ""
125+
local ret = {}
126+
127+
local lines = module.private.get_lines(file)
128+
for _, line in ipairs(lines) do
129+
local heading = { line:match("^%s*(%*+)%s+(.+)$") }
130+
if not vim.tbl_isempty(heading) and (not heading_level or #heading[1] == heading_level) then
131+
-- remove potential GTD status from link
132+
local stripped_heading = string.gsub(heading[2], "^%(.%)%s?", "")
133+
table.insert(ret, leading_whitespace .. stripped_heading .. closing_chars)
134+
end
135+
-- local marker_or_drawer = { line:match("^%s*(%|%|?%s+(.+))$") }
136+
-- if not vim.tbl_isempty(marker_or_drawer) then
137+
-- -- TODO: how do you link to these things
138+
-- -- what even are they?
139+
-- table.insert(ret, marker_or_drawer[2])
140+
-- end
141+
end
142+
143+
return ret
144+
end,
145+
146+
--- Find footers in the given file
147+
--- @param file string file path, norg syntax is accepted
148+
--- @return table<string>
149+
find_footnotes = function(file, context)
150+
local ret = {}
151+
local leading_whitespace = " "
152+
if context.before_char == " " then
153+
leading_whitespace = ""
154+
end
155+
156+
local closing_chars = module.private.get_closing_chars(context, false)
157+
leading_whitespace = leading_whitespace or ""
158+
local lines = module.private.get_lines(file)
159+
for _, line in ipairs(lines) do
160+
local footnote = { line:match("^%s*%^%^? (.+)$") }
161+
if not vim.tbl_isempty(footnote) then
162+
table.insert(ret, leading_whitespace .. footnote[1] .. closing_chars)
163+
end
164+
end
165+
166+
return ret
167+
end,
168+
169+
generate_file_links = function(context, _prev, _saved, _match)
170+
local res = {}
171+
local dirman = neorg.modules.get_module("core.dirman")
172+
if not dirman then
173+
return {}
174+
end
175+
176+
local files = module.private.get_norg_files()
177+
if not files or not files[2] then
178+
return {}
179+
end
180+
181+
local closing_chars = module.private.get_closing_chars(context, true)
182+
for _, file in pairs(files[2]) do
183+
assert(type(file) == "string")
184+
local bufnr = dirman.get_file_bufnr(file)
185+
186+
if vim.api.nvim_get_current_buf() ~= bufnr then
187+
-- using -6 to go to the end (-1) and remove '.norg' 5 more chars
188+
local link = "{:$" .. file:sub(#files[1] + 1, -6) .. closing_chars
189+
table.insert(res, link)
190+
end
191+
end
192+
193+
return res
194+
end,
195+
196+
generate_local_heading_links = function(context, _prev, _saved, match)
197+
local heading_level = match[2] and #match[2]
198+
return module.private.find_headers(vim.api.nvim_buf_get_name(0), context, heading_level)
199+
end,
200+
201+
generate_foreign_heading_links = function(context, _prev, _saved, match)
202+
local file = match[1]
203+
local heading_level = match[2] and #match[2]
204+
if file then
205+
return module.private.find_headers(file, context, heading_level)
206+
end
207+
return {}
208+
end,
209+
210+
generate_local_footnote_links = function(context, _prev, _saved, _match)
211+
return module.private.find_footnotes(vim.api.nvim_buf_get_name(0), context)
212+
end,
213+
214+
generate_foreign_footnote_links = function(context, _prev, _saved, match)
215+
if match[2] then
216+
return module.private.find_footnotes(match[2], context)
217+
end
218+
return {}
219+
end,
220+
221+
--- The node context for normal norg (ie. not in a code block)
222+
normal_norg = function(current, previous)
223+
-- If no previous node exists then try verifying the current node instead
224+
if not previous then
225+
return current and (current:type() ~= "translation_unit" or current:type() == "document") or false
226+
end
227+
228+
-- If the previous node is not tag parameters or the tag name
229+
-- (i.e. we are not inside of a tag) then show auto completions
230+
return previous:type() ~= "tag_parameters" and previous:type() ~= "tag_name"
231+
end,
36232
}
37233

38234
module.load = function()
@@ -70,21 +266,12 @@ module.public = {
70266

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

77273
-- If regex can be matched, this item then gets verified via TreeSitter's AST
78-
node = function(current, previous)
79-
-- If no previous node exists then try verifying the current node instead
80-
if not previous then
81-
return current and (current:type() ~= "translation_unit" or current:type() == "document") or false
82-
end
83-
84-
-- If the previous node is not tag parameters or the tag name
85-
-- (i.e. we are not inside of a tag) then show autocompletions
86-
return previous:type() ~= "tag_parameters" and previous:type() ~= "tag_name"
87-
end,
274+
node = module.private.normal_norg,
88275

89276
-- The actual elements to show if the above tests were true
90277
complete = {
@@ -185,7 +372,7 @@ module.public = {
185372
},
186373
},
187374
},
188-
{
375+
{ -- `#|tags`
189376
regex = "^%s*%#(%w*)",
190377

191378
complete = {
@@ -203,7 +390,7 @@ module.public = {
203390

204391
descend = {},
205392
},
206-
{
393+
{ -- `@|end` tags
207394
regex = "^%s*@e?n?",
208395
node = function(_, previous)
209396
if not previous then
@@ -222,7 +409,7 @@ module.public = {
222409
completion_start = "@",
223410
},
224411
},
225-
{
412+
{ -- TODO items `- (|)`
226413
regex = "^%s*%-+%s+%(([x%*%s]?)",
227414

228415
complete = {
@@ -249,6 +436,92 @@ module.public = {
249436
completion_start = "-",
250437
},
251438
},
439+
{ -- links for file paths `{:|`
440+
regex = "^.*{:([^:}]*)",
441+
442+
node = module.private.normal_norg,
443+
444+
complete = module.private.generate_file_links,
445+
446+
options = {
447+
type = "File",
448+
completion_start = "{",
449+
},
450+
},
451+
{ -- links that have a file path, suggest any heading from the file `{:...:#|}`
452+
regex = "^.*{:(.*):#[^}]*",
453+
454+
complete = module.private.generate_foreign_heading_links,
455+
456+
node = module.private.normal_norg,
457+
458+
options = {
459+
type = "Reference",
460+
completion_start = "#",
461+
},
462+
},
463+
{ -- links that have a file path, suggest direct headings from the file `{:...:*|}`
464+
regex = "^.*{:(.*):(%*+)[^}]*",
465+
466+
complete = module.private.generate_foreign_heading_links,
467+
468+
node = module.private.normal_norg,
469+
470+
options = {
471+
type = "Reference",
472+
completion_start = "*",
473+
},
474+
},
475+
{ -- # links to headings in the current file `{#|}`
476+
regex = "^.*{#[^}]*",
477+
478+
complete = module.private.generate_local_heading_links,
479+
480+
node = module.private.normal_norg,
481+
482+
options = {
483+
type = "Reference",
484+
completion_start = "#",
485+
},
486+
},
487+
{ -- * links to headings in current file `{*|}`
488+
regex = "^(.*){(%*+)[^}]*",
489+
-- the first capture group is a nothing group so that match[2] is reliably the heading
490+
-- level or nil if there's no heading level.
491+
492+
complete = module.private.generate_local_heading_links,
493+
494+
node = module.private.normal_norg,
495+
496+
options = {
497+
type = "Reference",
498+
completion_start = "*",
499+
},
500+
},
501+
{ -- ^ footnote links in the current file `{^|}`
502+
regex = "^(.*){%^[^}]*",
503+
504+
complete = module.private.generate_local_footnote_links,
505+
506+
node = module.private.normal_norg,
507+
508+
options = {
509+
type = "Reference",
510+
completion_start = "^",
511+
},
512+
},
513+
{ -- ^ footnote links in another file `{:path:^|}`
514+
regex = "^(.*){:(.*):%^[^}]*",
515+
516+
complete = module.private.generate_foreign_footnote_links,
517+
518+
node = module.private.normal_norg,
519+
520+
options = {
521+
type = "Reference",
522+
completion_start = "^",
523+
},
524+
},
252525
},
253526

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

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

279552
-- Set the match variable for the integration module

lua/neorg/modules/core/integrations/nvim-cmp/module.lua

+3-1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ module.public = {
5050
Property = module.private.cmp.lsp.CompletionItemKind.Property,
5151
Format = module.private.cmp.lsp.CompletionItemKind.Property,
5252
Embed = module.private.cmp.lsp.CompletionItemKind.Property,
53+
Reference = module.private.cmp.lsp.CompletionItemKind.Reference,
54+
File = module.private.cmp.lsp.CompletionItemKind.File,
5355
}
5456

5557
module.private.source.new = function()
@@ -85,7 +87,7 @@ module.public = {
8587
end
8688

8789
function module.private.source:get_trigger_characters()
88-
return { "@", "-", "(", " ", "." }
90+
return { "@", "-", "(", " ", ".", ":", "#", "*", "^" }
8991
end
9092

9193
module.private.cmp.register_source("neorg", module.private.source)

0 commit comments

Comments
 (0)