Last active
August 10, 2025 23:24
-
-
Save wroyca/1030bdfba744540fe76886e1226cf664 to your computer and use it in GitHub Desktop.
Hide and show comments in code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
---@class ConcealComment | |
local M = {} | |
---@alias BufferHandle number | |
---@alias LineNumber number | |
---@alias ColumnNumber number | |
---@alias ExtmarkId number | |
---@alias NamespaceId number | |
---@class CommentNode | |
---@field start_row LineNumber 0-based line number | |
---@field start_col ColumnNumber 0-based column number | |
---@field end_row LineNumber 0-based line number | |
---@field end_col ColumnNumber 0-based column number | |
---@field text string The comment text content | |
---@class ConcealedLine | |
---@field row LineNumber 0-based line number | |
---@field extmark_id ExtmarkId The extmark ID for this concealed line | |
---@field original_text string The original line content | |
---@class NavigationDirection | |
---@field down number | |
---@field up number | |
local direction = { | |
down = 1, | |
up = -1, | |
} | |
---@class ConcealCommentConfig | |
---@field auto_enable boolean Automatically enable concealing for all supported languages | |
---@field smart_navigation boolean Enable smart navigation that skips concealed lines | |
---@field conceal_level number The conceallevel to set when concealing (0-3) | |
---@field refresh_on_change boolean Refresh concealing when buffer content changes | |
---@field debug boolean Enable debug logging | |
---@type ConcealCommentConfig | |
local default_config = { | |
auto_enable = false, | |
smart_navigation = true, | |
conceal_level = 2, | |
refresh_on_change = true, | |
debug = false, | |
} | |
---@type ConcealCommentConfig | |
local config = vim.deepcopy(default_config) | |
---@type string The universal treesitter query for comments | |
local comment_query = "(comment) @comment" | |
---@type table<BufferHandle, ConcealedLine[]> Track concealed lines per buffer | |
local concealed_buffers = {} | |
---@type NamespaceId The namespace for concealing extmarks | |
local namespace_id = vim.api.nvim_create_namespace("conceal-comment") | |
---@type number Augroup ID for autocommands | |
local augroup_id = vim.api.nvim_create_augroup("ConcealComment", { clear = true }) | |
---Debug logging utility | |
---@param message string | |
---@param level? number vim.log.levels | |
local function debug_log(message, level) | |
if not config.debug then | |
return | |
end | |
level = level or vim.log.levels.DEBUG | |
vim.notify("[ConcealComment] " .. message, level) | |
end | |
---Validate buffer handle | |
---@param bufnr BufferHandle | |
---@return boolean is_valid | |
---@return string? error_message | |
local function validate_buffer(bufnr) | |
if not bufnr or type(bufnr) ~= "number" then | |
return false, "Invalid buffer handle" | |
end | |
if not vim.api.nvim_buf_is_valid(bufnr) then | |
return false, "Buffer is not valid" | |
end | |
return true, nil | |
end | |
---Get treesitter parser for buffer | |
---@param bufnr BufferHandle | |
---@return TSParser? parser | |
---@return string? error_message | |
local function get_treesitter_parser(bufnr) | |
local is_valid, error_msg = validate_buffer(bufnr) | |
if not is_valid then | |
return nil, error_msg | |
end | |
local ok, parser = pcall(vim.treesitter.get_parser, bufnr) | |
if not ok or not parser then | |
return nil, "Failed to get treesitter parser" | |
end | |
return parser, nil | |
end | |
---Get comment nodes from buffer using treesitter | |
---@param bufnr BufferHandle | |
---@return CommentNode[] nodes | |
---@return string? error_message | |
local function get_comment_nodes(bufnr) | |
debug_log("Getting comment nodes for buffer " .. bufnr) | |
local parser, parser_error = get_treesitter_parser(bufnr) | |
if not parser then | |
return {}, parser_error | |
end | |
local trees = parser:parse() | |
if not trees or #trees == 0 then | |
return {}, "No syntax trees available" | |
end | |
local root = trees[1]:root() | |
if not root then | |
return {}, "No root node available" | |
end | |
-- Try to parse the universal comment query | |
local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) | |
local ok, query = pcall(vim.treesitter.query.parse, filetype, comment_query) | |
if not ok or not query then | |
return {}, "Failed to parse comment query" | |
end | |
local nodes = {} | |
for _, node in query:iter_captures(root, bufnr) do | |
if node and node.range then | |
local start_row, start_col, end_row, end_col = node:range() | |
local text_lines = vim.api.nvim_buf_get_text(bufnr, start_row, start_col, end_row, end_col, {}) | |
local text = table.concat(text_lines, "\n") | |
table.insert(nodes, { | |
start_row = start_row, | |
start_col = start_col, | |
end_row = end_row, | |
end_col = end_col, | |
text = text, | |
}) | |
end | |
end | |
debug_log("Found " .. #nodes .. " comment nodes") | |
return nodes, nil | |
end | |
---Create concealing extmarks for comment nodes | |
---@param bufnr BufferHandle | |
---@param nodes CommentNode[] | |
---@return ConcealedLine[] | |
local function create_concealing_extmarks(bufnr, nodes) | |
local concealed_lines = {} | |
for _, node in ipairs(nodes) do | |
for row = node.start_row, node.end_row do | |
local line_text = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or "" | |
local ok, extmark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace_id, row, 0, { | |
end_row = row, | |
end_col = #line_text, | |
conceal_lines = "", | |
priority = 1000, | |
}) | |
if ok then | |
table.insert(concealed_lines, { | |
row = row, | |
extmark_id = extmark_id, | |
original_text = line_text, | |
}) | |
else | |
debug_log("Failed to create extmark for row " .. row, vim.log.levels.WARN) | |
end | |
end | |
end | |
return concealed_lines | |
end | |
---Apply comment concealing to buffer | |
---@param bufnr BufferHandle | |
---@return boolean success | |
---@return string? error_message | |
local function apply_concealing(bufnr) | |
debug_log("Applying concealing to buffer " .. bufnr) | |
local is_valid, error_msg = validate_buffer(bufnr) | |
if not is_valid then | |
return false, error_msg | |
end | |
-- Clear existing concealing | |
vim.api.nvim_buf_clear_namespace(bufnr, namespace_id, 0, -1) | |
concealed_buffers[bufnr] = nil | |
local nodes, nodes_error = get_comment_nodes(bufnr) | |
if nodes_error then | |
return false, nodes_error | |
end | |
if #nodes == 0 then | |
debug_log("No comment nodes found") | |
return true, nil | |
end | |
-- Set conceallevel | |
local current_win = vim.api.nvim_get_current_win() | |
local buf_win = vim.fn.bufwinid(bufnr) | |
if buf_win ~= -1 then | |
vim.api.nvim_set_option_value("conceallevel", config.conceal_level, { win = buf_win }) | |
else | |
vim.api.nvim_set_option_value("conceallevel", config.conceal_level, { win = current_win }) | |
end | |
local concealed_lines = create_concealing_extmarks(bufnr, nodes) | |
concealed_buffers[bufnr] = concealed_lines | |
debug_log("Successfully concealed " .. #concealed_lines .. " lines") | |
return true, nil | |
end | |
---Remove concealing from buffer | |
---@param bufnr BufferHandle | |
---@return boolean success | |
---@return string? error_message | |
local function remove_concealing(bufnr) | |
debug_log("Removing concealing from buffer " .. bufnr) | |
local is_valid, error_msg = validate_buffer(bufnr) | |
if not is_valid then | |
return false, error_msg | |
end | |
vim.api.nvim_buf_clear_namespace(bufnr, namespace_id, 0, -1) | |
concealed_buffers[bufnr] = nil | |
-- Reset conceallevel | |
local current_win = vim.api.nvim_get_current_win() | |
local buf_win = vim.fn.bufwinid(bufnr) | |
if buf_win ~= -1 then | |
vim.api.nvim_set_option_value("conceallevel", 0, { win = buf_win }) | |
else | |
vim.api.nvim_set_option_value("conceallevel", 0, { win = current_win }) | |
end | |
debug_log("Successfully removed concealing") | |
return true, nil | |
end | |
---Check if a line is concealed | |
---@param bufnr BufferHandle | |
---@param line_nr LineNumber 1-based line number | |
---@return boolean is_concealed | |
local function is_line_concealed(bufnr, line_nr) | |
local concealed_lines = concealed_buffers[bufnr] | |
if not concealed_lines then | |
return false | |
end | |
for _, concealed_line in ipairs(concealed_lines) do | |
if concealed_line.row + 1 == line_nr then -- Convert 0-based to 1-based | |
return true | |
end | |
end | |
return false | |
end | |
---Find next visible (non-concealed) line | |
---@param bufnr BufferHandle | |
---@param current_line LineNumber 1-based line number | |
---@param direction number direction.down or direction.up | |
---@return LineNumber next_line | |
local function find_next_visible_line(bufnr, current_line, direction) | |
local total_lines = vim.api.nvim_buf_line_count(bufnr) | |
local next_line = current_line + direction | |
while next_line >= 1 and next_line <= total_lines do | |
if not is_line_concealed(bufnr, next_line) then | |
return next_line | |
end | |
next_line = next_line + direction | |
end | |
-- Return boundary if no visible line found | |
return direction > 0 and total_lines or 1 | |
end | |
---Smart navigation that skips concealed lines | |
---@param direction number direction.down or direction.up | |
---@param count number Number of moves to make | |
local function smart_navigate(direction, count) | |
local bufnr = vim.api.nvim_get_current_buf() | |
-- Use normal navigation if concealing is not active | |
if not concealed_buffers[bufnr] then | |
local key = direction > 0 and "j" or "k" | |
local cmd = count > 1 and (count .. key) or key | |
vim.cmd("normal! " .. cmd) | |
return | |
end | |
local current_line = vim.fn.line(".") | |
local target_line = current_line | |
for _ = 1, count do | |
local next_line = find_next_visible_line(bufnr, target_line, direction) | |
if next_line == target_line then | |
break | |
end | |
target_line = next_line | |
end | |
if target_line ~= current_line then | |
vim.api.nvim_win_set_cursor(0, { target_line, vim.fn.col(".") - 1 }) | |
end | |
end | |
---Setup smart navigation keymaps | |
local function setup_navigation_keymaps() | |
local keymap_opts = { desc = "Smart comment navigation" } | |
local function move_down() | |
smart_navigate(direction.down, vim.v.count1) | |
end | |
local function move_up() | |
smart_navigate(direction.up, vim.v.count1) | |
end | |
-- Normal mode mappings | |
vim.keymap.set("n", "j", move_down, keymap_opts) | |
vim.keymap.set("n", "k", move_up, keymap_opts) | |
vim.keymap.set("n", "<Down>", move_down, keymap_opts) | |
vim.keymap.set("n", "<Up>", move_up, keymap_opts) | |
-- Visual mode mappings | |
vim.keymap.set("v", "j", move_down, keymap_opts) | |
vim.keymap.set("v", "k", move_up, keymap_opts) | |
vim.keymap.set("v", "<Down>", move_down, keymap_opts) | |
vim.keymap.set("v", "<Up>", move_up, keymap_opts) | |
end | |
---Setup autocommands for automatic concealing | |
local function setup_autocommands() | |
if not config.auto_enable then | |
return | |
end | |
vim.api.nvim_create_autocmd("FileType", { | |
group = augroup_id, | |
pattern = "*", | |
callback = function(args) | |
vim.schedule(function() | |
if vim.treesitter.get_parser(args.buf, nil, { error = false }) then | |
M.enable(args.buf) | |
end | |
end) | |
end, | |
desc = "Auto-enable comment concealing for all supported languages", | |
}) | |
if config.refresh_on_change then | |
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { | |
group = augroup_id, | |
callback = function(args) | |
if concealed_buffers[args.buf] then | |
vim.schedule(function() | |
M.enable(args.buf) | |
end) | |
end | |
end, | |
desc = "Refresh comment concealing on text changes", | |
}) | |
end | |
-- Cleanup on buffer delete | |
vim.api.nvim_create_autocmd("BufDelete", { | |
group = augroup_id, | |
callback = function(args) | |
concealed_buffers[args.buf] = nil | |
end, | |
desc = "Cleanup concealing data on buffer delete", | |
}) | |
end | |
---Setup user commands | |
local function setup_user_commands() | |
vim.api.nvim_create_user_command("ConcealComments", function() | |
local success, error_msg = M.enable() | |
if not success then | |
vim.notify("Failed to conceal comments: " .. (error_msg or "Unknown error"), vim.log.levels.ERROR) | |
end | |
end, { desc = "Conceal comments in current buffer" }) | |
vim.api.nvim_create_user_command("RevealComments", function() | |
local success, error_msg = M.disable() | |
if not success then | |
vim.notify("Failed to reveal comments: " .. (error_msg or "Unknown error"), vim.log.levels.ERROR) | |
end | |
end, { desc = "Reveal comments in current buffer" }) | |
vim.api.nvim_create_user_command("ToggleConcealComments", function() | |
local success, error_msg = M.toggle() | |
if not success then | |
vim.notify("Failed to toggle comment concealing: " .. (error_msg or "Unknown error"), vim.log.levels.ERROR) | |
end | |
end, { desc = "Toggle comment concealing in current buffer" }) | |
vim.api.nvim_create_user_command("ConcealCommentsStatus", function() | |
local bufnr = vim.api.nvim_get_current_buf() | |
local is_enabled = M.is_enabled(bufnr) | |
local status = is_enabled and "enabled" or "disabled" | |
local count = is_enabled and #concealed_buffers[bufnr] or 0 | |
vim.notify(string.format("Comment concealing is %s (%d lines concealed)", status, count), vim.log.levels.INFO) | |
end, { desc = "Show comment concealing status" }) | |
end | |
---Public API | |
---Setup the comment concealing module | |
---@param opts? ConcealCommentConfig User configuration options | |
function M.setup(opts) | |
-- Merge user configuration with defaults | |
config = vim.tbl_deep_extend("force", default_config, opts or {}) | |
debug_log("Setting up ConcealComment with config: " .. vim.inspect(config)) | |
-- Setup components | |
if config.smart_navigation then | |
setup_navigation_keymaps() | |
end | |
setup_autocommands() | |
setup_user_commands() | |
debug_log("ConcealComment setup completed successfully") | |
end | |
---Enable comment concealing for a buffer | |
---@param bufnr? BufferHandle Buffer handle (defaults to current buffer) | |
---@return boolean success | |
---@return string? error_message | |
function M.enable(bufnr) | |
bufnr = bufnr or vim.api.nvim_get_current_buf() | |
local success, error_msg = apply_concealing(bufnr) | |
if success then | |
vim.notify("Comments concealed", vim.log.levels.INFO) | |
end | |
return success, error_msg | |
end | |
---Disable comment concealing for a buffer | |
---@param bufnr? BufferHandle Buffer handle (defaults to current buffer) | |
---@return boolean success | |
---@return string? error_message | |
function M.disable(bufnr) | |
bufnr = bufnr or vim.api.nvim_get_current_buf() | |
local success, error_msg = remove_concealing(bufnr) | |
if success then | |
vim.notify("Comments revealed", vim.log.levels.INFO) | |
end | |
return success, error_msg | |
end | |
---Toggle comment concealing for a buffer | |
---@param bufnr? BufferHandle Buffer handle (defaults to current buffer) | |
---@return boolean success | |
---@return string? error_message | |
function M.toggle(bufnr) | |
bufnr = bufnr or vim.api.nvim_get_current_buf() | |
if M.is_enabled(bufnr) then | |
return M.disable(bufnr) | |
else | |
return M.enable(bufnr) | |
end | |
end | |
---Check if comment concealing is enabled for a buffer | |
---@param bufnr? BufferHandle Buffer handle (defaults to current buffer) | |
---@return boolean is_enabled | |
function M.is_enabled(bufnr) | |
bufnr = bufnr or vim.api.nvim_get_current_buf() | |
return concealed_buffers[bufnr] ~= nil | |
end | |
---Get concealing statistics for a buffer | |
---@param bufnr? BufferHandle Buffer handle (defaults to current buffer) | |
---@return table stats Statistics object with concealed line count and other info | |
function M.get_stats(bufnr) | |
bufnr = bufnr or vim.api.nvim_get_current_buf() | |
local concealed_lines = concealed_buffers[bufnr] or {} | |
local total_lines = vim.api.nvim_buf_line_count(bufnr) | |
return { | |
buffer = bufnr, | |
total_lines = total_lines, | |
concealed_lines = #concealed_lines, | |
concealed_percentage = total_lines > 0 and (#concealed_lines / total_lines * 100) or 0, | |
is_enabled = M.is_enabled(bufnr), | |
is_supported = vim.treesitter.get_parser(bufnr, nil, { error = false }) ~= nil, | |
} | |
end | |
---Get current configuration | |
---@return ConcealCommentConfig config Current configuration | |
function M.get_config() | |
return vim.deepcopy(config) | |
end | |
---Update configuration | |
---@param new_config ConcealCommentConfig New configuration options | |
function M.update_config(new_config) | |
config = vim.tbl_deep_extend("force", config, new_config) | |
debug_log("Configuration updated: " .. vim.inspect(new_config)) | |
end | |
return M |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment