Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
Skip to content

Commit 4f62b7f

Browse files
feat: Add support for footnotes (#874)
1 parent ac78216 commit 4f62b7f

File tree

9 files changed

+362
-6
lines changed

9 files changed

+362
-6
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
---@class OrgFootnotesHighlighter : OrgMarkupHighlighter
2+
---@field private markup OrgMarkupHighlighter
3+
local OrgFootnotes = {
4+
valid_capture_names = {
5+
['footnote.start'] = true,
6+
['footnote.end'] = true,
7+
},
8+
}
9+
10+
---@param opts { markup: OrgMarkupHighlighter }
11+
function OrgFootnotes:new(opts)
12+
local data = {
13+
markup = opts.markup,
14+
}
15+
setmetatable(data, self)
16+
self.__index = self
17+
return data
18+
end
19+
20+
---@param node TSNode
21+
---@param name string
22+
---@return OrgMarkupNode | false
23+
function OrgFootnotes:parse_node(node, name)
24+
if not self.valid_capture_names[name] then
25+
return false
26+
end
27+
local type = node:type()
28+
if type == '[' then
29+
return self:_parse_start_node(node)
30+
end
31+
32+
if type == ']' then
33+
return self:_parse_end_node(node)
34+
end
35+
36+
return false
37+
end
38+
39+
---@private
40+
---@param node TSNode
41+
---@return OrgMarkupNode | false
42+
function OrgFootnotes:_parse_start_node(node)
43+
local node_type = node:type()
44+
local first_sibling = node:next_sibling()
45+
local second_sibling = first_sibling and first_sibling:next_sibling()
46+
47+
if not first_sibling or not second_sibling then
48+
return false
49+
end
50+
if first_sibling:type() ~= 'str' or second_sibling:type() ~= ':' then
51+
return false
52+
end
53+
54+
return {
55+
type = 'footnote',
56+
id = 'footnote_start',
57+
char = node_type,
58+
seek_id = 'footnote_end',
59+
nestable = false,
60+
range = self.markup:node_to_range(node),
61+
node = node,
62+
}
63+
end
64+
65+
---@private
66+
---@param node TSNode
67+
---@return OrgMarkupNode | false
68+
function OrgFootnotes:_parse_end_node(node)
69+
local node_type = node:type()
70+
local prev_sibling = node:prev_sibling()
71+
72+
if not prev_sibling then
73+
return false
74+
end
75+
76+
return {
77+
type = 'footnote',
78+
id = 'footnote_end',
79+
seek_id = 'footnote_start',
80+
char = node_type,
81+
nestable = false,
82+
range = self.markup:node_to_range(node),
83+
node = node,
84+
}
85+
end
86+
87+
---@param entry OrgMarkupNode
88+
---@return boolean
89+
function OrgFootnotes:is_valid_start_node(entry)
90+
return entry.type == 'footnote' and entry.id == 'footnote_start'
91+
end
92+
93+
---@param entry OrgMarkupNode
94+
---@return boolean
95+
function OrgFootnotes:is_valid_end_node(entry)
96+
return entry.type == 'footnote' and entry.id == 'footnote_end'
97+
end
98+
99+
---@param highlights OrgMarkupHighlight[]
100+
---@param bufnr number
101+
function OrgFootnotes:highlight(highlights, bufnr)
102+
local namespace = self.markup.highlighter.namespace
103+
local ephemeral = self.markup:use_ephemeral()
104+
105+
for _, entry in ipairs(highlights) do
106+
vim.api.nvim_buf_set_extmark(bufnr, namespace, entry.from.line, entry.from.start_col, {
107+
ephemeral = ephemeral,
108+
end_col = entry.to.end_col,
109+
hl_group = '@org.footnote',
110+
priority = 110,
111+
})
112+
end
113+
end
114+
115+
---@param highlights OrgMarkupHighlight[]
116+
---@return OrgMarkupPreparedHighlight[]
117+
function OrgFootnotes:prepare_highlights(highlights)
118+
local ephemeral = self.markup:use_ephemeral()
119+
local extmarks = {}
120+
for _, entry in ipairs(highlights) do
121+
table.insert(extmarks, {
122+
start_line = entry.from.line,
123+
start_col = entry.from.start_col,
124+
end_col = entry.to.end_col,
125+
ephemeral = ephemeral,
126+
hl_group = '@org.footnote',
127+
priority = 110,
128+
})
129+
end
130+
return extmarks
131+
end
132+
133+
return OrgFootnotes

lua/orgmode/colors/highlighter/markup/init.lua

+6
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function OrgMarkup:_init_highlighters()
2424
emphasis = require('orgmode.colors.highlighter.markup.emphasis'):new({ markup = self }),
2525
link = require('orgmode.colors.highlighter.markup.link'):new({ markup = self }),
2626
date = require('orgmode.colors.highlighter.markup.dates'):new({ markup = self }),
27+
footnote = require('orgmode.colors.highlighter.markup.footnotes'):new({ markup = self }),
2728
latex = require('orgmode.colors.highlighter.markup.latex'):new({ markup = self }),
2829
}
2930
end
@@ -74,6 +75,7 @@ function OrgMarkup:get_node_highlights(root_node, source, line)
7475
link = {},
7576
latex = {},
7677
date = {},
78+
footnote = {},
7779
}
7880
---@type OrgMarkupNode[]
7981
local entries = {}
@@ -242,6 +244,10 @@ function OrgMarkup:has_valid_parent(item)
242244
return p:type() == 'drawer' or p:type() == 'cell'
243245
end
244246

