Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
# 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

- Auto-scrolls to the bottom of any buffer as new lines are added (if already at bottom)
- 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
Expand Down Expand Up @@ -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,
})
Comment on lines 48 to 57
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README documentation is incomplete. According to the PR description, users should be able to customize highlight groups via the log_level_groups config option, but this is not documented in the setup example. Consider adding documentation showing users how to customize the highlight groups, similar to how the PR description shows this feature.

Copilot uses AI. Check for mistakes.
```

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions lua/tail/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 },
Expand All @@ -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 = {},
}
Expand Down Expand Up @@ -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)
-- ---------------------------------------------------------------------------
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading