diff --git a/README.md b/README.md index 887de1f..a2e19bf 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # tail.nvim A minimal Neovim plugin that allows any buffer to follow appended lines—just like `tail -f`. It can -optionally display a timestamp before each new line using virtual text. +optionally display a timestamp before each new line using virtual text, and highlight log level +keywords (ERROR, WARN, INFO, DEBUG, TRACE). ## Features @@ -9,6 +10,7 @@ optionally display a timestamp before each new line using virtual text. - Respects user scrolling: won't yank you back if you've moved up - Works on any buffer type: `nofile`, plugin buffers, etc. Your mileage may vary for writeable or "exotic" buffers like :terminal - Optional per-buffer timestamps: prefix newly inserted lines with the current time. The timestamp is drawn with virtual text, so it does not modify the file’s content. +- Optional log level highlighting: colorize ERROR, WARN, INFO, DEBUG, TRACE keywords using Neovim's diagnostic highlight groups. - Does not move the cursor position on activation by default. Use neovim's move to end of buffer, default: Shift + g. ### Demo @@ -44,12 +46,14 @@ Set up the plugin in your init.lua: ```lua require("tail").setup({ - -- uncomment the next line to enable timestamps by default - -- timestamps = true, + -- enable timestamps by default + timestamps = false, -- customise the format (see `:help os.date`) timestamp_format = "%Y-%m-%d %H:%M:%S", -- customise the highlight group used for the timestamp timestamp_hl = "Comment", + -- enable log level highlighting by default + log_level_hl = false, }) ``` @@ -61,12 +65,16 @@ Then, from any buffer enable, disable or toggle tailing behaviour: :TailToggle ``` -Similarily the timestamps are controlled: +Similarly, timestamps and log level highlighting are controlled: ```vim -:TailTimestampToggle :TailTimestampEnable :TailTimestampDisable +:TailTimestampToggle + +:TailLogLevelHlEnable +:TailLogLevelHlDisable +:TailLogLevelHlToggle ``` The actual following behavior might not directly work, as the cursor position is not changed @@ -98,6 +106,10 @@ require("tail").timestamps_enable(bufnr, { backfill = true }) require("tail").timestamps_disable(bufnr) require("tail").timestamps_toggle(bufnr, { backfill = false }) +-- Log level highlighting +require("tail").log_level_hl_enable(bufnr, { backfill = true }) +require("tail").log_level_hl_disable(bufnr) +require("tail").log_level_hl_toggle(bufnr, { backfill = false }) ``` ## License diff --git a/lua/tail/init.lua b/lua/tail/init.lua index 67094e0..85f9bb4 100644 --- a/lua/tail/init.lua +++ b/lua/tail/init.lua @@ -20,6 +20,17 @@ local default_config = { timestamp_format = "%Y-%m-%d %H:%M:%S ", -- highlight group used for the timestamp virtual text timestamp_hl = "Comment", + -- enable log level highlighting by default for new buffers? + log_level_hl = false, + -- highlight groups for each log level keyword (uppercase only) + log_level_groups = { + TRACE = "DiagnosticHint", + DEBUG = "DiagnosticHint", + INFO = "DiagnosticInfo", + WARN = "DiagnosticWarn", + WARNING = "DiagnosticWarn", + ERROR = "DiagnosticError", + }, } local config = vim.deepcopy(default_config) @@ -37,6 +48,7 @@ local ns = vim.api.nvim_create_namespace("tail.nvim") -- buf_state[bufnr] = { -- enabled = bool, -- timestamps = bool, +-- log_level_hl = bool, -- attached = bool, -- wins = { -- [winid] = { pinned = bool }, @@ -51,6 +63,7 @@ local function get_buf_state(bufnr) s = { enabled = false, timestamps = config.timestamps, + log_level_hl = config.log_level_hl, attached = false, wins = {}, } @@ -131,6 +144,59 @@ local function backfill_timestamps(bufnr) end end +-- --------------------------------------------------------------------------- +-- Log level highlighting +-- --------------------------------------------------------------------------- + +local ns_loglevel = vim.api.nvim_create_namespace("tail.nvim.loglevel") + +--- Highlight log level keywords in the given line range (1-based, inclusive) +---@param bufnr number +---@param start_line number 1-based start line +---@param end_line number 1-based end line (inclusive) +local function highlight_log_levels(bufnr, start_line, end_line) + if not is_valid_buf(bufnr) then + return + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) + + for i, line in ipairs(lines) do + local lnum = start_line + i - 1 + + for keyword, hl_group in pairs(config.log_level_groups) do + -- Use frontier pattern for word boundary matching (uppercase only) + local pattern = "%f[%w]" .. keyword .. "%f[%W]" + local search_start = 1 + + while true do + local match_start, match_end = line:find(pattern, search_start) + if not match_start then + break + end + + vim.api.nvim_buf_set_extmark(bufnr, ns_loglevel, lnum - 1, match_start - 1, { + end_col = match_end, + hl_group = hl_group, + }) + + search_start = match_end + 1 + end + end + end +end + +local function backfill_log_levels(bufnr) + if not is_valid_buf(bufnr) then + return + end + vim.api.nvim_buf_clear_namespace(bufnr, ns_loglevel, 0, -1) + local line_count = vim.api.nvim_buf_line_count(bufnr) + if line_count > 0 then + highlight_log_levels(bufnr, 1, line_count) + end +end + -- --------------------------------------------------------------------------- -- Core: on_lines handler (tail + timestamps) -- --------------------------------------------------------------------------- @@ -151,6 +217,12 @@ local function on_lines(_, bufnr, _changedtick, firstline, lastline_old, new_las end end + -- log level highlighting: only care if lines were added + if bs.log_level_hl and new_lastline > lastline_old then + local start = firstline + 1 -- convert 0-based to 1-based + highlight_log_levels(bufnr, start, new_lastline) + end + -- tail-following: only if the window was “pinned” *before* the change local wins = vim.fn.win_findbuf(bufnr) if not wins or #wins == 0 then @@ -312,4 +384,42 @@ function M.timestamps_toggle(bufnr, opts) end end +-- --------------------------------------------------------------------------- +-- Public API: log level highlighting +-- --------------------------------------------------------------------------- + +function M.log_level_hl_enable(bufnr, opts) + bufnr = bufnr or vim.api.nvim_get_current_buf() + if not is_valid_buf(bufnr) then + return + end + local bs = get_buf_state(bufnr) + bs.log_level_hl = true + + opts = opts or {} + if opts.backfill then + backfill_log_levels(bufnr) + end +end + +function M.log_level_hl_disable(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + if not is_valid_buf(bufnr) then + return + end + local bs = get_buf_state(bufnr) + bs.log_level_hl = false + vim.api.nvim_buf_clear_namespace(bufnr, ns_loglevel, 0, -1) +end + +function M.log_level_hl_toggle(bufnr, opts) + bufnr = bufnr or vim.api.nvim_get_current_buf() + local bs = get_buf_state(bufnr) + if bs.log_level_hl then + M.log_level_hl_disable(bufnr) + else + M.log_level_hl_enable(bufnr, opts) + end +end + return M diff --git a/plugin/tail.lua b/plugin/tail.lua index f8f13fc..5993011 100644 --- a/plugin/tail.lua +++ b/plugin/tail.lua @@ -9,6 +9,10 @@ -- :TailTimestampDisable -- :TailTimestampToggle -- +-- :TailLogLevelHlEnable +-- :TailLogLevelHlDisable +-- :TailLogLevelHlToggle +-- -- We treat files (as they may be buffered) separately from other buffers in here. As (neo)vim has problems with -- reliable reloading buffered files, we use our own timer to watch over a files changes. The rest of this file -- plugs that into the other code in init.lua. @@ -18,7 +22,56 @@ local tail = require("tail") -- per-buffer polling / config state for FILE-tail buffers local timers = {} -- bufnr -> uv_timer local file_state = {} -- bufnr -> { path, offset, partial } -local file_cfg = {} -- bufnr -> { follow = bool, ts_enabled = bool, ts_format = string, ts_hl = string } +local file_cfg = {} -- bufnr -> { follow = bool, ts_enabled = bool, ts_format = string, ts_hl = string, log_level_hl = bool } + +-- namespace for log level highlighting in file-tail buffers +local ns_loglevel = vim.api.nvim_create_namespace("tail-loglevel") + +-- default log level highlight groups (same as init.lua) +local log_level_groups = { + TRACE = "DiagnosticHint", + DEBUG = "DiagnosticHint", + INFO = "DiagnosticInfo", + WARN = "DiagnosticWarn", + WARNING = "DiagnosticWarn", + ERROR = "DiagnosticError", +} + +--- Highlight log level keywords in the given line range (1-based, inclusive) +---@param bufnr number +---@param start_line number 1-based start line +---@param end_line number 1-based end line (inclusive) +local function highlight_log_levels(bufnr, start_line, end_line) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) + + for i, line in ipairs(lines) do + local lnum = start_line + i - 1 + + for keyword, hl_group in pairs(log_level_groups) do + -- Use frontier pattern for word boundary matching (uppercase only) + local pattern = "%f[%w]" .. keyword .. "%f[%W]" + local search_start = 1 + + while true do + local match_start, match_end = line:find(pattern, search_start) + if not match_start then + break + end + + vim.api.nvim_buf_set_extmark(bufnr, ns_loglevel, lnum - 1, match_start - 1, { + end_col = match_end, + hl_group = hl_group, + }) + + search_start = match_end + 1 + end + end + end +end ---------------------------------------------------------------------- -- Helper: is this buffer a regular file on disk (before tail mode)? @@ -145,10 +198,11 @@ local function start_file_poller(bufnr) -- initial config for this buffer file_cfg[bufnr] = file_cfg[bufnr] or { - follow = (tail.opts and tail.opts.follow) ~= false, -- default to true - ts_enabled = (tail.opts and tail.opts.timestamps) == true, -- default to false - ts_format = (tail.opts and tail.opts.timestamp_format) or "%Y-%m-%d %H:%M:%S ", - ts_hl = (tail.opts and tail.opts.timestamp_highlight) or "Comment", + follow = (tail.opts and tail.opts.follow) ~= false, -- default to true + ts_enabled = (tail.opts and tail.opts.timestamps) == true, -- default to false + ts_format = (tail.opts and tail.opts.timestamp_format) or "%Y-%m-%d %H:%M:%S ", + ts_hl = (tail.opts and tail.opts.timestamp_highlight) or "Comment", + log_level_hl = (tail.opts and tail.opts.log_level_hl) == true, -- default to false } -- move cursor to bottom initially @@ -254,6 +308,13 @@ local function start_file_poller(bufnr) vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, lines) vim.bo[bufnr].modified = false -- still just a view + -- log level highlighting for new lines if enabled + if cfg.log_level_hl then + local line_count = vim.api.nvim_buf_line_count(bufnr) + local start_line = line_count - #lines + 1 + highlight_log_levels(bufnr, start_line, line_count) + end + -- follow behaviour: only jump if follow=true if cfg.follow then local wins = vim.fn.win_findbuf(bufnr) @@ -333,6 +394,43 @@ local function file_timestamps_disable(bufnr) cfg.ts_enabled = false end +---------------------------------------------------------------------- +-- Log level highlighting for FILE-tail buffers only +---------------------------------------------------------------------- + +local function file_log_level_hl_enable(bufnr, opts) + opts = opts or {} + local cfg = file_cfg[bufnr] + if not cfg then + cfg = { + follow = true, + ts_enabled = false, + ts_format = "%Y-%m-%d %H:%M:%S ", + log_level_hl = false, + } + file_cfg[bufnr] = cfg + end + + cfg.log_level_hl = true + + if opts.backfill then + vim.api.nvim_buf_clear_namespace(bufnr, ns_loglevel, 0, -1) + local line_count = vim.api.nvim_buf_line_count(bufnr) + if line_count > 0 then + highlight_log_levels(bufnr, 1, line_count) + end + end +end + +local function file_log_level_hl_disable(bufnr) + local cfg = file_cfg[bufnr] + if not cfg then + return + end + cfg.log_level_hl = false + vim.api.nvim_buf_clear_namespace(bufnr, ns_loglevel, 0, -1) +end + ---------------------------------------------------------------------- -- Core commands ---------------------------------------------------------------------- @@ -427,3 +525,47 @@ vim.api.nvim_create_user_command("TailTimestampToggle", function() end, { desc = "Toggle timestamps", }) + +---------------------------------------------------------------------- +-- Log level highlighting commands +-- - File buffers (tail mode): our own behaviour +-- - Other buffers: delegate to core tail.nvim +---------------------------------------------------------------------- + +vim.api.nvim_create_user_command("TailLogLevelHlEnable", function() + local bufnr = vim.api.nvim_get_current_buf() + if vim.b[bufnr].tail_file_path then + file_log_level_hl_enable(bufnr, { backfill = true }) + else + tail.log_level_hl_enable(nil, { backfill = true }) + end +end, { + desc = "Enable log level highlighting", +}) + +vim.api.nvim_create_user_command("TailLogLevelHlDisable", function() + local bufnr = vim.api.nvim_get_current_buf() + if vim.b[bufnr].tail_file_path then + file_log_level_hl_disable(bufnr) + else + tail.log_level_hl_disable() + end +end, { + desc = "Disable log level highlighting", +}) + +vim.api.nvim_create_user_command("TailLogLevelHlToggle", function() + local bufnr = vim.api.nvim_get_current_buf() + if vim.b[bufnr].tail_file_path then + local cfg = file_cfg[bufnr] + if cfg and cfg.log_level_hl then + file_log_level_hl_disable(bufnr) + else + file_log_level_hl_enable(bufnr, { backfill = true }) + end + else + tail.log_level_hl_toggle(nil, { backfill = true }) + end +end, { + desc = "Toggle log level highlighting", +})