1package main
2
3import (
4	"errors"
5	"io/ioutil"
6	"os"
7	"path/filepath"
8	"strings"
9
10	"github.com/xyproto/mode"
11	"github.com/xyproto/vt100"
12)
13
14var (
15	searchHistoryFilename = filepath.Join(userCacheDir, "o/search.txt")
16	searchHistory         = []string{}
17	errNoSearchMatch      = errors.New("no search match")
18)
19
20// SetSearchTerm will set the current search term to highlight
21func (e *Editor) SetSearchTerm(c *vt100.Canvas, status *StatusBar, s string) {
22	// set the search term
23	e.searchTerm = s
24	// set the sticky search term (used by ctrl-n, cleared by Esc only)
25	e.stickySearchTerm = s
26	// Go to the first instance after the current line, if found
27	e.lineBeforeSearch = e.DataY()
28	for y := e.DataY(); y < LineIndex(e.Len()); y++ {
29		if strings.Contains(e.Line(y), s) {
30			// Found an instance, scroll there
31			// GoTo returns true if the screen should be redrawn
32			redraw := e.GoTo(y, c, status)
33			if redraw {
34				e.Center(c)
35			}
36			break
37		}
38	}
39	// draw the lines to the canvas
40	e.DrawLines(c, true, false)
41}
42
43// SearchTerm will return the current search term
44func (e *Editor) SearchTerm() string {
45	return e.searchTerm
46}
47
48// ClearSearchTerm will clear the current search term
49func (e *Editor) ClearSearchTerm() {
50	e.searchTerm = ""
51}
52
53// UseStickySearchTerm will use the sticky search term as the current search term,
54// which is not cleared by Esc, but by ctrl-p.
55func (e *Editor) UseStickySearchTerm() {
56	if e.stickySearchTerm != "" {
57		e.searchTerm = e.stickySearchTerm
58	}
59}
60
61// ClearStickySearchTerm will clear the sticky search term, for when ctrl-n is pressed.
62func (e *Editor) ClearStickySearchTerm() {
63	e.stickySearchTerm = ""
64}
65
66// forwardSearch is a helper function for searching for a string from the given startIndex,
67// up to the given stopIndex. -1
68// -1 is returned if there are no matches.
69// startIndex is expected to be smaller than stopIndex
70// x, y is returned.
71func (e *Editor) forwardSearch(startIndex, stopIndex LineIndex) (int, LineIndex) {
72	var (
73		s      = e.SearchTerm()
74		foundX = -1
75		foundY = LineIndex(-1)
76	)
77	if s == "" {
78		// Return -1, -1 if no search term is set
79		return foundX, foundY
80	}
81	currentIndex := e.DataY()
82	// Search from the given startIndex up to the given stopIndex
83	for y := startIndex; y < stopIndex; y++ {
84		lineContents := e.Line(y)
85		if y == currentIndex {
86			x, err := e.DataX()
87			if err != nil {
88				continue
89			}
90			// Search from the next byte (not rune) position on this line
91			// TODO: Move forward one rune instead of one byte
92			x++
93			if x >= len(lineContents) {
94				continue
95			}
96			if strings.Contains(lineContents[x:], s) {
97				foundX = x + strings.Index(lineContents[x:], s)
98				foundY = y
99				break
100			}
101		} else {
102			if strings.Contains(lineContents, s) {
103				foundX = strings.Index(lineContents, s)
104				foundY = y
105				break
106			}
107		}
108	}
109	return foundX, LineIndex(foundY)
110}
111
112// backwardSearch is a helper function for searching for a string from the given startIndex,
113// backwards to the given stopIndex. -1, -1 is returned if there are no matches.
114// startIndex is expected to be larger than stopIndex
115func (e *Editor) backwardSearch(startIndex, stopIndex LineIndex) (int, LineIndex) {
116	var (
117		s      = e.SearchTerm()
118		foundX = -1
119		foundY = LineIndex(-1)
120	)
121	if len(s) == 0 {
122		// Return -1, -1 if no search term is set
123		return foundX, foundY
124	}
125	currentIndex := e.DataY()
126	// Search from the given startIndex backwards up to the given stopIndex
127	for y := startIndex; y >= stopIndex; y-- {
128		lineContents := e.Line(y)
129		if y == currentIndex {
130			x, err := e.DataX()
131			if err != nil {
132				continue
133			}
134			// Search from the next byte (not rune) position on this line
135			// TODO: Move forward one rune instead of one byte
136			x++
137			if x >= len(lineContents) {
138				continue
139			}
140			if strings.Contains(lineContents[x:], s) {
141				foundX = x + strings.Index(lineContents[x:], s)
142				foundY = y
143				break
144			}
145		} else {
146			if strings.Contains(lineContents, s) {
147				foundX = strings.Index(lineContents, s)
148				foundY = y
149				break
150			}
151		}
152	}
153	return foundX, LineIndex(foundY)
154}
155
156// GoToNextMatch will go to the next match, searching for "e.SearchTerm()".
157// * The search wraps around if wrap is true.
158// * The search is backawards if forward is false.
159// * The search is case-sensitive.
160// Returns an error if the search was successful but no match was found.
161func (e *Editor) GoToNextMatch(c *vt100.Canvas, status *StatusBar, wrap, forward bool) error {
162	var (
163		foundX int
164		foundY LineIndex
165		s      = e.SearchTerm()
166	)
167
168	// Check if there's something to search for
169	if s == "" {
170		return nil
171	}
172
173	// Search forward or backward
174	if forward {
175		// Forward search from the current location
176		startIndex := e.DataY()
177		stopIndex := LineIndex(e.Len())
178		foundX, foundY = e.forwardSearch(startIndex, stopIndex)
179	} else {
180		// Backward search form the current location
181		startIndex := e.DataY()
182		stopIndex := LineIndex(0)
183		foundX, foundY = e.backwardSearch(startIndex, stopIndex)
184	}
185
186	if foundY == -1 && wrap {
187		if forward {
188			// Do a search from the top if a match was not found
189			startIndex := LineIndex(0)
190			stopIndex := LineIndex(e.Len())
191			foundX, foundY = e.forwardSearch(startIndex, stopIndex)
192		} else {
193			// Do a search from the bottom if a match was not found
194			startIndex := LineIndex(e.Len())
195			stopIndex := LineIndex(0)
196			foundX, foundY = e.backwardSearch(startIndex, stopIndex)
197		}
198	}
199
200	// Check if a match was found
201	if foundY == -1 {
202		// Not found
203		e.GoTo(e.lineBeforeSearch, c, status)
204		return errNoSearchMatch
205	}
206
207	// Go to the found match
208	e.redraw = e.GoTo(foundY, c, status)
209	if foundX != -1 {
210		tabs := strings.Count(e.Line(foundY), "\t")
211		e.pos.sx = foundX + (tabs * (e.tabsSpaces.PerTab - 1))
212		e.HorizontalScrollIfNeeded(c)
213	}
214
215	// Center and prepare to redraw
216	e.Center(c)
217	e.redraw = true
218	e.redrawCursor = e.redraw
219
220	return nil
221}
222
223// SearchMode will enter the interactive "search mode" where the user can type in a string and then press return to search
224func (e *Editor) SearchMode(c *vt100.Canvas, status *StatusBar, tty *vt100.TTY, clear bool, statusTextAfterRedraw *string, undo *Undo) {
225	var (
226		searchPrompt       = "Search:"
227		previousSearch     string
228		key                string
229		initialLocation    = e.DataY().LineNumber()
230		searchHistoryIndex int
231	)
232
233AGAIN:
234	doneCollectingLetters := false
235	pressedReturn := false
236	pressedTab := false
237	if clear {
238		// Clear the previous search
239		e.SetSearchTerm(c, status, "")
240	}
241	s := e.SearchTerm()
242	status.ClearAll(c)
243	if s == "" {
244		status.SetMessage(searchPrompt)
245	} else {
246		status.SetMessage(searchPrompt + " " + s)
247	}
248	status.ShowNoTimeout(c, e)
249	for !doneCollectingLetters {
250		key = tty.String()
251		switch key {
252		case "c:127": // backspace
253			if len(s) > 0 {
254				s = s[:len(s)-1]
255				if previousSearch == "" {
256					e.SetSearchTerm(c, status, s)
257				}
258				e.GoToLineNumber(initialLocation, c, status, false)
259				status.SetMessage(searchPrompt + " " + s)
260				status.ShowNoTimeout(c, e)
261			}
262		case "c:27", "c:17": // esc or ctrl-q
263			s = ""
264			if previousSearch == "" {
265				e.SetSearchTerm(c, status, s)
266			}
267			doneCollectingLetters = true
268		case "c:9": // tab
269			// collect letters again, this time for the replace term
270			pressedTab = true
271			doneCollectingLetters = true
272		case "c:13": // return
273			pressedReturn = true
274			doneCollectingLetters = true
275		case "↑": // previous in the search history
276			if len(searchHistory) == 0 {
277				break
278			}
279			searchHistoryIndex--
280			if searchHistoryIndex < 0 {
281				// wraparound
282				searchHistoryIndex = len(searchHistory) - 1
283			}
284			s = searchHistory[searchHistoryIndex]
285			if previousSearch == "" {
286				e.SetSearchTerm(c, status, s)
287			}
288			status.SetMessage(searchPrompt + " " + s)
289			status.ShowNoTimeout(c, e)
290		case "↓": // next in the search history
291			if len(searchHistory) == 0 {
292				break
293			}
294			searchHistoryIndex++
295			if searchHistoryIndex >= len(searchHistory) {
296				// wraparound
297				searchHistoryIndex = 0
298			}
299			s = searchHistory[searchHistoryIndex]
300			if previousSearch == "" {
301				e.SetSearchTerm(c, status, s)
302			}
303			status.SetMessage(searchPrompt + " " + s)
304			status.ShowNoTimeout(c, e)
305		default:
306			if key != "" && !strings.HasPrefix(key, "c:") {
307				s += key
308				if previousSearch == "" {
309					e.SetSearchTerm(c, status, s)
310				}
311				status.SetMessage(searchPrompt + " " + s)
312				status.ShowNoTimeout(c, e)
313			}
314		}
315	}
316	status.ClearAll(c)
317
318	// Search settings
319	forward := true // forward search
320	wrap := true    // with wraparound
321
322	// A special case, search backwards to the start of the function (or to "main")
323	if s == "f" {
324		switch e.mode {
325		case mode.Clojure:
326			s = "defn "
327		case mode.Crystal, mode.Nim, mode.Python, mode.Scala:
328			s = "def "
329		case mode.Go:
330			s = "func "
331		case mode.Kotlin:
332			s = "fun "
333		case mode.JavaScript, mode.Lua, mode.Shell, mode.TypeScript:
334			s = "function "
335		case mode.Odin:
336			s = "proc() "
337		case mode.Rust, mode.V, mode.Zig:
338			s = "fn "
339		default:
340			s = "main"
341		}
342		forward = false
343	}
344
345	if pressedTab && previousSearch == "" { // search text -> tab
346		// got the search text, now gather the replace text
347		previousSearch = e.searchTerm
348		searchPrompt = "Replace with:"
349		goto AGAIN
350	} else if pressedTab && previousSearch != "" { // search text -> tab -> replace text- > tab
351		undo.Snapshot(e)
352		// replace once
353		searchFor := previousSearch
354		replaceWith := s
355		replaced := strings.Replace(e.String(), searchFor, replaceWith, 1)
356		e.LoadBytes([]byte(replaced))
357		*statusTextAfterRedraw = "Replaced " + searchFor + " with " + replaceWith + ", once"
358		e.redraw = true
359		return
360	} else if pressedReturn && previousSearch != "" { // search text -> tab -> replace text -> return
361		undo.Snapshot(e)
362		// replace all
363		searchFor := previousSearch
364		replaceWith := s
365		replaced := strings.ReplaceAll(e.String(), searchFor, replaceWith)
366		e.LoadBytes([]byte(replaced))
367		*statusTextAfterRedraw = "Replaced all instances of " + searchFor + " with " + replaceWith
368		e.redraw = true
369		return
370	}
371
372	e.SetSearchTerm(c, status, s)
373
374	if pressedReturn {
375		// Return to the first location before performing the actual search
376		e.GoToLineNumber(initialLocation, c, status, false)
377		trimmedSearchString := strings.TrimSpace(s)
378		if len(trimmedSearchString) > 0 {
379			searchHistory = append(searchHistory, trimmedSearchString)
380			// ignore errors saving the search history, since it's not critical
381			if !e.slowLoad {
382				SaveSearchHistory(searchHistoryFilename, searchHistory)
383			}
384		} else if len(searchHistory) > 0 {
385			s = searchHistory[searchHistoryIndex]
386			e.SetSearchTerm(c, status, s)
387		}
388	}
389
390	if previousSearch == "" {
391		// Perform the actual search
392		if err := e.GoToNextMatch(c, status, wrap, forward); err == errNoSearchMatch {
393			// If no match was found, and return was not pressed, try again from the top
394			//e.redraw = e.GoToLineNumber(1, c, status, true)
395			//err = e.GoToNextMatch(c, status)
396			if err == errNoSearchMatch {
397				if wrap {
398					status.SetMessage(s + " not found")
399				} else {
400					status.SetMessage(s + " not found from here")
401				}
402				status.ShowNoTimeout(c, e)
403			}
404		}
405		e.Center(c)
406	}
407
408}
409
410// LoadSearchHistory will load a list of strings from the given filename
411func LoadSearchHistory(filename string) ([]string, error) {
412	data, err := ioutil.ReadFile(filename)
413	if err != nil {
414		return []string{}, err
415	}
416	// This can load empty words, but they should never be stored in the first place
417	return strings.Split(string(data), "\n"), nil
418}
419
420// SaveSearchHistory will save a list of strings to the given filename
421func SaveSearchHistory(filename string, list []string) error {
422	if len(list) == 0 {
423		return nil
424	}
425
426	// First create the folder, if needed, in a best effort attempt
427	folderPath := filepath.Dir(filename)
428	os.MkdirAll(folderPath, os.ModePerm)
429
430	// Then save the data, with strict permissions
431	data := []byte(strings.Join(list, "\n"))
432	return ioutil.WriteFile(filename, data, 0600)
433}
434