247+
if parent:type() == 'description' and p and p:type() == 'fndef' then
248+
return true
249+
end
250+
245251
if self.parsers[item.type].has_valid_parent then
246252
return self.parsers[item.type]:has_valid_parent(item)
247253
end

lua/orgmode/colors/highlights.lua

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ function M.link_highlights()
6464
['@org.hyperlink'] = '@markup.link.url',
6565
['@org.latex'] = '@markup.math',
6666
['@org.latex_env'] = '@markup.environment',
67+
['@org.footnote'] = '@markup.link.url',
6768
-- Other
6869
['@org.table.delimiter'] = '@punctuation.special',
6970
['@org.table.heading'] = '@markup.heading',

lua/orgmode/files/file.lua

+56
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ local config = require('orgmode.config')
77
local Block = require('orgmode.files.elements.block')
88
local Hyperlink = require('orgmode.org.links.hyperlink')
99
local Range = require('orgmode.files.elements.range')
10+
local Footnote = require('orgmode.objects.footnote')
1011
local Memoize = require('orgmode.utils.memoize')
1112

1213
---@class OrgFileMetadata
@@ -752,6 +753,61 @@ function OrgFile:get_links()
752753
return links
753754
end
754755

756+
memoize('get_footnote_references')
757+
---@return OrgFootnote[]
758+
function OrgFile:get_footnote_references()
759+
self:parse(true)
760+
local ts_query = ts_utils.get_query([[
761+
(paragraph (expr) @footnotes)
762+
(drawer (contents (expr) @footnotes))
763+
(headline (item (expr)) @footnotes)
764+
(fndef) @footnotes
765+
]])
766+
767+
local footnotes = {}
768+
local processed_lines = {}
769+
for _, match in ts_query:iter_captures(self.root, self:get_source()) do
770+
local line_start, _, line_end = match:range()
771+
if not processed_lines[line_start] then
772+
if line_start == line_end then
773+
vim.list_extend(footnotes, Footnote.all_from_line(self.lines[line_start + 1], line_start + 1))
774+
processed_lines[line_start] = true
775+
else
776+
for line = line_start, line_end - 1 do
777+
vim.list_extend(footnotes, Footnote.all_from_line(self.lines[line + 1], line + 1))
778+
processed_lines[line] = true
779+
end
780+
end
781+
end
782+
end
783+
return footnotes
784+
end
785+
786+
---@param footnote_reference OrgFootnote
787+
---@return OrgFootnote | nil
788+
function OrgFile:find_footnote(footnote_reference)
789+
local footnotes = self:get_footnote_references()
790+
for i = #footnotes, 1, -1 do
791+
if footnotes[i].value:lower() == footnote_reference.value:lower() and footnotes[i].range.start_col == 1 then
792+
return footnotes[i]
793+
end
794+
end
795+
end
796+
797+
---@param footnote OrgFootnote
798+
---@return OrgFootnote | nil
799+
function OrgFile:find_footnote_reference(footnote)
800+
local footnotes = self:get_footnote_references()
801+
for i = #footnotes, 1, -1 do
802+
if
803+
footnotes[i].value:lower() == footnote.value:lower()
804+
and footnotes[i].range.start_line < footnote.range.start_line
805+
then
806+
return footnotes[i]
807+
end
808+
end
809+
end
810+
755811
memoize('get_directive')
756812
---@param directive_name string
757813
---@return string | nil

