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  }