Skip to content

Commit 5cd103b

Browse files
committed
v0.1.3: Performance & stability improvements
- Fix: Removed duplicate line assignment in state.lua - Fix: Cleaned up unused variables (bufnr in editor.lua, skipped_count in ui.lua) - Perf: Replaced vim.deepcopy() with shallow copies for cursor positions - Perf: Added O(1) hash-based lookups (cursor_set, skipped_set) for position checks - Add: Buffer validity check in finder.find_matches() - Add: editor.cleanup() for proper module state reset - Add: keymaps.clear_global_keymaps() for plugin unload - Remove: Unused 'current' highlight configuration
1 parent 86e052f commit 5cd103b

6 files changed

Lines changed: 128 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,19 @@
22

33
## [0.1.3] - 2026-01-14
44

5+
### Fixed
6+
- **Duplicate Line Bug**: Removed duplicate assignment of `original_pos` in `state.lua`
7+
- **Unused Variables**: Cleaned up unused `bufnr` variables in `editor.lua` and `skipped_count` in `ui.lua`
8+
9+
### Improved
10+
- **Memory Efficiency**: Replaced `vim.deepcopy()` with shallow copies in `state.lua` for cursor position tables (reduces allocation overhead)
11+
- **O(1) Position Lookups**: Added hash sets (`cursor_set`, `skipped_set`) for instant position checks instead of O(n) array scans - significant speedup with many matches
12+
- **Buffer Safety**: Added buffer validity check in `finder.find_matches()` to prevent errors on invalid buffers
13+
- **Resource Cleanup**: Added `editor.cleanup()` function to properly reset module state
14+
- **Keymap Management**: Added `keymaps.clear_global_keymaps()` for proper plugin unload cleanup
15+
516
### Removed
6-
- **Unused `current` Highlight**: Removed the `current` highlight configuration (was defined but never used in highlighting logic). This simplifies the config without affecting functionality.
17+
- **Unused `current` Highlight**: Removed the `current` highlight configuration (was defined but never used)
718

819
---
920

lua/multiple-cursor/editor.lua

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ local function debug(msg)
2424
end
2525
end
2626

27+
---Cleanup function to reset module state (prevents memory leaks)
28+
function M.cleanup()
29+
edit_positions = {}
30+
applying_changes = false
31+
last_primary_length = 0
32+
last_line_len = 0
33+
pcall(vim.api.nvim_del_augroup_by_name, augroup_name)
34+
end
35+
2736
---Start editing mode - positions cursor at current word, does NOT delete
2837
function M.start_editing_mode()
2938
local bufnr = state.get_bufnr()
@@ -379,7 +388,6 @@ end
379388

380389
---Start editing at the start of all words (for 'I' key)
381390
function M.start_editing_at_start()
382-
local bufnr = state.get_bufnr()
383391
local cursors = state.get_cursors()
384392

385393
if #cursors == 0 then
@@ -397,7 +405,6 @@ end
397405

398406
---Start editing at the end of all words (for 'A' key)
399407
function M.start_editing_at_end()
400-
local bufnr = state.get_bufnr()
401408
local cursors = state.get_cursors()
402409

403410
if #cursors == 0 then

lua/multiple-cursor/finder.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ function M.find_matches(word, bufnr)
5151
return matches
5252
end
5353

54+
-- Validate buffer before accessing
55+
if not vim.api.nvim_buf_is_valid(bufnr) then
56+
return matches
57+
end
58+
5459
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
5560

5661
-- Build search pattern

lua/multiple-cursor/keymaps.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ function M.setup_global_keymaps(start_callback)
9494
local keys = opts.keymaps
9595

