Skip to content

Commit e47b286

Browse files
authored
fix(highlights): fixes several subtle bugs with list marker highlights (#114)
1 parent dd2b77d commit e47b286

File tree

3 files changed

+403
-66
lines changed

3 files changed

+403
-66
lines changed

lua/checkmate/highlights.lua

Lines changed: 108 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -269,19 +269,18 @@ function M.highlight_todo_item(bufnr, todo_item, todo_map, opts)
269269
ctx.depth = ctx.depth + 1
270270

271271
local success, err = pcall(function()
272-
-- 1. Highlight the todo marker
273-
M.highlight_todo_marker(bufnr, todo_item)
272+
-- row lookup for todo items for O(1) access
273+
local todo_rows = {}
274+
for _, todo in pairs(todo_map) do
275+
todo_rows[todo.range.start.row] = true
276+
end
274277

275-
-- 2. Highlight the list marker of the todo item
276-
M.highlight_list_marker(bufnr, todo_item)
278+
M.highlight_todo_marker(bufnr, todo_item)
277279

278-
-- 3. Highlight the child list markers within this todo item
279-
M.highlight_child_list_markers(bufnr, todo_item)
280+
M.highlight_list_markers_in_range(bufnr, todo_item, todo_rows)
280281

281-
-- 4. Highlight content directly in this todo item
282282
M.highlight_content(bufnr, todo_item, todo_map)
283283

284-
-- 5. Show child count indicator
285284
M.show_todo_count_indicator(bufnr, todo_item, todo_map)
286285
end)
287286

@@ -370,20 +369,16 @@ function M.highlight_list_marker(bufnr, todo_item)
370369

371370
local start_row, start_col, _, _ = list_marker.node:range()
372371

373-
local line = M.get_buffer_line(bufnr, start_row)
374-
if not line then
372+
local marker_text = list_marker.text
373+
if not marker_text then
375374
return
376375
end
377376

378-
-- get actual marker text (-, *, +, or digits followed by . or ))
379-
local marker_text = todo_item.list_marker.text
380-
local marker_end_col = start_col + #marker_text
381-
382377
local hl_group = list_marker.type == "ordered" and "CheckmateListMarkerOrdered" or "CheckmateListMarkerUnordered"
383378

