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: Add support for footnotes #874

Merged
merged 1 commit into from
Jan 29, 2025
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
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
Loading