From 92e9843ac8d41ed05f3caf724de18555cece79c7 Mon Sep 17 00:00:00 2001 From: delphinus Date: Thu, 24 Sep 2026 12:40:39 +0900 Subject: [PATCH] fix(lists): expand tabs in the indentation to four-column stops Everything that measures indentation counts spaces, so a list item indented with a tab was one column deep instead of four. A tab-indented item under another item did not nest: its continuation lines hung from the parent's column, and the tab itself reached the buffer, where 'tabstop' (8 by default) pushed the marker further right than its text's hanging indent assumed. render_document now expands tabs in each line's leading whitespace before anything measures it, with tab stops every four columns as CommonMark specifies, so `\t- item` renders exactly as ` - item` does. Fenced code is left alone, since its tabs are content. tests/list_indent_test.lua gains a case for a tab-indented nested item, a tab after spaces, and a tab inside fenced code. Four of its assertions fail before this change. Co-Authored-By: Claude Opus 5.5 (1M context) --- lua/md-render/content_builder.lua | 33 +++++++++++++++++++++++++++++++ tests/list_indent_test.lua | 24 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/lua/md-render/content_builder.lua b/lua/md-render/content_builder.lua index cebd7ca..ec580f2 100644 --- a/lua/md-render/content_builder.lua +++ b/lua/md-render/content_builder.lua @@ -1116,6 +1116,38 @@ local function list_content_column(line) return #ws + #marker + (#gap <= 4 and #gap or 1) end +--- Expand tabs in each line's leading whitespace to spaces, with tab stops +--- every four columns as CommonMark specifies. +--- +--- Everything that measures indentation counts spaces, so a list item +--- indented with a tab was one column deep instead of four: its continuation +--- lines hung from the wrong column, and the tab itself reached the buffer, +--- where 'tabstop' decided how wide it was. Fenced code is left alone, since +--- its tabs are content. +---@param lines string[] +---@return string[] +local function expand_leading_tabs(lines) + local result = {} + local in_code = false + for i, line in ipairs(lines) do + result[i] = line + local fence = line:match "^%s*```" or line:match "^%s*~~~" + if fence then + in_code = not in_code + elseif not in_code then + local ws = line:match "^[ \t]*" + if ws:find("\t", 1, true) then + local col = 0 + for c in ws:gmatch "." do + col = c == "\t" and (col + 4 - col % 4) or (col + 1) + end + result[i] = string.rep(" ", col) .. line:sub(#ws + 1) + end + end + end + return result +end + --- Move the content of a block container to column 0 and report the indent --- that was taken off each line. --- @@ -1429,6 +1461,7 @@ function ContentBuilder:render_document(lines, opts) -- container_indents keeps the indent per *original* line so the loop below -- can restore it on output. local container_indents + lines = expand_leading_tabs(lines) lines, container_indents = strip_container_indent(lines) lines, src_indices = preprocess_multiline_html(lines, src_indices) lines, src_indices = join_paragraph_continuations(lines, src_indices, container_indents) diff --git a/tests/list_indent_test.lua b/tests/list_indent_test.lua index ec9e566..2019899 100644 --- a/tests/list_indent_test.lua +++ b/tests/list_indent_test.lua @@ -153,5 +153,29 @@ do ) end +-- Test 10: a tab in the indentation advances to the next multiple of four, +-- so a tab-indented item nests exactly as a four-space one does, wraps under +-- its own text, and no tab reaches the output +do + local long = string.rep("あ", 30) + local tabbed = render({ "- 外側", "\t- " .. long }, 24) + assert_eq(tabbed, render({ "- 外側", " - " .. long }, 24), "a tab should indent like four spaces") + assert_eq( + #tabbed[3]:match "^ *", + vim.api.nvim_strwidth(tabbed[2]:match "^ *[^ ]+ "), + "a tab-indented item should hang under its text" + ) + assert_eq(table.concat(tabbed):find("\t", 1, true), nil, "no tab should reach the output") + + assert_eq( + render { "- 外側", " \t- 内側" }, + render { "- 外側", " - 内側" }, + "a tab after spaces should stop at column four" + ) + + local out = render { "- 項目", "", " ```", " \tx = 1", " ```" } + assert_eq(out[2]:find("\t", 1, true) ~= nil, true, "a tab in fenced code is content and must stay") +end + print(string.format("\nlist_indent_test: %d passed, %d failed", pass_count, fail_count)) if fail_count > 0 then os.exit(1) end