9696
if keys.start_next and keys.start_next ~= false then
97+
-- Track global keymaps for cleanup
98+
M._global_keymaps = M._global_keymaps or {}
99+
table.insert(M._global_keymaps, { mode = "n", lhs = keys.start_next })
100+
table.insert(M._global_keymaps, { mode = "v", lhs = keys.start_next })
101+
97102
vim.keymap.set("n", keys.start_next, start_callback, {
98103
noremap = true,
99104
silent = true,
@@ -114,4 +119,14 @@ function M.setup_global_keymaps(start_callback)
114119
end
115120
end
116121

122+
---Clear global keymaps (for plugin unload)
123+
function M.clear_global_keymaps()
124+
if M._global_keymaps then
125+
for _, keymap in ipairs(M._global_keymaps) do
126+
pcall(vim.keymap.del, keymap.mode, keymap.lhs)
127+
end
128+
M._global_keymaps = {}
129+
end
130+
end
131+
117132
return M

lua/multiple-cursor/state.lua

Lines changed: 86 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717

1818
local M = {}
1919

20+
-- Helper to create a unique numeric key from line and col_start
21+
-- Using multiplication to create a unique key (supports up to 1M columns per line)
22+
local function make_key(line, col_start)
23+
return line * 1000000 + col_start
24+
end
25+
2026
---@type MultipleCursor.State
2127
M.state = {
2228
active = false,
@@ -25,6 +31,9 @@ M.state = {
2531
cursors = {},
2632
matches = {},
2733
skipped = {},
34+
-- Hash sets for O(1) lookups
35+
cursor_set = {},
36+
skipped_set = {},
2837
current_idx = 0,
2938
namespace = 0,
3039
original_pos = {},
@@ -45,6 +54,8 @@ function M.reset()
4554
M.state.cursors = {}
4655
M.state.matches = {}
4756
M.state.skipped = {}
57+
M.state.cursor_set = {}
58+
M.state.skipped_set = {}
4859
M.state.current_idx = 0
4960
M.state.original_pos = {}
5061
end
@@ -72,9 +83,10 @@ function M.start(word, bufnr, matches)
7283
M.state.matches = matches
7384
M.state.cursors = {}
7485
M.state.skipped = {}
86+
M.state.cursor_set = {}
87+
M.state.skipped_set = {}
7588
M.state.current_idx = 1
7689
M.state.original_pos = vim.api.nvim_win_get_cursor(0)
77-
M.state.original_pos = vim.api.nvim_win_get_cursor(0)
7890
end
7991

8092
---Update matches and current word (e.g. after editing)
@@ -86,43 +98,48 @@ function M.update_matches(word, matches)
8698
-- Current cursors remain selected; we don't reset them
8799
end
88100

89-
---Add a cursor at the current match
90-
---@return boolean success
91101
function M.add_cursor()
92102
if M.state.current_idx > #M.state.matches then
93103
return false
94104
end
95105

96106
local match = M.state.matches[M.state.current_idx]
97-
table.insert(M.state.cursors, vim.deepcopy(match))
107+
-- Shallow copy is sufficient since CursorPosition only contains primitives
108+
table.insert(M.state.cursors, { line = match.line, col_start = match.col_start, col_end = match.col_end })
109+
-- Update hash set
110+
M.state.cursor_set[make_key(match.line, match.col_start)] = true
98111
M.state.current_idx = M.state.current_idx + 1
99112
return true
100113
end
101114

102-
---Skip the current match
103-
---@return boolean success
104115
function M.skip_current()
105116
if M.state.current_idx > #M.state.matches then
106117
return false
107118
end
108119

109120
-- Store the skipped match for potential re-selection
110121
local skipped_match = M.state.matches[M.state.current_idx]
111-
table.insert(M.state.skipped, vim.deepcopy(skipped_match))
122+
table.insert(
123+
M.state.skipped,
124+
{ line = skipped_match.line, col_start = skipped_match.col_start, col_end = skipped_match.col_end }
125+
)
126+
-- Update hash set
127+
M.state.skipped_set[make_key(skipped_match.line, skipped_match.col_start)] = true
112128

113129
M.state.current_idx = M.state.current_idx + 1
114130
return true
115131
end
116132

117-
---Re-select the last skipped match
118-
---@return boolean success
119133
function M.reselect_last()
120134
if #M.state.skipped == 0 then
121135
return false
122136
end
123137

124138
-- Get the last skipped match
125139
local last_skipped = table.remove(M.state.skipped)
140+
-- Update hash sets
141+
M.state.skipped_set[make_key(last_skipped.line, last_skipped.col_start)] = nil
142+
M.state.cursor_set[make_key(last_skipped.line, last_skipped.col_start)] = true
126143

127144
-- Add it to cursors
128145
table.insert(M.state.cursors, last_skipped)
@@ -136,25 +153,26 @@ function M.get_skipped()
136153
return M.state.skipped
137154
end
138155

139-
---Remove the last added cursor (does NOT add to skipped - just removes)
140-
---@return boolean success
141156
function M.remove_last()
142157
if #M.state.cursors == 0 then
143158
return false
144159
end
145160

146-
table.remove(M.state.cursors)
161+
local removed = table.remove(M.state.cursors)
162+
-- Update hash set
163+
M.state.cursor_set[make_key(removed.line, removed.col_start)] = nil
147164
return true
148165
end
149166

150-
---Remove the last added cursor AND add it to skipped list
151-
---@return boolean success
152167
function M.remove_last_to_skipped()
153168
if #M.state.cursors == 0 then
154169
return false
155170
end
156171

157172
local removed = table.remove(M.state.cursors)
173+
-- Update hash sets
174+
M.state.cursor_set[make_key(removed.line, removed.col_start)] = nil
175+
M.state.skipped_set[make_key(removed.line, removed.col_start)] = true
158176
table.insert(M.state.skipped, removed)
159177
return true
160178
end
@@ -172,28 +190,38 @@ function M.get_match_at_position(line, col)
172190
return nil, nil
173191
end
174192

175-
---Check if position is already in cursors (selected)
193+
---Check if position is already in cursors (selected) - O(1) lookup
176194
---@param line number
177195
---@param col_start number
178-
---@return boolean, number? is_selected and index in cursors
196+
---@return boolean, number? is_selected and index in cursors (index only if needed)
179197
function M.is_position_selected(line, col_start)
180-
for i, cursor in ipairs(M.state.cursors) do
181-
if cursor.line == line and cursor.col_start == col_start then
182-
return true, i
198+
local key = make_key(line, col_start)
199+
if M.state.cursor_set[key] then
200+
-- Only compute index if needed (for removal operations)
201+
for i, cursor in ipairs(M.state.cursors) do
202+
if cursor.line == line and cursor.col_start == col_start then
203+
return true, i
204+
end
183205
end
206+
return true, nil
184207
end
185208
return false, nil
186209
end
187210

188-
---Check if position is in skipped list
211+
---Check if position is in skipped list - O(1) lookup
189212
---@param line number
190213
---@param col_start number
191-
---@return boolean, number? is_skipped and index in skipped
214+
---@return boolean, number? is_skipped and index in skipped (index only if needed)
192215
function M.is_position_skipped(line, col_start)
193-
for i, skip in ipairs(M.state.skipped) do
194-
if skip.line == line and skip.col_start == col_start then
195-
return true, i
216+
local key = make_key(line, col_start)
217+
if M.state.skipped_set[key] then
218+
-- Only compute index if needed (for removal operations)
219+
for i, skip in ipairs(M.state.skipped) do
220+
if skip.line == line and skip.col_start == col_start then
221+
return true, i
222+
end
196223
end
224+
return true, nil
197225
end
198226
return false, nil
199227
end
@@ -208,52 +236,64 @@ function M.add_cursor_at_position(line, col)
208236
return false
209237
end
210238

211-
if M.is_position_selected(match.line, match.col_start) then
239+
local key = make_key(match.line, match.col_start)
240+
if M.state.cursor_set[key] then
212241
return false
213242
end
214243

215-
local is_skipped, skip_idx = M.is_position_skipped(match.line, match.col_start)
216-
if is_skipped and skip_idx then
217-
table.remove(M.state.skipped, skip_idx)
244+
-- Remove from skipped if present
245+
if M.state.skipped_set[key] then
246+
M.state.skipped_set[key] = nil
247+
for i, skip in ipairs(M.state.skipped) do
248+
if skip.line == match.line and skip.col_start == match.col_start then
249+
table.remove(M.state.skipped, i)
250+
break
251+
end
252+
end
218253
end
219254

220-
table.insert(M.state.cursors, vim.deepcopy(match))
255+
table.insert(M.state.cursors, { line = match.line, col_start = match.col_start, col_end = match.col_end })
256+
M.state.cursor_set[key] = true
221257
return true
222258
end
223259

224-
---Skip/remove cursor at specific position
225-
---@param line number
226-
---@param col number
227-
---@return boolean success, string action ("skipped" or "removed" or nil)
228260
function M.skip_at_position(line, col)
229261
local match, _ = M.get_match_at_position(line, col)
230262
if not match then
231263
return false, nil
232264
end
233265

234-
local is_selected, cursor_idx = M.is_position_selected(match.line, match.col_start)
235-
if is_selected and cursor_idx then
236-
local removed = table.remove(M.state.cursors, cursor_idx)
237-
table.insert(M.state.skipped, removed)
238-
return true, "removed"
266+
local key = make_key(match.line, match.col_start)
267+
268+
-- If selected, move to skipped
269+
if M.state.cursor_set[key] then
270+
M.state.cursor_set[key] = nil
271+
for i, cursor in ipairs(M.state.cursors) do
272+
if cursor.line == match.line and cursor.col_start == match.col_start then
273+
local removed = table.remove(M.state.cursors, i)
274+
table.insert(M.state.skipped, removed)
275+
M.state.skipped_set[key] = true
276+
return true, "removed"
277+
end
278+
end
239279
end
240280

241-
if not M.is_position_skipped(match.line, match.col_start) then
242-
table.insert(M.state.skipped, vim.deepcopy(match))
281+
-- If not skipped, add to skipped
282+
if not M.state.skipped_set[key] then
283+
table.insert(M.state.skipped, { line = match.line, col_start = match.col_start, col_end = match.col_end })
284+
M.state.skipped_set[key] = true
243285
return true, "skipped"
244286
end
245287

246288
return false, nil
247289
end
248290

249-
---Select all remaining matches
250291
function M.select_all()
251292
for _, match in ipairs(M.state.matches) do
252-
if
253-
not M.is_position_selected(match.line, match.col_start)
254-
and not M.is_position_skipped(match.line, match.col_start)
255-
then
256-
table.insert(M.state.cursors, vim.deepcopy(match))
293+
local key = make_key(match.line, match.col_start)
294+
if not M.state.cursor_set[key] and not M.state.skipped_set[key] then
295+
table.insert(M.state.cursors, { line = match.line, col_start = match.col_start, col_end = match.col_end })
296+
M.state.cursor_set[key] = true
257297
end
258298
end
259299
end

lua/multiple-cursor/ui.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ function M.update_highlights()
314314
end
315315

316316
-- Add virtual text showing count
317-
local total, selected, skipped_count = state.get_counts()
317+
local total, selected, _skipped_count = state.get_counts()
318318
local status_text = string.format(" [%d/%d] selected", selected, total)
319319

320320
-- Show status in virtual text at the end of current line

0 commit comments

Comments
 (0)