Skip to content

Commit

Permalink
feat: Add support for footnotes (#874)
Browse files Browse the repository at this point in the history
  • Loading branch information
kristijanhusak authored Jan 29, 2025
1 parent ac78216 commit 4f62b7f
Show file tree
Hide file tree
Showing 9 changed files with 362 additions and 6 deletions.
133 changes: 133 additions & 0 deletions lua/orgmode/colors/highlighter/markup/footnotes.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---@class OrgFootnotesHighlighter : OrgMarkupHighlighter
---@field private markup OrgMarkupHighlighter
local OrgFootnotes = {
valid_capture_names = {
['footnote.start'] = true,
['footnote.end'] = true,
},
}

---@param opts { markup: OrgMarkupHighlighter }
function OrgFootnotes:new(opts)
local data = {
markup = opts.markup,
}
setmetatable(data, self)
self.__index = self
return data
end

---@param node TSNode
---@param name string
---@return OrgMarkupNode | false
function OrgFootnotes:parse_node(node, name)
if not self.valid_capture_names[name] then
return false
end
local type = node:type()
if type == '[' then
return self:_parse_start_node(node)
end

if type == ']' then
return self:_parse_end_node(node)
end

return false
end

---@private
---@param node TSNode
---@return OrgMarkupNode | false
function OrgFootnotes:_parse_start_node(node)
local node_type = node:type()
local first_sibling = node:next_sibling()
local second_sibling = first_sibling and first_sibling:next_sibling()

if not first_sibling or not second_sibling then
return false
end
if first_sibling:type() ~= 'str' or second_sibling:type() ~= ':' then
return false
end

return {
type = 'footnote',
id = 'footnote_start',
char = node_type,
seek_id = 'footnote_end',
nestable = false,
range = self.markup:node_to_range(node),
node = node,
}
end

---@private
---@param node TSNode
---@return OrgMarkupNode | false
function OrgFootnotes:_parse_end_node(node)
local node_type = node:type()
local prev_sibling = node:prev_sibling()

if not prev_sibling then
return false
end

return {
type = 'footnote',
id = 'footnote_end',
seek_id = 'footnote_start',
char = node_type,
nestable = false,
range = self.markup:node_to_range(node),
node = node,
}
end

---@param entry OrgMarkupNode
---@return boolean
function OrgFootnotes:is_valid_start_node(entry)
return entry.type == 'footnote' and entry.id == 'footnote_start'
end

---@param entry OrgMarkupNode
---@return boolean
function OrgFootnotes:is_valid_end_node(entry)
return entry.type == 'footnote' and entry.id == 'footnote_end'
end

---@param highlights OrgMarkupHighlight[]
---@param bufnr number
function OrgFootnotes:highlight(highlights, bufnr)
local namespace = self.markup.highlighter.namespace
local ephemeral = self.markup:use_ephemeral()

for _, entry in ipairs(highlights) do
vim.api.nvim_buf_set_extmark(bufnr, namespace, entry.from.line, entry.from.start_col, {
ephemeral = ephemeral,
end_col = entry.to.end_col,
hl_group = '@org.footnote',
priority = 110,
})
end
end

---@param highlights OrgMarkupHighlight[]
---@return OrgMarkupPreparedHighlight[]
function OrgFootnotes:prepare_highlights(highlights)
local ephemeral = self.markup:use_ephemeral()
local extmarks = {}
for _, entry in ipairs(highlights) do
table.insert(extmarks, {
start_line = entry.from.line,
start_col = entry.from.start_col,
end_col = entry.to.end_col,
ephemeral = ephemeral,
hl_group = '@org.footnote',
priority = 110,
})
end
return extmarks
end

return OrgFootnotes
6 changes: 6 additions & 0 deletions lua/orgmode/colors/highlighter/markup/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function OrgMarkup:_init_highlighters()
emphasis = require('orgmode.colors.highlighter.markup.emphasis'):new({ markup = self }),
link = require('orgmode.colors.highlighter.markup.link'):new({ markup = self }),
date = require('orgmode.colors.highlighter.markup.dates'):new({ markup = self }),
footnote = require('orgmode.colors.highlighter.markup.footnotes'):new({ markup = self }),
latex = require('orgmode.colors.highlighter.markup.latex'):new({ markup = self }),
}
end
Expand Down Expand Up @@ -74,6 +75,7 @@ function OrgMarkup:get_node_highlights(root_node, source, line)
link = {},
latex = {},
date = {},
footnote = {},
}
---@type OrgMarkupNode[]
local entries = {}
Expand Down Expand Up @@ -242,6 +244,10 @@ function OrgMarkup:has_valid_parent(item)
return p:type() == 'drawer' or p:type() == 'cell'
end

