Skip to content
Open

Main #12

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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,15 @@ Beyond the basic actions such as yank(`CR`), delete(`d`), and change

## Installation

Requires Neovim >= 0.9.0

``` lua
{
"sustech-data/wildfire.nvim",
event = "VeryLazy",
dependencies = { "nvim-treesitter/nvim-treesitter" },
dependencies = {
{ "nvim-treesitter/nvim-treesitter", branch = "main" }
},
config = function()
require("wildfire").setup()
end,
Expand Down
66 changes: 58 additions & 8 deletions lua/wildfire/init.lua
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
local api = vim.api

local keymap = vim.keymap
local ts_utils = require("nvim-treesitter.ts_utils")
local parsers = require("nvim-treesitter.parsers")
local utils = require("wildfire.utils")

local M = {}
Expand Down Expand Up @@ -30,7 +28,24 @@ local count = 1
function M.unsurround_coordinates(node_or_range, buf)
-- local lines = vim.split(s, "\n")
local srow, scol, erow, ecol = utils.get_range(node_or_range)
local lines = vim.api.nvim_buf_get_text(buf, srow - 1, scol - 1, erow - 1, ecol, {})

-- Validate buffer bounds to prevent index out of bounds error
local line_count = vim.api.nvim_buf_line_count(buf)
if srow < 1 or erow > line_count or srow > erow then
return false, { srow, scol, erow, ecol }
end

-- Validate column bounds for the specific lines
if scol < 1 or ecol < 0 then
return false, { srow, scol, erow, ecol }
end

-- Use pcall to safely get text, handle any edge cases
local ok, lines = pcall(vim.api.nvim_buf_get_text, buf, srow - 1, scol - 1, erow - 1, ecol, {})
if not ok or not lines or #lines == 0 then
return false, { srow, scol, erow, ecol }
end

Comment on lines +43 to +48
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The pcall on line 44 is used to safely handle potential errors from nvim_buf_get_text, but after the comprehensive bounds checking on lines 32-41, it's unclear what additional errors pcall is meant to catch.

The bounds checking validates:

  1. Row bounds (lines 34-36)
  2. Column lower bounds (lines 39-41)

However, it doesn't validate that ecol is within the length of the end line, which could cause nvim_buf_get_text to fail. If the intention is to handle cases where ecol exceeds the line length, it would be clearer to explicitly validate this condition rather than relying on pcall to catch the error. This would make the error handling more predictable and easier to debug.

Suggested change
-- Use pcall to safely get text, handle any edge cases
local ok, lines = pcall(vim.api.nvim_buf_get_text, buf, srow - 1, scol - 1, erow - 1, ecol, {})
if not ok or not lines or #lines == 0 then
return false, { srow, scol, erow, ecol }
end
-- Validate that the end column does not exceed the length of the end line
local end_line = vim.api.nvim_buf_get_lines(buf, erow - 1, erow, false)[1] or ""
if ecol > #end_line then
return false, { srow, scol, erow, ecol }
end
-- Bounds are validated; safely get text without relying on pcall
local lines = vim.api.nvim_buf_get_text(buf, srow - 1, scol - 1, erow - 1, ecol, {})
if not lines or #lines == 0 then
return false, { srow, scol, erow, ecol }
end

Copilot uses AI. Check for mistakes.
local node_text = table.concat(lines, "\n")
local match_brackets = nil
for _, pair in ipairs(M.options.surrounds) do
Expand Down Expand Up @@ -102,8 +117,17 @@ local function init_by_node(node)
end
function M.init_selection()
count = vim.v.count1
local node = ts_utils.get_node_at_cursor()
local node = vim.treesitter.get_node()
if not node then
-- No treesitter node available, try to handle gracefully
-- Check if treesitter is available for this filetype
local buf = api.nvim_get_current_buf()
local ok, parser = pcall(vim.treesitter.get_parser, buf)
if not ok or not parser then
-- No parser available for this filetype
vim.notify("Wildfire: No treesitter parser available for this filetype", vim.log.levels.WARN)
return
end
return
Comment on lines 121 to 131
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

After checking if a parser exists and is valid, the code issues a warning notification but then continues to return on line 131. This creates a situation where a warning is shown even when get_node() returns nil for legitimate reasons (like being at the start of a file with no node at cursor).

The second return statement on line 131 is redundant and unreachable after the warning notification on line 128 already returns. Either the warning block should not return (to allow the outer return to execute), or the second return should be removed. Additionally, consider whether showing a warning is appropriate when get_node() returns nil, as this may be a normal occurrence.

Copilot uses AI. Check for mistakes.
end
init_by_node(node)
Expand Down Expand Up @@ -134,9 +158,21 @@ local function select_incremental(get_parent)

-- Initialize incremental selection with current selection
if not nodes or #nodes == 0 then
local root = parsers.get_parser():parse()[1]:root()
-- Use native vim.treesitter API to get the parser
local ok, parser = pcall(vim.treesitter.get_parser, buf)
if not ok or not parser then
-- No parser available for this filetype, fallback to visual selection
return
end
local tree = parser:parse()[1]
if not tree then
return
end
local root = tree:root()
local node = root:named_descendant_for_range(csrow - 1, cscol - 1, cerow - 1, cecol)
update_selection_by_node(node)
if node then
update_selection_by_node(node)
end
return
end

Expand All @@ -147,15 +183,29 @@ local function select_incremental(get_parent)
if not parent or parent == node then
-- Keep searching in the main tree
-- TODO: we should search on the parent tree of the current node.
local root = parsers.get_parser():parse()[1]:root()
local ok, parser = pcall(vim.treesitter.get_parser, buf)
if not ok or not parser then
utils.update_selection(buf, node)
return
end
local tree = parser:parse()[1]
if not tree then
utils.update_selection(buf, node)
return
end
local root = tree:root()
parent = root:named_descendant_for_range(csrow - 1, cscol - 1, cerow - 1, cecol)
if not parent or root == node or parent == node then
utils.update_selection(buf, node)
return
end
end
node = parent
local nsrow, nscol, nerow, necol = ts_utils.get_vim_range({ node:range() })
local nsrow, nscol, nerow, necol = vim.treesitter.get_node_range(node)
-- Convert 0-based to 1-based indexing to match vim coordinates
-- Note: treesitter end_col is exclusive, but vim coordinates are inclusive
nsrow, nscol, nerow, necol = nsrow + 1, nscol + 1, nerow + 1, necol
-- necol: 0-based exclusive == 1-based inclusive, so no +1 needed
Comment on lines +205 to +208
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The comment explanation about coordinate conversion appears to be misleading. It states "necol: 0-based exclusive == 1-based inclusive, so no +1 needed", but the actual reason is more nuanced.

When converting from 0-based exclusive to 1-based inclusive:

  • The transformation is: (N + 1) - 1 = N
  • Where +1 converts from 0-based to 1-based
  • And -1 converts from exclusive to inclusive

So while the result (no adjustment) is correct, the reasoning "0-based exclusive == 1-based inclusive" oversimplifies the actual mathematical transformation.

Suggested change
-- Convert 0-based to 1-based indexing to match vim coordinates
-- Note: treesitter end_col is exclusive, but vim coordinates are inclusive
nsrow, nscol, nerow, necol = nsrow + 1, nscol + 1, nerow + 1, necol
-- necol: 0-based exclusive == 1-based inclusive, so no +1 needed
-- Convert 0-based to 1-based indexing to match vim coordinates.
-- Treesitter rows/cols are 0-based; Vim's are 1-based. Treesitter end_col is
-- 0-based *exclusive*, while Vim uses 1-based *inclusive* end columns.
-- For the end column, the +1 (0-based → 1-based) and -1 (exclusive → inclusive)
-- cancel out: (necol + 1) - 1 == necol, so we leave `necol` unchanged.
nsrow, nscol, nerow, necol = nsrow + 1, nscol + 1, nerow + 1, necol

Copilot uses AI. Check for mistakes.

local larger_range = utils.range_larger({ nsrow, nscol, nerow, necol }, { csrow, cscol, cerow, cecol })

Expand Down
55 changes: 45 additions & 10 deletions lua/wildfire/utils.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
local api = vim.api

local ts_utils = require("nvim-treesitter.ts_utils")
local ts = vim.treesitter

local M = {}
Expand All @@ -9,8 +8,14 @@ function M.get_range(node_or_range)
if type(node_or_range) == "table" then
start_row, start_col, end_row, end_col = unpack(node_or_range)
else
local buf = api.nvim_get_current_buf()
start_row, start_col, end_row, end_col = ts_utils.get_vim_range({ ts.get_node_range(node_or_range) }, buf)
start_row, start_col, end_row, end_col = ts.get_node_range(node_or_range)
-- Convert 0-based to 1-based indexing to match vim coordinates
-- Note: treesitter end_col is exclusive, but vim coordinates are inclusive
start_row = start_row + 1
start_col = start_col + 1
end_row = end_row + 1
-- end_col is already exclusive in treesitter (0-based), converting to 1-based inclusive means no change needed
-- because: 0-based exclusive position == 1-based inclusive position
Comment on lines +13 to +18
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The comment states that "end_col is already exclusive in treesitter (0-based), converting to 1-based inclusive means no change needed". However, this logic appears to be incorrect.

When treesitter returns an exclusive end column in 0-based indexing, and we need to convert it to 1-based inclusive indexing:

  • A 0-based exclusive position N means "just before position N"
  • Converting to 1-based: position N becomes N+1
  • But we need inclusive, so the last character is at position N (0-based) = N+1 (1-based) - 1 = N

The comment suggests no adjustment is needed, which happens to arrive at the correct result (not adding 1), but the reasoning is misleading. The actual transformation is: (N + 1) - 1 = N, where +1 converts from 0-based to 1-based, and -1 converts from exclusive to inclusive.

Suggested change
-- Note: treesitter end_col is exclusive, but vim coordinates are inclusive
start_row = start_row + 1
start_col = start_col + 1
end_row = end_row + 1
-- end_col is already exclusive in treesitter (0-based), converting to 1-based inclusive means no change needed
-- because: 0-based exclusive position == 1-based inclusive position
-- Note: treesitter end_col is 0-based exclusive, while vim coordinates are 1-based inclusive.
start_row = start_row + 1
start_col = start_col + 1
end_row = end_row + 1
-- Conceptually, converting end_col from 0-based exclusive to 1-based inclusive is: (end_col + 1) - 1.
-- The +1 (0→1-based) and -1 (exclusive→inclusive) cancel each other, so the numeric value of end_col stays the same.

Copilot uses AI. Check for mistakes.
end
return start_row, start_col, end_row, end_col ---@type integer, integer, integer, integer
end
Expand Down Expand Up @@ -63,15 +68,15 @@ end

function M.print_selection(node_or_range)
local bufnr = api.nvim_get_current_buf()
local lines
local node_text
if type(node_or_range) == "table" then
local srow, scol, erow, ecol
srow, scol, erow, ecol = unpack(node_or_range)
lines = vim.api.nvim_buf_get_text(bufnr, srow - 1, scol - 1, erow - 1, ecol, {})
local lines = vim.api.nvim_buf_get_text(bufnr, srow - 1, scol - 1, erow - 1, ecol, {})
node_text = table.concat(lines, "\n")
else
lines = ts_utils.get_node_text(node_or_range, bufnr)
node_text = vim.treesitter.get_node_text(node_or_range, bufnr)
end
local node_text = table.concat(lines, "\n")
print(node_text)
end

Expand All @@ -81,9 +86,39 @@ function M.update_selection(buf, node_or_range, selection_mode)
if type(node_or_range) == "table" then
start_row, start_col, end_row, end_col = unpack(node_or_range)
else
start_row, start_col, end_row, end_col = ts_utils.get_vim_range({ ts.get_node_range(node_or_range) }, buf)
start_row, start_col, end_row, end_col = ts.get_node_range(node_or_range)
-- Convert 0-based to 1-based indexing to match vim coordinates
-- Note: treesitter end_col is exclusive, but vim coordinates are inclusive
start_row = start_row + 1
start_col = start_col + 1
end_row = end_row + 1
-- end_col is already exclusive in treesitter (0-based), converting to 1-based inclusive means no change needed
-- because: 0-based exclusive position == 1-based inclusive position
Comment on lines +91 to +96
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The comment states that "end_col is already exclusive in treesitter (0-based), converting to 1-based inclusive means no change needed". However, this logic appears to be incorrect.

When treesitter returns an exclusive end column in 0-based indexing, and we need to convert it to 1-based inclusive indexing:

  • A 0-based exclusive position N means "just before position N"
  • Converting to 1-based: position N becomes N+1
  • But we need inclusive, so the last character is at position N (0-based) = N+1 (1-based) - 1 = N

The comment suggests no adjustment is needed, which happens to arrive at the correct result (not adding 1), but the reasoning is misleading. The actual transformation is: (N + 1) - 1 = N, where +1 converts from 0-based to 1-based, and -1 converts from exclusive to inclusive.

Suggested change
-- Note: treesitter end_col is exclusive, but vim coordinates are inclusive
start_row = start_row + 1
start_col = start_col + 1
end_row = end_row + 1
-- end_col is already exclusive in treesitter (0-based), converting to 1-based inclusive means no change needed
-- because: 0-based exclusive position == 1-based inclusive position
-- Note: treesitter end_col is 0-based and exclusive, while vim uses 1-based inclusive positions.
start_row = start_row + 1
start_col = start_col + 1
end_row = end_row + 1
-- For the end column, conceptually: (0-based exclusive N) -> (1-based exclusive N+1) -> (1-based inclusive (N+1)-1 = N).
-- These steps cancel out numerically, so no adjustment to end_col is needed here.

Copilot uses AI. Check for mistakes.
end

-- Validate buffer bounds to prevent cursor position errors
local line_count = api.nvim_buf_line_count(buf)
if start_row < 1 or end_row < 1 or start_row > line_count or end_row > line_count then
-- Invalid row range, cannot update selection
return
end

-- Validate column bounds
if start_col < 1 or end_col < 0 then
Comment on lines +106 to +107
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The validation on line 107 checks if start_col is less than 1, but after the coordinate conversion on line 93, start_col should always be at least 1 (since it's start_col_0based + 1). The only way start_col could be less than 1 after line 93 is if the original 0-based start_col from treesitter was less than 0, which should not happen with valid treesitter data.

Similarly, the check for end_col less than 0 on line 107 would only trigger if the original 0-based exclusive end_col from treesitter was less than 0. While these checks provide defensive programming, they're checking for conditions that indicate corrupted data from treesitter rather than normal edge cases. Consider whether these represent truly recoverable errors or if they should be assertions/errors instead of silent returns.

Suggested change
-- Validate column bounds
if start_col < 1 or end_col < 0 then
-- Validate column bounds only for manually supplied ranges.
-- Tree-sitter ranges are expected to be valid after conversion above.
if type(node_or_range) == "table" and (start_col < 1 or end_col < 0) then

Copilot uses AI. Check for mistakes.
-- Invalid column range, cannot update selection
return
end

-- Get the actual line lengths to validate column positions
local start_line_text = api.nvim_buf_get_lines(buf, start_row - 1, start_row, false)[1] or ""
local end_line_text = api.nvim_buf_get_lines(buf, end_row - 1, end_row, false)[1] or ""
Comment on lines +112 to +114
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The validation logic calls nvim_buf_get_lines twice to fetch line text for length calculation (lines 113-114), even though this information is only used for clamping. For large files or frequent calls to update_selection, this could have a performance impact.

Consider whether this validation is necessary in all cases. If the coordinates come from treesitter (which should always return valid ranges), the validation may be redundant. Alternatively, the validation could be made conditional or the line fetching could be optimized to fetch both lines in a single call when they're on adjacent or same lines.

Suggested change
-- Get the actual line lengths to validate column positions
local start_line_text = api.nvim_buf_get_lines(buf, start_row - 1, start_row, false)[1] or ""
local end_line_text = api.nvim_buf_get_lines(buf, end_row - 1, end_row, false)[1] or ""
-- Get the actual line lengths to validate column positions.
-- Optimize for the common case where start and end are on the same line by
-- fetching that line only once.
local start_line_text, end_line_text
if start_row == end_row then
local line = api.nvim_buf_get_lines(buf, start_row - 1, start_row, false)[1] or ""
start_line_text = line
end_line_text = line
else
start_line_text = api.nvim_buf_get_lines(buf, start_row - 1, start_row, false)[1] or ""
end_line_text = api.nvim_buf_get_lines(buf, end_row - 1, end_row, false)[1] or ""
end

Copilot uses AI. Check for mistakes.
local start_line_len = #start_line_text
local end_line_len = #end_line_text

-- Clamp column positions to valid ranges (0-indexed for nvim_win_set_cursor)
start_col = math.max(0, math.min(start_col - 1, start_line_len))
end_col = math.max(0, math.min(end_col - 1, end_line_len))
Comment on lines +119 to +120
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The column clamping logic may allow the cursor to be positioned one past the end of a line. For a line with length N characters, valid 0-indexed column positions are 0 to N-1. However, the clamping uses math.min(col - 1, line_len), which allows column values up to N (the line length), not N-1 (the last valid character position).

While Vim/Neovim may accept a column value equal to the line length (positioning the cursor after the last character in some modes), this should be verified as intentional behavior. If the intention is to clamp to the last character position, the code should use math.min(col - 1, math.max(0, line_len - 1)) instead.

Suggested change
start_col = math.max(0, math.min(start_col - 1, start_line_len))
end_col = math.max(0, math.min(end_col - 1, end_line_len))
start_col = math.max(0, math.min(start_col - 1, math.max(0, start_line_len - 1)))
end_col = math.max(0, math.min(end_col - 1, math.max(0, end_line_len - 1)))

Copilot uses AI. Check for mistakes.

local v_table = { charwise = "v", linewise = "V", blockwise = "<C-v>" }
selection_mode = selection_mode or "charwise"

Expand All @@ -103,8 +138,8 @@ function M.update_selection(buf, node_or_range, selection_mode)
api.nvim_cmd({ cmd = "normal", bang = true, args = { selection_mode } }, {})
end

api.nvim_win_set_cursor(0, { start_row, start_col - 1 })
api.nvim_win_set_cursor(0, { start_row, start_col })
vim.cmd("normal! o")
api.nvim_win_set_cursor(0, { end_row, end_col - 1 })
api.nvim_win_set_cursor(0, { end_row, end_col })
end
return M
Loading