Skip to content

Commit 51c8a66

Browse files
committed
feat: fall back to Vim text objects when no treesitter parser available
1 parent 81d22f0 commit 51c8a66

2 files changed

Lines changed: 139 additions & 27 deletions

File tree

lua/wildfire/init.lua

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@ local api = vim.api
22
local keymap = vim.keymap
33
local utils = require("wildfire.utils")
44
local surround = require("wildfire.surround")
5-
local ts_select = require("vim.treesitter._select")
5+
local textobjects = require("wildfire.textobjects")
6+
7+
local ok_tss, ts_select = pcall(require, "vim.treesitter._select")
8+
if not ok_tss then ts_select = nil end
9+
10+
local function use_treesitter(buf)
11+
if not ts_select then return false end
12+
local ok, parser = pcall(vim.treesitter.get_parser, buf)
13+
return ok and parser ~= nil
14+
end
615

716
local M = {}
817

@@ -57,11 +66,20 @@ local function find_node_at_range(buf, sr, sc, er, ec)
5766
end
5867

5968
function M.init_selection()
60-
local node = vim.treesitter.get_node({ ignore_injections = false })
61-
if not node then return end
6269
local buf = api.nvim_get_current_buf()
6370
checkpoint:reset(buf)
64-
update_selection_by_node(node)
71+
72+
if use_treesitter(buf) then
73+
local node = vim.treesitter.get_node({ ignore_injections = false })
74+
if not node then return end
75+
update_selection_by_node(node)
76+
else
77+
local range = textobjects.init()
78+
if not range then return end
79+
utils.update_selection(buf, range)
80+
checkpoint:save(buf, range)
81+
end
82+
6583
for _ = 1, vim.v.count1 - 1 do
6684
M.node_incremental()
6785
end
@@ -70,33 +88,42 @@ end
7088
function M.node_incremental()
7189
local buf = api.nvim_get_current_buf()
7290

73-
-- Initialize from current visual selection when no prior state
74-
if not checkpoint:has_state(buf) then
75-
local csrow, cscol, cerow, cecol = utils.visual_selection_range()
76-
local ok, parser = pcall(vim.treesitter.get_parser, buf)
77-
if not ok or not parser then return end
78-
local tree = parser:parse()[1]
79-
if not tree then return end
80-
local node = tree:root():named_descendant_for_range(csrow - 1, cscol - 1, cerow - 1, cecol)
81-
if node then update_selection_by_node(node) end
82-
return
83-
end
91+
if use_treesitter(buf) then
92+
-- Initialize from current visual selection when no prior state
93+
if not checkpoint:has_state(buf) then
94+
local csrow, cscol, cerow, cecol = utils.visual_selection_range()
95+
local ok, parser = pcall(vim.treesitter.get_parser, buf)
96+
if not ok or not parser then return end
97+
local tree = parser:parse()[1]
98+
if not tree then return end
99+
local node = tree:root():named_descendant_for_range(csrow - 1, cscol - 1, cerow - 1, cecol)
100+
if node then update_selection_by_node(node) end
101+
return
102+
end
84103

85-
local csrow, cscol, cerow, cecol = utils.visual_selection_range()
86-
ts_select.select_parent(1)
87-
local nsr, nsc, ner, nec = utils.visual_selection_range()
104+
local csrow, cscol, cerow, cecol = utils.visual_selection_range()
105+
ts_select.select_parent(1)
106+
local nsr, nsc, ner, nec = utils.visual_selection_range()
88107

89-
-- No change → at tree root
90-
if nsr == csrow and nsc == cscol and ner == cerow and nec == cecol then
91-
return
92-
end
108+
-- No change → at tree root
109+
if nsr == csrow and nsc == cscol and ner == cerow and nec == cecol then
110+
return
111+
end
93112