if parent:type() == 'description' and p and p:type() == 'fndef' then
return true
end

if self.parsers[item.type].has_valid_parent then
return self.parsers[item.type]:has_valid_parent(item)
end
Expand Down
1 change: 1 addition & 0 deletions lua/orgmode/colors/highlights.lua
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function M.link_highlights()
['@org.hyperlink'] = '@markup.link.url',
['@org.latex'] = '@markup.math',
['@org.latex_env'] = '@markup.environment',
['@org.footnote'] = '@markup.link.url',
-- Other
['@org.table.delimiter'] = '@punctuation.special',
['@org.table.heading'] = '@markup.heading',
Expand Down
56 changes: 56 additions & 0 deletions lua/orgmode/files/file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ local config = require('orgmode.config')
local Block = require('orgmode.files.elements.block')
local Hyperlink = require('orgmode.org.links.hyperlink')
local Range = require('orgmode.files.elements.range')
local Footnote = require('orgmode.objects.footnote')
local Memoize = require('orgmode.utils.memoize')

---@class OrgFileMetadata
Expand Down Expand Up @@ -752,6 +753,61 @@ function OrgFile:get_links()
return links
end

memoize('get_footnote_references')
---@return OrgFootnote[]
function OrgFile:get_footnote_references()
self:parse(true)
local ts_query = ts_utils.get_query([[
(paragraph (expr) @footnotes)
(drawer (contents (expr) @footnotes))
(headline (item (expr)) @footnotes)
(fndef) @footnotes
]])

local footnotes = {}
local processed_lines = {}
for _, match in ts_query:iter_captures(self.root, self:get_source()) do
local line_start, _, line_end = match:range()
if not processed_lines[line_start] then
if line_start == line_end then
vim.list_extend(footnotes, Footnote.all_from_line(self.lines[line_start + 1], line_start + 1))
processed_lines[line_start] = true
else
for line = line_start, line_end - 1 do
vim.list_extend(footnotes, Footnote.all_from_line(self.lines[line + 1], line + 1))
processed_lines[line] = true
end
end
end
end
return footnotes
end

---@param footnote_reference OrgFootnote
---@return OrgFootnote | nil
function OrgFile:find_footnote(footnote_reference)
local footnotes = self:get_footnote_references()
for i = #footnotes, 1, -1 do
if footnotes[i].value:lower() == footnote_reference.value:lower() and footnotes[i].range.start_col == 1 then
return footnotes[i]
end
end
end

---@param footnote OrgFootnote
---@return OrgFootnote | nil
function OrgFile:find_footnote_reference(footnote)
local footnotes = self:get_footnote_references()
for i = #footnotes, 1, -1 do
if
footnotes[i].value:lower() == footnote.value:lower()
and footnotes[i].range.start_line < footnote.range.start_line
then
return footnotes[i]
end
end
end

memoize('get_directive')
---@param directive_name string
---@return string | nil
Expand Down
54 changes: 54 additions & 0 deletions lua/orgmode/objects/footnote.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
local Range = require('orgmode.files.elements.range')

---@class OrgFootnote
---@field value string
---@field range? OrgRange
local OrgFootnote = {}
OrgFootnote.__index = OrgFootnote

local pattern = '%[fn:[^%]]+%]'

---@param str string
---@param range? OrgRange
---@return OrgFootnote
function OrgFootnote:new(str, range)
local this = setmetatable({}, { __index = OrgFootnote })
this.value = str
this.range = range
return this
end

