Skip to content

Commit dd2b77d

Browse files
authored
fix(parser): fixes incorrect parsing of metadata tags and values (#111)
Should not parse malformed @tag(value) Should correctly parse complex values, e.g. with nested parentheses
1 parent abca68b commit dd2b77d

File tree

4 files changed

+86
-13
lines changed

4 files changed

+86
-13
lines changed

lua/checkmate/api.lua

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,7 +1362,7 @@ function M.rebuild_line_with_sorted_metadata(line, metadata)
13621362
local log = require("checkmate.log")
13631363

13641364
-- remove all metadata tags but preserve all other content including whitespace
1365-
local content_without_metadata = line:gsub("@%w+%([^)]*%)", "")
1365+
local content_without_metadata = line:gsub("@[%a][%w_%-]*%b()", "")
13661366

13671367
-- remove trailing whitespace but keep all indentation
13681368
content_without_metadata = content_without_metadata:gsub("%s+$", "")
@@ -1373,7 +1373,6 @@ function M.rebuild_line_with_sorted_metadata(line, metadata)
13731373

13741374
local sorted_entries = M.sort_metadata_entries(metadata.entries)
13751375

1376-
-- Rebuild the line with content and sorted metadata
13771376
local result_line = content_without_metadata
13781377

13791378
-- add back each metadata tag in sorted order

lua/checkmate/parser.lua

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local M = {}
22

33
---@alias checkmate.TodoItemState "checked" | "unchecked"
4+
--@alias checkmate.TodoItemState "checked" | "unchecked"
45

56
--- @class TodoMarkerInfo
67
--- @field position {row: integer, col: integer} Position of the marker (0-indexed)
@@ -63,7 +64,8 @@ local FULL_TODO_QUERY = vim.treesitter.query.parse(
6364
]]
6465
)
6566

66-
local METADATA_PATTERN = "@([%a][%w_%-]*)%(%s*(.-)%s*%)"
67+
-- match the @tag and then a balanced sequence %b(), which is everything in the outer parentheses
68+
local METADATA_PATTERN = "@([%a][%w_%-]*)(%b())"
6769

6870
M.list_item_markers = { "-", "+", "*" }
6971

@@ -774,27 +776,44 @@ function M.extract_metadata(line, row)
774776
by_tag = {},
775777
}
776778

779+
---@param _line string String to search
780+
---@param from_byte_pos integer start looking at this byte position
781+
---@return string? tag, string? value, integer? start_byte, integer? end_byte
782+
local function find_metadata(_line, from_byte_pos)
783+
local s, e, tag, raw = _line:find(METADATA_PATTERN, from_byte_pos)
784+
if not s or not e then
785+
return nil
786+
end
787+
788+
-- raw is "( ... )", so we gotta strip outer parens
789+
local inner = raw:sub(2, -2)
790+
791+
-- trim whitespace
792+
local value = inner:match("^%s*(.-)%s*$")
793+
794+
return tag, value, s, e
795+
end
796+
777797
-- find all @tag(value) patterns and their positions
778798
local byte_pos = 1
779799
while true do
780-
-- will capture tag names that include underscores and hypens
781-
local tag_start_byte, tag_end_byte, tag, value = line:find(METADATA_PATTERN, byte_pos)
782-
if not tag_start_byte or not tag_end_byte then
800+
local tag, value, start_byte, end_byte = find_metadata(line, byte_pos)
801+
if not tag or not start_byte or not end_byte then
783802
break
784803
end
785804

786805
---@type checkmate.MetadataEntry
787806
local entry = {
788807
tag = tag,
789-
value = value,
808+
value = value or "",
790809
range = {
791-
start = { row = row, col = tag_start_byte - 1 }, -- 0-indexed column
810+
start = { row = row, col = start_byte - 1 }, -- 0-indexed column
792811
-- For the end col, we need 0 indexed (subtract 1) and since it is end-exclusive we add 1, cancelling out
793812
-- end-exclusive means the end col points to the pos after the last char
794-
["end"] = { row = row, col = tag_end_byte },
813+
["end"] = { row = row, col = end_byte },
795814
},
796815
alias_for = nil, -- Will be set later if it's an alias
797-
position_in_line = tag_start_byte, -- track original position in the line, use byte pos for sorting
816+
position_in_line = start_byte, -- track original position in the line, use byte pos for sorting
798817
}
799818

