diff --git a/lua/orgmode/colors/highlighter/markup/footnotes.lua b/lua/orgmode/colors/highlighter/markup/footnotes.lua new file mode 100644 index 000000000..301bb3252 --- /dev/null +++ b/lua/orgmode/colors/highlighter/markup/footnotes.lua @@ -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 diff --git a/lua/orgmode/colors/highlighter/markup/init.lua b/lua/orgmode/colors/highlighter/markup/init.lua index 5036c82f3..7ff23e960 100644 --- a/lua/orgmode/colors/highlighter/markup/init.lua +++ b/lua/orgmode/colors/highlighter/markup/init.lua @@ -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 @@ -74,6 +75,7 @@ function OrgMarkup:get_node_highlights(root_node, source, line) link = {}, latex = {}, date = {}, + footnote = {}, } ---@type OrgMarkupNode[] local entries = {} @@ -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 diff --git a/lua/orgmode/colors/highlights.lua b/lua/orgmode/colors/highlights.lua index 3a5da821f..8fe844b14 100644 --- a/lua/orgmode/colors/highlights.lua +++ b/lua/orgmode/colors/highlights.lua @@ -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', diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 309cb76d1..17255398d 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -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 @@ -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 diff --git a/lua/orgmode/objects/footnote.lua b/lua/orgmode/objects/footnote.lua new file mode 100644 index 000000000..f1e54c181 --- /dev/null +++ b/lua/orgmode/objects/footnote.lua @@ -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 diff --git a/lua/orgmode/org/mappings.lua b/lua/orgmode/org/mappings.lua index f3905c103..e1135001b 100644 --- a/lua/orgmode/org/mappings.lua +++ b/lua/orgmode/org/mappings.lua @@ -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 @@ -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() diff --git a/queries/org/highlights.scm b/queries/org/highlights.scm index 4ba949b95..a2e13666e 100644 --- a/queries/org/highlights.scm +++ b/queries/org/highlights.scm @@ -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)) diff --git a/queries/org/markup.scm b/queries/org/markup.scm index 58e32eaa4..ed782a89f 100644 --- a/queries/org/markup.scm +++ b/queries/org/markup.scm @@ -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) diff --git a/tests/plenary/ui/mappings/footnote_spec.lua b/tests/plenary/ui/mappings/footnote_spec.lua new file mode 100644 index 000000000..1adca7296 --- /dev/null +++ b/tests/plenary/ui/mappings/footnote_spec.lua @@ -0,0 +1,58 @@ +local helpers = require('tests.plenary.helpers') + +describe('footnotes', function() + local file_content = { + '* Headline', + 'This is footnote reference [fn:footref] here', + '', + '* Foo', + 'This is a second footnote reference [fn:second]', + '* Bar', + 'Bar content', + '* Footnotes', + '[fn:footref] This is the footnote', + } + + it('should jump to footnote from footnote reference', function() + helpers.create_file(file_content) + + vim.fn.cursor(2, 29) + vim.cmd([[norm ,oo]]) + assert.are.same({ 9, 1 }, { vim.fn.line('.'), vim.fn.col('.') }) + end) + + it('should jump to footnote from footnote reference', function() + helpers.create_file(file_content) + + vim.fn.cursor(9, 1) + vim.cmd([[norm ,oo]]) + assert.are.same({ 2, 28 }, { vim.fn.line('.'), vim.fn.col('.') }) + end) + + it('should prompt to create a footnote from footnote reference', function() + helpers.create_file(file_content) + vim.fn.cursor(5, 38) + vim.cmd([[norm ,oothe second footnote]]) + assert.are.same({ + '* Headline', + 'This is footnote reference [fn:footref] here', + '', + '* Foo', + 'This is a second footnote reference [fn:second]', + '* Bar', + 'Bar content', + '* Footnotes', + '[fn:second] the second footnote', + '[fn:footref] This is the footnote', + }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) + end) + + it('should not do anything if it cannot find the footnote reference from footnote', function() + local content = vim.list_extend(file_content, { '[fn:third] the third footnote' }) + helpers.create_file(content) + vim.fn.cursor(10, 1) + vim.cmd([[norm ,oo]]) + assert.are.same(content, vim.api.nvim_buf_get_lines(0, 0, -1, false)) + assert.are.same({ 10, 1 }, { vim.fn.line('.'), vim.fn.col('.') }) + end) +end)