lua/orgmode/objects/footnote.lua

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
local Range = require('orgmode.files.elements.range')
2+
3+
---@class OrgFootnote
4+
---@field value string
5+
---@field range? OrgRange
6+
local OrgFootnote = {}
7+
OrgFootnote.__index = OrgFootnote
8+
9+
local pattern = '%[fn:[^%]]+%]'
10+
11+
---@param str string
12+
---@param range? OrgRange
13+
---@return OrgFootnote
14+
function OrgFootnote:new(str, range)
15+
local this = setmetatable({}, { __index = OrgFootnote })
16+
this.value = str
17+
this.range = range
18+
return this
19+
end
20+
21+
function OrgFootnote:get_name()
22+
local name = self.value:match('^%[fn:([^%]]+)%]$')
23+
return name
24+
end
25+
26+
---@return OrgFootnote | nil
27+
function OrgFootnote.at_cursor()
28+
local line_nr = vim.fn.line('.')
29+
local col = vim.fn.col('.') or 0
30+
local on_line = OrgFootnote.all_from_line(vim.fn.getline('.'), line_nr)
31+
32+
return vim.iter(on_line):find(function(footnote)
33+
return footnote.range:is_in_range(line_nr, col)
34+
end)
35+
end
36+
37+
---@return OrgFootnote[]
38+
function OrgFootnote.all_from_line(line, line_number)
39+
local links = {}
40+
for link in line:gmatch(pattern) do
41+
local start_from = #links > 0 and links[#links].range.end_col or nil
42+
local from, to = line:find(pattern, start_from)
43+
if from and to then
44+
local range = Range.from_line(line_number)
45+
range.start_col = from
46+
range.end_col = to
47+
table.insert(links, OrgFootnote:new(link, range))
48+
end
49+
end
50+
51+
return links
52+
end
53+
54+
return OrgFootnote

lua/orgmode/org/mappings.lua

+51-6
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ local events = EventManager.event
1515
local Babel = require('orgmode.babel')
1616
local Promise = require('orgmode.utils.promise')
1717
local Input = require('orgmode.ui.input')
18+
local Footnote = require('orgmode.objects.footnote')
1819

1920
---@class OrgMappings
2021
---@field capture OrgCapture
@@ -888,15 +889,59 @@ end
888889

889890
function OrgMappings:open_at_point()
890891
local link = OrgHyperlink.at_cursor()
891-
if not link then
892-
local date = self:_get_date_under_cursor()
893-
if date then
894-
return self.agenda:open_day(date)
892+
893+
if link then
894+
return self.links:follow(link.url:to_string())
895+
end
896+
897+
local date = self:_get_date_under_cursor()
898+
if date then
899+
return self.agenda:open_day(date)
900+
end
901+
902+
local footnote = Footnote.at_cursor()
903+
if footnote then
904+
return self:_jump_to_footnote(footnote)
905+
end
906+
end
907+
908+
---@param footnote_reference OrgFootnote
909+
function OrgMappings:_jump_to_footnote(footnote_reference)
910+
local file = self.files:get_current_file()
911+
local footnote = file:find_footnote(footnote_reference)
912+
913+
if not footnote then
914+
local choice = vim.fn.confirm('No footnote found. Create one?', '&Yes\n&No')
915+
if choice ~= 1 then
916+
return
895917
end
896-
return
918+
919+
local footnotes_headline = file:find_headline_by_title('footnotes')
920+
if footnotes_headline then
921+
local append_line = footnotes_headline:get_append_line()
922+
vim.api.nvim_buf_set_lines(0, append_line, append_line, false, { footnote_reference.value .. ' ' })
923+
vim.fn.cursor({ append_line + 1, #footnote_reference.value + 1 })
924+
return vim.cmd('startinsert!')
925+
end
926+
local last_line = vim.api.nvim_buf_line_count(0)
927+
vim.api.nvim_buf_set_lines(0, last_line, last_line, false, { '', '* Footnotes', footnote_reference.value .. ' ' })
928+
vim.fn.cursor({ last_line + 3, #footnote_reference.value + 1 })
929+
return vim.cmd('startinsert!')
930+
end
931+
932+
local is_footnote_marker = footnote.range:is_same(footnote_reference.range)
933+
934+
if not is_footnote_marker then
935+
return vim.fn.cursor({ footnote.range.start_line, footnote.range.start_col })
936+
end
937+
938+
local reference = file:find_footnote_reference(footnote)
939+
940+
if reference then
941+
return vim.fn.cursor({ reference.range.start_line, reference.range.start_col })
897942
end
898943

899-
return self.links:follow(link.url:to_string())
944+
utils.echo_info(('Cannot find reference for footnote "%s"'):format(footnote_reference:get_name()))
900945
end
901946

902947
function OrgMappings:export()

queries/org/highlights.scm

+1
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@
4141
(cell "|" @org.table.delimiter)
4242
(table (row (cell (contents) @org.table.heading)))
4343
(table (hr) @org.table.delimiter)
44+
(fndef label: (expr) @org.footnote (#offset! @org.footnote 0 -4 0 1))

queries/org/markup.scm

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
(expr "]" @date_inactive.end)
1111
(expr "<" @date_active.start "num" "-" "num" "-" "num")
1212
(expr ">" @date_active.end)
13+
(expr "[" @footnote.start "str" @_fn ":" (#eq? @_fn "fn"))
14+
(expr "]" @footnote.end)
1315
(expr "\\" "str" @latex.plain)
1416
(expr "\\" "(" @latex.bracket.start)
1517
(expr "\\" ")" @latex.bracket.end)

0 commit comments

Comments
 (0)