Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Basic cache system, see `:Obsidian help Cache`
- `:Obsidian quick_switch` now works with aliases.
- `opts.resolvers` to allow better way to add/pick dates/attachment and other future primitives.
- Dedicated line tag parser.

### Fixed

Expand Down
9 changes: 4 additions & 5 deletions lua/obsidian/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ local config = require "obsidian.config"
local attachment = require "obsidian.attachment"
local Range = require "obsidian.range"
local parse_refs = require "obsidian.parse.refs"
local parse_tags = require "obsidian.parse.tags"

M.dir = require("obsidian.fs").dir

Expand Down Expand Up @@ -166,12 +167,10 @@ end
M.cursor_tag = function()
local current_line = vim.api.nvim_get_current_line()
local _, cur_col = unpack(vim.api.nvim_win_get_cursor(0))
cur_col = cur_col + 1 -- nvim_win_get_cursor returns 0-indexed column

for _, match in ipairs(util.parse_tags(current_line)) do
local open, close, _ = unpack(match)
if open <= cur_col and cur_col <= close then
return string.sub(current_line, open + 1, close)
for _, tag in ipairs(parse_tags.extract(current_line)) do
if tag.range.start_col <= cur_col and cur_col < tag.range.end_col then
return tag.tag
end
end

Expand Down
6 changes: 3 additions & 3 deletions lua/obsidian/cache/note.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
local Note = require "obsidian.note"
local parse_refs = require "obsidian.parse.refs"
local parse_tags = require("obsidian.parse.tags").parse_tags
local parse_tags = require "obsidian.parse.tags"

local M = {}

