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