94-
local node = find_node_at_range(buf, nsr, nsc, ner, nec)
95-
if node then
96-
update_selection_by_node(node)
113+
local node = find_node_at_range(buf, nsr, nsc, ner, nec)
114+
if node then
115+
update_selection_by_node(node)
116+
else
117+
utils.update_selection(buf, { nsr, nsc, ner, nec })
118+
checkpoint:save(buf, { nsr, nsc, ner, nec })
119+
end
97120
else
98-
utils.update_selection(buf, { nsr, nsc, ner, nec })
99-
checkpoint:save(buf, { nsr, nsc, ner, nec })
121+
local csrow, cscol, cerow, cecol = utils.visual_selection_range()
122+
local range = textobjects.expand(buf, csrow, cscol, cerow, cecol)
123+
if range then
124+
utils.update_selection(buf, range)
125+
checkpoint:save(buf, range)
126+
end
100127
end
101128
end
102129

lua/wildfire/textobjects.lua

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
local api = vim.api
2+
3+
local M = {}
4+
5+
local ESC = api.nvim_replace_termcodes("<Esc>", true, true, true)
6+
7+
local CANDIDATES = {
8+
"iw", "aw", "iW", "aW",
9+
'i"', 'a"', "i'", "a'", "i`", "a`",
10+
"i)", "a)", "i]", "a]", "i}", "a}", "i>", "a>",
11+
"is", "as", "ip", "ap",
12+
}
13+
14+
--- Execute one text object from (row, col) and return 1-based {sr, sc, er, ec} or nil.
15+
---@param obj string
16+
---@param row integer 1-based
17+
---@param col integer 1-based
18+
---@return integer[]|nil
19+
local function try_textobj(obj, row, col)
20+
api.nvim_win_set_cursor(0, { row, col - 1 })
21+
pcall(vim.cmd, "normal! v" .. obj)
22+
local mode = api.nvim_get_mode().mode
23+
if mode ~= "v" then
24+
if mode == "V" or mode == "\22" then
25+
api.nvim_cmd({ cmd = "normal", bang = true, args = { ESC } }, {})
26+
end
27+
return nil
28+
end
29+
local s = vim.fn.getpos("v")
30+
local e = vim.fn.getpos(".")
31+
api.nvim_cmd({ cmd = "normal", bang = true, args = { ESC } }, {})
32+
local sr, sc, er, ec = s[2], s[3], e[2], e[3]
33+
if sr > er or (sr == er and sc > ec) then
34+
sr, sc, er, ec = er, ec, sr, sc
35+
end
36+
return { sr, sc, er, ec }
37+
end
38+
39+
local function strictly_contains(outer, inner)
40+
local start_ok = outer[1] < inner[1] or (outer[1] == inner[1] and outer[2] <= inner[2])
41+
local end_ok = outer[3] > inner[3] or (outer[3] == inner[3] and outer[4] >= inner[4])
42+
local neq = outer[1] ~= inner[1] or outer[2] ~= inner[2] or outer[3] ~= inner[3] or outer[4] ~= inner[4]
43+
return start_ok and end_ok and neq
44+
end
45+
46+
local function range_size(r)
47+
return (r[3] - r[1]) * 10000 + (r[4] - r[2])
48+
end
49+
50+
--- Find the smallest text object range strictly larger than the current selection.
51+
---@param _buf integer
52+
---@param sr integer 1-based start row
53+
---@param sc integer 1-based start col
54+
---@param er integer 1-based end row
55+
---@param ec integer 1-based end col
56+
---@return integer[]|nil
57+
function M.expand(_buf, sr, sc, er, ec)
58+
local current = { sr, sc, er, ec }
59+
-- Exit visual mode for text object probing
60+
local mode = api.nvim_get_mode().mode
61+
if mode == "v" or mode == "V" or mode == "\22" then
62+
api.nvim_cmd({ cmd = "normal", bang = true, args = { ESC } }, {})
63+
end
64+
65+
local best, best_size = nil, math.huge
66+
for _, obj in ipairs(CANDIDATES) do
67+
local range = try_textobj(obj, sr, sc)
68+
if range and strictly_contains(range, current) then
69+
local s = range_size(range)
70+
if s < best_size then
71+
best, best_size = range, s
72+
end
73+
end
74+
end
75+
return best
76+
end
77+
78+
--- Select initial text object (iw) at current cursor position.
79+
---@return integer[]|nil
80+
function M.init()
81+
local pos = api.nvim_win_get_cursor(0)
82+
return try_textobj("iw", pos[1], pos[2] + 1)
83+
end
84+
85+
return M

0 commit comments

Comments
 (0)