github.com/aretext/aretext@v1.3.0/state/search.go (about) 1 package state 2 3 import ( 4 "fmt" 5 "strings" 6 "unicode" 7 "unicode/utf8" 8 9 "golang.org/x/text/cases" 10 "golang.org/x/text/language" 11 "golang.org/x/text/transform" 12 13 "github.com/aretext/aretext/clipboard" 14 "github.com/aretext/aretext/locate" 15 "github.com/aretext/aretext/text" 16 ) 17 18 // SearchDirection represents the direction of the search (forward or backward). 19 type SearchDirection int 20 21 const ( 22 SearchDirectionForward = SearchDirection(iota) 23 SearchDirectionBackward 24 ) 25 26 // Reverse returns the opposite direction. 27 func (d SearchDirection) Reverse() SearchDirection { 28 switch d { 29 case SearchDirectionForward: 30 return SearchDirectionBackward 31 case SearchDirectionBackward: 32 return SearchDirectionForward 33 default: 34 panic("Unrecognized direction") 35 } 36 } 37 38 // SearchCompleteAction is the action to perform when a user completes a search. 39 type SearchCompleteAction func(*EditorState, string, SearchDirection, SearchMatch) 40 41 // searchState represents the state of a text search. 42 type searchState struct { 43 query string 44 direction SearchDirection 45 completeAction SearchCompleteAction 46 prevQuery string 47 prevDirection SearchDirection 48 history []string 49 historyIdx int 50 match *SearchMatch 51 } 52 53 // SearchMatch represents the successful result of a text search. 54 type SearchMatch struct { 55 StartPos uint64 56 EndPos uint64 57 } 58 59 func (sm *SearchMatch) ContainsPosition(pos uint64) bool { 60 return sm != nil && pos >= sm.StartPos && pos < sm.EndPos 61 } 62 63 // StartSearch initiates a new text search. 64 func StartSearch(state *EditorState, direction SearchDirection, completeAction SearchCompleteAction) { 65 search := &state.documentBuffer.search 66 prevQuery, prevDirection := search.query, search.direction 67 *search = searchState{ 68 direction: direction, 69 completeAction: completeAction, 70 prevQuery: prevQuery, 71 prevDirection: prevDirection, 72 history: search.history, 73 historyIdx: len(search.history), 74 } 75 setInputMode(state, InputModeSearch) 76 } 77 78 // CompleteSearch terminates a text search and returns to normal mode. 79 // If commit is true, execute the complete search action. 80 // Otherwise, return to the original cursor position. 81 func CompleteSearch(state *EditorState, commit bool) { 82 search := &state.documentBuffer.search 83 84 if search.query != "" { 85 if len(search.history) == 0 || search.history[len(search.history)-1] != search.query { 86 search.history = append(search.history, search.query) 87 } 88 } 89 90 // Return to normal mode. 91 // This must run BEFORE executing the complete action, because some actions 92 // change the input mode again to insert mode (specifically "c/" and "c?") 93 setInputMode(state, InputModeNormal) 94 95 if commit { 96 if search.match != nil { 97 search.completeAction(state, search.query, search.direction, *search.match) 98 } 99 } else { 100 prevQuery, prevDirection := search.prevQuery, search.prevDirection 101 *search = searchState{ 102 query: prevQuery, 103 direction: prevDirection, 104 history: search.history, 105 } 106 } 107 108 search.match = nil 109 110 ScrollViewToCursor(state) 111 } 112 113 // AppendRuneToSearchQuery appends a rune to the text search query. 114 func AppendRuneToSearchQuery(state *EditorState, r rune) { 115 search := &state.documentBuffer.search 116 q := search.query + string(r) 117 runTextSearchQuery(state, q) 118 search.historyIdx = len(search.history) 119 } 120 121 // DeleteRuneFromSearchQuery deletes the last rune from the text search query. 122 // A deletion in an empty query aborts the search and returns the editor to normal mode. 123 func DeleteRuneFromSearchQuery(state *EditorState) { 124 search := &state.documentBuffer.search 125 if len(search.query) == 0 { 126 CompleteSearch(state, false) 127 return 128 } 129 130 q := search.query[0 : len(search.query)-1] 131 runTextSearchQuery(state, q) 132 search.historyIdx = len(search.history) 133 } 134 135 // SetSearchQueryToPrevInHistory sets the search query to a previous search query in the history. 136 func SetSearchQueryToPrevInHistory(state *EditorState) { 137 search := &state.documentBuffer.search 138 if search.historyIdx == 0 { 139 return 140 } 141 search.historyIdx-- 142 q := search.history[search.historyIdx] 143 runTextSearchQuery(state, q) 144 } 145 146 // SetSearchQueryToNextInHistory sets the search query to the next search query in the history. 147 func SetSearchQueryToNextInHistory(state *EditorState) { 148 search := &state.documentBuffer.search 149 if search.historyIdx >= len(search.history)-1 { 150 return 151 } 152 153 search.historyIdx++ 154 q := search.history[search.historyIdx] 155 runTextSearchQuery(state, q) 156 } 157 158 // SearchWordUnderCursor starts a search for the word under the cursor. 159 func SearchWordUnderCursor(state *EditorState, direction SearchDirection, completeAction SearchCompleteAction, targetCount uint64) { 160 // Retrieve the current word under the cursor. 161 // If the cursor is on leading whitespace, this will retrieve the word after the whitespace. 162 buffer := state.documentBuffer 163 wordStartPos, wordEndPos := locate.WordObject(buffer.textTree, buffer.cursor.position, targetCount) 164 word := strings.TrimSpace(copyText(buffer.textTree, wordStartPos, wordEndPos-wordStartPos)) 165 if word == "" { 166 return 167 } 168 169 query := fmt.Sprintf("%s\\C", word) // Force case-sensitive search. 170 171 // Search for the word. 172 StartSearch(state, direction, completeAction) 173 runTextSearchQuery(state, query) 174 CompleteSearch(state, true) 175 } 176 177 func runTextSearchQuery(state *EditorState, q string) { 178 buffer := state.documentBuffer 179 buffer.search.query = q 180 foundMatch, matchStartPos := false, uint64(0) 181 parsedQuery := parseQuery(q) 182 if buffer.search.direction == SearchDirectionForward { 183 foundMatch, matchStartPos = searchTextForward( 184 buffer.cursor.position, 185 buffer.textTree, 186 parsedQuery) 187 } else { 188 foundMatch, matchStartPos = searchTextBackward( 189 buffer.cursor.position, 190 buffer.textTree, 191 parsedQuery) 192 } 193 194 if !foundMatch { 195 buffer.search.match = nil 196 ScrollViewToCursor(state) 197 return 198 } 199 200 buffer.search.match = &SearchMatch{ 201 StartPos: matchStartPos, 202 EndPos: matchStartPos + uint64(utf8.RuneCountInString(parsedQuery.queryText)), 203 } 204 scrollViewToPosition(buffer, matchStartPos) 205 } 206 207 // FindNextMatch moves the cursor to the next position matching the search query. 208 func FindNextMatch(state *EditorState, reverse bool) { 209 buffer := state.documentBuffer 210 parsedQuery := parseQuery(buffer.search.query) 211 212 direction := buffer.search.direction 213 if reverse { 214 direction = direction.Reverse() 215 } 216 217 foundMatch, newCursorPos := false, uint64(0) 218 if direction == SearchDirectionForward { 219 foundMatch, newCursorPos = searchTextForward( 220 buffer.cursor.position, 221 buffer.textTree, 222 parsedQuery) 223 } else { 224 foundMatch, newCursorPos = searchTextBackward( 225 buffer.cursor.position, 226 buffer.textTree, 227 parsedQuery) 228 } 229 230 if foundMatch { 231 buffer.cursor = cursorState{position: newCursorPos} 232 } 233 } 234 235 type parsedQuery struct { 236 queryText string 237 caseSensitive bool 238 } 239 240 // parseQuery interprets the user's search query. 241 // By default, if the query is all lowercase, it's case-insensitive; 242 // otherwise, it's case-sensitive (equivalent to vim's smartcase option). 243 // Users can override this by setting the suffix to "\c" for case-insensitive 244 // and "\C" for case-sensitive. 245 func parseQuery(rawQuery string) parsedQuery { 246 if strings.HasSuffix(rawQuery, `\c`) { 247 return parsedQuery{ 248 queryText: rawQuery[0 : len(rawQuery)-2], 249 caseSensitive: false, 250 } 251 } 252 253 if strings.HasSuffix(rawQuery, `\C`) { 254 return parsedQuery{ 255 queryText: rawQuery[0 : len(rawQuery)-2], 256 caseSensitive: true, 257 } 258 } 259 260 var caseSensitive bool 261 for _, r := range rawQuery { 262 if unicode.IsUpper(r) { 263 caseSensitive = true 264 break 265 } 266 } 267 268 return parsedQuery{ 269 queryText: rawQuery, 270 caseSensitive: caseSensitive, 271 } 272 273 } 274 275 func transformerForSearch(caseSensitive bool) transform.Transformer { 276 if caseSensitive { 277 // No transformation for case-sensitive search. 278 return transform.Nop 279 } else { 280 // Make the search case-insensitive by lowercasing the query and document. 281 return cases.Lower(language.Und) 282 } 283 } 284 285 // searchTextForward finds the position of the next occurrence of a query string after the start position. 286 func searchTextForward(startPos uint64, tree *text.Tree, parsedQuery parsedQuery) (bool, uint64) { 287 // Start the search one after the provided start position so we skip a match on the current position. 288 startPos++ 289 290 transformer := transformerForSearch(parsedQuery.caseSensitive) 291 transformedQuery, _, err := transform.String(transformer, parsedQuery.queryText) 292 if err != nil { 293 panic(err) 294 } 295 296 // Search forward from the start position to the end of the text, looking for the first match. 297 searcher := text.NewSearcher(transformedQuery) 298 treeReader := tree.ReaderAtPosition(startPos) 299 transformedReader := transform.NewReader(&treeReader, transformer) 300 foundMatch, matchOffset, err := searcher.NextInReader(transformedReader) 301 if err != nil { 302 panic(err) // should never happen for text.Reader. 303 } 304 305 if foundMatch { 306 return true, startPos + matchOffset 307 } 308 309 // Wraparound search from the beginning of the text to the start position. 310 treeReader = tree.ReaderAtPosition(0) 311 transformedReader = transform.NewReader(&treeReader, transformer) 312 limit := startPos + uint64(utf8.RuneCountInString(transformedQuery)) 313 if limit > 0 { 314 limit-- 315 } 316 foundMatch, matchOffset, err = searcher.Limit(limit).NextInReader(transformedReader) 317 if err != nil { 318 panic(err) 319 } 320 return foundMatch, matchOffset 321 } 322 323 // searchTextBackward finds the beginning of the previous match before the start position. 324 func searchTextBackward(startPos uint64, tree *text.Tree, parsedQuery parsedQuery) (bool, uint64) { 325 transformer := transformerForSearch(parsedQuery.caseSensitive) 326 transformedQuery, _, err := transform.String(transformer, parsedQuery.queryText) 327 if err != nil { 328 panic(err) 329 } 330 331 // Search from the beginning of the text just past the start position, looking for the last match. 332 // Set the limit to startPos + queryLen - 1 to include matches overlapping startPos, but not startPos itself. 333 searcher := text.NewSearcher(transformedQuery) 334 treeReader := tree.ReaderAtPosition(0) 335 transformedReader := transform.NewReader(&treeReader, transformer) 336 limit := startPos + uint64(utf8.RuneCountInString(transformedQuery)) 337 if limit > 0 { 338 limit-- 339 } 340 foundMatch, matchOffset, err := searcher.Limit(limit).LastInReader(transformedReader) 341 if err != nil { 342 panic(err) // should never happen for text.Reader. 343 } 344 345 if foundMatch { 346 return true, matchOffset 347 } 348 349 // Wraparound search from the start position to the end of the text, looking for the last match. 350 // Begin the search at startPos + 1 to exclude a potential match at startPos. 351 readerStartPos := startPos + 1 352 treeReader = tree.ReaderAtPosition(readerStartPos) 353 transformedReader = transform.NewReader(&treeReader, transformer) 354 foundMatch, matchOffset, err = searcher.NoLimit().LastInReader(transformedReader) 355 if err != nil { 356 panic(err) 357 } 358 return foundMatch, readerStartPos + matchOffset 359 } 360 361 // SearchCompleteMoveCursorToMatch is a SearchCompleteAction that moves the cursor to the start of the search match. 362 func SearchCompleteMoveCursorToMatch(state *EditorState, query string, direction SearchDirection, match SearchMatch) { 363 state.documentBuffer.cursor = cursorState{position: match.StartPos} 364 } 365 366 // SearchCompleteDeleteToMatch is a SearchCompleteAction that deletes from the cursor position to the search match. 367 func SearchCompleteDeleteToMatch(clipboardPage clipboard.PageId) SearchCompleteAction { 368 return func(state *EditorState, query string, direction SearchDirection, match SearchMatch) { 369 completeAction := func(state *EditorState, query string, direction SearchDirection, match SearchMatch) { 370 deleteToSearchMatch(state, direction, match, clipboardPage) 371 } 372 completeAction(state, query, direction, match) 373 replaySearchInLastActionMacro(state, query, direction, completeAction) 374 } 375 } 376 377 // SearchCompleteChangeToMatch is a SearchCompleteAction that deletes to the search match, then enters insert mode. 378 func SearchCompleteChangeToMatch(clipboardPage clipboard.PageId) SearchCompleteAction { 379 return func(state *EditorState, query string, direction SearchDirection, match SearchMatch) { 380 completeAction := func(state *EditorState, query string, direction SearchDirection, match SearchMatch) { 381 // Delete to the match (exactly the same as the "search and delete" commands). 382 // Then go to insert mode (override default transition back to normal mode). 383 deleteToSearchMatch(state, direction, match, clipboardPage) 384 setInputMode(state, InputModeInsert) 385 } 386 completeAction(state, query, direction, match) 387 replaySearchInLastActionMacro(state, query, direction, completeAction) 388 } 389 } 390 391 // SearchCompleteCopyToMatch is a SearchCompleteAction that copies text from the cursor position to the search match. 392 func SearchCompleteCopyToMatch(clipboardPage clipboard.PageId) SearchCompleteAction { 393 return func(state *EditorState, query string, direction SearchDirection, match SearchMatch) { 394 // If the search wraps around, then the range start will be >= range end, 395 // so nothing will be copied. 396 CopyRange(state, clipboardPage, func(params LocatorParams) (uint64, uint64) { 397 if direction == SearchDirectionForward { 398 return params.CursorPos, match.StartPos 399 } else { 400 return match.EndPos, params.CursorPos 401 } 402 }) 403 } 404 } 405 406 func deleteToSearchMatch(state *EditorState, direction SearchDirection, match SearchMatch, clipboardPage clipboard.PageId) { 407 DeleteToPos(state, func(params LocatorParams) uint64 { 408 if direction == SearchDirectionForward { 409 return match.StartPos 410 } else { 411 if params.CursorPos > match.EndPos { 412 return match.EndPos 413 } else { 414 // Match vim's behavior for backward search with wraparound. 415 return match.StartPos 416 } 417 } 418 }, clipboardPage) 419 } 420 421 func replaySearchInLastActionMacro(state *EditorState, query string, direction SearchDirection, completeAction SearchCompleteAction) { 422 // The "last action" must use the original search query (even if the user subsequently searched for something else). 423 // Construct a macro action that performs the original search again, then performs the specified complete action. 424 ClearLastActionMacro(state) 425 AddToLastActionMacro(state, func(state *EditorState) { 426 StartSearch(state, direction, completeAction) 427 for _, r := range query { 428 AppendRuneToSearchQuery(state, r) 429 } 430 CompleteSearch(state, true) 431 }) 432 }