800819
-- check if this is an alias and map to canonical name
@@ -827,10 +846,10 @@ function M.extract_metadata(line, row)
827846
end
828847

829848
-- move position for next search
830-
byte_pos = tag_end_byte + 1
849+
byte_pos = end_byte + 1
831850

832851
log.debug(
833-
string.format("Metadata found: %s=%s at [%d,%d]-[%d,%d]", tag, value, row, tag_start_byte - 1, row, tag_end_byte),
852+
string.format("Metadata found: %s=%s at [%d,%d]-[%d,%d]", tag, value, row, start_byte - 1, row, end_byte),
834853
{ module = "parser" }
835854
)
836855
end

tests/checkmate/api_spec.lua

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,37 @@ Line 2
743743
end)
744744
end)
745745

746+
it("should remove metadata with complex value", function()
747+
local config = require("checkmate.config")
748+
local unchecked = config.get_defaults().todo_markers.unchecked
749+
local file_path = h.create_temp_file()
750+
751+
local content = [[
752+
- [ ] Task @issue(issue #1 - fix(api): broken! @author)
753+
]]
754+
755+
local bufnr = setup_todo_buffer(file_path, content, {})
756+
757+
local todo_map = require("checkmate.parser").discover_todos(bufnr)
758+
local first_todo = h.find_todo_by_text(todo_map, "- " .. unchecked .. " Task @issue")
759+
760+
assert.is_not_nil(first_todo)
761+
---@cast first_todo checkmate.TodoItem
762+
763+
assert.is_not_nil(first_todo.metadata)
764+
assert.is_true(#first_todo.metadata.entries > 0)
765+
766+
-- remove @issue
767+
vim.api.nvim_win_set_cursor(0, { first_todo.range.start.row + 1, 0 }) -- adjust from 0 index to 1-indexed
768+
require("checkmate").remove_metadata("issue")
769+
770+
vim.cmd("sleep 10m")
771+
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
772+
773+
assert.no.matches("@issue", lines[1])
774+
assert.matches("- " .. vim.pesc(unchecked) .. " Task", lines[1])
775+
end)
776+
746777
it("should remove all metadata from todo items", function()
747778
local config = require("checkmate.config")
748779
local unchecked = config.get_defaults().todo_markers.unchecked

tests/checkmate/parser_spec.lua

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,18 @@ Line that should not affect parent-child relationship
629629
assert.same(metadata.entries[3], metadata.by_tag.tags)
630630
end)
631631

632+
it("should not malformed metadata", function()
633+
local parser = require("checkmate.parser")
634+
635+
-- space between @tag and ()
636+
local line = "- □ Task @tag (value)"
637+
local row = 0
638+
639+
local metadata = parser.extract_metadata(line, row)
640+
641+
assert.equal(0, #metadata.entries)
642+
end)
643+
632644
it("should handle metadata with spaces in values", function()
633645
local parser = require("checkmate.parser")
634646
local line = "- □ Task @note(this is a note with spaces)"
@@ -650,7 +662,19 @@ Line that should not affect parent-child relationship
650662

651663
assert.equal(1, #metadata.entries)
652664
assert.equal("note", metadata.entries[1].tag)
653-
assert.equal("spaced value", metadata.entries[1].value) -- Spaces should be trimmed
665+
assert.equal("spaced value", metadata.entries[1].value)
666+
end)
667+
668+
it("should handle metadata with parentheses in value", function()
669+
local parser = require("checkmate.parser")
670+
local line = "- □ Task @issue(fix(api))"
671+
local row = 0
672+
673+
local metadata = parser.extract_metadata(line, row)
674+
675+
assert.equal(1, #metadata.entries)
676+
assert.equal("issue", metadata.entries[1].tag)
677+
assert.equal("fix(api)", metadata.entries[1].value)
654678
end)
655679

656680
it("should properly track position_in_line", function()

0 commit comments

Comments
 (0)