1package main
2
3// Vim-style range replacement feature (s/pattern/replace/g). Works within a
4// visual selection and supports regex patterns and flags.
5
6import (
7 "fmt"
8 "regexp"
9 "strings"
10
11 "github.com/nsf/termbox-go"
12)
13
14// startReplaceMode captures the current visual selection and enters Replace mode.
15func (e *Editor) startReplaceMode() {
16 b := e.activeBuffer()
17 if b == nil {
18 return
19 }
20
21 // Calculate and store the bounds of the selection to be operated on.
22 if e.visualStartY < b.PrimaryCursor().Y || (e.visualStartY == b.PrimaryCursor().Y && e.visualStartX < b.PrimaryCursor().X) {
23 e.replaceSelStartX = e.visualStartX
24 e.replaceSelStartY = e.visualStartY
25 e.replaceSelEndX = b.PrimaryCursor().X
26 e.replaceSelEndY = b.PrimaryCursor().Y
27 } else {
28 e.replaceSelStartX = b.PrimaryCursor().X
29 e.replaceSelStartY = b.PrimaryCursor().Y
30 e.replaceSelEndX = e.visualStartX
31 e.replaceSelEndY = e.visualStartY
32 }
33
34 // Logging for debugging purposes.
35 e.addLog("Replace", fmt.Sprintf("Selection: (%d,%d) to (%d,%d)", e.replaceSelStartY, e.replaceSelStartX, e.replaceSelEndY, e.replaceSelEndX))
36 e.addLog("Replace", fmt.Sprintf("Mode: %v", e.mode))
37
38 // Handle Visual Line mode by selecting entire lines.
39 if e.mode == ModeVisualLine {
40 e.replaceSelStartX = 0
41 if e.replaceSelEndY < len(b.buffer) {
42 e.replaceSelEndX = len(b.buffer[e.replaceSelEndY])
43 }
44 } else {
45 // In character-wise visual mode, include the character at the end position.
46 if e.replaceSelEndY < len(b.buffer) && e.replaceSelEndX < len(b.buffer[e.replaceSelEndY]) {
47 e.replaceSelEndX++
48 }
49 }
50
51 // Initialize the replace input prompt with a starting slash.
52 e.replaceInput = []rune{'/'}
53 e.replaceMatches = []MatchRange{}
54 e.mode = ModeReplace
55}
56
57// handleReplaceMode processes input for the replace prompt (/pattern/replacement/flags).
58func (e *Editor) handleReplaceMode(ev termbox.Event) {
59 switch ev.Key {
60 case termbox.KeyEsc:
61 e.mode = ModeNormal
62 e.replaceInput = []rune{}
63 e.replaceMatches = []MatchRange{}
64 case termbox.KeyEnter:
65 // User finished typing; execute the replacement.
66 e.executeReplace()
67 case termbox.KeyBackspace, termbox.KeyBackspace2:
68 if len(e.replaceInput) > 0 {
69 e.replaceInput = e.replaceInput[:len(e.replaceInput)-1]
70 e.updateReplacePreview()
71 } else {
72 e.mode = ModeNormal
73 }
74 case termbox.KeySpace:
75 e.replaceInput = append(e.replaceInput, ' ')
76 e.updateReplacePreview()
77 default:
78 if ev.Ch != 0 {
79 e.replaceInput = append(e.replaceInput, ev.Ch)
80 e.updateReplacePreview() // Live preview of matches as user types.
81 }
82 }
83}
84
85// parseReplaceCommand splits the raw input string into pattern, replacement, and flags.
86func parseReplaceCommand(input string) (pattern, replacement string, globalFlag, ignoreCaseFlag bool, err error) {
87 // Expected syntax: /pattern/replacement/[flags]
88 if !strings.HasPrefix(input, "/") {
89 return "", "", false, false, nil
90 }
91
92 parts := []string{}
93 current := ""
94 escaped := false
95 slashCount := 0
96
97 // Custom parser to handle escaped slashes within patterns.
98 for i, ch := range input {
99 if i == 0 {
100 slashCount++
101 continue
102 }
103
104 if escaped {
105 current += string(ch)
106 escaped = false
107 continue
108 }
109
110 if ch == '\\' {
111 escaped = true
112 current += string(ch)
113 continue
114 }
115
116 if ch == '/' {
117 slashCount++
118 parts = append(parts, current)
119 current = ""
120 continue
121 }
122
123 current += string(ch)
124 }
125
126 if current != "" || slashCount >= 2 {
127 parts = append(parts, current)
128 }
129
130 if len(parts) < 2 {
131 return "", "", false, false, nil
132 }
133
134 pattern = parts[0]
135 replacement = parts[1]
136
137 // Check optional flags (e.g., 'g' for global, 'i' for case-insensitive).
138 if len(parts) >= 3 {
139 flags := parts[2]
140 globalFlag = strings.Contains(flags, "g")
141 ignoreCaseFlag = strings.Contains(flags, "i")
142 }
143
144 return pattern, replacement, globalFlag, ignoreCaseFlag, nil
145}
146
147// updateReplacePreview finds and highlights matches in the buffer based on the current prompt.
148func (e *Editor) updateReplacePreview() {
149 e.replaceMatches = []MatchRange{}
150
151 input := string(e.replaceInput)
152 pattern, _, globalFlag, _, err := parseReplaceCommand(input)
153 if err != nil || pattern == "" {
154 return
155 }
156
157 // Always use case-insensitive matching by default (?i).
158 regexPattern := "(?i)" + pattern
159
160 re, err := regexp.Compile(regexPattern)
161 if err != nil {
162 return
163 }
164
165 b := e.activeBuffer()
166 if b == nil {
167 return
168 }
169
170 // Scan each line within the selected range for matches.
171 for lineIdx := e.replaceSelStartY; lineIdx <= e.replaceSelEndY && lineIdx < len(b.buffer); lineIdx++ {
172 line := b.buffer[lineIdx]
173 lineStr := string(line)
174
175 startCol := 0
176 endCol := len(line)
177
178 if lineIdx == e.replaceSelStartY {
179 startCol = e.replaceSelStartX
180 }
181 if lineIdx == e.replaceSelEndY {
182 endCol = e.replaceSelEndX
183 }
184
185 if startCol >= len(line) {
186 continue
187 }
188
189 searchStr := lineStr[startCol:endCol]
190
191 if globalFlag {
192 matches := re.FindAllStringIndex(searchStr, -1)
193 for _, match := range matches {
194 e.replaceMatches = append(e.replaceMatches, MatchRange{
195 startLine: lineIdx,
196 startCol: startCol + match[0],
197 endLine: lineIdx,
198 endCol: startCol + match[1],
199 })
200 }
201 } else {
202 match := re.FindStringIndex(searchStr)
203 if match != nil {
204 e.replaceMatches = append(e.replaceMatches, MatchRange{
205 startLine: lineIdx,
206 startCol: startCol + match[0],
207 endLine: lineIdx,
208 endCol: startCol + match[1],
209 })
210 }
211 }
212 }
213}
214
215// executeReplace performs the actual string transformation in the active buffer.
216func (e *Editor) executeReplace() {
217 input := string(e.replaceInput)
218 pattern, replacement, globalFlag, ignoreCaseFlag, err := parseReplaceCommand(input)
219
220 // Logging for debugging purposes.
221 e.addLog("Replace", fmt.Sprintf("Input: '%s'", input))
222 e.addLog("Replace", fmt.Sprintf("Pattern: '%s', Replacement: '%s', g=%v, i=%v", pattern, replacement, globalFlag, ignoreCaseFlag))
223
224 if err != nil {
225 e.message = "Invalid regex pattern"
226 e.mode = ModeNormal
227 e.replaceInput = []rune{}
228 e.replaceMatches = []MatchRange{}
229 return
230 }
231
232 if pattern == "" {
233 e.message = "No pattern specified"
234 e.mode = ModeNormal
235 e.replaceInput = []rune{}
236 e.replaceMatches = []MatchRange{}
237 return
238 }
239
240 // Always use case-insensitive matching by default (?i).
241 regexPattern := "(?i)" + pattern
242
243 re, err := regexp.Compile(regexPattern)
244 if err != nil {
245 e.message = "Invalid regex pattern"
246 e.mode = ModeNormal
247 e.replaceInput = []rune{}
248 e.replaceMatches = []MatchRange{}
249 return
250 }
251
252 b := e.activeBuffer()
253 if b == nil {
254 return
255 }
256
257 // Save state for Undo/Redo support before modifying text.
258 e.saveState()
259
260 replacementCount := 0
261
262 e.addLog("Replace", fmt.Sprintf("Starting replacement: lines %d-%d", e.replaceSelStartY, e.replaceSelEndY))
263
264 // Important: Iterate backwards from top to bottom through lines,
265 // but this loop actually goes from replaceSelEndY down to replaceSelStartY.
266 // This helps maintain line index stability during multi-line operations.
267 for lineIdx := e.replaceSelEndY; lineIdx >= e.replaceSelStartY && lineIdx < len(b.buffer); lineIdx-- {
268 line := b.buffer[lineIdx]
269 lineStr := string(line)
270
271 startCol := 0
272 endCol := len(line)
273
274 if lineIdx == e.replaceSelStartY {
275 startCol = e.replaceSelStartX
276 }
277 if lineIdx == e.replaceSelEndY {
278 endCol = e.replaceSelEndX
279 }
280
281 if startCol >= len(line) {
282 e.addLog("Replace", fmt.Sprintf("Line %d: skipped (startCol >= len)", lineIdx))
283 continue
284 }
285
286 prefix := lineStr[:startCol]
287 searchPart := lineStr[startCol:endCol]
288 suffix := ""
289 if endCol < len(lineStr) {
290 suffix = lineStr[endCol:]
291 }
292
293 e.addLog("Replace", fmt.Sprintf("Line %d: searching '%s' in range [%d:%d]", lineIdx, searchPart, startCol, endCol))
294
295 var newSearchPart string
296 if globalFlag {
297 // Replace all occurrences in the slice.
298 newSearchPart = re.ReplaceAllString(searchPart, replacement)
299 matches := re.FindAllStringIndex(searchPart, -1)
300 matchCount := len(matches)
301 replacementCount += matchCount
302 e.addLog("Replace", fmt.Sprintf("Line %d: found %d matches (global)", lineIdx, matchCount))
303 } else {
304 // Replace first match only.
305 if re.MatchString(searchPart) {
306 newSearchPart = re.ReplaceAllStringFunc(searchPart, func(match string) string {
307 if replacementCount == 0 {
308 replacementCount++
309 return re.ReplaceAllString(match, replacement)
310 }
311 return match
312 })
313 e.addLog("Replace", fmt.Sprintf("Line %d: found 1 match (first only)", lineIdx))
314 } else {
315 newSearchPart = searchPart
316 e.addLog("Replace", fmt.Sprintf("Line %d: no matches", lineIdx))
317 }
318 }
319
320 // Update the line content and notify syntax highlighter of the edit.
321 oldLine := b.buffer[lineIdx]
322 newLineStr := prefix + newSearchPart + suffix
323 b.buffer[lineIdx] = []rune(newLineStr)
324 e.addLog("Replace", fmt.Sprintf("Line %d: '%s' -> '%s'", lineIdx, lineStr, newLineStr))
325
326 if b.syntax != nil {
327 oldLineBytes := uint32(len(string(oldLine)))
328 newLineBytes := uint32(len(newLineStr))
329 oldEndColBytes := b.getLineByteOffset(oldLine, endCol)
330 newEndColBytes := b.getLineByteOffset(b.buffer[lineIdx], len(prefix)+len(newSearchPart))
331
332 b.handleEdit(
333 lineIdx, startCol,
334 oldLineBytes, newLineBytes,
335 lineIdx, oldEndColBytes,
336 lineIdx, newEndColBytes,
337 )
338 }
339 }
340
341 if replacementCount > 0 {
342 e.message = fmt.Sprintf("%d replacements made", replacementCount)
343 e.markModified()
344 } else {
345 e.message = "Pattern not found"
346 }
347
348 // Force a full reparse of syntax to ensure all highlights are correct after mass edits.
349 if b.syntax != nil {
350 b.syntax.Reparse([]byte(b.toString()))
351 }
352
353 e.mode = ModeNormal
354 e.replaceInput = []rune{}
355 e.replaceMatches = []MatchRange{}
356}