-
Notifications
You must be signed in to change notification settings - Fork 14
refactor: migrate to built-in treesitter API and extract surround module #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
10ae1c2
8097db8
cb27671
fb86e5e
7bdd328
785e60d
a60bd4e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| local ts = vim.treesitter | ||
|
|
||
| local M = {} | ||
|
|
||
| ---Check if node is a surround type by examining its children | ||
| ---A surround node has unnamed (anonymous) nodes as first and last children | ||
| ---These unnamed nodes are typically delimiters like (, ), {, }, ", ', etc. | ||
| ---@param node TSNode | ||
| ---@return boolean | ||
| function M.is_surround(node) | ||
| local child_count = node:child_count() | ||
| if child_count < 2 then | ||
| return false | ||
| end | ||
|
|
||
| local first = node:child(0) | ||
| local last = node:child(child_count - 1) | ||
|
|
||
| -- Anonymous nodes are delimiters | ||
| return first and last and not first:named() and not last:named() | ||
| end | ||
|
|
||
| ---Get inner range (excluding delimiter nodes) | ||
| ---@param node TSNode | ||
| ---@param buf? integer buffer number (needed for edge case handling) | ||
| ---@return table|nil {srow, scol, erow, ecol} 1-based vim coordinates | ||
| function M.get_inner_range(node, buf) | ||
| local child_count = node:child_count() | ||
| if child_count < 2 then | ||
| return nil | ||
| end | ||
|
|
||
| local first = node:child(0) | ||
| local last = node:child(child_count - 1) | ||
|
|
||
| if not first or not last or first:named() or last:named() then | ||
| return nil | ||
| end | ||
|
|
||
| -- Get the end of first delimiter and start of last delimiter | ||
| local first_end_row, first_end_col = first:end_() | ||
| local last_start_row, last_start_col = last:start() | ||
|
|
||
| -- Convert to 1-based vim coordinates | ||
| local srow = first_end_row + 1 | ||
| local scol = first_end_col + 1 | ||
| local erow = last_start_row + 1 | ||
| local ecol = last_start_col -- 0-based exclusive == 1-based inclusive | ||
|
|
||
| buf = buf or vim.api.nvim_get_current_buf() | ||
|
|
||
| -- Handle edge case: when first delimiter ends at line end, | ||
| -- content starts at next line's beginning | ||
| local first_line = vim.api.nvim_buf_get_lines(buf, srow - 1, srow, false)[1] | ||
| if first_line and scol > #first_line then | ||
| srow = srow + 1 | ||
| scol = 1 | ||
| end | ||
|
|
||
| -- Handle edge case: when last delimiter is at line start (col 0), | ||
| -- content ends at previous line's end | ||
| if ecol < 1 and erow > srow then | ||
| erow = erow - 1 | ||
| local line = vim.api.nvim_buf_get_lines(buf, erow - 1, erow, false)[1] | ||
| ecol = line and #line or 1 | ||
| end | ||
|
|
||
| -- Handle empty content: when delimiters are adjacent (e.g., () or {}) | ||
| -- Return nil to indicate no inner content | ||
| if srow > erow or (srow == erow and scol > ecol) then | ||
| return nil | ||
| end | ||
|
|
||
| return { srow, scol, erow, ecol } | ||
| end | ||
|
|
||
| return M | ||
| 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 = {} | ||
|
|
@@ -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 | ||
| end | ||
| return start_row, start_col, end_row, end_col ---@type integer, integer, integer, integer | ||
| end | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -81,14 +86,44 @@ 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 | ||
| 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 | ||
|
Comment on lines
+101
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This guard rejects valid Tree-sitter ranges at file end because ranges are end-exclusive before conversion. For nodes that end at EOF (common when the buffer has a trailing newline), Useful? React with 👍 / 👎. |
||
| return | ||
| end | ||
|
|
||
| -- Validate column bounds | ||
| if start_col < 1 or end_col < 0 then | ||
| -- 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 "" | ||
| 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)) | ||
|
|
||
| local v_table = { charwise = "v", linewise = "V", blockwise = "<C-v>" } | ||
| selection_mode = selection_mode or "charwise" | ||
|
|
||
| -- Normalise selection_mode | ||
| if vim.tbl_contains(vim.tbl_keys(v_table), selection_mode) then | ||
| if v_table[selection_mode] then | ||
| selection_mode = v_table[selection_mode] | ||
| end | ||
|
|
||
|
|
@@ -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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This heuristic classifies any node with anonymous first/last children as a surround, which incorrectly matches many non-delimiter constructs (for example statement nodes like
return x;in JS grammars). In those casesunsurround_coordinatestreats keywords/punctuation as wrappers and can introduce unintended intermediate selections, changing expansion behavior beyond bracket-like surrounds that the plugin previously targeted.Useful? React with 👍 / 👎.