Expand Down Expand Up @@ -102,8 +102,8 @@ function M.build(abs_path, _vault_root)
for _, l in ipairs(extract_links(line, i)) do
links_out[#links_out + 1] = l
end
for _, tag in ipairs(parse_tags(line)) do
add_tag(line:sub(tag[1] + 1, tag[2]))
for _, tag_match in ipairs(parse_tags.extract(line, { row = i - 1 })) do
add_tag(tag_match.tag)
end
local indent, state, text = match_task(line)
if indent ~= nil then
Expand Down
46 changes: 40 additions & 6 deletions lua/obsidian/parse/tags.lua
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
local Range = require "obsidian.range"

local lpeg = vim.lpeg
local P, R, S = lpeg.P, lpeg.R, lpeg.S
local C, Cp, Ct = lpeg.C, lpeg.Cp, lpeg.Ct

local M = {}

---@class obsidian.parse.Tag : obsidian.parse.Match
---@field kind "tag"
---@field tag string Tag text without leading `#`.

-- Match exactly one UTF-8 codepoint (valid UTF-8 sequences).
local utf8_char = R "\0\127" -- 1-byte (ASCII)
+ R "\194\223" * R "\128\191" -- 2-byte
Expand Down Expand Up @@ -40,12 +46,12 @@ local one_tag = tag_at_bol + boundary
-- (skip 1 UTF-8 char at a time until a match is found; repeat)
local all_tags = Ct(((utf8_char - one_tag) ^ 0 * one_tag) ^ 0)

--- Find Obsidian-style tags in a markdown line (Unicode-safe).
--- UTF-8 indices are 0-based and end-exclusive.
--- Find Obsidian-style tag ranges in a markdown line (Unicode-safe).
--- Byte indices are 1-based and end-inclusive.
---
--- @param line string
--- @return { [1]: integer, [2]: integer }[]
M.parse_tags = function(line)
---@param line string
---@return { [1]: integer, [2]: integer }[]
local function collect_tag_ranges(line)
if string.find(line, "<!--.*-->") ~= nil then
return {}
end
Expand All @@ -57,8 +63,11 @@ M.parse_tags = function(line)
return tonumber(tag) ~= nil
end

---@param start_byte_index integer
local is_bound = function(start_byte_index)
local char_ahead = line:sub(start_byte_index - 1, start_byte_index - 1)
local prev_byte_index = start_byte_index - 1
---@cast prev_byte_index integer
local char_ahead = line:sub(prev_byte_index, prev_byte_index)
return start_byte_index == 1 or char_ahead == " "
end

Expand All @@ -79,4 +88,29 @@ M.parse_tags = function(line)
return out
end

---Find Obsidian-style tags in a markdown line.
---@param line string
---@param opts obsidian.parse.LineOpts?
---@return obsidian.parse.Tag[]
function M.extract(line, opts)
opts = opts or {}
local row = opts.row or 0
---@cast row integer
local out = {}

for _, match in ipairs(collect_tag_ranges(line)) do
local start_byte_index, end_byte_index = match[1], match[2]
---@cast start_byte_index integer
---@cast end_byte_index integer
out[#out + 1] = {
kind = "tag",
raw = line:sub(start_byte_index, end_byte_index),
range = Range.new(row, start_byte_index - 1, row, end_byte_index),
tag = line:sub(start_byte_index + 1, end_byte_index),
}
end

return out
end

return M
15 changes: 11 additions & 4 deletions lua/obsidian/search/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -806,10 +806,17 @@ M.find_tags_async = function(term, callback, opts)
local n_matches = 0

-- check for tag in the wild of the form '#{tag}'
for _, match in ipairs(util.parse_tags(line)) do
local m_start, m_end, _ = unpack(match)
local tag = string.sub(line, m_start + 1, m_end)
add_match(tag, path, note, match_data.line_number, line, m_start, m_end)
local parse_tags = require "obsidian.parse.tags"
for _, tag_match in ipairs(parse_tags.extract(line)) do
add_match(
tag_match.tag,
path,
note,
match_data.line_number,
line,
tag_match.range.start_col + 1,
tag_match.range.end_col
)
n_matches = n_matches + 1
end

Expand Down
5 changes: 3 additions & 2 deletions lua/obsidian/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ local log = require "obsidian.log"
local search = require "obsidian.search"
local parse_refs = require "obsidian.parse.refs"
local parse_block_id = require "obsidian.parse.block_id"
local parse_tags = require "obsidian.parse.tags"
local iter = vim.iter

local M = {}
Expand Down Expand Up @@ -414,8 +415,8 @@ local function get_line_ref_extmarks(marks, line, lnum, ui_opts)
inline_code_blocks[#inline_code_blocks + 1] = { m_start, m_end }
end

for _, match in ipairs(util.parse_tags(line)) do
local m_start, m_end = unpack(match)
for _, tag_match in ipairs(parse_tags.extract(line)) do
local m_start, m_end = tag_match.range.start_col + 1, tag_match.range.end_col
local inside_code_block = false
for _, code_block_boundary in ipairs(inline_code_blocks) do
if code_block_boundary[1] < m_start and m_end < code_block_boundary[2] then
Expand Down
2 changes: 0 additions & 2 deletions lua/obsidian/util.lua
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,6 @@ util.is_checkbox = function(s)
return false
end

util.parse_tags = require("obsidian.parse.tags").parse_tags

---@param link string
---@return string|? link_location
---@return string|? link_name
Expand Down
73 changes: 50 additions & 23 deletions tests/util/test_parse_tag.lua
Original file line number Diff line number Diff line change
@@ -1,77 +1,104 @@
local Range = require "obsidian.range"
local M = require "obsidian.parse.tags"
local new_set, eq = MiniTest.new_set, MiniTest.expect.equality

local T = new_set()

local function extract_ranges(line)
local out = {}
for _, tag in ipairs(M.extract(line)) do
out[#out + 1] = { tag.range.start_col + 1, tag.range.end_col }
end
return out
end

T["should find positions of all tags"] = function()
local s = "#TODO I have a #meeting at noon"
eq({ { 1, 5 }, { 16, 23 } }, M.parse_tags(s))
eq({ { 1, 5 }, { 16, 23 } }, extract_ranges(s))
end

T["extract returns tag matches"] = function()
local s = "#TODO I have a #meeting at noon"
eq({
{
kind = "tag",
raw = "#TODO",
range = Range.new(4, 0, 4, 5),
tag = "TODO",
},
{
kind = "tag",
raw = "#meeting",
range = Range.new(4, 15, 4, 23),
tag = "meeting",
},
}, M.extract(s, { row = 4 }))
end

T["should find four cases"] = function()
eq(1, #M.parse_tags "#camelCase")
eq(1, #M.parse_tags "#PascalCase")
eq(1, #M.parse_tags "#snake_case")
eq(1, #M.parse_tags "#kebab-case")
eq(1, #M.extract "#camelCase")
eq(1, #M.extract "#PascalCase")
eq(1, #M.extract "#snake_case")
eq(1, #M.extract "#kebab-case")
end

T["should find nested tags"] = function()
eq(1, #M.parse_tags " #inbox/processing")
eq(1, #M.parse_tags " #inbox/to-read")
eq(1, #M.extract " #inbox/processing")
eq(1, #M.extract " #inbox/to-read")
end

T["should ignore escaped tags"] = function()
local s = "I have a #meeting at noon \\#not-a-tag"
eq({ { 10, 17 } }, M.parse_tags(s))
eq({ { 10, 17 } }, extract_ranges(s))
s = [[\#notatag]]
eq({}, M.parse_tags(s))
eq({}, M.extract(s))
end

T["should ignore issue numbers"] = function()
local s = "Issue: #100"
eq({}, M.parse_tags(s))
eq({}, M.extract(s))
end

T["should ignore hexcolors"] = function()
local s = "background: #f0f0f0"
eq({}, M.parse_tags(s))
eq({}, M.extract(s))
end

T["should ignore anchor links that look like tags"] = function()
local s = "[readme](README#installation)"
eq({}, M.parse_tags(s))
eq({}, M.extract(s))
end

T["should ignore section in urls"] = function()
local s = "https://example.com/page#section"
eq({}, M.parse_tags(s))
eq({}, M.extract(s))
end

T["should ignore tags in HTML entities"] = function()
eq({}, M.parse_tags "Here is an entity: &#NOT_A_TAG;")
eq({}, M.extract "Here is an entity: &#NOT_A_TAG;")
end

T["should ignore tags not on word boundaries"] = function()
eq({}, M.parse_tags "foobar#notatag")
eq({ { 9, 12 } }, M.parse_tags "foo bar #tag")
eq({}, M.extract "foobar#notatag")
eq({ { 9, 12 } }, extract_ranges "foo bar #tag")
end

T["should ignore tags in markdown links with parentheses"] = function()
local s = "[autobox](https://en.wikipedia.org/wiki/Object_type_(object-oriented_programming)#NOT_A_TAG)"
eq({}, M.parse_tags(s))
eq({}, M.extract(s))
end

T["should ignore tags in html comments"] = function()
local s = "<!-- #region -->"
eq({}, M.parse_tags(s))
eq({}, M.extract(s))
end

T["should find non-English tags"] = function()
eq(1, #M.parse_tags "#你好")
eq(1, #M.parse_tags "#タグ")
eq(1, #M.parse_tags "#mañana")
eq(1, #M.parse_tags "#день")
eq(1, #M.parse_tags "#项目_计划")
eq(1, #M.extract "#你好")
eq(1, #M.extract "#タグ")
eq(1, #M.extract "#mañana")
eq(1, #M.extract "#день")
eq(1, #M.extract "#项目_计划")
end

return T
Loading