1717
1818local 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
2127M .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 = {}
5061end
@@ -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 )
7890end
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
8799end
88100
89- --- Add a cursor at the current match
90- --- @return boolean success
91101function 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
100113end
101114
102- --- Skip the current match
103- --- @return boolean success
104115function 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
115131end
116132
117- --- Re-select the last skipped match
118- --- @return boolean success
119133function 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
137154end
138155
139- --- Remove the last added cursor (does NOT add to skipped - just removes)
140- --- @return boolean success
141156function 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
148165end
149166
150- --- Remove the last added cursor AND add it to skipped list
151- --- @return boolean success
152167function 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
160178end
@@ -172,28 +190,38 @@ function M.get_match_at_position(line, col)
172190 return nil , nil
173191end
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 )
179197function 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
186209end
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 )
192215function 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
199227end
@@ -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
222258end
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 )
228260function 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
247289end
248290
249- --- Select all remaining matches
250291function 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
259299end
0 commit comments