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}