diff --git a/lua/orgmode/api/file.lua b/lua/orgmode/api/file.lua index 87b2695d1..a7fc9e28d 100644 --- a/lua/orgmode/api/file.lua +++ b/lua/orgmode/api/file.lua @@ -1,6 +1,5 @@ ---@diagnostic disable: invisible local OrgHeadline = require('orgmode.api.headline') -local Hyperlinks = require('orgmode.org.hyperlinks') local org = require('orgmode') ---@class OrgApiFile @@ -94,6 +93,22 @@ function OrgFile:get_closest_headline(cursor) return nil end +---@param file OrgFile +---@param path? string +local function get_link_to_file(file, path) + 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 + + path = path or file.filename + return ('file:%s::*%s'):format(path, title) +end + --- Get a link destination as string --- --- Depending if org_id_link_to_org_use_id is set the format is @@ -112,12 +127,12 @@ function OrgFile:get_link() -- do remote edit return org.files :update_file(filename, function(file) - return Hyperlinks.get_link_to_file(file) + return get_link_to_file(file) end) :wait() end - return Hyperlinks.get_link_to_file(self._file) + return get_link_to_file(self._file) end return OrgFile diff --git a/lua/orgmode/api/headline.lua b/lua/orgmode/api/headline.lua index eb8215028..f2ec0399f 100644 --- a/lua/orgmode/api/headline.lua +++ b/lua/orgmode/api/headline.lua @@ -1,10 +1,10 @@ +local utils = require('orgmode.utils') local OrgPosition = require('orgmode.api.position') local config = require('orgmode.config') local PriorityState = require('orgmode.objects.priority_state') local Date = require('orgmode.objects.date') local Calendar = require('orgmode.objects.calendar') local Promise = require('orgmode.utils.promise') -local Hyperlinks = require('orgmode.org.hyperlinks') local org = require('orgmode') ---@class OrgApiHeadline @@ -263,6 +263,22 @@ function OrgHeadline:_do_action(action) end) end +---@param headline OrgHeadline +---@param path? string +local function get_link_to_headline(headline, path) + 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 + + path = path or utils.current_file_path() + return ('file:%s::*%s'):format(path, title) +end + --- Get a link destination as string --- --- Depending if org_id_link_to_org_use_id is set the format is @@ -281,12 +297,12 @@ function OrgHeadline:get_link() -- do remote edit return org.files :update_file(filename, function(_) - return Hyperlinks.get_link_to_headline(self._section) + return get_link_to_headline(self._section) end) :wait() end - return Hyperlinks.get_link_to_headline(self._section) + return get_link_to_headline(self._section) end return OrgHeadline diff --git a/lua/orgmode/api/init.lua b/lua/orgmode/api/init.lua index cf7926da5..1ec129f08 100644 --- a/lua/orgmode/api/init.lua +++ b/lua/orgmode/api/init.lua @@ -1,7 +1,8 @@ ---@diagnostic disable: invisible local OrgFile = require('orgmode.api.file') local OrgHeadline = require('orgmode.api.headline') -local Hyperlinks = require('orgmode.org.hyperlinks') +local Link = require('orgmode.org.hyperlinks.link') +local HyperLink = require('orgmode.org.hyperlinks') local orgmode = require('orgmode') ---@class OrgApiRefileOpts @@ -110,7 +111,19 @@ end --- @param link_location string --- @return boolean function OrgApi.insert_link(link_location) - Hyperlinks.insert_link(link_location) + local link = Link.parse(link_location) + if not link then + return false + end + + local desc = nil + if link.target and link.target.headline then + desc = link.target.headline + end + + HyperLink.insert_link(HyperLink:new(link, desc)) + + return true end return OrgApi diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index d877bcfcc..5485494b4 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -1,3 +1,5 @@ +local default_hyperlinks = require('orgmode.org.hyperlinks.builtin') + ---@class OrgDefaultConfig ---@field org_id_method 'uuid' | 'ts' | 'org' ---@field org_agenda_span 'day' | 'week' | 'month' | 'year' | number @@ -208,6 +210,7 @@ local DefaultConfig = { handler = nil, }, }, + hyperlinks = default_hyperlinks, } return DefaultConfig diff --git a/lua/orgmode/config/init.lua b/lua/orgmode/config/init.lua index 2cc7fb55e..67b84ff54 100644 --- a/lua/orgmode/config/init.lua +++ b/lua/orgmode/config/init.lua @@ -6,6 +6,8 @@ local defaults = require('orgmode.config.defaults') local mappings = require('orgmode.config.mappings') local TodoKeywords = require('orgmode.objects.todo_keywords') local PriorityState = require('orgmode.objects.priority_state') +local Alias = require('orgmode.org.hyperlinks.builtin.alias') +local Link = require('orgmode.org.hyperlinks.link') ---@class OrgConfig:OrgDefaultConfig ---@field opts table @@ -53,6 +55,7 @@ function Config:extend(opts) opts.org_priority_lowest = self.opts.org_priority_lowest opts.org_priority_default = self.opts.org_priority_default end + opts.hyperlinks = self:_process_links(opts.hyperlinks) self.opts = vim.tbl_deep_extend('force', self.opts, opts) if self.org_startup_indented then self.org_adapt_indentation = not self.org_indent_mode_turns_off_org_adapt_indentation @@ -60,6 +63,79 @@ function Config:extend(opts) return self end +function Config:_process_links(links) + if not (links or type(links) == table) then + return nil + end + + local processed = {} + + for protocol, hyperlink in pairs(links) do + if type(hyperlink) == 'string' then + if not protocol then + utils.echo_warning(('A link alias must have a protocol key. Skipped %s'):format(hyperlink)) + else + hyperlink = self:_process_link_alias(protocol, hyperlink) + processed[hyperlink.protocol] = hyperlink + end + goto continue + end + + if type(hyperlink) == 'table' then + hyperlink = self:_process_link_table(protocol, hyperlink) + if hyperlink then + processed[hyperlink.protocol] = hyperlink + end + end + + ::continue:: + end + + return processed +end + +function Config:_process_link_table(protocol, link) + if not link.parse or not (type(link.parse) == 'function') then + utils.echo_warning("A link must have a 'parse' function.") + return + end + + if not link.protocol then + if not protocol then + utils.echo_warning('A link must have a protocol.') + return + end + link.protocol = protocol + end + + -- Inherit basics from Link class + for k, v in pairs(Link) do + if not link[k] then + link[k] = v + end + end + + return link +end + +function Config:_process_link_alias(protocol, alias) + local components = {} + local expression = vim.regex([[%s\|%h\|%(.-)]]) + repeat + local start_special, end_special = expression:match_str(alias) + if not start_special then + table.insert(components, alias) + break + end + + table.insert(components, alias:sub(0, start_special)) + table.insert(components, alias:sub(start_special + 1, end_special)) + alias = alias:sub(end_special + 1) + until #alias <= 0 + + return Alias(protocol, components) +end + function Config:_are_priorities_valid(opts) local high = opts.org_priority_highest local low = opts.org_priority_lowest @@ -97,7 +173,7 @@ function Config:_are_priorities_valid(opts) ) return false end - -- one-char strings + -- one-char strings elseif (type(high) == 'string' and #high == 1) and (type(low) == 'string' and #low == 1) diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 269339ead..3c72b22c1 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -5,7 +5,7 @@ local Headline = require('orgmode.files.headline') local ts = vim.treesitter local config = require('orgmode.config') local Block = require('orgmode.files.elements.block') -local Link = require('orgmode.org.hyperlinks.link') +local HyperLink = require('orgmode.org.hyperlinks') local Range = require('orgmode.files.elements.range') local Memoize = require('orgmode.utils.memoize') @@ -715,7 +715,7 @@ function OrgFile:get_archive_file_location() end memoize('get_links') ----@return OrgLink[] +---@return OrgHyperLink[] function OrgFile:get_links() self:parse(true) local ts_query = ts_utils.get_query([[ @@ -729,7 +729,7 @@ function OrgFile:get_links() for _, match in ts_query:iter_captures(self.root, self:_get_source()) do local line = match:start() if not processed_lines[line] then - vim.list_extend(links, Link.all_from_line(self.lines[line + 1], line + 1)) + vim.list_extend(links, HyperLink.all_from_line(self.lines[line + 1], line + 1)) processed_lines[line] = true end end diff --git a/lua/orgmode/files/init.lua b/lua/orgmode/files/init.lua index 5ca9a7d0b..7cd81e532 100644 --- a/lua/orgmode/files/init.lua +++ b/lua/orgmode/files/init.lua @@ -273,6 +273,16 @@ function OrgFiles:find_files_with_property(property_name, term) return files end +---@param property_name string +---@param term string +---@return OrgHeadline[] +function OrgFiles:find_files_with_property_matching(property_name, term) + return vim.tbl_filter(function(item) + local property = item:get_property(property_name) + return property and property:lower():match('^' .. vim.pesc(term:lower())) + end, self:all()) +end + ---@param term string ---@param no_escape boolean ---@param search_extra_files boolean diff --git a/lua/orgmode/org/autocompletion/sources/hyperlinks.lua b/lua/orgmode/org/autocompletion/sources/hyperlinks.lua index 1155baae3..e354b9a65 100644 --- a/lua/orgmode/org/autocompletion/sources/hyperlinks.lua +++ b/lua/orgmode/org/autocompletion/sources/hyperlinks.lua @@ -1,9 +1,11 @@ -local Hyperlinks = require('orgmode.org.hyperlinks') -local Link = require('orgmode.org.hyperlinks.link') +local HyperLink = require('orgmode.org.hyperlinks') + ---@class OrgCompletionHyperlinks:OrgCompletionSource ---@field completion OrgCompletion ---@field private pattern vim.regex -local OrgCompletionHyperlinks = {} +local OrgCompletionHyperlinks = { + stored_links = {}, +} OrgCompletionHyperlinks.__index = OrgCompletionHyperlinks ---@param opts { completion: OrgCompletion } @@ -26,9 +28,10 @@ end ---@return string[] function OrgCompletionHyperlinks:get_results(context) - local link = Link:new(context.base) - local result, mapper = Hyperlinks.find_matching_links(link.url) - return mapper(result) + local hyperlinks = HyperLink:autocompletions(context.base) + return vim.tbl_map(function(hyperlink) + return hyperlink.link:__tostring() + end, hyperlinks) end return OrgCompletionHyperlinks diff --git a/lua/orgmode/org/hyperlinks/builtin/alias.lua b/lua/orgmode/org/hyperlinks/builtin/alias.lua new file mode 100644 index 000000000..ac56bdfba --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/alias.lua @@ -0,0 +1,40 @@ +local Link = require('orgmode.org.hyperlinks.link') + +return function(protocol, components) + for _, component in pairs(components) do + if not (type(component) == 'string') then + return nil + end + end + + ---@class OrgLinkAlias:OrgLink + local Alias = Link:new(protocol) + Alias.components = components + + ---@param input string + function Alias.parse(input) + local processed_components = {} + for _, component in pairs(Alias.components) do + ---@cast component string + if component == '%s' then + table.insert(processed_components, input) + goto continue + end + if component == '%h' then + table.insert(processed_components, vim.uri_encode(input)) + goto continue + end + if component:find('^%%%b()$') then + local func = component:sub(3, -2) + table.insert(processed_components, vim.fn.luaeval(('%s(_A)'):format(func), input)) + goto continue + end + table.insert(processed_components, component) + ::continue:: + end + + return Link.parse(table.concat(processed_components)) + end + + return Alias +end diff --git a/lua/orgmode/org/hyperlinks/builtin/custom_id.lua b/lua/orgmode/org/hyperlinks/builtin/custom_id.lua new file mode 100644 index 000000000..41bfe5244 --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/custom_id.lua @@ -0,0 +1,53 @@ +local utils = require('orgmode.utils') +local Org = require('orgmode') +local Internal = require('orgmode.org.hyperlinks.builtin.internal') + +---@class OrgLinkCustomId:OrgLinkInternal +local CustomId = Internal:new() + +function CustomId:new(custom_id) + ---@class OrgLinkCustomId + local this = Internal:new() + this.custom_id = custom_id + setmetatable(this, self) + self.__index = self + return this +end + +---@param input string +function CustomId.parse(input) + return CustomId:new(input) +end + +function CustomId:__tostring() + return string.format('#%s', self.custom_id) +end + +function CustomId:follow() + local headlines = Org.files:get_current_file():find_headlines_with_property_matching('custom_id', self.custom_id) + + if #headlines == 0 then + return utils.echo_warning(('Could not find custom ID "%s".'):format(self.custom_id)) + end + + self.goto_oneof(headlines) +end + +function CustomId:insert_description() + return self.custom_id +end + +function CustomId:complete(lead, context) + local file = self.get_file_from_context(context) + local headlines = file:find_headlines_with_property_matching('CUSTOM_ID', lead) + + local completions = {} + for _, headline in pairs(headlines) do + local id = headline:get_property('CUSTOM_ID') + table.insert(completions, self:new(id)) + end + + return completions +end + +return CustomId diff --git a/lua/orgmode/org/hyperlinks/builtin/file.lua b/lua/orgmode/org/hyperlinks/builtin/file.lua new file mode 100644 index 000000000..007905e01 --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/file.lua @@ -0,0 +1,139 @@ +local Org = require('orgmode') +local fs = require('orgmode.utils.fs') +local Link = require('orgmode.org.hyperlinks.link') +local Id = require('orgmode.org.hyperlinks.builtin.id') +local Internal = require('orgmode.org.hyperlinks.builtin.internal') +local Id = require('orgmode.org.hyperlinks.builtin.id') + +---@class OrgLinkFile:OrgLink +---@field new fun(self: OrgLinkFile, path: string, target: OrgLinkInternal | nil, prefix: boolean | nil): OrgLinkFile +---@field parse fun(link: string, prefix: boolean | nil): OrgLinkFile | nil +---@field path string +---@field skip_prefix boolean +---@field target OrgLinkInternal | nil +local File = Link:new('file') + +function File:new(path, target, skip_prefix) + ---@class OrgLinkFile + local this = Link:new() + this.skip_prefix = skip_prefix or false + this.path = path + this.target = target + setmetatable(this, self) + self.__index = self + return this +end + +function File.parse(input, skip_prefix) + if input == nil or #input == 0 then + return nil + end + local deliniator_start, deliniator_stop = input:find('::') + + ---@type OrgLinkInternal | nil + local target = nil + local path = input + + if deliniator_start then + ---@class OrgLinkInternal | nil + target = Internal.parse(input:sub(deliniator_stop + 1), true) + path = input:sub(0, deliniator_start - 1) + end + + return File:new(path, target, skip_prefix) +end + +-- TODO make protocol prefix optional. Based on what? +function File:__tostring() + local v = '' + if self.skip_prefix then + v = ('%s'):format(self.path) + else + v = ('%s:%s'):format(self.protocol, self.path) + end + + if self.target then + v = string.format('%s::%s', v, self.target) + end + + return v +end + +function File:follow() + vim.cmd('edit ' .. fs.get_real_path(self.path)) + + if self.target then + self.target:follow() + end +end + +local function autocompletions_filenames(lead) + local filenames = Org.files:filenames() + + local matches = {} + for _, f in ipairs(filenames) do + local realpath = fs.substitute_path(lead) or lead + if f:find('^' .. realpath) then + local path = f:gsub('^' .. realpath, lead) + table.insert(matches, { real = f, path = path }) + end + end + + print(vim.inspect(matches)) + return matches +end + +function File:resolve() + local path = fs.get_real_path(self.path) + if not path then + return self + end + local file = Org.files:get(path) + if not file then + return self + end + local id = file:get_property('id') + if not id then + return self + end + + return Id:new(id, self.target):resolve() +end + +function File:insert_description() + if self.target then + return self.target:insert_description() + end + + local path = fs.get_real_path(self.path) + if not path then + return nil + end + local file = Org.files:get(path) + if not file then + return nil + end + + return file:get_title() +end + +function File:complete(lead, context) + context = context or {} + local deliniator_start, deliniator_stop = lead:find('::') + + if not deliniator_start then + return vim.tbl_map(function(f) + return self:new(f.path, nil, context.skip_prefix) + end, autocompletions_filenames(lead)) + else + local path = lead:sub(0, deliniator_start - 1) + return vim.tbl_map(function(t) + return self:new(path, t, context.skip_prefix) + end, Internal:complete( + lead:sub(deliniator_stop + 1), + { filename = fs.get_real_path(path), only_internal = true } + )) + end +end + +return File diff --git a/lua/orgmode/org/hyperlinks/builtin/headline.lua b/lua/orgmode/org/hyperlinks/builtin/headline.lua new file mode 100644 index 000000000..3407c5b05 --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/headline.lua @@ -0,0 +1,68 @@ +local utils = require('orgmode.utils') +local Org = require('orgmode') +local Internal = require('orgmode.org.hyperlinks.builtin.internal') +local Id = require('orgmode.org.hyperlinks.builtin.id') + +---@class OrgLinkHeadline:OrgLinkInternal +local Headline = Internal:new() + +function Headline:new(headline) + ---@class OrgLinkHeadline + local this = Internal:new() + this.headline = headline + setmetatable(this, self) + self.__index = self + return this +end + +---@param input string +function Headline.parse(input) + return Headline:new(input) +end + +function Headline:__tostring() + return string.format('*%s', self.headline) +end + +function Headline:follow() + local headlines = Org.files:get_current_file():find_headlines_by_title(self.headline) + + if #headlines == 0 then + return utils.echo_warning(('Could not find headline "%s".'):format(self.headline)) + end + + self.goto_oneof(headlines) +end + +function Headline:resolve() + local headlines = Org.files:get_current_file():find_headlines_by_title(self.headline) + + if #headlines == 0 then + return self + end + + local id = headlines[1]:get_property('id') + if not id then + return self + end + + return Id:new(id):resolve() +end + +function Headline:insert_description() + return self.headline +end + +function Headline:complete(lead, context) + local file = self.get_file_from_context(context) + local headlines = file:find_headlines_by_title(lead) + + local completions = {} + for _, headline in pairs(headlines) do + table.insert(completions, Headline:new(headline:get_title())) + end + + return completions +end + +return Headline diff --git a/lua/orgmode/org/hyperlinks/builtin/http.lua b/lua/orgmode/org/hyperlinks/builtin/http.lua new file mode 100644 index 000000000..ef6b58f53 --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/http.lua @@ -0,0 +1,35 @@ +local utils = require('orgmode.utils') +local Link = require('orgmode.org.hyperlinks.link') + +---@class OrgLinkHttp:OrgLink +local Http = Link:new('http') + +function Http:new(url) + ---@class OrgLinkHttp + local this = Link:new() + this.url = url + setmetatable(this, self) + self.__index = self + return this +end + +---@param input string +function Http.parse(input) + return Http:new(input:gsub('^/*', '')) +end + +function Http:__tostring() + return string.format('%s://%s', self.protocol, self.url) +end + +function Http:follow() + if vim.ui['open'] then + return vim.ui.open(self:__tostring()) + end + if not vim.g.loaded_netrwPlugin then + return utils.echo_warning('Netrw plugin must be loaded in order to open urls.') + end + return vim.fn['netrw#BrowseX'](self:__tostring(), vim.fn['netrw#CheckIfRemote']()) +end + +return Http diff --git a/lua/orgmode/org/hyperlinks/builtin/https.lua b/lua/orgmode/org/hyperlinks/builtin/https.lua new file mode 100644 index 000000000..c6a8497c3 --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/https.lua @@ -0,0 +1,35 @@ +local utils = require('orgmode.utils') +local Link = require('orgmode.org.hyperlinks.link') + +---@class OrgLinkHttps:OrgLink +local Https = Link:new('https') + +function Https:new(url) + ---@class OrgLinkHttps + local this = Link:new() + this.url = url + setmetatable(this, self) + self.__index = self + return this +end + +---@param input string +function Https.parse(input) + return Https:new(input:gsub('^/*', '')) +end + +function Https:__tostring() + return string.format('%s://%s', self.protocol, self.url) +end + +function Https:follow() + if vim.ui['open'] then + return vim.ui.open(self:__tostring()) + end + if not vim.g.loaded_netrwPlugin then + return utils.echo_warning('Netrw plugin must be loaded in order to open urls.') + end + return vim.fn['netrw#BrowseX'](self:__tostring(), vim.fn['netrw#CheckIfRemote']()) +end + +return Https diff --git a/lua/orgmode/org/hyperlinks/builtin/id.lua b/lua/orgmode/org/hyperlinks/builtin/id.lua new file mode 100644 index 000000000..33e9ac5d9 --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/id.lua @@ -0,0 +1,113 @@ +local Org = require('orgmode') +local utils = require('orgmode.utils') +local Link = require('orgmode.org.hyperlinks.link') +local Internal = require('orgmode.org.hyperlinks.builtin.internal') + +---@class OrgLinkId:OrgLink +local Id = Link:new('id') + +function Id:new(id, target) + ---@class OrgLinkId + local this = Link:new() + this.id = id + this.target = target + setmetatable(this, self) + self.__index = self + return this +end + +---@param input string +function Id.parse(input) + if input == nil or #input == 0 then + return nil + end + local deliniator_start, deliniator_stop = input:find('::') + + ---@type OrgLinkInternal | nil + local target = nil + local path = input + + if not deliniator_start == nil then + ---@class OrgLinkInternal | nil + target = Internal.parse(input:sub(deliniator_stop + 1), true) + path = input:sub(0, deliniator_start - 1) + end + + return Id:new(path, target) +end + +function Id:follow() + local files = Org.files:find_files_with_property('id', self.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', self.id)) + end + vim.cmd(('edit %s'):format(files[1].filename)) + return + end + + local headlines = Org.files:find_headlines_with_property('id', self.id) + if #headlines == 0 then + return utils.echo_warning(string.format('No id "%s" found.', self.id)) + end + if #headlines > 1 then + return utils.echo_warning( + string.format('Multiple headlines found with id: %s, jumping to first one found', self.id) + ) + end + utils.goto_headline(headlines[1]) + + if self.target then + self.target:follow() + end +end + +function Id:__tostring() + local v = string.format('%s:%s', self.protocol, self.id) + + if self.target then + v = string.format('%s::%s', v, self.target) + end + + return v +end + +local function autocompletions_ids(lead) + local headlines = Org.files:find_headlines_with_property_matching('id', lead) + + local matches = {} + for _, headline in ipairs(headlines) do + local id = headline:get_property('id') + if id and id:find('^' .. lead) then + table.insert(matches, id) + end + end + + local files = Org.files:find_files_with_property_matching('id', lead) + for _, file in ipairs(files) do + local id = file:get_property('id') + if id and id:find('^' .. lead) then + table.insert(matches, id) + end + end + + return matches +end + +-- TODO Completion for targets +function Id:complete(lead) + local deliniator_start, deliniator_stop = lead:find('::') + + if not deliniator_start then + return vim.tbl_map(function(f) + return self:new(f) + end, autocompletions_ids(lead)) + else + local id = lead:sub(0, deliniator_start - 1) + return vim.tbl_map(function(t) + return self:new(id, t.label or t.link) + end, Internal:complete(lead:sub(deliniator_stop + 1), { id = id, only_internal = true })) + end +end + +return Id diff --git a/lua/orgmode/org/hyperlinks/builtin/init.lua b/lua/orgmode/org/hyperlinks/builtin/init.lua new file mode 100644 index 000000000..3e928fb71 --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/init.lua @@ -0,0 +1,13 @@ +local Http = require('orgmode.org.hyperlinks.builtin.http') +local Https = require('orgmode.org.hyperlinks.builtin.https') +local File = require('orgmode.org.hyperlinks.builtin.file') +local Id = require('orgmode.org.hyperlinks.builtin.id') +local Internal = require('orgmode.org.hyperlinks.builtin.internal') + +return { + Internal, + [Http.protocol] = Http, + [Https.protocol] = Https, + [File.protocol] = File, + [Id.protocol] = Id, +} diff --git a/lua/orgmode/org/hyperlinks/builtin/internal.lua b/lua/orgmode/org/hyperlinks/builtin/internal.lua new file mode 100644 index 000000000..ef2235424 --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/internal.lua @@ -0,0 +1,114 @@ +local Org = require('orgmode') +local utils = require('orgmode.utils') +local Link = require('orgmode.org.hyperlinks.link') + +---@class OrgLinkInternal:OrgLink +local Internal = Link:new() + +function Internal:new(protocol) + ---@class OrgLinkInternal + local this = Link:new(protocol) + setmetatable(this, self) + self.__index = self + return this +end + +---@param target string | nil +---@param disallow_file boolean? +---@return OrgLinkInternal | OrgLinkFile | nil +function Internal.parse(target, disallow_file) + local Headline = require('orgmode.org.hyperlinks.builtin.headline') + local CustomId = require('orgmode.org.hyperlinks.builtin.custom_id') + local LineNumber = require('orgmode.org.hyperlinks.builtin.line_number') + local File = require('orgmode.org.hyperlinks.builtin.file') + local Plain = require('orgmode.org.hyperlinks.builtin.plain') + if target == nil then + return nil + end + if target:match('^*') then + return Headline.parse(target:sub(2)) + end + if target:match('^#') then + return CustomId.parse(target:sub(2)) + end + if target:match('^#d+$') then + return LineNumber.parse(target) + end + if not disallow_file and target:match('^~?/') or target:match('^%.%.?/') then + return File.parse(target, true) + end + + return Plain.parse(target) +end + +---@param headlines OrgHeadline[] +function Internal.goto_oneof(headlines) + if #headlines == 0 then + return + end + + local headline = headlines[1] + if #headlines > 1 then + 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( + '%-' .. math.ceil(math.log(#headlines, 10)) .. '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 + end + headline = headlines[choice] + end + + return utils.goto_headline(headline) +end + +function Internal.get_file_from_context(context) + context = context or {} + local file = nil + if context.id then + file = (Org.files:find_files_with_property('ID', context.id) or {})[1] + elseif context.filename then + file = Org.files:get(context.filename) + end + + return file or Org.files:get_current_file() +end + +function Internal:complete(lead, context) + context = context or {} + local Headline = require('orgmode.org.hyperlinks.builtin.headline') + local CustomId = require('orgmode.org.hyperlinks.builtin.custom_id') + local LineNumber = require('orgmode.org.hyperlinks.builtin.line_number') + local File = require('orgmode.org.hyperlinks.builtin.file') + local Plain = require('orgmode.org.hyperlinks.builtin.plain') + + if lead:match('^*') then + return Headline:complete(lead:sub(2), context) + end + if lead:match('^#') then + return CustomId:complete(lead:sub(2), context) + end + if lead:match('^#d+$') then + return LineNumber:complete(lead, context) + end + if not context.only_internal and lead:match('^~?/') or lead:match('^%.%.?/') then + return File:complete(lead, vim.tbl_extend('force', context, { skip_prefix = true })) + end + + return Plain:complete(lead, context) +end + +return Internal diff --git a/lua/orgmode/org/hyperlinks/builtin/line_number.lua b/lua/orgmode/org/hyperlinks/builtin/line_number.lua new file mode 100644 index 000000000..be203e8df --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/line_number.lua @@ -0,0 +1,28 @@ +local Internal = require('orgmode.org.hyperlinks.builtin.internal') + +---@class OrgLinkLineNumber:OrgLinkInternal +local LineNumber = Internal:new() + +function LineNumber:new(line_number) + ---@class OrgLinkLineNumber + local this = Internal:new() + this.line_number = line_number + setmetatable(this, self) + self.__index = self + return this +end + +---@param input string +function LineNumber.parse(input) + return LineNumber:new(tonumber(input)) +end + +function LineNumber:__tostring() + return string.format('%d', self.line_number) +end + +function LineNumber:follow() + vim.cmd(('normal! %dGzv'):format(self.line_number)) +end + +return LineNumber diff --git a/lua/orgmode/org/hyperlinks/builtin/plain.lua b/lua/orgmode/org/hyperlinks/builtin/plain.lua new file mode 100644 index 000000000..4cb0adc3f --- /dev/null +++ b/lua/orgmode/org/hyperlinks/builtin/plain.lua @@ -0,0 +1,69 @@ +local utils = require('orgmode.utils') +local Org = require('orgmode') +local Internal = require('orgmode.org.hyperlinks.builtin.internal') + +---@class OrgLinkPlain:OrgLinkInternal +local Plain = Internal:new() + +function Plain:new(text) + ---@class OrgLinkPlain + local this = Internal:new() + this.text = text + setmetatable(this, self) + self.__index = self + return this +end + +---@param input string +function Plain.parse(input) + return Plain:new(input) +end + +function Plain:__tostring() + return self.text +end + +function Plain:follow() + local anchors = vim.fn.matchbufline(0, ('<<]*>>>?'):format(self.text), 0, '$') + + if #anchors >= 1 then + vim.fn.cursor(anchors[1].lnum, anchors[1].byteidx) + return + end + + -- TODO from here, behaviour should depend on org-link-search-must-match-exact-headline + -- TODO #+NAME tag support should be added, but it didn't exists yet + + local plain_text_matches = vim.fn.matchbufline(0, self.text, 0, '$') + + if #plain_text_matches >= 1 then + vim.fn.cursor(plain_text_matches[1].lnum, plain_text_matches[1].byteidx) + return + end + + return utils.echo_warning(('No matches found for "%s".'):format(self.text)) +end + +function Plain:insert_description() + return self.text +end + +-- TODO #+NAME tag support should be added, but it didn't exists yet +function Plain:complete(lead, context) + local file = self.get_file_from_context(context) + local completions = {} + + local anchors = file.content:gmatch(('<<]*>>>?'):format(lead)) + for anchor in anchors do + table.insert(completions, Plain:new(anchor)) + end + + local headlines = file:find_headlines_by_title(lead) + for _, headline in pairs(headlines) do + table.insert(completions, Plain:new(headline:get_title())) + end + + return completions +end + +return Plain diff --git a/lua/orgmode/org/hyperlinks/init.lua b/lua/orgmode/org/hyperlinks/init.lua index 39608c12b..05e2589b6 100644 --- a/lua/orgmode/org/hyperlinks/init.lua +++ b/lua/orgmode/org/hyperlinks/init.lua @@ -1,271 +1,255 @@ -local org = require('orgmode') local utils = require('orgmode.utils') -local fs = require('orgmode.utils.fs') -local Url = require('orgmode.org.hyperlinks.url') local Link = require('orgmode.org.hyperlinks.link') -local config = require('orgmode.config') -local Hyperlinks = { +local Range = require('orgmode.files.elements.range') + +---@class OrgHyperLink +---@field link OrgLink +---@field desc? string +---@field range? OrgRange +---@field stored_links OrgHyperLink[] +local HyperLink = { stored_links = {}, } ----@param url OrgUrl -local function get_file_from_url(url) - local file_path = url:get_file() - local canonical_path = file_path and fs.get_real_path(file_path) - return canonical_path and org.files:get(canonical_path) or org.files:get_current_file() -end +---@param self OrgHyperLink +---@param link string | OrgLink | nil +---@param desc string? +---@param range? OrgRange +---@return OrgHyperLink | nil +function HyperLink:new(link, desc, range) + local this = setmetatable({}, self) + self.__index = self ----@param url OrgUrl ----@return string[] -function Hyperlinks.find_by_filepath(url) - local filenames = org.files:filenames() - local file_base = url:get_file() - if not file_base then - return {} + if type(link) == 'string' then + link = Link.parse(link) end - --TODO integrate with orgmode.utils.fs or orgmode.objects.url - local valid_filenames = {} - for _, f in ipairs(filenames) do - if f:find('^' .. file_base) then - if url.realpath then - f = f:gsub(file_base, url.path) - end - table.insert(valid_filenames, f) - end + + if not link then + return nil + end + if vim.trim(desc or '') == '' then + desc = nil end - local protocol = url.protocol - local prefix = protocol and protocol == 'file' and 'file:' or '' + this.link = link + this.desc = desc + this.range = range - return vim.tbl_map(function(path) - return prefix .. path - end, valid_filenames) + return this end ----@param url OrgUrl ----@return OrgHeadline[] -function Hyperlinks.find_by_custom_id_property(url) - local custom_id = url:get_custom_id() or '' - local file = get_file_from_url(url) - return file:find_headlines_with_property_matching('CUSTOM_ID', custom_id) +function HyperLink:follow() + self.link:follow() end ----@param url OrgUrl ----@return fun(headlines: OrgHeadline[]): string[] -function Hyperlinks.as_custom_id_anchors(url) - local prefix = url:is_file_custom_id() and url:get_file_with_protocol() .. '::' or '' - return function(headlines) - return vim.tbl_map(function(headline) - ---@cast headline OrgHeadline - local custom_id = headline:get_property('custom_id') - return ('%s#%s'):format(prefix, custom_id) - end, headlines) +---@return string +function HyperLink:__tostring() + if self.desc then + return string.format('[[%s][%s]]', self.link, self.desc) + else + return string.format('[[%s]]', self.link) end end ----@param url OrgUrl ----@param omit_prefix? boolean ----@return fun(headlines: OrgHeadline[]): string[] -function Hyperlinks.as_headline_anchors(url, omit_prefix) - local prefix = url:is_file_headline() and url:get_file_with_protocol() .. '::' or '' - return function(headlines) - return vim.tbl_map(function(headline) - local title = (omit_prefix and '' or '*') .. headline:get_title() - return ('%s%s'):format(prefix, title) - end, headlines) +--- Given a string, tries to parse the start of the string as an Org hyperlink +--- Returns the link, then the description, then the rest of the string +---@param input string @ String with potential link at the start +---@return string?, string?, string? +local function parse_link(input) + -- Doesn't start with a link + if input:find('^%[%[') == nil then + return nil end -end ----@param url OrgUrl ----@return OrgHeadline[] -function Hyperlinks.find_by_title(url) - local headline = url:get_headline() - if not headline then - return {} - end - local file = get_file_from_url(url) - return file:find_headlines_by_title(headline) -end + local substr = input:sub(3) + local _, close = substr:find('[^\\]%]') + local _, open = substr:find('[^\\]%[') -function Hyperlinks.find_by_plain_title(url) - local headline = url:get_plain() - if not headline then - return {} + -- No closing ] -> invalid + if not close then + return nil end - return org.files:get_current_file():find_headlines_by_title(headline) -end - -local function as_dedicated_anchor_pattern(anchor_str) - return string.format('<<]*)>>>?', anchor_str):lower() -end ----@param url OrgUrl ----@return OrgHeadline[] -function Hyperlinks.find_by_dedicated_target(url) - local anchor = url:get_plain() - if not anchor then - return {} + -- Unescaped [ before unescaped ] means it's an invalid link + if open and close > open then + return nil end - return org.files:get_current_file():find_headlines_matching_search_term(as_dedicated_anchor_pattern(anchor), true) -end ----@param url OrgUrl ----@return fun(headlines: OrgHeadline[]): string[] -function Hyperlinks.as_dedicated_targets(url) - return function(headlines) - local targets = {} - local term = as_dedicated_anchor_pattern(url:get_plain()) - for _, headline in ipairs(headlines) do - for m in headline:get_title():lower():gmatch(term) do - table.insert(targets, m) - end - for _, content in ipairs(headline:content()) do - for m in content:lower():gmatch(term) do - table.insert(targets, m) - end - end - end - return targets - end -end + local link = substr:sub(0, close - 1) + substr = substr:sub(close) ----@param url OrgUrl ----@return fun(headlines: OrgHeadline[]): table -function Hyperlinks.as_dedicated_anchors_or_internal_titles(url) - return function(headlines) - local dedicated_anchors = Hyperlinks.as_dedicated_targets(url)(headlines) - local fuzzy_titles = Hyperlinks.as_headline_anchors(url, true)(headlines) - return utils.concat(dedicated_anchors, fuzzy_titles, true) + -- Link without description + if substr:find('^%]%]') then + return link, nil, substr:sub(3) end -end ----@param url OrgUrl ----@return OrgHeadline[], fun(headline: OrgHeadline[]): string[] -function Hyperlinks.find_matching_links(url) - local result = {} - local mapper = function(item) - return item - end - if not url then - return result, mapper - elseif url:is_custom_id() then - result = Hyperlinks.find_by_custom_id_property(url) - mapper = Hyperlinks.as_custom_id_anchors(url) - elseif url:is_headline() then - result = Hyperlinks.find_by_title(url) - mapper = Hyperlinks.as_headline_anchors(url) - elseif url:is_file_only() then - result = Hyperlinks.find_by_filepath(url) - elseif url:is_plain() then - result = utils.concat(Hyperlinks.find_by_dedicated_target(url), Hyperlinks.find_by_plain_title(url)) - mapper = Hyperlinks.as_dedicated_anchors_or_internal_titles(url) + -- Must have a description at this point, else it's invalid syntax + if substr:find('^%]%[') == nil then + return nil end - return result, mapper -end - ----@param headline OrgHeadline ----@param path? string -function Hyperlinks.get_link_to_headline(headline, path) - local title = headline:get_title() + substr = substr:sub(3) + local desc_end = substr:find('.%]%]') - 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 + -- Description must have content, and end at some point, else it's invalid syntax + if desc_end == nil then + return nil end - path = path or utils.current_file_path() - return ('file:%s::*%s'):format(path, title) + return link, substr:sub(0, desc_end), substr:sub(desc_end + 3) end ----@param file OrgFile ----@param path? string -function Hyperlinks.get_link_to_file(file, path) - local title = file:get_title() +---@param line string @ line contents +---@param line_number number? @ line number for range +---@return OrgHyperLink[] +function HyperLink.all_from_line(line, line_number) + local links = {} + local str = line + local pos = 0 - 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) + repeat + local start = str:find('[^\\]%[%[') + if start == nil then + break end - end - - path = path or file.filename - return ('file:%s::*%s'):format(path, title) -end + str = str:sub(start + 1) + pos = pos + start + 1 + + local link, desc, next_str = parse_link(str) + + if link then + local range = Range:new({ + start_line = line_number, + end_line = line_number, + start_col = pos, + end_col = pos + (#str - #next_str), + }) + links[#links + 1] = HyperLink:new(link, desc, range) + str = next_str + pos = range.end_col + 1 + else + str = str:sub(2) + end + until #str == 0 ----@param headline OrgHeadline -function Hyperlinks.store_link_to_headline(headline) - local title = headline:get_title() - Hyperlinks.stored_links[Hyperlinks.get_link_to_headline(headline)] = title + return links end ----@param arg_lead string ----@return string[] -function Hyperlinks.autocomplete_links(arg_lead) - local url = Url:new(arg_lead) - local result, mapper = Hyperlinks.find_matching_links(url) +---@param line string @ line contents +---@param pos number +---@return OrgHyperLink | nil +function HyperLink.at_pos(line, pos) + local links = HyperLink.all_from_line(line) - if url:is_file_only() or url:is_custom_id() or url:is_headline() then - return mapper(result) + for _, link in pairs(links) do + if link.range.start_col <= pos and link.range.end_col >= pos then + return link + end end - - return vim.tbl_keys(Hyperlinks.stored_links) + return nil end ----@return OrgLink|nil, table | nil -function Hyperlinks.get_link_under_cursor() +---@return OrgHyperLink|nil +function HyperLink.get_link_under_cursor() local line = vim.fn.getline('.') local col = vim.fn.col('.') or 0 - return Link.at_pos(line, col) + local link = HyperLink.at_pos(line, col) + if not link then + return nil + end + + local line_number = vim.fn.line('.') or 0 + link.range.start_line = line_number + link.range.end_line = line_number + return link end -function Hyperlinks.insert_link(link_location) - local selected_link = Link:new(link_location) - local desc = selected_link.url:get_target_value() +function HyperLink:insert_link() + local link_under_cursor = HyperLink.get_link_under_cursor() + local cursor_line = vim.fn.getline('.') + local line_pre + local line_post - if selected_link.url:is_id() then - link_location = ('id:%s'):format(selected_link.url:get_id()) + if link_under_cursor then + line_pre = cursor_line:sub(0, link_under_cursor.range.start_col) + line_post = cursor_line:sub(link_under_cursor.range.end_col) + else + local cursor_pos = vim.fn.col('.') + line_pre = cursor_line:sub(0, cursor_pos) + line_post = cursor_line:sub(cursor_pos + 1) end - local link_description = vim.trim(vim.fn.OrgmodeInput('Description: ', desc or '')) + self.link = self.link:resolve() + local link_str = self:__tostring() + local new_line = line_pre .. link_str .. line_post + + local linenr = vim.fn.line('.') + vim.fn.setline(linenr, new_line) + vim.fn.cursor(linenr, #line_pre + #link_str + 1) +end - link_location = '[' .. vim.trim(link_location) .. ']' +---@param link OrgLink? +---@param desc string? +function HyperLink:store_link(link, desc) + table.insert(self.stored_links, HyperLink:new(link, desc)) +end - if link_description ~= '' then - link_description = '[' .. link_description .. ']' +---@param lead string +---@return string[] +function HyperLink:autocompletions(lead) + if not lead then + return {} end - local insert_from - local insert_to - local target_col = #link_location + #link_description + 2 + local config = require('orgmode.config') + local _, protocol_deliniator = lead:find('[a-z0-9-_]*:') + local completions = {} - -- check if currently on link - local link, position = Hyperlinks.get_link_under_cursor() - if link and position then - insert_from = position.from - 1 - insert_to = position.to + 1 - target_col = target_col + position.from + -- If no protocol has been decided yet, search through the protocols and + -- show the ones that could match the lead. + -- Also check local file completions, for headlines, custom ids, and files + if not protocol_deliniator then + completions = utils.concat( + completions, + vim.tbl_filter(function(prot) + if type(prot) == 'string' and (lead == '' or prot:find('^' .. lead)) then + return true + end + return false + end, vim.tbl_keys(config.hyperlinks)) + ) + + completions = utils.concat( + completions, + vim.tbl_map(function(link) + return link:__tostring() + end, config.hyperlinks[1]:complete(lead)) + ) else - local colnr = vim.fn.col('.') - insert_from = colnr - insert_to = colnr + 1 - target_col = target_col + colnr + -- Protocol has been decided, we only need to check its autocompletions + local protocol = lead:sub(1, protocol_deliniator - 1) + lead = lead:sub(protocol_deliniator + 1) + local handler = config.hyperlinks[protocol] + + if handler then + completions = utils.concat( + completions, + + vim.tbl_map(function(link) + return link:__tostring() + end, handler:complete(lead)) + ) + end 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) + -- TODO filter on actually being relevant links + -- Should maybe be given priority? Didn't in original + for _, comp in pairs(self.stored_links) do + table.insert(completions, comp) + end - vim.fn.setline(linenr, new_line) - vim.fn.cursor(linenr, target_col) + print(vim.inspect(completions)) + return completions end -return Hyperlinks +return HyperLink diff --git a/lua/orgmode/org/hyperlinks/link.lua b/lua/orgmode/org/hyperlinks/link.lua index 63f320ba2..6c32ebd76 100644 --- a/lua/orgmode/org/hyperlinks/link.lua +++ b/lua/orgmode/org/hyperlinks/link.lua @@ -1,73 +1,58 @@ -local Url = require('orgmode.org.hyperlinks.url') -local Range = require('orgmode.files.elements.range') +local utils = require('orgmode.utils') ---@class OrgLink ----@field url OrgUrl ----@field desc string | nil ----@field range? OrgRange +---@field new fun(self: OrgLink, protocol?: string): OrgLink +---@field parse fun(input: string): OrgLink | nil +---@field complete fun(self: OrgLink, lead: string): OrgLink[] +---@field resolve fun(self: OrgLink): OrgLink +---@field insert_description fun(self: OrgLink): string | nil +---@field protocol string? local Link = {} -local pattern = '%[%[([^%]]+.-)%]%]' - ----@param str string ----@param range? OrgRange ----@return OrgLink -function Link:new(str, range) - local this = setmetatable({}, { __index = Link }) - local parts = vim.split(str, '][', { plain = true }) - this.url = Url:new(parts[1] or '') - this.desc = parts[2] - this.range = range +function Link:new(protocol) + local this = { protocol = protocol } + setmetatable(this, self) + self.__index = self return this end ----@return string -function Link: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()) +function Link.parse(input) + local config = require('orgmode.config') + + -- Finds singular : + local _, protocol_deliniator = input:find('[a-z0-9-_]*:') + + -- If no protocol is specified, fall back to internal links + if protocol_deliniator == nil then + return config.hyperlinks[1].parse(input) end -end ----@param line string ----@param pos number ----@return OrgLink | nil, table | nil -function Link.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 + local protocol = input:sub(1, protocol_deliniator - 1) + local target = input:sub(protocol_deliniator + 1) + for prot, handler in pairs(config.hyperlinks) do + if + (type(prot) == 'table' and vim.tbl_contains(prot, protocol)) + or (type(prot) == 'string' and prot == protocol) + then + return handler.parse(target) end - table.insert(links, current_pos) - end - if not found_link then - return nil, nil end - return Link:new(found_link), position end -function Link.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, Link:new(link, range)) - end - end +function Link:follow() + utils.echo_warning(string.format('Unsupported link protocol: %q', self.protocol)) +end + +function Link:resolve() + return self +end + +function Link:insert_description() + return nil +end - return links +function Link:complete(_) + return {} end return Link diff --git a/lua/orgmode/org/hyperlinks/url.lua b/lua/orgmode/org/hyperlinks/url.lua deleted file mode 100644 index ac1575f33..000000000 --- a/lua/orgmode/org/hyperlinks/url.lua +++ /dev/null @@ -1,275 +0,0 @@ -local fs = require('orgmode.utils.fs') ----@alias OrgUrlPathType 'file' | 'headline' | 'custom-id' | 'id' | 'external-url' | 'plain' | nil ----@alias OrgUrlTargetType 'headline' | 'custom-id' | 'line-number' | 'unknown' | nil - ----@class OrgUrl ----@field url string ----@field protocol string ----@field path string ----@field path_type OrgUrlPathType ----@field realpath string ----@field target { type: OrgUrlTargetType, value: string | number | nil } -local Url = {} -Url.__index = Url - ----@param url string -function Url:new(url) - local this = setmetatable({ - url = url or '', - }, Url) - this:_parse() - return this -end - ----@return string -function Url:to_string() - return self.url -end - ----@return string | number | nil -function Url:get_target_value() - return self.target and self.target.value -end - ----@return boolean -function Url:is_headline() - return self:is_file_headline() or self:is_internal_headline() -end - ----@return string | nil -function Url:get_custom_id() - if self:is_file_custom_id() then - return tostring(self.target.value) - end - if self:is_internal_custom_id() then - return self.path - end - return nil -end - ----@return string | nil -function Url:get_headline() - if self:is_file_headline() then - return tostring(self.target.value) - end - if self:is_internal_headline() then - return self.path - end - return nil -end - ----@return boolean -function Url:is_custom_id() - return self:is_file_custom_id() or self:is_internal_custom_id() -end - -function Url:is_id() - return self.protocol == 'id' -end - ----@return string | nil -function Url:get_id() - if not self:is_id() then - return nil - end - return self.path -end - ----@return boolean -function Url:is_file_line_number() - return self:is_file() and self:get_line_number() and true or false -end - ----@return boolean -function Url:is_file_headline() - return self:is_file() and self.target and self.target.type == 'headline' or false -end - ----@return boolean -function Url:is_plain() - return self.path_type == 'plain' -end - ----@return string | nil -function Url:get_plain() - if self:is_plain() then - return self.path - end - return nil -end - ----@return boolean -function Url:is_internal_headline() - return self.path_type == 'headline' -end - ----@return boolean -function Url:is_file_custom_id() - return self:is_file() and self.target and self.target.type == 'custom-id' or false -end - ----@return boolean -function Url:is_internal_custom_id() - return self.path_type == 'custom-id' -end - ----@return string | nil -function Url:get_file() - if not self:is_file() then - return nil - end - return self.realpath or self.path -end - ----@return string | nil -function Url:get_file_with_protocol() - if not self:is_file() then - return nil - end - if self.protocol and self.protocol ~= '' then - return table.concat({ self.protocol, self.path }, ':') - end - return self.path -end - ----@return number | nil -function Url:get_line_number() - if self.target and self.target.type == 'line-number' then - return tonumber(self.target.value) - end - return nil -end - ----@return boolean -function Url:is_file() - if self.protocol and self.protocol ~= 'file' then - return false - end - return self.path_type == 'file' -end - ----@return boolean -function Url:is_file_only() - return self:is_file() and not self.target -end - ----@return boolean -function Url:is_external_url() - return self:get_external_url() and true or false -end - ----@return string | nil -function Url:get_external_url() - if self.path_type == 'external-url' then - return self.path - end - return nil -end - ----@return boolean -function Url:is_supported_protocol() - if not self.protocol then - return true - end - return self.protocol == 'file' or self.protocol == 'id' or self.protocol:match('https?') -end - ----@private -function Url:_parse() - local path_and_target = vim.split(self.url, '::', { plain = true }) - - self:_parse_path_and_protocol(path_and_target[1]) - self:_parse_target(path_and_target[2]) -end - ----@private ----@param value string -function Url:_parse_path_and_protocol(value) - local path_and_protocol = vim.split(value, ':', { plain = true }) - - if #path_and_protocol >= 2 then - self.protocol = vim.trim(path_and_protocol[1]) - self.path = vim.trim(table.concat({ unpack(path_and_protocol, 2, #path_and_protocol) }, '')) - else - self.path = vim.trim(path_and_protocol[1]) - end - - self:_parse_legacy_line_number() - self:_parse_path_type() -end - ----@private -function Url:_parse_legacy_line_number() - -- Parse legacy line number syntax - if self.path:match('%s+%+%d+$') then - self.target = { type = 'line-number', value = tonumber(self.path:match('%s+%+(%d+)$')) or 0 } - self.path = self.path:match('^(.-)%s+%+%d+$') - end -end - ----@private ----@param value string -function Url:_parse_target(value) - local target = value and vim.trim(value) or nil - if not target or target == '' then - return - end - self.target = { type = 'unknown', value = target } - if target:find('^%d+$') then - self.target.type = 'line-number' - self.target.value = tonumber(target) or 0 - elseif target:find('^*') then - self.target.type = 'headline' - self.target.value = target:sub(2) - elseif target:find('^#') then - self.target.type = 'custom-id' - self.target.value = target:sub(2) - end -end - ----@private ----@return OrgUrlPathType -function Url:_parse_path_type() - local protocol = self.protocol or '' - if protocol == 'file' or protocol == 'id' then - self.path_type = protocol - return - end - - if protocol:match('https?') then - self.path_type = 'external-url' - return - end - - local first_char = self.path:sub(1, 1) - - if first_char == '/' then - self.path_type = 'file' - return - 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 - self.path_type = 'file' - self.realpath = fs.get_real_path(self.path) or self.path - return - end - - if first_char == '*' then - self.path_type = 'headline' - self.path = self.path:sub(2) - return - end - - if first_char == '#' then - self.path_type = 'custom-id' - self.path = self.path:sub(2) - return - end - - self.path_type = 'plain' -end - -return Url diff --git a/lua/orgmode/org/mappings.lua b/lua/orgmode/org/mappings.lua index 2da42bff4..2a2ae2307 100644 --- a/lua/orgmode/org/mappings.lua +++ b/lua/orgmode/org/mappings.lua @@ -2,19 +2,24 @@ local Calendar = require('orgmode.objects.calendar') local Date = require('orgmode.objects.date') local EditSpecial = require('orgmode.objects.edit_special') local Help = require('orgmode.objects.help') -local Hyperlinks = require('orgmode.org.hyperlinks') local PriorityState = require('orgmode.objects.priority_state') local TodoState = require('orgmode.objects.todo_state') local config = require('orgmode.config') local constants = require('orgmode.utils.constants') local ts_utils = require('orgmode.utils.treesitter') local utils = require('orgmode.utils') -local fs = require('orgmode.utils.fs') local Table = require('orgmode.files.elements.table') local EventManager = require('orgmode.events') local events = EventManager.event local Babel = require('orgmode.babel') -local ListItem = require('orgmode.files.elements.listitem') + +local Link = require('orgmode.org.hyperlinks.link') +local File = require('orgmode.org.hyperlinks.builtin.file') +local Plain = require('orgmode.org.hyperlinks.builtin.plain') +local CustomId = require('orgmode.org.hyperlinks.builtin.custom_id') +local Id = require('orgmode.org.hyperlinks.builtin.id') +local Headline = require('orgmode.org.hyperlinks.builtin.headline') +local HyperLink = require('orgmode.org.hyperlinks') ---@class OrgMappings ---@field capture OrgCapture @@ -775,22 +780,62 @@ function OrgMappings:_insert_heading_from_plain_line(suffix) end end +local function autocompletions(lead) + return HyperLink:autocompletions(lead) +end + -- Inserts a new link after the cursor position or modifies the link the cursor is -- currently on function OrgMappings:insert_link() - local link_location = vim.fn.OrgmodeInput('Links: ', '', Hyperlinks.autocomplete_links) - if vim.trim(link_location) == '' then + local link = vim.fn.OrgmodeInput('Links: ', '', autocompletions) + if vim.trim(link) == '' then utils.echo_warning('No Link selected') return end - Hyperlinks.insert_link(link_location) + link = Link.parse(link) + if not link then + utils.echo_warning('Unrecognised link format') + return + end + + local desc = link:insert_description() + desc = vim.trim(vim.fn.OrgmodeInput('Description: ', desc or '')) + + local hyperlink = HyperLink:new(link, desc) + if not hyperlink then + utils.echo_warning('Unrecognised link format. Unexpected, please contact developer') + return + end + hyperlink:insert_link() end function OrgMappings:store_link() + if not (vim.bo.filetype == 'org') then + HyperLink:store_link(File:new(vim.fn.expand('%:p'))) + end + + local cursor_pos = vim.fn.col('.') + local line = vim.fn.getline('.') + local target_start, target_stop, target = line:match('<<])*>>>?') + if target_start and target_start <= cursor_pos and target_stop >= cursor_pos then + HyperLink:store_link(Plain:new(target)) + end + local headline = self.files:get_closest_headline() - Hyperlinks.store_link_to_headline(headline) - return utils.echo_info('Stored: ' .. headline:get_title()) + local custom_id = headline:get_property('CUSTOM_ID', false) + if custom_id then + HyperLink:store_link(CustomId:new(custom_id), headline:get_title()) + else + HyperLink:store_link(Headline:new(headline:get_title()), headline:get_title()) + end + + if config.org_id_link_to_org_use_id then + local id = headline:id_get_or_create() + if id then + HyperLink:store_link(Id:new(id), headline:get_title()) + end + end end function OrgMappings:move_subtree_up() @@ -854,7 +899,7 @@ function OrgMappings:add_note() end function OrgMappings:open_at_point() - local link = Hyperlinks.get_link_under_cursor() + local link = HyperLink.get_link_under_cursor() if not link then local date = self:_get_date_under_cursor() if date then @@ -863,91 +908,7 @@ function OrgMappings:open_at_point() return end - -- handle external links (non-org or without org-specific line target) - - if link.url:is_id() then - local id = link.url:get_id() or '' - 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 - vim.cmd(('edit %s'):format(files[1].filename)) - return - end - - local headlines = self.files:find_headlines_with_property('id', id) - if #headlines == 0 then - return utils.echo_warning(string.format('No headline found with id: %s', id)) - end - if #headlines > 1 then - return utils.echo_warning(string.format('Multiple headlines found with id: %s', id)) - end - local headline = headlines[1] - return self:_goto_headline(headline) - end - - if link.url:is_file_line_number() then - local line_number = link.url:get_line_number() or 0 - local file_path = link.url:get_file() or utils.current_file_path() - local cmd = string.format('edit +%s %s', line_number, fs.get_real_path(file_path)) - vim.cmd(cmd) - return vim.cmd([[normal! zv]]) - end - - if link.url:is_external_url() then - if vim.ui['open'] then - return vim.ui.open(link.url:to_string()) - end - if not vim.g.loaded_netrwPlugin then - return utils.echo_warning('Netrw plugin must be loaded in order to open urls.') - end - return vim.fn['netrw#BrowseX'](link.url:to_string(), vim.fn['netrw#CheckIfRemote']()) - end - - if link.url:is_file_only() then - local file_path = link.url:get_file() - local cmd = file_path and string.format('edit %s', fs.get_real_path(file_path)) or '' - vim.cmd(cmd) - vim.cmd([[normal! zv]]) - end - - if link.url.protocol and not link.url:is_supported_protocol() then - utils.echo_warning(string.format('Unsupported link protocol: %q', link.url.protocol)) - return - end - - local headlines = Hyperlinks.find_matching_links(link.url) - local current_headline = self.files:get_closest_headline_or_nil() - if current_headline then - headlines = vim.tbl_filter(function(headline) - return not current_headline:is_same(headline) - end, headlines) - end - if #headlines == 0 then - return - end - local headline = headlines[1] - if #headlines > 1 then - 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 - end - headline = headlines[choice] - end - - return self:_goto_headline(headline) + link.link:follow() end function OrgMappings:export() diff --git a/lua/orgmode/utils/fs.lua b/lua/orgmode/utils/fs.lua index a131773b7..c79da337d 100644 --- a/lua/orgmode/utils/fs.lua +++ b/lua/orgmode/utils/fs.lua @@ -15,7 +15,7 @@ function M.substitute_path(path_str) return base .. '/' .. path_str:gsub('^%./', '') elseif path_str:match('^%.%./') then local base = vim.fn.fnamemodify(utils.current_file_path(), ':p:h') - return base .. '/' .. path_str + return vim.fn.fnamemodify(base .. '/' .. path_str, ':p') end return false end diff --git a/lua/orgmode/utils/init.lua b/lua/orgmode/utils/init.lua index 2caba3824..41c3c7c4b 100644 --- a/lua/orgmode/utils/init.lua +++ b/lua/orgmode/utils/init.lua @@ -591,4 +591,16 @@ function utils.detect_filetype(name) return name:lower() end +---@param headline OrgHeadline +function 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]]) +end + return utils diff --git a/tests/plenary/org/hyperlinks/link_spec.lua b/tests/plenary/org/hyperlinks/link_spec.lua deleted file mode 100644 index 5416d6feb..000000000 --- a/tests/plenary/org/hyperlinks/link_spec.lua +++ /dev/null @@ -1,114 +0,0 @@ -local Link = require('orgmode.org.hyperlinks.link') - -describe('Link.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 = Link.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 = Link.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 = Link.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/hyperlinks/url_spec.lua b/tests/plenary/org/hyperlinks/url_spec.lua deleted file mode 100644 index 3d1799c85..000000000 --- a/tests/plenary/org/hyperlinks/url_spec.lua +++ /dev/null @@ -1,422 +0,0 @@ -local Url = require('orgmode.org.hyperlinks.url') -describe('Url', function() - describe('File url', function() - it('should parse absolute url', function() - local result = Url:new('/path/to/some/file.org') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.is.Nil(result.target) - assert.is.Nil(result.protocol) - end) - it('should parse relative url', function() - local result = Url:new('./path/to/relative/file.org') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.is.Nil(result.target) - assert.is.Nil(result.protocol) - end) - it('should parse absolute url with protocol', function() - local result = Url:new('file:/path/to/some/file.org') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.is.Nil(result.target) - assert.are.same('file', result.protocol) - end) - it('should parse relative url with protocol', function() - local result = Url:new('file:./path/to/relative/file.org') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.is.Nil(result.target) - assert.are.same('file', result.protocol) - end) - it('should return proper checks', function() - local result = Url:new('file:./path/to/relative/file.org') - assert.is.True(result:is_file()) - assert.is.False(result:is_headline()) - assert.is.False(result:is_internal_headline()) - assert.is.False(result:is_file_headline()) - - assert.is.False(result:is_custom_id()) - assert.is.False(result:is_file_custom_id()) - assert.is.False(result:is_internal_custom_id()) - - assert.is.False(result:is_file_line_number()) - - assert.is.False(result:is_plain()) - - assert.are.same('./path/to/relative/file.org', result:get_file()) - end) - end) - - describe('Headline url', function() - it('should parse absolute url and headline', function() - local result = Url:new('/path/to/some/file.org::*Headline') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'headline', value = 'Headline' }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse relative url and headline', function() - local result = Url:new('./path/to/relative/file.org::*Headline') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'headline', value = 'Headline' }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse absolute url with protocol and headline', function() - local result = Url:new('file:/path/to/some/file.org::*Headline') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'headline', value = 'Headline' }, result.target) - assert.are.same('file', result.protocol) - end) - it('should parse relative url with protocol and headline', function() - local result = Url:new('file:./path/to/relative/file.org::*Headline') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'headline', value = 'Headline' }, result.target) - assert.are.same('file', result.protocol) - end) - - it('should return proper checks', function() - local result = Url:new('file:./path/to/relative/file.org::*Headline') - assert.is.True(result:is_file()) - - assert.is.True(result:is_headline()) - assert.is.False(result:is_internal_headline()) - assert.is.True(result:is_file_headline()) - - assert.is.False(result:is_custom_id()) - assert.is.False(result:is_file_custom_id()) - assert.is.False(result:is_internal_custom_id()) - - assert.is.False(result:is_file_line_number()) - assert.is.False(result:is_plain()) - assert.is.False(result:is_id()) - - assert.are.same('./path/to/relative/file.org', result:get_file()) - assert.are.same('Headline', result:get_headline()) - end) - end) - - describe('Custom id url', function() - it('should parse absolute url and custom id', function() - local result = Url:new('/path/to/some/file.org::#some-custom-id') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'custom-id', value = 'some-custom-id' }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse relative url and custom id', function() - local result = Url:new('./path/to/relative/file.org::#some-custom-id') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'custom-id', value = 'some-custom-id' }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse absolute url with protocol and custom id', function() - local result = Url:new('file:/path/to/some/file.org::#some-custom-id') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'custom-id', value = 'some-custom-id' }, result.target) - assert.are.same('file', result.protocol) - end) - it('should parse relative url with protocol and custom id', function() - local result = Url:new('file:./path/to/relative/file.org::#some-custom-id') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'custom-id', value = 'some-custom-id' }, result.target) - assert.are.same('file', result.protocol) - end) - it('should return proper checks', function() - local result = Url:new('file:./path/to/relative/file.org::#some-custom-id') - assert.is.True(result:is_file()) - assert.is.False(result:is_headline()) - assert.is.False(result:is_internal_headline()) - assert.is.False(result:is_file_headline()) - - assert.is.True(result:is_custom_id()) - assert.is.True(result:is_file_custom_id()) - assert.is.False(result:is_internal_custom_id()) - - assert.is.False(result:is_file_line_number()) - assert.is.False(result:is_plain()) - assert.is.False(result:is_id()) - - assert.are.same('./path/to/relative/file.org', result:get_file()) - assert.are.same('some-custom-id', result:get_custom_id()) - end) - end) - - describe('Line number url', function() - it('should parse absolute url and line number', function() - local result = Url:new('/path/to/some/file.org::125') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'line-number', value = 125 }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse relative url and line number', function() - local result = Url:new('./path/to/relative/file.org::125') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'line-number', value = 125 }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse absolute url with protocol and line number', function() - local result = Url:new('file:/path/to/some/file.org::125') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'line-number', value = 125 }, result.target) - assert.are.same('file', result.protocol) - end) - it('should parse relative url with protocol and line number', function() - local result = Url:new('file:./path/to/relative/file.org::125') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'line-number', value = 125 }, result.target) - assert.are.same('file', result.protocol) - end) - it('should return proper checks', function() - local result = Url:new('file:./path/to/relative/file.org::125') - assert.is.True(result:is_file()) - assert.is.False(result:is_headline()) - assert.is.False(result:is_internal_headline()) - assert.is.False(result:is_file_headline()) - - assert.is.False(result:is_custom_id()) - assert.is.False(result:is_file_custom_id()) - assert.is.False(result:is_internal_custom_id()) - - assert.is.True(result:is_file_line_number()) - assert.is.False(result:is_plain()) - assert.is.False(result:is_id()) - - assert.are.same('./path/to/relative/file.org', result:get_file()) - assert.are.same(125, result:get_line_number()) - end) - end) - - describe('Legacy line number url', function() - it('should parse absolute url and legacy line number', function() - local result = Url:new('/path/to/some/file.org +125') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'line-number', value = 125 }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse relative url and legacy line number', function() - local result = Url:new('./path/to/relative/file.org +125') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'line-number', value = 125 }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse absolute url with protocol and legacy line number', function() - local result = Url:new('file:/path/to/some/file.org +125') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'line-number', value = 125 }, result.target) - assert.are.same('file', result.protocol) - end) - it('should parse relative url with protocol and legacy line number', function() - local result = Url:new('file:./path/to/relative/file.org +125') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'line-number', value = 125 }, result.target) - assert.are.same('file', result.protocol) - end) - it('should return proper checks', function() - local result = Url:new('file:./path/to/relative/file.org::125') - assert.is.True(result:is_file()) - assert.is.False(result:is_headline()) - assert.is.False(result:is_internal_headline()) - assert.is.False(result:is_file_headline()) - - assert.is.False(result:is_custom_id()) - assert.is.False(result:is_file_custom_id()) - assert.is.False(result:is_internal_custom_id()) - - assert.is.True(result:is_file_line_number()) - - assert.is.False(result:is_plain()) - - assert.is.False(result:is_id()) - - assert.are.same('./path/to/relative/file.org', result:get_file()) - assert.are.same(125, result:get_line_number()) - end) - end) - - describe('Unknown target file url', function() - it('should parse absolute url and unknown target', function() - local result = Url:new('/path/to/some/file.org::some target') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'unknown', value = 'some target' }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse relative url and plain target', function() - local result = Url:new('./path/to/relative/file.org::some target') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'unknown', value = 'some target' }, result.target) - assert.is.Nil(result.protocol) - end) - it('should parse absolute url with protocol and plain target', function() - local result = Url:new('file:/path/to/some/file.org::some target') - assert.are.same('/path/to/some/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'unknown', value = 'some target' }, result.target) - assert.are.same('file', result.protocol) - end) - it('should parse relative url with protocol and plain target', function() - local result = Url:new('file:./path/to/relative/file.org::some target') - assert.are.same('./path/to/relative/file.org', result.path) - assert.are.same('file', result.path_type) - assert.are.same({ type = 'unknown', value = 'some target' }, result.target) - assert.are.same('file', result.protocol) - end) - it('should return proper checks', function() - local result = Url:new('file:./path/to/relative/file.org::some target') - assert.is.True(result:is_file()) - assert.is.False(result:is_headline()) - assert.is.False(result:is_internal_headline()) - assert.is.False(result:is_file_headline()) - - assert.is.False(result:is_custom_id()) - assert.is.False(result:is_file_custom_id()) - assert.is.False(result:is_internal_custom_id()) - - assert.is.False(result:is_file_line_number()) - - assert.is.False(result:is_plain()) - - assert.is.False(result:is_id()) - - assert.are.same('./path/to/relative/file.org', result:get_file()) - end) - end) - - describe('Internal headline url', function() - it('should parse internal headline', function() - local result = Url:new('*Some headline') - assert.are.same('Some headline', result.path) - assert.are.same('headline', result.path_type) - assert.is.Nil(result.target) - assert.is.Nil(result.protocol) - assert.are.same('headline', result.path_type) - end) - - it('should return proper checks', function() - local result = Url:new('*Some headline') - assert.is.False(result:is_file()) - assert.is.True(result:is_headline()) - assert.is.True(result:is_internal_headline()) - assert.is.False(result:is_file_headline()) - - assert.is.False(result:is_custom_id()) - assert.is.False(result:is_file_custom_id()) - assert.is.False(result:is_internal_custom_id()) - - assert.is.False(result:is_file_line_number()) - - assert.is.False(result:is_plain()) - - assert.is.False(result:is_id()) - - assert.are.same('Some headline', result:get_headline()) - end) - end) - - describe('Internal custom id url', function() - it('should parse internal custom id', function() - local result = Url:new('#some-custom-id') - assert.are.same('some-custom-id', result.path) - assert.are.same('custom-id', result.path_type) - assert.is.Nil(result.target) - assert.is.Nil(result.protocol) - assert.are.same('custom-id', result.path_type) - end) - - it('should return proper checks', function() - local result = Url:new('#some-custom-id') - assert.is.False(result:is_file()) - assert.is.False(result:is_headline()) - assert.is.False(result:is_internal_headline()) - assert.is.False(result:is_file_headline()) - - assert.is.True(result:is_custom_id()) - assert.is.False(result:is_file_custom_id()) - assert.is.True(result:is_internal_custom_id()) - - assert.is.False(result:is_file_line_number()) - - assert.is.False(result:is_plain()) - - assert.is.False(result:is_id()) - - assert.are.same('some-custom-id', result:get_custom_id()) - end) - end) - - describe('Plain url', function() - it('should parse plain url', function() - local result = Url:new('something url') - assert.are.same('something url', result.path) - assert.are.same('plain', result.path_type) - assert.is.Nil(result.target) - assert.is.Nil(result.protocol) - assert.are.same('plain', result.path_type) - end) - - it('should return proper checks', function() - local result = Url:new('something url') - assert.is.False(result:is_file()) - assert.is.False(result:is_headline()) - assert.is.False(result:is_internal_headline()) - assert.is.False(result:is_file_headline()) - - assert.is.False(result:is_custom_id()) - assert.is.False(result:is_file_custom_id()) - assert.is.False(result:is_internal_custom_id()) - - assert.is.False(result:is_file_line_number()) - - assert.is.True(result:is_plain()) - - assert.is.False(result:is_id()) - - assert.are.same('something url', result:get_plain()) - end) - end) - - describe('Id url', function() - it('should parse id as path', function() - local result = Url:new('id:6f48b815-9d7a-413f-80b3-e52fb50f97d8') - assert.are.same('6f48b815-9d7a-413f-80b3-e52fb50f97d8', result.path) - assert.is.Nil(result.target) - assert.are.same('id', result.protocol) - end) - - it('should return proper checks', function() - local result = Url:new('id:6f48b815-9d7a-413f-80b3-e52fb50f97d8') - assert.is.False(result:is_file()) - assert.is.False(result:is_headline()) - assert.is.False(result:is_internal_headline()) - assert.is.False(result:is_file_headline()) - - assert.is.False(result:is_custom_id()) - assert.is.False(result:is_file_custom_id()) - assert.is.False(result:is_internal_custom_id()) - - assert.is.False(result:is_file_line_number()) - - assert.is.False(result:is_plain()) - - assert.is.True(result:is_id()) - - assert.are.same('6f48b815-9d7a-413f-80b3-e52fb50f97d8', result:get_id()) - end) - end) -end) diff --git a/tests/plenary/ui/mappings/hyperlink_spec.lua b/tests/plenary/ui/mappings/hyperlink_spec.lua index b0135d80f..2784e19a3 100644 --- a/tests/plenary/ui/mappings/hyperlink_spec.lua +++ b/tests/plenary/ui/mappings/hyperlink_spec.lua @@ -91,6 +91,7 @@ describe('Hyperlink mappings', function() end) it('should store link to a headline', function() + local Headline = require('orgmode.org.hyperlinks.builtin.headline') local target_file = helpers.create_agenda_file({ '* Test hyperlink', ' - some', @@ -103,7 +104,10 @@ describe('Hyperlink mappings', function() vim.fn.cursor(4, 10) vim.cmd([[norm ,ols]]) assert.are.same({ - [('file:%s::*headline of target id'):format(target_file.filename)] = 'headline of target id', + { + desc = 'headline of target id', + link = Headline:new('headline of target id'), + }, }, require('orgmode.org.hyperlinks').stored_links) end)