384379
vim.api.nvim_buf_set_extmark(bufnr, config.ns, start_row, start_col, {
385380
end_row = start_row, -- don't let it ever span multiple lines
386-
end_col = marker_end_col, -- end exclusive (end col doesn't actually include the list marker)
381+
end_col = start_col + #marker_text, -- end exclusive (end col doesn't actually include the list marker)
387382
hl_group = hl_group,
388383
priority = M.PRIORITY.LIST_MARKER,
389384
right_gravity = false,
@@ -392,7 +387,7 @@ function M.highlight_list_marker(bufnr, todo_item)
392387
})
393388
end
394389

395-
---Finds and highlights all markdown list_markers within the todo item, excluding the
390+
---Highlight all child list markers (non-todo list items) within this todo item's range
396391
---list_marker for the todo item itself (i.e. the first list_marker in the todo item's list_item node)
397392
---@param bufnr integer Buffer number
398393
---@param todo_item checkmate.TodoItem
@@ -404,28 +399,29 @@ function M.highlight_child_list_markers(bufnr, todo_item)
404399
return
405400
end
406401

407-
local list_marker_query = parser.get_list_marker_query()
402+
local list_items = parser.get_all_list_items(bufnr)
408403

409-
for id, marker_node, _ in list_marker_query:iter_captures(todo_item.node, bufnr, 0, -1) do
410-
local name = list_marker_query.captures[id]
411-
local marker_type = parser.get_marker_type_from_capture_name(name)
404+
for _, list_item in ipairs(list_items) do
405+
local item_start_row = list_item.range.start.row
412406

413-
-- only process if it's not the todo item's own list marker
414-
if not (todo_item.list_marker and todo_item.list_marker.node == marker_node) then
415-
local marker_start_row, marker_start_col, marker_end_row, _ = marker_node:range()
416-
417-
-- safety check: only highlight list markers within the parent todo's range
418-
if marker_start_row >= todo_item.range.start.row and marker_end_row <= todo_item.range["end"].row then
419-
local line = M.get_buffer_line(bufnr, marker_start_row)
420-
if line then
421-
local marker_text = todo_item.list_marker.text
407+
-- Skip if:
408+
-- 1. outside todo item's range
409+
-- 2. it's the todo item itself (same start row)
410+
-- 3. it's another todo item (has a todo state)
411+
if item_start_row > todo_item.range.start.row and item_start_row <= todo_item.range["end"].row then
412+
local line = M.get_buffer_line(bufnr, item_start_row)
413+
if line and not parser.get_todo_item_state(line) then
414+
local marker = list_item.list_marker
415+
if marker and marker.node then
416+
local marker_text = list_item.text:match("^%s*([%-%*%+])") or list_item.text:match("^%s*(%d+[%.%)])")
422417

423418
if marker_text then
424-
local hl_group = marker_type == "ordered" and "CheckmateListMarkerOrdered" or "CheckmateListMarkerUnordered"
419+
local _, marker_col = marker.node:range()
420+
local hl_group = marker.type == "ordered" and "CheckmateListMarkerOrdered" or "CheckmateListMarkerUnordered"
425421

426-
vim.api.nvim_buf_set_extmark(bufnr, config.ns, marker_start_row, marker_start_col, {
427-
end_row = marker_start_row, -- never span lines
428-
end_col = marker_start_col + #marker_text, -- end exclusive
422+
vim.api.nvim_buf_set_extmark(bufnr, config.ns, item_start_row, marker_col, {
423+
end_row = item_start_row,
424+
end_col = marker_col + #marker_text,
429425
hl_group = hl_group,
430426
priority = M.PRIORITY.LIST_MARKER,
431427
right_gravity = false,
@@ -439,6 +435,64 @@ function M.highlight_child_list_markers(bufnr, todo_item)
439435
end
440436
end
441437

438+
---Highlight list markers for todo items and their child list items
439+
---@param bufnr integer Buffer number
440+
---@param todo_item checkmate.TodoItem
441+
---@param todo_rows table<integer, checkmate.TodoItem> row to todo lookup table
442+
function M.highlight_list_markers_in_range(bufnr, todo_item, todo_rows)
443+
local config = require("checkmate.config")
444+
local parser = require("checkmate.parser")
445+
446+
-- highlight the todo item's own marker
447+
if todo_item.list_marker and todo_item.list_marker.node then
448+
local start_row, start_col = todo_item.list_marker.node:range()
449+
local marker_text = todo_item.list_marker.text
450+
451+
if marker_text then
452+
local hl_group = todo_item.list_marker.type == "ordered" and "CheckmateListMarkerOrdered"
453+
or "CheckmateListMarkerUnordered"
454+
455+
vim.api.nvim_buf_set_extmark(bufnr, config.ns, start_row, start_col, {
456+
end_row = start_row,
457+
end_col = start_col + #marker_text,
458+
hl_group = hl_group,
459+
priority = M.PRIORITY.LIST_MARKER,
460+
right_gravity = false,
461+
end_right_gravity = false,
462+
hl_eol = false,
463+
})
464+
end
465+
end
466+
467+
local query = parser.FULL_TODO_QUERY
468+
469+
for id, node in query:iter_captures(todo_item.node, bufnr) do
470+
local capture_name = query.captures[id]
471+
472+
if capture_name == "list_marker" or capture_name == "list_marker_ordered" then
473+
local marker_start_row, marker_start_col, _, marker_end_col = node:range()
474+
475+
-- skip if:
476+
-- - the todo item's own marker (same row)
477+
-- - not another todo item
478+
if marker_start_row > todo_item.range.start.row and not todo_rows[marker_start_row] then
479+
local marker_type = parser.get_marker_type_from_capture_name(capture_name)
480+
local hl_group = marker_type == "ordered" and "CheckmateListMarkerOrdered" or "CheckmateListMarkerUnordered"
481+
482+
vim.api.nvim_buf_set_extmark(bufnr, config.ns, marker_start_row, marker_start_col, {
483+
end_row = marker_start_row,
484+
end_col = marker_end_col - 1, -- ts includes trailing space after list marker, we exclude it here
485+
hl_group = hl_group,
486+
priority = M.PRIORITY.LIST_MARKER,
487+
right_gravity = false,
488+
end_right_gravity = false,
489+
hl_eol = false,
490+
})
491+
end
492+
end
493+
end
494+
end
495+
442496
---Applies highlight groups to metadata entries
443497
---@param bufnr integer Buffer number
444498
---@param config checkmate.Config.mod Configuration module
@@ -577,18 +631,32 @@ function M.highlight_content(bufnr, todo_item, todo_map)
577631
-- skip empty lines
578632
if row_line and not row_line:match("^%s*$") then
579633
local content_start = nil
580-
for i = 0, #row_line - 1 do
581-
local char = row_line:sub(i + 1, i + 1)
582-
if char ~= " " and char ~= "\t" then
583-
content_start = i
584-
break
634+
635+
local list_marker_pattern = "^(%s*)[%-%*%+]%s+" -- unordered
636+
local ordered_pattern = "^(%s*)%d+[%.%)]%s+" -- ordered
637+
638+
local _, marker_end = row_line:find(list_marker_pattern)
639+
if not marker_end then
640+
_, marker_end = row_line:find(ordered_pattern)
641+
end
642+
643+
if marker_end then
644+
content_start = marker_end -- already 0-based since find returns 1-based
645+
else
646+
-- no list marker, find first non-whitespace
647+
for i = 0, #row_line - 1 do
648+
local char = row_line:sub(i + 1, i + 1)
649+
if char ~= " " and char ~= "\t" then
650+
content_start = i
651+
break
652+
end
585653
end
586654
end
587655

588-
if content_start then
656+
if content_start and content_start < #row_line then
589657
vim.api.nvim_buf_set_extmark(bufnr, config.ns, row, content_start, {
590658
end_row = row,
591-
end_col = #row_line, -- byte length
659+
end_col = #row_line,
592660
hl_group = additional_content_hl,
593661
priority = M.PRIORITY.CONTENT,
594662
hl_eol = false,

lua/checkmate/parser.lua

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ local M = {}
5151
--- @field children integer[] IDs of child todo items
5252
--- @field parent_id integer? ID of parent todo item
5353

54-
local FULL_TODO_QUERY = vim.treesitter.query.parse(
54+
M.FULL_TODO_QUERY = vim.treesitter.query.parse(
5555
"markdown",
5656
[[
5757
(list_item) @list_item
@@ -543,8 +543,8 @@ function M.discover_todos(bufnr)
543543
paragraphs_by_parent = {}, -- parent node id -> paragraph nodes
544544
}
545545

546-
for id, node, _ in FULL_TODO_QUERY:iter_captures(root, bufnr, 0, -1) do
547-
local capture_name = FULL_TODO_QUERY.captures[id]
546+
for id, node, _ in M.FULL_TODO_QUERY:iter_captures(root, bufnr, 0, -1) do
547+
local capture_name = M.FULL_TODO_QUERY.captures[id]
548548

549549
if capture_name == "list_item" then
550550
local start_row, start_col, end_row, end_col = node:range()
@@ -673,12 +673,6 @@ function M.discover_todos(bufnr)
673673
return todo_map
674674
end
675675

676-
---Returns a TS query for finding markdown list_markers
677-
---@return vim.treesitter.Query
678-
function M.get_list_marker_query()
679-
return FULL_TODO_QUERY
680-
end
681-
682676
---Returns the list_marker type as "unordered" or "ordered"
683677
---@param capture_name string A capture name returned from a TS query
684678
---@return string: "ordered" or "unordered"
@@ -905,7 +899,7 @@ function M.get_all_list_items(bufnr)
905899
local marker_type = nil
906900

907901
-- Find direct children that are list markers
908-
local marker_query = M.get_list_marker_query()
902+
local marker_query = M.FULL_TODO_QUERY
909903
for marker_id, marker, _ in marker_query:iter_captures(node, bufnr, 0, -1) do
910904
local name = marker_query.captures[marker_id]
911905
local m_type = M.get_marker_type_from_capture_name(name)

0 commit comments

Comments
 (0)