function OrgFootnote:get_name()
local name = self.value:match('^%[fn:([^%]]+)%]$')
return name
end

---@return OrgFootnote | nil
function OrgFootnote.at_cursor()
local line_nr = vim.fn.line('.')
local col = vim.fn.col('.') or 0
local on_line = OrgFootnote.all_from_line(vim.fn.getline('.'), line_nr)

return vim.iter(on_line):find(function(footnote)
return footnote.range:is_in_range(line_nr, col)
end)
end

---@return OrgFootnote[]
function OrgFootnote.all_from_line(line, line_number)
local links = {}
for link in line:gmatch(pattern) do
local start_from = #links > 0 and links[#links].range.end_col or nil
local from, to = line:find(pattern, start_from)
if from and to then
local range = Range.from_line(line_number)
range.start_col = from
range.end_col = to
table.insert(links, OrgFootnote:new(link, range))
end
end

return links
end

return OrgFootnote
57 changes: 51 additions & 6 deletions lua/orgmode/org/mappings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ local events = EventManager.event
local Babel = require('orgmode.babel')
local Promise = require('orgmode.utils.promise')
local Input = require('orgmode.ui.input')
local Footnote = require('orgmode.objects.footnote')

---@class OrgMappings
---@field capture OrgCapture
Expand Down Expand Up @@ -888,15 +889,59 @@ end

function OrgMappings:open_at_point()
local link = OrgHyperlink.at_cursor()
if not link then
local date = self:_get_date_under_cursor()
if date then
return self.agenda:open_day(date)

if link then
return self.links:follow(link.url:to_string())
end

local date = self:_get_date_under_cursor()
if date then
return self.agenda:open_day(date)
end

local footnote = Footnote.at_cursor()
if footnote then
return self:_jump_to_footnote(footnote)
end
end

---@param footnote_reference OrgFootnote
function OrgMappings:_jump_to_footnote(footnote_reference)
local file = self.files:get_current_file()
local footnote = file:find_footnote(footnote_reference)

if not footnote then
local choice = vim.fn.confirm('No footnote found. Create one?', '&Yes\n&No')
if choice ~= 1 then
return
end
return

local footnotes_headline = file:find_headline_by_title('footnotes')
if footnotes_headline then
local append_line = footnotes_headline:get_append_line()
vim.api.nvim_buf_set_lines(0, append_line, append_line, false, { footnote_reference.value .. ' ' })
vim.fn.cursor({ append_line + 1, #footnote_reference.value + 1 })
return vim.cmd('startinsert!')
end
local last_line = vim.api.nvim_buf_line_count(0)
vim.api.nvim_buf_set_lines(0, last_line, last_line, false, { '', '* Footnotes', footnote_reference.value .. ' ' })
vim.fn.cursor({ last_line + 3, #footnote_reference.value + 1 })
return vim.cmd('startinsert!')
end

local is_footnote_marker = footnote.range:is_same(footnote_reference.range)

if not is_footnote_marker then
return vim.fn.cursor({ footnote.range.start_line, footnote.range.start_col })
end

local reference = file:find_footnote_reference(footnote)

if reference then
return vim.fn.cursor({ reference.range.start_line, reference.range.start_col })
end

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

function OrgMappings:export()
Expand Down
1 change: 1 addition & 0 deletions queries/org/highlights.scm
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@
(cell "|" @org.table.delimiter)
(table (row (cell (contents) @org.table.heading)))
(table (hr) @org.table.delimiter)
(fndef label: (expr) @org.footnote (#offset! @org.footnote 0 -4 0 1))
2 changes: 2 additions & 0 deletions queries/org/markup.scm
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
(expr "]" @date_inactive.end)
(expr "<" @date_active.start "num" "-" "num" "-" "num")
(expr ">" @date_active.end)
(expr "[" @footnote.start "str" @_fn ":" (#eq? @_fn "fn"))
(expr "]" @footnote.end)
(expr "\\" "str" @latex.plain)
(expr "\\" "(" @latex.bracket.start)
(expr "\\" ")" @latex.bracket.end)
Expand Down
Loading

0 comments on commit 4f62b7f

Please sign in to comment.