From befc1e1213029c932d2c8da352d7ab16800751c1 Mon Sep 17 00:00:00 2001 From: Kristijan Husak Date: Tue, 20 Aug 2024 15:06:54 +0200 Subject: [PATCH 1/4] feat(links): Add basic structure for links refactor --- lua/orgmode/init.lua | 3 ++ lua/orgmode/org/links/_meta.lua | 5 ++ lua/orgmode/org/links/init.lua | 42 +++++++++++++++ lua/orgmode/org/links/types/custom_id.lua | 49 ++++++++++++++++++ lua/orgmode/org/links/types/headline.lua | 48 +++++++++++++++++ lua/orgmode/org/links/types/http.lua | 54 +++++++++++++++++++ lua/orgmode/org/links/types/id.lua | 57 +++++++++++++++++++++ lua/orgmode/org/links/types/line_number.lua | 57 +++++++++++++++++++++ lua/orgmode/org/links/utils.lua | 55 ++++++++++++++++++++ 9 files changed, 370 insertions(+) create mode 100644 lua/orgmode/org/links/_meta.lua create mode 100644 lua/orgmode/org/links/init.lua create mode 100644 lua/orgmode/org/links/types/custom_id.lua create mode 100644 lua/orgmode/org/links/types/headline.lua create mode 100644 lua/orgmode/org/links/types/http.lua create mode 100644 lua/orgmode/org/links/types/id.lua create mode 100644 lua/orgmode/org/links/types/line_number.lua create mode 100644 lua/orgmode/org/links/utils.lua diff --git a/lua/orgmode/init.lua b/lua/orgmode/init.lua index 94286c1c1..2b55ec16f 100644 --- a/lua/orgmode/init.lua +++ b/lua/orgmode/init.lua @@ -10,6 +10,7 @@ local auto_instance_keys = { org_mappings = true, notifications = true, completion = true, + links = true, } ---@class Org @@ -22,6 +23,7 @@ local auto_instance_keys = { ---@field completion OrgCompletion ---@field org_mappings OrgMappings ---@field notifications OrgNotifications +---@field links OrgLinks local Org = {} setmetatable(Org, { __index = function(tbl, key) @@ -66,6 +68,7 @@ function Org:init() files = self.files, }) self.completion = require('orgmode.org.autocompletion'):new({ files = self.files }) + self.links = require('orgmode.org.links'):new({ files = self.files }) self.statusline_debounced = require('orgmode.utils').debounce('statusline', function() return self.clock:get_statusline() end, 300) diff --git a/lua/orgmode/org/links/_meta.lua b/lua/orgmode/org/links/_meta.lua new file mode 100644 index 000000000..e989e01f8 --- /dev/null +++ b/lua/orgmode/org/links/_meta.lua @@ -0,0 +1,5 @@ +---@meta + +---@class OrgLinkType +---@field get_name fun(self: OrgLinkType): string +---@field follow fun(self: OrgLinkType, link: string): boolean diff --git a/lua/orgmode/org/links/init.lua b/lua/orgmode/org/links/init.lua new file mode 100644 index 000000000..6c2b3af14 --- /dev/null +++ b/lua/orgmode/org/links/init.lua @@ -0,0 +1,42 @@ +---@class OrgLinks:OrgLinkType +---@field private files OrgFiles +---@field private types OrgLinkType[] +---@field private types_by_name table +local OrgLinks = {} +OrgLinks.__index = OrgLinks + +---@param opts { files: OrgFiles } +function OrgLinks:new(opts) + local this = setmetatable({ + files = opts.files, + types = {}, + types_by_name = {}, + }, OrgLinks) + this:setup_builtin_types() + return this +end + +function OrgLinks:setup_builtin_types() + self:add_type(require('orgmode.org.links.types.id'):new({ files = self.files })) +end + +function OrgLinks:add_type(link_type) + if self.types_by_name[link_type:get_name()] then + error('Link type ' .. link_type:get_name() .. ' already exists') + end + self.types_by_name[link_type:get_name()] = link_type + table.insert(self.types, link_type) +end + +---@param link string +---@return boolean +function OrgLinks:follow(link) + for _, source in ipairs(self.types) do + if source:follow(link) then + return true + end + end + return false +end + +return OrgLinks diff --git a/lua/orgmode/org/links/types/custom_id.lua b/lua/orgmode/org/links/types/custom_id.lua new file mode 100644 index 000000000..6362fd846 --- /dev/null +++ b/lua/orgmode/org/links/types/custom_id.lua @@ -0,0 +1,49 @@ +local utils = require('orgmode.utils') +local link_utils = require('orgmode.org.links.utils') + +---@class OrgLinkCustomId:OrgLinkType +---@field private files OrgFiles +local OrgLinkCustomId = {} +OrgLinkCustomId.__index = OrgLinkCustomId + +---@param opts { files: OrgFiles } +function OrgLinkCustomId:new(opts) + local this = setmetatable({ + files = opts.files, + }, OrgLinkCustomId) + return this +end + +function OrgLinkCustomId:get_name() + return 'custom_id' +end + +---@param link string +---@return boolean +function OrgLinkCustomId:follow(link) + local opts = self:_parse(link) + if not opts then + return false + end + + local headlines = opts.file:find_headlines_with_property('CUSTOM_ID', opts.custom_id) + return link_utils.goto_oneof_headlines(headlines) +end + +---@private +---@param link string +---@return { custom_id: string, file: OrgFile } | nil +function OrgLinkCustomId:_parse(link) + local custom_id = link:match('^#(.+)$') + if custom_id then + return { + custom_id = custom_id, + file = self.files:get_current_file(), + } + end + + -- TODO: Add support for file format + return nil +end + +return OrgLinkCustomId diff --git a/lua/orgmode/org/links/types/headline.lua b/lua/orgmode/org/links/types/headline.lua new file mode 100644 index 000000000..fde96ba73 --- /dev/null +++ b/lua/orgmode/org/links/types/headline.lua @@ -0,0 +1,48 @@ +local link_utils = require('orgmode.org.links.utils') + +---@class OrgLinkHeadline:OrgLinkType +---@field private files OrgFiles +local OrgLinkHeadline = {} +OrgLinkHeadline.__index = OrgLinkHeadline + +---@param opts { files: OrgFiles } +function OrgLinkHeadline:new(opts) + local this = setmetatable({ + files = opts.files, + }, OrgLinkHeadline) + return this +end + +function OrgLinkHeadline:get_name() + return 'headline' +end + +---@param link string +---@return boolean +function OrgLinkHeadline:follow(link) + local opts = self:_parse(link) + if not opts then + return false + end + + local headlines = opts.file:find_headlines_by_title(opts.headline_title) + return link_utils.goto_oneof_headlines(headlines) +end + +---@private +---@param link string +---@return { headline_title: string, file: OrgFile } | nil +function OrgLinkHeadline:_parse(link) + local headline_title = link:match('^%*(.+)$') + if headline_title then + return { + headline_title = headline_title, + file = self.files:get_current_file(), + } + end + + -- TODO: Add support for file format + return nil +end + +return OrgLinkHeadline diff --git a/lua/orgmode/org/links/types/http.lua b/lua/orgmode/org/links/types/http.lua new file mode 100644 index 000000000..419fb1cb6 --- /dev/null +++ b/lua/orgmode/org/links/types/http.lua @@ -0,0 +1,54 @@ +local utils = require('orgmode.utils') + +---@class OrgLinkHttp:OrgLinkType +---@field private files OrgFiles +local OrgLinkHttp = {} +OrgLinkHttp.__index = OrgLinkHttp + +---@param opts { files: OrgFiles } +function OrgLinkHttp:new(opts) + local this = setmetatable({ + files = opts.files, + }, OrgLinkHttp) + return this +end + +function OrgLinkHttp:get_name() + return 'http' +end + +---@param link string +---@return boolean +function OrgLinkHttp:follow(link) + local url = self:_parse(link) + if not url then + return false + end + + if vim.ui['open'] then + vim.ui.open(url) + return true + end + + if not vim.g.loaded_netrwPlugin then + utils.echo_warning('Netrw plugin must be loaded in order to open urls.') + return false + end + + vim.fn['netrw#BrowseX'](url, vim.fn['netrw#CheckIfRemote']()) + return true +end + +---@private +---@param link string +---@return string | nil +function OrgLinkHttp:_parse(link) + local is_url = link:match('^https?://(.+)$') + if is_url then + return link + end + + return nil +end + +return OrgLinkHttp diff --git a/lua/orgmode/org/links/types/id.lua b/lua/orgmode/org/links/types/id.lua new file mode 100644 index 000000000..f4c798351 --- /dev/null +++ b/lua/orgmode/org/links/types/id.lua @@ -0,0 +1,57 @@ +local utils = require('orgmode.utils') +local link_utils = require('orgmode.org.links.utils') + +---@class OrgLinkId:OrgLinkType +---@field private files OrgFiles +local OrgLinkId = {} +OrgLinkId.__index = OrgLinkId + +---@param opts { files: OrgFiles } +function OrgLinkId:new(opts) + local this = setmetatable({ + files = opts.files, + }, OrgLinkId) + return this +end + +function OrgLinkId:get_name() + return 'id' +end + +---@param link string +---@return boolean +function OrgLinkId:follow(link) + local id = self:_parse(link) + if not id then + return false + end + + local files = self.files:find_files_with_property('id', id) + if #files > 0 then + if #files > 1 then + utils.echo_warning(string.format('Multiple files found with id: %s, jumping to first one found', id)) + end + return link_utils.goto_file(files[1]) + end + + local headlines = self.files:find_headlines_with_property('id', id) + if #headlines == 0 then + utils.echo_warning(string.format('No headline found with id: %s', id)) + return true + end + if #headlines > 1 then + utils.echo_warning(string.format('Multiple headlines found with id: %s', id)) + return true + end + local headline = headlines[1] + return link_utils.goto_headline(headline) +end + +---@private +---@param link string +---@return string +function OrgLinkId:_parse(link) + return link:match('^id:(.+)$') +end + +return OrgLinkId diff --git a/lua/orgmode/org/links/types/line_number.lua b/lua/orgmode/org/links/types/line_number.lua new file mode 100644 index 000000000..e764b2b71 --- /dev/null +++ b/lua/orgmode/org/links/types/line_number.lua @@ -0,0 +1,57 @@ +local fs = require('orgmode.utils.fs') +local link_utils = require('orgmode.org.links.utils') + +---@class OrgLinkLineNumber:OrgLinkType +---@field private files OrgFiles +local OrgLinkLineNumber = {} +OrgLinkLineNumber.__index = OrgLinkLineNumber + +---@param opts { files: OrgFiles } +function OrgLinkLineNumber:new(opts) + local this = setmetatable({ + files = opts.files, + }, OrgLinkLineNumber) + return this +end + +function OrgLinkLineNumber:get_name() + return 'headline' +end + +---@param link string +---@return boolean +function OrgLinkLineNumber:follow(link) + local opts = self:_parse(link) + if not opts then + return false + end + + local cmd = string.format('edit +%s %s', opts.line_number, fs.get_real_path(opts.file.filename)) + vim.cmd(cmd) + vim.cmd([[normal! zv]]) + return true +end + +---@private +---@param link string +---@return { line_number: number, file: OrgFile } | nil +function OrgLinkLineNumber:_parse(link) + local parts = vim.split(link, '::', { plain = true }) + if #parts < 2 then + return nil + end + + local line_number = parts[#parts]:match('^%d+$') + + if line_number then + return { + line_number = tonumber(line_number), + file = self.files:get_current_file(), + } + end + + -- TODO: Add support for file format + return nil +end + +return OrgLinkLineNumber diff --git a/lua/orgmode/org/links/utils.lua b/lua/orgmode/org/links/utils.lua new file mode 100644 index 000000000..7c1b01337 --- /dev/null +++ b/lua/orgmode/org/links/utils.lua @@ -0,0 +1,55 @@ +local utils = require('orgmode.utils') +local link_utils = {} + +---@param file OrgFile +---@return boolean +function link_utils.goto_file(file) + vim.cmd(('edit %s'):format(file.filename)) + return true +end + +---@param headline OrgHeadline +---@return boolean +function link_utils.goto_headline(headline) + local current_file_path = utils.current_file_path() + if headline.file.filename ~= current_file_path then + vim.cmd(string.format('edit %s', headline.file.filename)) + else + vim.cmd([[normal! m']]) -- add link source to jumplist + end + vim.fn.cursor({ headline:get_range().start_line, 1 }) + vim.cmd([[normal! zv]]) + return true +end + +---@param headlines OrgHeadline[] +---@return boolean +function link_utils.goto_oneof_headlines(headlines) + if #headlines == 0 then + return false + end + + if #headlines == 1 then + return link_utils.goto_headline(headlines[1]) + end + + local longest_headline = utils.reduce(headlines, function(acc, h) + return math.max(acc, h:get_headline_line_content():len()) + end, 0) + local options = {} + for i, h in ipairs(headlines) do + table.insert( + options, + string.format('%d) %-' .. longest_headline .. 's (%s)', i, h:get_headline_line_content(), h.file.filename) + ) + end + vim.cmd([[echo "Multiple targets found. Select target:"]]) + local choice = vim.fn.inputlist(options) + if choice < 1 or choice > #headlines then + return false + end + + return link_utils.goto_headline(headlines[choice]) +end + +return link_utils From 09139c60eb664a765ba5f372e6f9d6f8744fe9ef Mon Sep 17 00:00:00 2001 From: Kristijan Husak Date: Tue, 20 Aug 2024 23:21:07 +0200 Subject: [PATCH 2/4] feat(links): Add all link types --- lua/orgmode/files/file.lua | 3 +- lua/orgmode/files/init.lua | 5 +- lua/orgmode/init.lua | 3 +- lua/orgmode/org/links/init.lua | 80 +++++++++++++++--- lua/orgmode/org/links/types/custom_id.lua | 24 ++++-- lua/orgmode/org/links/types/headline.lua | 41 ++++++++-- .../org/links/types/headline_search.lua | 69 ++++++++++++++++ lua/orgmode/org/links/types/line_number.lua | 29 ++++--- lua/orgmode/org/links/url.lua | 81 +++++++++++++++++++ lua/orgmode/org/links/utils.lua | 29 ++++++- lua/orgmode/org/mappings.lua | 2 + lua/orgmode/utils/init.lua | 7 ++ 12 files changed, 322 insertions(+), 51 deletions(-) create mode 100644 lua/orgmode/org/links/types/headline_search.lua create mode 100644 lua/orgmode/org/links/url.lua diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 269339ead..59bf36ec3 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -55,8 +55,7 @@ end ---Load the file ---@return OrgPromise function OrgFile.load(filename) - local ext = vim.fn.fnamemodify(filename, ':e') - if ext ~= 'org' and ext ~= 'org_archive' then + if not utils.is_org_file(filename) or not vim.loop.fs_stat(filename) then return Promise.resolve(false) end local bufnr = vim.fn.bufnr(filename) or -1 diff --git a/lua/orgmode/files/init.lua b/lua/orgmode/files/init.lua index 5ca9a7d0b..05032db85 100644 --- a/lua/orgmode/files/init.lua +++ b/lua/orgmode/files/init.lua @@ -338,10 +338,7 @@ function OrgFiles:_files(skip_resolve) all_files = vim.tbl_flatten(all_files) return vim.tbl_filter(function(file) - local ext = vim.fn.fnamemodify(file, ':e') - local is_org = ext == 'org' or ext == 'org_archive' - - if not is_org then + if not utils.is_org_file(file) then return false end diff --git a/lua/orgmode/init.lua b/lua/orgmode/init.lua index 2b55ec16f..cef4723db 100644 --- a/lua/orgmode/init.lua +++ b/lua/orgmode/init.lua @@ -53,6 +53,7 @@ function Org:init() paths = require('orgmode.config').org_agenda_files, }) :load_sync(true, 20000) + self.links = require('orgmode.org.links'):new({ files = self.files }) self.agenda = require('orgmode.agenda'):new({ files = self.files, }) @@ -63,12 +64,12 @@ function Org:init() capture = self.capture, agenda = self.agenda, files = self.files, + links = self.links, }) self.clock = require('orgmode.clock'):new({ files = self.files, }) self.completion = require('orgmode.org.autocompletion'):new({ files = self.files }) - self.links = require('orgmode.org.links'):new({ files = self.files }) self.statusline_debounced = require('orgmode.utils').debounce('statusline', function() return self.clock:get_statusline() end, 300) diff --git a/lua/orgmode/org/links/init.lua b/lua/orgmode/org/links/init.lua index 6c2b3af14..290a5e116 100644 --- a/lua/orgmode/org/links/init.lua +++ b/lua/orgmode/org/links/init.lua @@ -1,8 +1,16 @@ +local config = require('orgmode.config') +local utils = require('orgmode.utils') +local OrgLinkUrl = require('orgmode.org.links.url') + ---@class OrgLinks:OrgLinkType ---@field private files OrgFiles ---@field private types OrgLinkType[] ---@field private types_by_name table -local OrgLinks = {} +---@field private stored_links table +---@field private headline_search OrgLinkHeadlineSearch +local OrgLinks = { + stored_links = {}, +} OrgLinks.__index = OrgLinks ---@param opts { files: OrgFiles } @@ -16,8 +24,67 @@ function OrgLinks:new(opts) return this end +---@param link string +---@return boolean +function OrgLinks:follow(link) + for _, source in ipairs(self.types) do + if source:follow(link) then + return true + end + end + + local org_link_url = OrgLinkUrl:new(link) + if org_link_url.protocol and org_link_url.protocol ~= 'file' then + utils.echo_warning(string.format('Unsupported link protocol: %q', org_link_url.protocol)) + return false + end + + return self.headline_search:follow(link) +end + +---@param headline OrgHeadline +function OrgLinks:store_link_to_headline(headline) + self.stored_links[self:get_link_to_headline(headline)] = headline:get_title() +end + +---@param headline OrgHeadline +---@return string +function OrgLinks:get_link_to_headline(headline) + local title = headline:get_title() + + if config.org_id_link_to_org_use_id then + local id = headline:id_get_or_create() + if id then + return ('id:%s::*%s'):format(id, title) + end + end + + return ('file:%s::*%s'):format(headline.file.filename, title) +end + +---@param file OrgFile +---@return string +function OrgLinks:get_link_to_file(file) + local title = file:get_title() + + if config.org_id_link_to_org_use_id then + local id = file:id_get_or_create() + if id then + return ('id:%s::*%s'):format(id, title) + end + end + + return ('file:%s::*%s'):format(file.filename, title) +end + function OrgLinks:setup_builtin_types() + self:add_type(require('orgmode.org.links.types.http'):new({ files = self.files })) self:add_type(require('orgmode.org.links.types.id'):new({ files = self.files })) + self:add_type(require('orgmode.org.links.types.line_number'):new({ files = self.files })) + self:add_type(require('orgmode.org.links.types.custom_id'):new({ files = self.files })) + self:add_type(require('orgmode.org.links.types.headline'):new({ files = self.files })) + + self.headline_search = require('orgmode.org.links.types.headline_search'):new({ files = self.files }) end function OrgLinks:add_type(link_type) @@ -28,15 +95,4 @@ function OrgLinks:add_type(link_type) table.insert(self.types, link_type) end ----@param link string ----@return boolean -function OrgLinks:follow(link) - for _, source in ipairs(self.types) do - if source:follow(link) then - return true - end - end - return false -end - return OrgLinks diff --git a/lua/orgmode/org/links/types/custom_id.lua b/lua/orgmode/org/links/types/custom_id.lua index 6362fd846..bcfc59933 100644 --- a/lua/orgmode/org/links/types/custom_id.lua +++ b/lua/orgmode/org/links/types/custom_id.lua @@ -1,4 +1,5 @@ local utils = require('orgmode.utils') +local OrgLinkUrl = require('orgmode.org.links.url') local link_utils = require('orgmode.org.links.utils') ---@class OrgLinkCustomId:OrgLinkType @@ -26,23 +27,34 @@ function OrgLinkCustomId:follow(link) return false end - local headlines = opts.file:find_headlines_with_property('CUSTOM_ID', opts.custom_id) - return link_utils.goto_oneof_headlines(headlines) + local file = self.files:load_file_sync(opts.file_path) + local err_msg = 'No headline found with custom id: ' .. opts.custom_id + + if file then + local headlines = file:find_headlines_with_property('CUSTOM_ID', opts.custom_id) + return link_utils.goto_oneof_headlines(headlines, file.filename, err_msg) + end + + return link_utils.open_file_and_search(opts.file_path, opts.custom_id) end ---@private ---@param link string ----@return { custom_id: string, file: OrgFile } | nil +---@return { custom_id: string, file_path: string } | nil function OrgLinkCustomId:_parse(link) - local custom_id = link:match('^#(.+)$') + local link_url = OrgLinkUrl:new(link) + + local target = link_url:get_target() + local path = link_url:get_path() + local custom_id = (target and target:match('^#(.+)$')) or (path and path:match('^#(.+)$')) + if custom_id then return { custom_id = custom_id, - file = self.files:get_current_file(), + file_path = link_url:get_file_path() or utils.current_file_path(), } end - -- TODO: Add support for file format return nil end diff --git a/lua/orgmode/org/links/types/headline.lua b/lua/orgmode/org/links/types/headline.lua index fde96ba73..ecf64a391 100644 --- a/lua/orgmode/org/links/types/headline.lua +++ b/lua/orgmode/org/links/types/headline.lua @@ -1,3 +1,5 @@ +local utils = require('orgmode.utils') +local OrgLinkUrl = require('orgmode.org.links.url') local link_utils = require('orgmode.org.links.utils') ---@class OrgLinkHeadline:OrgLinkType @@ -25,23 +27,46 @@ function OrgLinkHeadline:follow(link) return false end - local headlines = opts.file:find_headlines_by_title(opts.headline_title) - return link_utils.goto_oneof_headlines(headlines) + local org_file = self.files:load_file_sync(opts.file_path) + + if org_file then + local headlines = org_file:find_headlines_by_title(opts.headline) + return link_utils.goto_oneof_headlines(headlines, opts.file_path, 'No headline found with title: ' .. opts.headline) + end + + return link_utils.open_file_and_search(opts.file_path, opts.headline) end ---@private ---@param link string ----@return { headline_title: string, file: OrgFile } | nil +---@return { headline: string, file_path: string } | nil function OrgLinkHeadline:_parse(link) - local headline_title = link:match('^%*(.+)$') - if headline_title then + local link_url = OrgLinkUrl:new(link) + + local target = link_url:get_target() + local path = link_url:get_path() + + local file_path_headline = target and target:match('^%*(.+)$') + local current_file_headline = path and path:match('^%*(.+)$') + + if file_path_headline then + local file_path = link_url:get_file_path() + if not file_path then + return nil + end + return { + headline = file_path_headline, + file_path = file_path, + } + end + + if current_file_headline then return { - headline_title = headline_title, - file = self.files:get_current_file(), + headline = current_file_headline, + file_path = utils.current_file_path(), } end - -- TODO: Add support for file format return nil end diff --git a/lua/orgmode/org/links/types/headline_search.lua b/lua/orgmode/org/links/types/headline_search.lua new file mode 100644 index 000000000..8c04f459a --- /dev/null +++ b/lua/orgmode/org/links/types/headline_search.lua @@ -0,0 +1,69 @@ +local utils = require('orgmode.utils') +local OrgLinkUrl = require('orgmode.org.links.url') +local link_utils = require('orgmode.org.links.utils') + +---@class OrgLinkHeadlineSearch:OrgLinkType +---@field private files OrgFiles +local OrgLinkHeadlineSearch = {} +OrgLinkHeadlineSearch.__index = OrgLinkHeadlineSearch + +---@param opts { files: OrgFiles } +function OrgLinkHeadlineSearch:new(opts) + local this = setmetatable({ + files = opts.files, + }, OrgLinkHeadlineSearch) + return this +end + +function OrgLinkHeadlineSearch:get_name() + return 'headline' +end + +---@param link string +---@return boolean +function OrgLinkHeadlineSearch:follow(link) + local opts = self:_parse(link) + if not opts then + return false + end + + local file = self.files:load_file_sync(opts.file_path) + + if file then + local pattern = ('<<]*)>>>?'):format(opts.headline_text):lower() + local headlines = file:find_headlines_matching_search_term(pattern, true) + if #headlines == 0 then + headlines = file:find_headlines_by_title(opts.headline_text) + end + + return link_utils.goto_oneof_headlines( + headlines, + file.filename, + 'No headline found with title: ' .. opts.headline_text + ) + end + + return link_utils.open_file_and_search(opts.file_path, opts.headline_text) +end + +---@private +---@param link string +---@return { headline_text: string, file_path: string } | nil +function OrgLinkHeadlineSearch:_parse(link) + local link_url = OrgLinkUrl:new(link) + + local target = link_url:get_target() + local path = link_url:get_path() + local headline_text = target or path + + if headline_text and headline_text ~= '' then + return { + headline_text = headline_text, + file_path = link_url:get_file_path() or utils.current_file_path(), + } + end + + return nil +end + +return OrgLinkHeadlineSearch diff --git a/lua/orgmode/org/links/types/line_number.lua b/lua/orgmode/org/links/types/line_number.lua index e764b2b71..85100fb7a 100644 --- a/lua/orgmode/org/links/types/line_number.lua +++ b/lua/orgmode/org/links/types/line_number.lua @@ -1,5 +1,5 @@ -local fs = require('orgmode.utils.fs') -local link_utils = require('orgmode.org.links.utils') +local utils = require('orgmode.utils') +local OrgLinkUrl = require('orgmode.org.links.url') ---@class OrgLinkLineNumber:OrgLinkType ---@field private files OrgFiles @@ -15,7 +15,7 @@ function OrgLinkLineNumber:new(opts) end function OrgLinkLineNumber:get_name() - return 'headline' + return 'line_number' end ---@param link string @@ -26,7 +26,7 @@ function OrgLinkLineNumber:follow(link) return false end - local cmd = string.format('edit +%s %s', opts.line_number, fs.get_real_path(opts.file.filename)) + local cmd = string.format('edit +%s %s', opts.line_number, opts.file_path) vim.cmd(cmd) vim.cmd([[normal! zv]]) return true @@ -34,23 +34,22 @@ end ---@private ---@param link string ----@return { line_number: number, file: OrgFile } | nil +---@return { line_number: number, file_path: string } | nil function OrgLinkLineNumber:_parse(link) - local parts = vim.split(link, '::', { plain = true }) - if #parts < 2 then - return nil - end - - local line_number = parts[#parts]:match('^%d+$') - - if line_number then + local link_url = OrgLinkUrl:new(link) + local target = link_url:get_target() + local path = link_url:get_path() + local file_path = link_url:get_file_path() + local line_number = target and target:match('^%d+$') + local protocol = link_url:get_protocol() + + if (protocol == 'file' or file_path) and line_number then return { line_number = tonumber(line_number), - file = self.files:get_current_file(), + file_path = file_path and file_path ~= '' and file_path or utils.current_file_path(), } end - -- TODO: Add support for file format return nil end diff --git a/lua/orgmode/org/links/url.lua b/lua/orgmode/org/links/url.lua new file mode 100644 index 000000000..0a9ad9d69 --- /dev/null +++ b/lua/orgmode/org/links/url.lua @@ -0,0 +1,81 @@ +local fs = require('orgmode.utils.fs') + +---@class OrgLinkUrl +---@field url string +---@field protocol string | nil +---@field path string +---@field target string | nil +local OrgLinkUrl = {} +OrgLinkUrl.__index = OrgLinkUrl + +---@param url string +function OrgLinkUrl:new(url) + local this = setmetatable({ + url = url, + }, OrgLinkUrl) + this:_parse() + return this +end + +---@return string | nil +function OrgLinkUrl:get_file_path() + if self.protocol == 'file' then + return self:_get_real_path() + end + + local first_char = self.path:sub(1, 1) + + if first_char == '/' then + return self:_get_real_path() + end + + if + (first_char == '.' and (self.path:sub(1, 3) == '../' or self.path:sub(1, 2) == './')) + or (first_char == '~' and self.path:sub(2, 2) == '/') + then + return self:_get_real_path() + end + + return nil +end + +---@return string +function OrgLinkUrl:get_target() + return self.target +end + +---@return string +function OrgLinkUrl:get_path() + return self.path +end + +---@return string +function OrgLinkUrl:get_protocol() + return self.protocol +end + +---@private +---@return string +function OrgLinkUrl:_get_real_path() + return fs.get_real_path(self.path) or self.path +end + +---@private +function OrgLinkUrl:_parse() + self.protocol = self.url:match('^(%w+):') + self.path = self.protocol and self.url:sub(#self.protocol + 2) or self.url + + self:_parse_target() +end + +---@private +function OrgLinkUrl:_parse_target() + local path_and_target = vim.split(self.path, '::', { plain = true }) + if #path_and_target < 2 then + return + end + self.path = vim.trim(path_and_target[1]) + self.target = vim.trim(table.concat({ unpack(path_and_target, 2, #path_and_target) }, '')) +end + +return OrgLinkUrl diff --git a/lua/orgmode/org/links/utils.lua b/lua/orgmode/org/links/utils.lua index 7c1b01337..2e08d5238 100644 --- a/lua/orgmode/org/links/utils.lua +++ b/lua/orgmode/org/links/utils.lua @@ -23,10 +23,16 @@ function link_utils.goto_headline(headline) end ---@param headlines OrgHeadline[] +---@param file_path string +---@param error_message string ---@return boolean -function link_utils.goto_oneof_headlines(headlines) +function link_utils.goto_oneof_headlines(headlines, file_path, error_message) if #headlines == 0 then - return false + if file_path ~= utils.current_file_path() then + vim.cmd(('edit %s'):format(file_path)) + end + utils.echo_warning(error_message) + return true end if #headlines == 1 then @@ -46,10 +52,27 @@ function link_utils.goto_oneof_headlines(headlines) vim.cmd([[echo "Multiple targets found. Select target:"]]) local choice = vim.fn.inputlist(options) if choice < 1 or choice > #headlines then - return false + return true end return link_utils.goto_headline(headlines[choice]) end +---@param file_path string +---@param search_text string +---@return boolean +function link_utils.open_file_and_search(file_path, search_text) + if not file_path or file_path == '' then + return true + end + if file_path ~= utils.current_file_path() then + vim.cmd(('edit %s'):format(file_path)) + end + local result = vim.fn.search(search_text, 'W') + if result == 0 then + utils.echo_warning(string.format('No match found for expression: %s', search_text)) + end + return true +end + return link_utils diff --git a/lua/orgmode/org/mappings.lua b/lua/orgmode/org/mappings.lua index 77d0c4a05..6398b571b 100644 --- a/lua/orgmode/org/mappings.lua +++ b/lua/orgmode/org/mappings.lua @@ -20,6 +20,7 @@ local ListItem = require('orgmode.files.elements.listitem') ---@field capture OrgCapture ---@field agenda OrgAgenda ---@field files OrgFiles +---@field links OrgLinks local OrgMappings = {} ---@param data table @@ -29,6 +30,7 @@ function OrgMappings:new(data) opts.capture = data.capture opts.agenda = data.agenda opts.files = data.files + opts.links = data.links setmetatable(opts, self) self.__index = self return opts diff --git a/lua/orgmode/utils/init.lua b/lua/orgmode/utils/init.lua index dd24aa467..b8396174b 100644 --- a/lua/orgmode/utils/init.lua +++ b/lua/orgmode/utils/init.lua @@ -594,4 +594,11 @@ function utils.detect_filetype(name, skip_ftmatch) return name:lower() end +---@param filename string +---@return boolean +function utils.is_org_file(filename) + local ext = vim.fn.fnamemodify(filename, ':e') + return ext == 'org' or ext == 'org_archive' +end + return utils From 5a0e33b60f20fef00b309d0ea785e111ddccdd3b Mon Sep 17 00:00:00 2001 From: Kristijan Husak Date: Thu, 22 Aug 2024 23:27:48 +0200 Subject: [PATCH 3/4] feat(links): Add autocompletion --- lua/orgmode/org/links/_meta.lua | 1 + lua/orgmode/org/links/init.lua | 12 ++++ lua/orgmode/org/links/types/custom_id.lua | 59 +++++++++++++++--- lua/orgmode/org/links/types/headline.lua | 35 +++++++++-- .../org/links/types/headline_search.lua | 60 ++++++++++++++++++- lua/orgmode/org/links/types/http.lua | 5 ++ lua/orgmode/org/links/types/id.lua | 5 ++ lua/orgmode/org/links/types/line_number.lua | 5 ++ lua/orgmode/org/links/url.lua | 9 +++ 9 files changed, 176 insertions(+), 15 deletions(-) diff --git a/lua/orgmode/org/links/_meta.lua b/lua/orgmode/org/links/_meta.lua index e989e01f8..9130c9f2c 100644 --- a/lua/orgmode/org/links/_meta.lua +++ b/lua/orgmode/org/links/_meta.lua @@ -3,3 +3,4 @@ ---@class OrgLinkType ---@field get_name fun(self: OrgLinkType): string ---@field follow fun(self: OrgLinkType, link: string): boolean +---@field autocomplete fun(self: OrgLinkType, link: string): string[] diff --git a/lua/orgmode/org/links/init.lua b/lua/orgmode/org/links/init.lua index 290a5e116..6c5ce49ae 100644 --- a/lua/orgmode/org/links/init.lua +++ b/lua/orgmode/org/links/init.lua @@ -42,6 +42,18 @@ function OrgLinks:follow(link) return self.headline_search:follow(link) end +---@param link string +---@return string[] +function OrgLinks:autocomplete(link) + local items = {} + for _, source in ipairs(self.types) do + utils.concat(items, source:autocomplete(link)) + end + + utils.concat(items, self.headline_search:autocomplete(link)) + return items +end + ---@param headline OrgHeadline function OrgLinks:store_link_to_headline(headline) self.stored_links[self:get_link_to_headline(headline)] = headline:get_title() diff --git a/lua/orgmode/org/links/types/custom_id.lua b/lua/orgmode/org/links/types/custom_id.lua index bcfc59933..0ee52615f 100644 --- a/lua/orgmode/org/links/types/custom_id.lua +++ b/lua/orgmode/org/links/types/custom_id.lua @@ -28,30 +28,73 @@ function OrgLinkCustomId:follow(link) end local file = self.files:load_file_sync(opts.file_path) - local err_msg = 'No headline found with custom id: ' .. opts.custom_id - if file then + if file and vim.trim(opts.custom_id) ~= '' then local headlines = file:find_headlines_with_property('CUSTOM_ID', opts.custom_id) - return link_utils.goto_oneof_headlines(headlines, file.filename, err_msg) + return link_utils.goto_oneof_headlines( + headlines, + file.filename, + 'No headline found with custom id: ' .. opts.custom_id + ) end return link_utils.open_file_and_search(opts.file_path, opts.custom_id) end +---@param link string +---@return string[] +function OrgLinkCustomId:autocomplete(link) + local opts = self:_parse(link) + if not opts then + return {} + end + + local file = self.files:load_file_sync(opts.file_path) + + if not file then + return {} + end + + local headlines = file:find_headlines_with_property_matching('CUSTOM_ID', opts.custom_id) + local prefix = opts.type == 'internal' and '' or opts.link_url:get_path_with_protocol() .. '::' + + return vim.tbl_map(function(headline) + local custom_id = headline:get_property('custom_id') + return prefix .. '#' .. custom_id + end, headlines) +end + ---@private ---@param link string ----@return { custom_id: string, file_path: string } | nil +---@return { custom_id: string, file_path: string, link_url: OrgLinkUrl, type: 'file' | 'internal' } | nil function OrgLinkCustomId:_parse(link) local link_url = OrgLinkUrl:new(link) local target = link_url:get_target() local path = link_url:get_path() - local custom_id = (target and target:match('^#(.+)$')) or (path and path:match('^#(.+)$')) - if custom_id then + local file_path_custom_id = target and target:match('^#(.*)$') + local current_file_custom_id = path and path:match('^#(.*)$') + + if file_path_custom_id then + local file_path = link_url:get_file_path() + if not file_path then + return nil + end + return { + custom_id = file_path_custom_id, + file_path = file_path, + link_url = link_url, + type = 'file', + } + end + + if current_file_custom_id then return { - custom_id = custom_id, - file_path = link_url:get_file_path() or utils.current_file_path(), + custom_id = current_file_custom_id, + file_path = utils.current_file_path(), + link_url = link_url, + type = 'internal', } end diff --git a/lua/orgmode/org/links/types/headline.lua b/lua/orgmode/org/links/types/headline.lua index ecf64a391..b99f87c6a 100644 --- a/lua/orgmode/org/links/types/headline.lua +++ b/lua/orgmode/org/links/types/headline.lua @@ -29,7 +29,7 @@ function OrgLinkHeadline:follow(link) local org_file = self.files:load_file_sync(opts.file_path) - if org_file then + if org_file and vim.trim(opts.headline) ~= '' then local headlines = org_file:find_headlines_by_title(opts.headline) return link_utils.goto_oneof_headlines(headlines, opts.file_path, 'No headline found with title: ' .. opts.headline) end @@ -37,17 +37,40 @@ function OrgLinkHeadline:follow(link) return link_utils.open_file_and_search(opts.file_path, opts.headline) end +---@param link string +---@return string[] +function OrgLinkHeadline:autocomplete(link) + local opts = self:_parse(link) + if not opts then + return {} + end + + local file = self.files:load_file_sync(opts.file_path) + + if not file then + return {} + end + + local headlines = file:find_headlines_by_title(opts.headline) + local prefix = opts.type == 'internal' and '' or opts.link_url:get_path_with_protocol() .. '::' + + return vim.tbl_map(function(headline) + local title = headline:get_title() + return prefix .. '*' .. title + end, headlines) +end + ---@private ---@param link string ----@return { headline: string, file_path: string } | nil +---@return { headline: string, file_path: string, link_url: OrgLinkUrl, type: 'file' | 'internal' } | nil function OrgLinkHeadline:_parse(link) local link_url = OrgLinkUrl:new(link) local target = link_url:get_target() local path = link_url:get_path() - local file_path_headline = target and target:match('^%*(.+)$') - local current_file_headline = path and path:match('^%*(.+)$') + local file_path_headline = target and target:match('^%*(.*)$') + local current_file_headline = path and path:match('^%*(.*)$') if file_path_headline then local file_path = link_url:get_file_path() @@ -57,6 +80,8 @@ function OrgLinkHeadline:_parse(link) return { headline = file_path_headline, file_path = file_path, + link_url = link_url, + type = 'file', } end @@ -64,6 +89,8 @@ function OrgLinkHeadline:_parse(link) return { headline = current_file_headline, file_path = utils.current_file_path(), + link_url = link_url, + type = 'internal', } end diff --git a/lua/orgmode/org/links/types/headline_search.lua b/lua/orgmode/org/links/types/headline_search.lua index 8c04f459a..e2dd2331b 100644 --- a/lua/orgmode/org/links/types/headline_search.lua +++ b/lua/orgmode/org/links/types/headline_search.lua @@ -46,9 +46,59 @@ function OrgLinkHeadlineSearch:follow(link) return link_utils.open_file_and_search(opts.file_path, opts.headline_text) end +---@param link string +---@return string[] +function OrgLinkHeadlineSearch:autocomplete(link) + local opts = self:_parse(link) + if not opts then + return {} + end + + if opts.type == 'file' and not opts.target then + local filenames = self.files:filenames() + local valid_filenames = {} + for _, f in ipairs(filenames) do + if f:find('^' .. opts.file_path) then + f = f:gsub('^' .. opts.file_path, opts.link_url.path) + table.insert(valid_filenames, f) + end + end + + local prefix = opts.link_url:get_protocol() == 'file' and 'file:' or '' + + return vim.tbl_map(function(path) + return prefix .. path + end, valid_filenames) + end + + local file = self.files:load_file_sync(opts.file_path) + + if not file then + return {} + end + + local pattern = ('<<]*)>>>?'):format(opts.headline_text):lower() + local headlines = vim.tbl_map(function(headline) + return headline:get_title() + end, file:find_headlines_matching_search_term(pattern, true)) + + utils.concat( + headlines, + vim.tbl_map(function(headline) + return headline:get_title() + end, file:find_headlines_by_title(opts.headline_text)), + true + ) + local prefix = opts.type == 'internal' and '' or opts.link_url:get_path_with_protocol() .. '::' + + return vim.tbl_map(function(headline_title) + return prefix .. headline_title + end, headlines) +end + ---@private ---@param link string ----@return { headline_text: string, file_path: string } | nil +---@return { headline_text: string, file_path: string, link_url: OrgLinkUrl, type: 'file' | 'internal', target: string | nil } | nil function OrgLinkHeadlineSearch:_parse(link) local link_url = OrgLinkUrl:new(link) @@ -56,10 +106,14 @@ function OrgLinkHeadlineSearch:_parse(link) local path = link_url:get_path() local headline_text = target or path - if headline_text and headline_text ~= '' then + if headline_text then + local file_path = link_url:get_file_path() return { headline_text = headline_text, - file_path = link_url:get_file_path() or utils.current_file_path(), + file_path = file_path or utils.current_file_path(), + link_url = link_url, + target = target, + type = file_path and 'file' or 'internal', } end diff --git a/lua/orgmode/org/links/types/http.lua b/lua/orgmode/org/links/types/http.lua index 419fb1cb6..83879be03 100644 --- a/lua/orgmode/org/links/types/http.lua +++ b/lua/orgmode/org/links/types/http.lua @@ -39,6 +39,11 @@ function OrgLinkHttp:follow(link) return true end +---@return string[] +function OrgLinkHttp:autocomplete(_) + return {} +end + ---@private ---@param link string ---@return string | nil diff --git a/lua/orgmode/org/links/types/id.lua b/lua/orgmode/org/links/types/id.lua index f4c798351..ee7e84156 100644 --- a/lua/orgmode/org/links/types/id.lua +++ b/lua/orgmode/org/links/types/id.lua @@ -47,6 +47,11 @@ function OrgLinkId:follow(link) return link_utils.goto_headline(headline) end +---@return string[] +function OrgLinkId:autocomplete(_) + return {} +end + ---@private ---@param link string ---@return string diff --git a/lua/orgmode/org/links/types/line_number.lua b/lua/orgmode/org/links/types/line_number.lua index 85100fb7a..1277d98bd 100644 --- a/lua/orgmode/org/links/types/line_number.lua +++ b/lua/orgmode/org/links/types/line_number.lua @@ -32,6 +32,11 @@ function OrgLinkLineNumber:follow(link) return true end +---@return string[] +function OrgLinkLineNumber:autocomplete(_) + return {} +end + ---@private ---@param link string ---@return { line_number: number, file_path: string } | nil diff --git a/lua/orgmode/org/links/url.lua b/lua/orgmode/org/links/url.lua index 0a9ad9d69..21623dd45 100644 --- a/lua/orgmode/org/links/url.lua +++ b/lua/orgmode/org/links/url.lua @@ -39,6 +39,15 @@ function OrgLinkUrl:get_file_path() return nil end +---@return string +function OrgLinkUrl:get_path_with_protocol() + if not self.protocol or self.protocol == '' then + return self.path + end + + return ('%s:%s'):format(self.protocol, self.path) +end + ---@return string function OrgLinkUrl:get_target() return self.target From 1be93fbca8e5baab7d8e57484e3ac6b01384ccfa Mon Sep 17 00:00:00 2001 From: Kristijan Husak Date: Fri, 23 Aug 2024 15:42:13 +0200 Subject: [PATCH 4/4] feat(links): Add missing handlers --- lua/orgmode/files/file.lua | 6 +- lua/orgmode/init.lua | 2 +- lua/orgmode/org/autocompletion/init.lua | 4 +- lua/orgmode/org/links/hyperlink.lua | 81 +++++++++++++ lua/orgmode/org/links/init.lua | 78 ++++++++++-- lua/orgmode/org/links/types/custom_id.lua | 1 + lua/orgmode/org/links/types/headline.lua | 1 + .../org/links/types/headline_search.lua | 1 + lua/orgmode/org/links/types/http.lua | 1 + lua/orgmode/org/links/types/id.lua | 1 + lua/orgmode/org/links/types/line_number.lua | 1 + lua/orgmode/org/links/url.lua | 18 +++ lua/orgmode/utils/fs.lua | 2 +- tests/plenary/org/links/hyperlink_spec.lua | 114 ++++++++++++++++++ tests/plenary/org/links/url_spec.lua | 92 ++++++++++++++ 15 files changed, 389 insertions(+), 14 deletions(-) create mode 100644 lua/orgmode/org/links/hyperlink.lua create mode 100644 tests/plenary/org/links/hyperlink_spec.lua create mode 100644 tests/plenary/org/links/url_spec.lua diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 59bf36ec3..b76ea02be 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -55,7 +55,7 @@ end ---Load the file ---@return OrgPromise function OrgFile.load(filename) - if not utils.is_org_file(filename) or not vim.loop.fs_stat(filename) then + if not utils.is_org_file(filename) then return Promise.resolve(false) end local bufnr = vim.fn.bufnr(filename) or -1 @@ -68,6 +68,10 @@ function OrgFile.load(filename) })) end + if not vim.loop.fs_stat(filename) then + return Promise.resolve(false) + end + return utils.readfile(filename, { schedule = true }):next(function(lines) return OrgFile:new({ filename = filename, diff --git a/lua/orgmode/init.lua b/lua/orgmode/init.lua index cef4723db..08fdd5249 100644 --- a/lua/orgmode/init.lua +++ b/lua/orgmode/init.lua @@ -69,7 +69,7 @@ function Org:init() self.clock = require('orgmode.clock'):new({ files = self.files, }) - self.completion = require('orgmode.org.autocompletion'):new({ files = self.files }) + self.completion = require('orgmode.org.autocompletion'):new({ files = self.files, links = self.links }) self.statusline_debounced = require('orgmode.utils').debounce('statusline', function() return self.clock:get_statusline() end, 300) diff --git a/lua/orgmode/org/autocompletion/init.lua b/lua/orgmode/org/autocompletion/init.lua index 9a376c514..e402825fd 100644 --- a/lua/orgmode/org/autocompletion/init.lua +++ b/lua/orgmode/org/autocompletion/init.lua @@ -1,5 +1,6 @@ ---@class OrgCompletion ---@field files OrgFiles +---@field links OrgLinks ---@field private sources OrgCompletionSource[] ---@field private sources_by_name table ---@field menu string @@ -8,10 +9,11 @@ local OrgCompletion = { } OrgCompletion.__index = OrgCompletion ----@param opts { files: OrgFiles } +---@param opts { files: OrgFiles, links: OrgLinks } function OrgCompletion:new(opts) local this = setmetatable({ files = opts.files, + links = opts.links, sources = {}, sources_by_name = {}, }, OrgCompletion) diff --git a/lua/orgmode/org/links/hyperlink.lua b/lua/orgmode/org/links/hyperlink.lua new file mode 100644 index 000000000..c6f321655 --- /dev/null +++ b/lua/orgmode/org/links/hyperlink.lua @@ -0,0 +1,81 @@ +local OrgLinkUrl = require('orgmode.org.links.url') +local Range = require('orgmode.files.elements.range') + +---@class OrgHyperlink +---@field url OrgLinkUrl +---@field desc string | nil +---@field range? OrgRange +local OrgHyperlink = {} + +local pattern = '%[%[([^%]]+.-)%]%]' + +---@param str string +---@param range? OrgRange +---@return OrgHyperlink +function OrgHyperlink:new(str, range) + local this = setmetatable({}, { __index = OrgHyperlink }) + local parts = vim.split(str, '][', { plain = true }) + this.url = OrgLinkUrl:new(parts[1] or '') + this.desc = parts[2] + this.range = range + return this +end + +---@return string +function OrgHyperlink:to_str() + if self.desc then + return string.format('[[%s][%s]]', self.url:to_string(), self.desc) + else + return string.format('[[%s]]', self.url:to_string()) + end +end + +---@param line string +---@param pos number +---@return OrgHyperlink | nil, { from: number, to: number } | nil +function OrgHyperlink.at_pos(line, pos) + local links = {} + local found_link = nil + local position + for link in line:gmatch(pattern) do + local start_from = #links > 0 and links[#links].to or nil + local from, to = line:find(pattern, start_from) + local current_pos = { from = from, to = to } + if pos >= from and pos <= to then + found_link = link + position = current_pos + break + end + table.insert(links, current_pos) + end + if not found_link then + return nil, nil + end + return OrgHyperlink:new(found_link), position +end + +---@return OrgHyperlink | nil, { from: number, to: number } | nil +function OrgHyperlink.at_cursor() + local line = vim.fn.getline('.') + local col = vim.fn.col('.') or 0 + return OrgHyperlink.at_pos(line, col) +end + +---@return OrgHyperlink[] +function OrgHyperlink.all_from_line(line, line_number) + local links = {} + for link in line:gmatch(pattern) do + local start_from = #links > 0 and links[#links].to 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, OrgHyperlink:new(link, range)) + end + end + + return links +end + +return OrgHyperlink diff --git a/lua/orgmode/org/links/init.lua b/lua/orgmode/org/links/init.lua index 6c5ce49ae..c6d37df24 100644 --- a/lua/orgmode/org/links/init.lua +++ b/lua/orgmode/org/links/init.lua @@ -1,6 +1,7 @@ local config = require('orgmode.config') local utils = require('orgmode.utils') local OrgLinkUrl = require('orgmode.org.links.url') +local OrgHyperlink = require('orgmode.org.links.hyperlink') ---@class OrgLinks:OrgLinkType ---@field private files OrgFiles @@ -20,7 +21,7 @@ function OrgLinks:new(opts) types = {}, types_by_name = {}, }, OrgLinks) - this:setup_builtin_types() + this:_setup_builtin_types() return this end @@ -34,7 +35,7 @@ function OrgLinks:follow(link) end local org_link_url = OrgLinkUrl:new(link) - if org_link_url.protocol and org_link_url.protocol ~= 'file' then + if org_link_url.protocol and org_link_url.protocol ~= 'file' and org_link_url.protocol ~= 'id' then utils.echo_warning(string.format('Unsupported link protocol: %q', org_link_url.protocol)) return false end @@ -45,7 +46,12 @@ end ---@param link string ---@return string[] function OrgLinks:autocomplete(link) - local items = {} + local pattern = '^' .. vim.pesc(link:lower()) + + local items = vim.tbl_filter(function(stored_link) + return stored_link:lower():match(pattern) + end, vim.tbl_keys(self.stored_links)) + for _, source in ipairs(self.types) do utils.concat(items, source:autocomplete(link)) end @@ -89,16 +95,57 @@ function OrgLinks:get_link_to_file(file) return ('file:%s::*%s'):format(file.filename, title) end -function OrgLinks:setup_builtin_types() - self:add_type(require('orgmode.org.links.types.http'):new({ files = self.files })) - self:add_type(require('orgmode.org.links.types.id'):new({ files = self.files })) - self:add_type(require('orgmode.org.links.types.line_number'):new({ files = self.files })) - self:add_type(require('orgmode.org.links.types.custom_id'):new({ files = self.files })) - self:add_type(require('orgmode.org.links.types.headline'):new({ files = self.files })) +---@param link_location string +function OrgLinks:insert_link(link_location) + local selected_link = OrgHyperlink:new(link_location) + local desc = selected_link.url:get_target() + if desc and (desc:match('^%*') or desc:match('^#')) then + desc = desc:sub(2) + end - self.headline_search = require('orgmode.org.links.types.headline_search'):new({ files = self.files }) + if selected_link.url:get_protocol() == 'id' then + link_location = ('id:%s'):format(selected_link.url:get_path()) + end + + local link_description = vim.trim(vim.fn.OrgmodeInput('Description: ', desc or '')) + + link_location = '[' .. vim.trim(link_location) .. ']' + + if link_description ~= '' then + link_description = '[' .. link_description .. ']' + end + + local insert_from + local insert_to + local target_col = #link_location + #link_description + 2 + + -- check if currently on link + local link, position = OrgHyperlink.at_cursor() + if link and position then + insert_from = position.from - 1 + insert_to = position.to + 1 + target_col = target_col + position.from + else + local colnr = vim.fn.col('.') + insert_from = colnr + insert_to = colnr + 1 + target_col = target_col + colnr + end + + local linenr = vim.fn.line('.') or 0 + local curr_line = vim.fn.getline(linenr) + local new_line = string.sub(curr_line, 0, insert_from) + .. '[' + .. link_location + .. link_description + .. ']' + .. string.sub(curr_line, insert_to, #curr_line) + + vim.fn.setline(linenr, new_line) + vim.fn.cursor(linenr, target_col) end +---@param link_type OrgLinkType function OrgLinks:add_type(link_type) if self.types_by_name[link_type:get_name()] then error('Link type ' .. link_type:get_name() .. ' already exists') @@ -107,4 +154,15 @@ function OrgLinks:add_type(link_type) table.insert(self.types, link_type) end +---@private +function OrgLinks:_setup_builtin_types() + self:add_type(require('orgmode.org.links.types.http'):new({ files = self.files })) + self:add_type(require('orgmode.org.links.types.id'):new({ files = self.files })) + self:add_type(require('orgmode.org.links.types.line_number'):new({ files = self.files })) + self:add_type(require('orgmode.org.links.types.custom_id'):new({ files = self.files })) + self:add_type(require('orgmode.org.links.types.headline'):new({ files = self.files })) + + self.headline_search = require('orgmode.org.links.types.headline_search'):new({ files = self.files }) +end + return OrgLinks diff --git a/lua/orgmode/org/links/types/custom_id.lua b/lua/orgmode/org/links/types/custom_id.lua index 0ee52615f..ba9f74ecc 100644 --- a/lua/orgmode/org/links/types/custom_id.lua +++ b/lua/orgmode/org/links/types/custom_id.lua @@ -15,6 +15,7 @@ function OrgLinkCustomId:new(opts) return this end +---@return string function OrgLinkCustomId:get_name() return 'custom_id' end diff --git a/lua/orgmode/org/links/types/headline.lua b/lua/orgmode/org/links/types/headline.lua index b99f87c6a..465e33985 100644 --- a/lua/orgmode/org/links/types/headline.lua +++ b/lua/orgmode/org/links/types/headline.lua @@ -15,6 +15,7 @@ function OrgLinkHeadline:new(opts) return this end +---@return string function OrgLinkHeadline:get_name() return 'headline' end diff --git a/lua/orgmode/org/links/types/headline_search.lua b/lua/orgmode/org/links/types/headline_search.lua index e2dd2331b..a0f0a1950 100644 --- a/lua/orgmode/org/links/types/headline_search.lua +++ b/lua/orgmode/org/links/types/headline_search.lua @@ -15,6 +15,7 @@ function OrgLinkHeadlineSearch:new(opts) return this end +---@return string function OrgLinkHeadlineSearch:get_name() return 'headline' end diff --git a/lua/orgmode/org/links/types/http.lua b/lua/orgmode/org/links/types/http.lua index 83879be03..69db0d687 100644 --- a/lua/orgmode/org/links/types/http.lua +++ b/lua/orgmode/org/links/types/http.lua @@ -13,6 +13,7 @@ function OrgLinkHttp:new(opts) return this end +---@return string function OrgLinkHttp:get_name() return 'http' end diff --git a/lua/orgmode/org/links/types/id.lua b/lua/orgmode/org/links/types/id.lua index ee7e84156..ed291cd60 100644 --- a/lua/orgmode/org/links/types/id.lua +++ b/lua/orgmode/org/links/types/id.lua @@ -14,6 +14,7 @@ function OrgLinkId:new(opts) return this end +---@return string function OrgLinkId:get_name() return 'id' end diff --git a/lua/orgmode/org/links/types/line_number.lua b/lua/orgmode/org/links/types/line_number.lua index 1277d98bd..31786fc39 100644 --- a/lua/orgmode/org/links/types/line_number.lua +++ b/lua/orgmode/org/links/types/line_number.lua @@ -14,6 +14,7 @@ function OrgLinkLineNumber:new(opts) return this end +---@return string function OrgLinkLineNumber:get_name() return 'line_number' end diff --git a/lua/orgmode/org/links/url.lua b/lua/orgmode/org/links/url.lua index 21623dd45..e188c82c9 100644 --- a/lua/orgmode/org/links/url.lua +++ b/lua/orgmode/org/links/url.lua @@ -63,12 +63,30 @@ function OrgLinkUrl:get_protocol() return self.protocol end +---@return boolean +function OrgLinkUrl:is_id() + return self.protocol == 'id' +end + +---@return string | nil +function OrgLinkUrl:get_id() + if not self:is_id() then + return nil + end + return self.path +end + ---@private ---@return string function OrgLinkUrl:_get_real_path() return fs.get_real_path(self.path) or self.path end +---@return string +function OrgLinkUrl:to_string() + return self.url +end + ---@private function OrgLinkUrl:_parse() self.protocol = self.url:match('^(%w+):') diff --git a/lua/orgmode/utils/fs.lua b/lua/orgmode/utils/fs.lua index a131773b7..e68bedc3f 100644 --- a/lua/orgmode/utils/fs.lua +++ b/lua/orgmode/utils/fs.lua @@ -30,7 +30,7 @@ function M.get_real_path(filepath) return false end local real = vim.loop.fs_realpath(substituted) - if filepath:sub(-1, -1) == '/' then + if real and filepath:sub(-1, -1) == '/' then -- make sure if filepath gets a trailing slash, the realpath gets one, too. real = real .. '/' end diff --git a/tests/plenary/org/links/hyperlink_spec.lua b/tests/plenary/org/links/hyperlink_spec.lua new file mode 100644 index 000000000..06d5934cd --- /dev/null +++ b/tests/plenary/org/links/hyperlink_spec.lua @@ -0,0 +1,114 @@ +local Hyperlink = require('orgmode.org.links.hyperlink') + +describe('Hyperlink.at_pos', function() + ---@param obj any sut + ---@param col number cursor position in line + local function assert_valid_link_at(obj, col) + assert(obj, string.format('%q at pos %d', obj, col)) + end + + ---@param property string 'url' or 'desc' + ---@param obj any sut + ---@param col number cursor position in line + ---@param exp any + local function assert_valid_link_property_at(property, obj, col, exp) + local msg = function(_exp) + return string.format('%s: Expected to be %s at %s, actually %q.', property, _exp, col, obj) + end + if exp then + assert(obj == exp, msg(exp)) + else + assert(obj ~= nil, msg('valid')) + end + end + + ---@param property string 'url' or 'desc' + ---@param line string line of an orgfile + ---@param col number cursor position in line + local function assert_empty_link_property_at(property, line, col) + assert(line == nil, string.format("%s: Expected to be 'nil' at %s, actually %q.", property, col, line)) + end + + ---@param line string line of an orgfile + ---@param lb number position of left outer bracket of the link within the line + ---@param rb number position of right outer bracket of the link within the line + local function assert_link_in_range(line, lb, rb, opt) + for pos = lb, rb do + local link = Hyperlink.at_pos(line, pos) + assert_valid_link_at(link, pos) + if not link then + return + end + assert_valid_link_property_at('url', link.url, pos) + assert_valid_link_property_at('url', link.url:to_string(), pos, opt and opt.url) + if not opt or not opt.desc then + assert_empty_link_property_at('desc', link.desc, pos) + elseif opt and opt.desc then + assert_valid_link_property_at('desc', link.desc, pos, opt.desc) + else + assert(false, string.format('invalid opt %s', opt)) + end + end + end + + local function assert_not_link_in_range(line, lb, rb) + for pos = lb, rb do + local nil_link = Hyperlink.at_pos(line, pos) + assert( + not nil_link, + string.format('Expected no link between %s and %s, got actually %q', lb, rb, nil_link and nil_link:to_str()) + ) + end + end + + it('should not be empty like [[]]', function() + local line = '[[]]' + assert_not_link_in_range(line, 1, #line) + end) + it('should not be empty like [[][]]', function() + local line = '[[][]]' + assert_not_link_in_range(line, 1, #line) + end) + it('should not have an empty url like [[][some description]]', function() + local line = '[[][some description]]' + assert_not_link_in_range(line, 1, #line) + end) + it('could have an empty description like [[someurl]]', function() + local line = '[[someurl]]' + assert_link_in_range(line, 1, #line) + local link_str = Hyperlink.at_pos(line, 1):to_str() + assert(link_str == line, string.format('Expected %q, actually %q', line, link_str)) + end) + it('should parse valid [[somefile][Some Description]]', function() + local line = '[[somefile][Some Description]]' + assert_link_in_range(line, 1, #line, { url = 'somefile', desc = 'Some Description' }) + end) + it('should find link at valid positions in "1...5[[u_1][desc_1]]21.[[u_2]]...35[[u_3][desc_2]]51......60"', function() + local line = '1...5[[u_1][desc_1]]21.[[u_2]]...35[[u_3][desc_2]]51......60' + assert_not_link_in_range(line, 1, 5) + assert_link_in_range(line, 6, 20, { url = 'u_1', desc = 'desc_1' }) + assert_not_link_in_range(line, 21, 23) + assert_link_in_range(line, 24, 30, { url = 'u_2' }) + assert_not_link_in_range(line, 33, 35) + assert_link_in_range(line, 36, 50, { url = 'u_3', desc = 'desc_2' }) + assert_not_link_in_range(line, 51, 60) + end) + it('should resolve a relative file path', function() + local examples = { + { + '- [ ] Look here: [[file:./../sibling-folder/somefile.org::*some headline][some description]]', + { 3, 4, 5 }, + { 20, 90 }, + }, + } + for _, o in ipairs(examples) do + local line, valid_cols, invalid_cols = o[1], o[2], o[3] + for _, valid_pos in ipairs(valid_cols) do + assert_valid_link_at(line, valid_pos) + end + for _, invalid_pos in ipairs(invalid_cols) do + assert_valid_link_at(line, invalid_pos) + end + end + end) +end) diff --git a/tests/plenary/org/links/url_spec.lua b/tests/plenary/org/links/url_spec.lua new file mode 100644 index 000000000..416cc3cb3 --- /dev/null +++ b/tests/plenary/org/links/url_spec.lua @@ -0,0 +1,92 @@ +local OrgLinkUrl = require('orgmode.org.links.url') +describe('OrgLinkUrl', function() + describe('File url', function() + it('should parse absolute url', function() + local result = OrgLinkUrl:new('/path/to/some/file.org') + assert.are.same('/path/to/some/file.org', result.path) + assert.are.same('/path/to/some/file.org', result:get_file_path()) + assert.is.Nil(result.target) + assert.is.Nil(result.protocol) + end) + + it('should parse relative url', function() + local result = OrgLinkUrl:new('./path/to/relative/file.org') + assert.are.same('./path/to/relative/file.org', result.path) + assert.are.same('./path/to/relative/file.org', result:get_file_path()) + assert.is.Nil(result.target) + assert.is.Nil(result.protocol) + end) + it('should parse absolute url with protocol', function() + local result = OrgLinkUrl:new('file:/path/to/some/file.org') + assert.are.same('/path/to/some/file.org', result.path) + assert.is.Nil(result.target) + assert.are.same('file', result.protocol) + end) + it('should parse relative url with protocol', function() + local result = OrgLinkUrl:new('file:./path/to/relative/file.org') + assert.are.same('./path/to/relative/file.org', result.path) + assert.are.same('./path/to/relative/file.org', result:get_file_path()) + assert.is.Nil(result.target) + assert.are.same('file', result.protocol) + end) + it('should return proper checks', function() + local result = OrgLinkUrl:new('file:./path/to/relative/file.org') + assert.is.False(result:is_id()) + assert.are.same('./path/to/relative/file.org', result:get_file_path()) + end) + end) + + describe('Target url', function() + it('should parse absolute url and target', function() + local result = OrgLinkUrl:new('/path/to/some/file.org::*Headline') + assert.are.same('/path/to/some/file.org', result.path) + assert.are.same('/path/to/some/file.org', result:get_file_path()) + assert.are.same('*Headline', result.target) + assert.is.Nil(result.protocol) + end) + it('should parse relative url and target', function() + local result = OrgLinkUrl:new('./path/to/relative/file.org::*Headline') + assert.are.same('./path/to/relative/file.org', result.path) + assert.are.same('./path/to/relative/file.org', result:get_file_path()) + assert.are.same('*Headline', result.target) + assert.is.Nil(result.protocol) + end) + it('should parse absolute url with protocol and target', function() + local result = OrgLinkUrl:new('file:/path/to/some/file.org::*Headline') + assert.are.same('/path/to/some/file.org', result.path) + assert.are.same('/path/to/some/file.org', result:get_file_path()) + assert.are.same('*Headline', result.target) + assert.are.same('file', result.protocol) + end) + it('should parse relative url with protocol and headline', function() + local result = OrgLinkUrl:new('file:./path/to/relative/file.org::*Headline') + assert.are.same('./path/to/relative/file.org', result.path) + assert.are.same('*Headline', result.target) + assert.are.same('file', result.protocol) + end) + end) + + describe('Id url', function() + it('should parse id as path', function() + local result = OrgLinkUrl:new('id:6f48b815-9d7a-413f-80b3-e52fb50f97d8') + assert.are.same('6f48b815-9d7a-413f-80b3-e52fb50f97d8', result.path) + assert.is.Nil(result:get_file_path()) + assert.is.Nil(result.target) + assert.are.same('id', result.protocol) + end) + + it('should parse id with target', function() + local result = OrgLinkUrl:new('id:6f48b815-9d7a-413f-80b3-e52fb50f97d8::*Headline') + assert.are.same('6f48b815-9d7a-413f-80b3-e52fb50f97d8', result.path) + assert.is.Nil(result:get_file_path()) + assert.are.same('*Headline', result.target) + assert.are.same('id', result.protocol) + end) + + it('should return proper checks', function() + local result = OrgLinkUrl:new('id:6f48b815-9d7a-413f-80b3-e52fb50f97d8') + assert.is.True(result:is_id()) + assert.are.same('6f48b815-9d7a-413f-80b3-e52fb50f97d8', result:get_id()) + end) + end) +end)