| 1 | /* |
| 2 | SPDX-FileCopyrightText: 2013-2016 Simon St James <kdedevel@etotheipiplusone.com> |
| 3 | |
| 4 | SPDX-License-Identifier: LGPL-2.0-or-later |
| 5 | */ |
| 6 | |
| 7 | #include "searchmode.h" |
| 8 | |
| 9 | #include "../globalstate.h" |
| 10 | #include "../history.h" |
| 11 | #include "katedocument.h" |
| 12 | #include "kateview.h" |
| 13 | #include <vimode/inputmodemanager.h> |
| 14 | #include <vimode/modes/modebase.h> |
| 15 | |
| 16 | #include <KColorScheme> |
| 17 | |
| 18 | #include <QApplication> |
| 19 | #include <QLineEdit> |
| 20 | |
| 21 | using namespace KateVi; |
| 22 | |
| 23 | namespace |
| 24 | { |
| 25 | bool isCharEscaped(const QString &string, int charPos) |
| 26 | { |
| 27 | if (charPos == 0) { |
| 28 | return false; |
| 29 | } |
| 30 | int numContiguousBackslashesToLeft = 0; |
| 31 | charPos--; |
| 32 | while (charPos >= 0 && string[charPos] == QLatin1Char('\\')) { |
| 33 | numContiguousBackslashesToLeft++; |
| 34 | charPos--; |
| 35 | } |
| 36 | return ((numContiguousBackslashesToLeft % 2) == 1); |
| 37 | } |
| 38 | |
| 39 | QString toggledEscaped(const QString &originalString, QChar escapeChar) |
| 40 | { |
| 41 | int searchFrom = 0; |
| 42 | QString toggledEscapedString = originalString; |
| 43 | do { |
| 44 | const int indexOfEscapeChar = toggledEscapedString.indexOf(ch: escapeChar, from: searchFrom); |
| 45 | if (indexOfEscapeChar == -1) { |
| 46 | break; |
| 47 | } |
| 48 | if (!isCharEscaped(string: toggledEscapedString, charPos: indexOfEscapeChar)) { |
| 49 | // Escape. |
| 50 | toggledEscapedString.replace(i: indexOfEscapeChar, len: 1, after: QLatin1String("\\" ) + escapeChar); |
| 51 | searchFrom = indexOfEscapeChar + 2; |
| 52 | } else { |
| 53 | // Unescape. |
| 54 | toggledEscapedString.remove(i: indexOfEscapeChar - 1, len: 1); |
| 55 | searchFrom = indexOfEscapeChar; |
| 56 | } |
| 57 | } while (true); |
| 58 | |
| 59 | return toggledEscapedString; |
| 60 | } |
| 61 | |
| 62 | int findPosOfSearchConfigMarker(const QString &searchText, const bool isSearchBackwards) |
| 63 | { |
| 64 | const QChar searchConfigMarkerChar = (isSearchBackwards ? QLatin1Char('?') : QLatin1Char('/')); |
| 65 | for (int pos = 0; pos < searchText.length(); pos++) { |
| 66 | if (searchText.at(i: pos) == searchConfigMarkerChar) { |
| 67 | if (!isCharEscaped(string: searchText, charPos: pos)) { |
| 68 | return pos; |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | return -1; |
| 73 | } |
| 74 | |
| 75 | bool isRepeatLastSearch(const QString &searchText, const bool isSearchBackwards) |
| 76 | { |
| 77 | const int posOfSearchConfigMarker = findPosOfSearchConfigMarker(searchText, isSearchBackwards); |
| 78 | if (posOfSearchConfigMarker != -1) { |
| 79 | if (QStringView(searchText).left(n: posOfSearchConfigMarker).isEmpty()) { |
| 80 | return true; |
| 81 | } |
| 82 | } |
| 83 | return false; |
| 84 | } |
| 85 | |
| 86 | bool shouldPlaceCursorAtEndOfMatch(const QString &searchText, const bool isSearchBackwards) |
| 87 | { |
| 88 | const int posOfSearchConfigMarker = findPosOfSearchConfigMarker(searchText, isSearchBackwards); |
| 89 | if (posOfSearchConfigMarker != -1) { |
| 90 | if (searchText.length() > posOfSearchConfigMarker + 1 && searchText.at(i: posOfSearchConfigMarker + 1) == QLatin1Char('e')) { |
| 91 | return true; |
| 92 | } |
| 93 | } |
| 94 | return false; |
| 95 | } |
| 96 | |
| 97 | QString withSearchConfigRemoved(const QString &originalSearchText, const bool isSearchBackwards) |
| 98 | { |
| 99 | const int posOfSearchConfigMarker = findPosOfSearchConfigMarker(searchText: originalSearchText, isSearchBackwards); |
| 100 | if (posOfSearchConfigMarker == -1) { |
| 101 | return originalSearchText; |
| 102 | } else { |
| 103 | return originalSearchText.left(n: posOfSearchConfigMarker); |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | QString KateVi::vimRegexToQtRegexPattern(const QString &vimRegexPattern) |
| 109 | { |
| 110 | QString qtRegexPattern = vimRegexPattern; |
| 111 | qtRegexPattern = toggledEscaped(originalString: qtRegexPattern, escapeChar: QLatin1Char('(')); |
| 112 | qtRegexPattern = toggledEscaped(originalString: qtRegexPattern, escapeChar: QLatin1Char(')')); |
| 113 | qtRegexPattern = toggledEscaped(originalString: qtRegexPattern, escapeChar: QLatin1Char('+')); |
| 114 | qtRegexPattern = toggledEscaped(originalString: qtRegexPattern, escapeChar: QLatin1Char('|')); |
| 115 | qtRegexPattern = ensuredCharEscaped(originalString: qtRegexPattern, charToEscape: QLatin1Char('?')); |
| 116 | { |
| 117 | // All curly brackets, except the closing curly bracket of a matching pair where the opening bracket is escaped, |
| 118 | // must have their escaping toggled. |
| 119 | bool lookingForMatchingCloseBracket = false; |
| 120 | QList<int> matchingClosedCurlyBracketPositions; |
| 121 | for (int i = 0; i < qtRegexPattern.length(); i++) { |
| 122 | if (qtRegexPattern[i] == QLatin1Char('{') && isCharEscaped(string: qtRegexPattern, charPos: i)) { |
| 123 | lookingForMatchingCloseBracket = true; |
| 124 | } |
| 125 | if (qtRegexPattern[i] == QLatin1Char('}') && lookingForMatchingCloseBracket && qtRegexPattern[i - 1] != QLatin1Char('\\')) { |
| 126 | matchingClosedCurlyBracketPositions.append(t: i); |
| 127 | } |
| 128 | } |
| 129 | if (matchingClosedCurlyBracketPositions.isEmpty()) { |
| 130 | // Escape all {'s and }'s - there are no matching pairs. |
| 131 | qtRegexPattern = toggledEscaped(originalString: qtRegexPattern, escapeChar: QLatin1Char('{')); |
| 132 | qtRegexPattern = toggledEscaped(originalString: qtRegexPattern, escapeChar: QLatin1Char('}')); |
| 133 | } else { |
| 134 | // Ensure that every chunk of qtRegexPattern that does *not* contain a curly closing bracket |
| 135 | // that is matched have their { and } escaping toggled. |
| 136 | QString qtRegexPatternNonMatchingCurliesToggled; |
| 137 | int previousNonMatchingClosedCurlyPos = 0; // i.e. the position of the last character which is either |
| 138 | // not a curly closing bracket, or is a curly closing bracket |
| 139 | // that is not matched. |
| 140 | for (int matchingClosedCurlyPos : std::as_const(t&: matchingClosedCurlyBracketPositions)) { |
| 141 | QString chunkExcludingMatchingCurlyClosed = |
| 142 | qtRegexPattern.mid(position: previousNonMatchingClosedCurlyPos, n: matchingClosedCurlyPos - previousNonMatchingClosedCurlyPos); |
| 143 | chunkExcludingMatchingCurlyClosed = toggledEscaped(originalString: chunkExcludingMatchingCurlyClosed, escapeChar: QLatin1Char('{')); |
| 144 | chunkExcludingMatchingCurlyClosed = toggledEscaped(originalString: chunkExcludingMatchingCurlyClosed, escapeChar: QLatin1Char('}')); |
| 145 | qtRegexPatternNonMatchingCurliesToggled += chunkExcludingMatchingCurlyClosed + qtRegexPattern[matchingClosedCurlyPos]; |
| 146 | previousNonMatchingClosedCurlyPos = matchingClosedCurlyPos + 1; |
| 147 | } |
| 148 | QString chunkAfterLastMatchingClosedCurly = qtRegexPattern.mid(position: matchingClosedCurlyBracketPositions.last() + 1); |
| 149 | chunkAfterLastMatchingClosedCurly = toggledEscaped(originalString: chunkAfterLastMatchingClosedCurly, escapeChar: QLatin1Char('{')); |
| 150 | chunkAfterLastMatchingClosedCurly = toggledEscaped(originalString: chunkAfterLastMatchingClosedCurly, escapeChar: QLatin1Char('}')); |
| 151 | qtRegexPatternNonMatchingCurliesToggled += chunkAfterLastMatchingClosedCurly; |
| 152 | |
| 153 | qtRegexPattern = qtRegexPatternNonMatchingCurliesToggled; |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // All square brackets, *except* for those that are a) unescaped; and b) form a matching pair, must be |
| 158 | // escaped. |
| 159 | bool lookingForMatchingCloseBracket = false; |
| 160 | int openingBracketPos = -1; |
| 161 | QList<int> matchingSquareBracketPositions; |
| 162 | for (int i = 0; i < qtRegexPattern.length(); i++) { |
| 163 | if (qtRegexPattern[i] == QLatin1Char('[') && !isCharEscaped(string: qtRegexPattern, charPos: i) && !lookingForMatchingCloseBracket) { |
| 164 | lookingForMatchingCloseBracket = true; |
| 165 | openingBracketPos = i; |
| 166 | } |
| 167 | if (qtRegexPattern[i] == QLatin1Char(']') && lookingForMatchingCloseBracket && !isCharEscaped(string: qtRegexPattern, charPos: i)) { |
| 168 | lookingForMatchingCloseBracket = false; |
| 169 | matchingSquareBracketPositions.append(t: openingBracketPos); |
| 170 | matchingSquareBracketPositions.append(t: i); |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | if (matchingSquareBracketPositions.isEmpty()) { |
| 175 | // Escape all ['s and ]'s - there are no matching pairs. |
| 176 | qtRegexPattern = ensuredCharEscaped(originalString: qtRegexPattern, charToEscape: QLatin1Char('[')); |
| 177 | qtRegexPattern = ensuredCharEscaped(originalString: qtRegexPattern, charToEscape: QLatin1Char(']')); |
| 178 | } else { |
| 179 | // Ensure that every chunk of qtRegexPattern that does *not* contain one of the matching pairs of |
| 180 | // square brackets have their square brackets escaped. |
| 181 | QString qtRegexPatternNonMatchingSquaresMadeLiteral; |
| 182 | int previousNonMatchingSquareBracketPos = 0; // i.e. the position of the last character which is |
| 183 | // either not a square bracket, or is a square bracket but |
| 184 | // which is not matched. |
| 185 | for (int matchingSquareBracketPos : std::as_const(t&: matchingSquareBracketPositions)) { |
| 186 | QString chunkExcludingMatchingSquareBrackets = |
| 187 | qtRegexPattern.mid(position: previousNonMatchingSquareBracketPos, n: matchingSquareBracketPos - previousNonMatchingSquareBracketPos); |
| 188 | chunkExcludingMatchingSquareBrackets = ensuredCharEscaped(originalString: chunkExcludingMatchingSquareBrackets, charToEscape: QLatin1Char('[')); |
| 189 | chunkExcludingMatchingSquareBrackets = ensuredCharEscaped(originalString: chunkExcludingMatchingSquareBrackets, charToEscape: QLatin1Char(']')); |
| 190 | qtRegexPatternNonMatchingSquaresMadeLiteral += chunkExcludingMatchingSquareBrackets + qtRegexPattern[matchingSquareBracketPos]; |
| 191 | previousNonMatchingSquareBracketPos = matchingSquareBracketPos + 1; |
| 192 | } |
| 193 | QString chunkAfterLastMatchingSquareBracket = qtRegexPattern.mid(position: matchingSquareBracketPositions.last() + 1); |
| 194 | chunkAfterLastMatchingSquareBracket = ensuredCharEscaped(originalString: chunkAfterLastMatchingSquareBracket, charToEscape: QLatin1Char('[')); |
| 195 | chunkAfterLastMatchingSquareBracket = ensuredCharEscaped(originalString: chunkAfterLastMatchingSquareBracket, charToEscape: QLatin1Char(']')); |
| 196 | qtRegexPatternNonMatchingSquaresMadeLiteral += chunkAfterLastMatchingSquareBracket; |
| 197 | |
| 198 | qtRegexPattern = qtRegexPatternNonMatchingSquaresMadeLiteral; |
| 199 | } |
| 200 | |
| 201 | qtRegexPattern.replace(before: QLatin1String("\\>" ), after: QLatin1String("\\b" )); |
| 202 | qtRegexPattern.replace(before: QLatin1String("\\<" ), after: QLatin1String("\\b" )); |
| 203 | |
| 204 | return qtRegexPattern; |
| 205 | } |
| 206 | |
| 207 | QString KateVi::ensuredCharEscaped(const QString &originalString, QChar charToEscape) |
| 208 | { |
| 209 | QString escapedString = originalString; |
| 210 | for (int i = 0; i < escapedString.length(); i++) { |
| 211 | if (escapedString[i] == charToEscape && !isCharEscaped(string: escapedString, charPos: i)) { |
| 212 | escapedString.replace(i, len: 1, after: QLatin1String("\\" ) + charToEscape); |
| 213 | } |
| 214 | } |
| 215 | return escapedString; |
| 216 | } |
| 217 | |
| 218 | QString KateVi::(const QString &originalSearchTerm) |
| 219 | { |
| 220 | // Only \C is handled, for now - I'll implement \c if someone asks for it. |
| 221 | int pos = 0; |
| 222 | QString = originalSearchTerm; |
| 223 | while (pos < caseSensitivityMarkersStripped.length()) { |
| 224 | if (caseSensitivityMarkersStripped.at(i: pos) == QLatin1Char('C') && isCharEscaped(string: caseSensitivityMarkersStripped, charPos: pos)) { |
| 225 | caseSensitivityMarkersStripped.remove(i: pos - 1, len: 2); |
| 226 | pos--; |
| 227 | } |
| 228 | pos++; |
| 229 | } |
| 230 | return caseSensitivityMarkersStripped; |
| 231 | } |
| 232 | |
| 233 | QStringList KateVi::reversed(const QStringList &originalList) |
| 234 | { |
| 235 | QStringList reversedList = originalList; |
| 236 | std::reverse(first: reversedList.begin(), last: reversedList.end()); |
| 237 | return reversedList; |
| 238 | } |
| 239 | |
| 240 | SearchMode::SearchMode(EmulatedCommandBar *emulatedCommandBar, |
| 241 | MatchHighlighter *matchHighlighter, |
| 242 | InputModeManager *viInputModeManager, |
| 243 | KTextEditor::ViewPrivate *view, |
| 244 | QLineEdit *edit) |
| 245 | : ActiveMode(emulatedCommandBar, matchHighlighter, viInputModeManager, view) |
| 246 | , m_edit(edit) |
| 247 | { |
| 248 | } |
| 249 | |
| 250 | void SearchMode::init(SearchMode::SearchDirection searchDirection) |
| 251 | { |
| 252 | m_searchDirection = searchDirection; |
| 253 | m_startingCursorPos = view()->cursorPosition(); |
| 254 | } |
| 255 | |
| 256 | bool SearchMode::handleKeyPress(const QKeyEvent *keyEvent) |
| 257 | { |
| 258 | Q_UNUSED(keyEvent); |
| 259 | return false; |
| 260 | } |
| 261 | |
| 262 | void SearchMode::editTextChanged(const QString &newText) |
| 263 | { |
| 264 | QString qtRegexPattern = newText; |
| 265 | const bool searchBackwards = (m_searchDirection == SearchDirection::Backward); |
| 266 | const bool placeCursorAtEndOfMatch = shouldPlaceCursorAtEndOfMatch(searchText: qtRegexPattern, isSearchBackwards: searchBackwards); |
| 267 | if (isRepeatLastSearch(searchText: qtRegexPattern, isSearchBackwards: searchBackwards)) { |
| 268 | qtRegexPattern = viInputModeManager()->searcher()->getLastSearchPattern(); |
| 269 | } else { |
| 270 | qtRegexPattern = withSearchConfigRemoved(originalSearchText: qtRegexPattern, isSearchBackwards: searchBackwards); |
| 271 | qtRegexPattern = vimRegexToQtRegexPattern(vimRegexPattern: qtRegexPattern); |
| 272 | } |
| 273 | |
| 274 | // Decide case-sensitivity via SmartCase (note: if the expression contains \C, the "case-sensitive" marker, then |
| 275 | // we will be case-sensitive "by coincidence", as it were.). |
| 276 | bool caseSensitive = true; |
| 277 | if (qtRegexPattern.toLower() == qtRegexPattern) { |
| 278 | caseSensitive = false; |
| 279 | } |
| 280 | |
| 281 | qtRegexPattern = withCaseSensitivityMarkersStripped(originalSearchTerm: qtRegexPattern); |
| 282 | |
| 283 | m_currentSearchParams.pattern = qtRegexPattern; |
| 284 | m_currentSearchParams.isCaseSensitive = caseSensitive; |
| 285 | m_currentSearchParams.isBackwards = searchBackwards; |
| 286 | m_currentSearchParams.shouldPlaceCursorAtEndOfMatch = placeCursorAtEndOfMatch; |
| 287 | |
| 288 | // The "count" for the current search is not shared between Visual & Normal mode, so we need to pick |
| 289 | // the right one to handle the counted search. |
| 290 | int c = viInputModeManager()->getCurrentViModeHandler()->getCount(); |
| 291 | KTextEditor::Range match = viInputModeManager()->searcher()->findPattern(searchParams: m_currentSearchParams, |
| 292 | startFrom: m_startingCursorPos, |
| 293 | count: c, |
| 294 | addToSearchHistory: false /* Don't add incremental searches to search history */); |
| 295 | |
| 296 | if (match.isValid()) { |
| 297 | // The returned range ends one past the last character of the match, so adjust. |
| 298 | KTextEditor::Cursor realMatchEnd = KTextEditor::Cursor(match.end().line(), match.end().column() - 1); |
| 299 | if (realMatchEnd.column() == -1) { |
| 300 | realMatchEnd = KTextEditor::Cursor(realMatchEnd.line() - 1, view()->doc()->lineLength(line: realMatchEnd.line() - 1)); |
| 301 | } |
| 302 | moveCursorTo(cursorPos: placeCursorAtEndOfMatch ? realMatchEnd : match.start()); |
| 303 | setBarBackground(SearchMode::MatchFound); |
| 304 | } else { |
| 305 | moveCursorTo(cursorPos: m_startingCursorPos); |
| 306 | if (!m_edit->text().isEmpty()) { |
| 307 | setBarBackground(SearchMode::NoMatchFound); |
| 308 | } else { |
| 309 | setBarBackground(SearchMode::Normal); |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | if (!viInputModeManager()->searcher()->isHighlightSearchEnabled()) |
| 314 | updateMatchHighlight(matchRange: match); |
| 315 | } |
| 316 | |
| 317 | void SearchMode::deactivate(bool wasAborted) |
| 318 | { |
| 319 | // "Deactivate" can be called multiple times between init()'s, so only reset the cursor once! |
| 320 | if (m_startingCursorPos.isValid()) { |
| 321 | if (wasAborted) { |
| 322 | moveCursorTo(cursorPos: m_startingCursorPos); |
| 323 | } |
| 324 | } |
| 325 | m_startingCursorPos = KTextEditor::Cursor::invalid(); |
| 326 | setBarBackground(SearchMode::Normal); |
| 327 | // Send a synthetic keypress through the system that signals whether the search was aborted or |
| 328 | // not. If not, the keypress will "complete" the search motion, thus triggering it. |
| 329 | // We send to KateViewInternal as it updates the status bar and removes the "?". |
| 330 | const Qt::Key syntheticSearchCompletedKey = (wasAborted ? static_cast<Qt::Key>(0) : Qt::Key_Enter); |
| 331 | QKeyEvent syntheticSearchCompletedKeyPress(QEvent::KeyPress, syntheticSearchCompletedKey, Qt::NoModifier); |
| 332 | m_isSendingSyntheticSearchCompletedKeypress = true; |
| 333 | QApplication::sendEvent(receiver: view()->focusProxy(), event: &syntheticSearchCompletedKeyPress); |
| 334 | m_isSendingSyntheticSearchCompletedKeypress = false; |
| 335 | if (!wasAborted) { |
| 336 | // Search was actually executed, so store it as the last search. |
| 337 | viInputModeManager()->searcher()->setLastSearchParams(m_currentSearchParams); |
| 338 | } |
| 339 | // Append the raw text of the search to the search history (i.e. without conversion |
| 340 | // from Vim-style regex; without case-sensitivity markers stripped; etc. |
| 341 | // Vim does this even if the search was aborted, so we follow suit. |
| 342 | viInputModeManager()->globalState()->searchHistory()->append(historyItem: m_edit->text()); |
| 343 | viInputModeManager()->searcher()->patternDone(wasAborted); |
| 344 | } |
| 345 | |
| 346 | CompletionStartParams SearchMode::completionInvoked(Completer::CompletionInvocation invocationType) |
| 347 | { |
| 348 | Q_UNUSED(invocationType); |
| 349 | return activateSearchHistoryCompletion(); |
| 350 | } |
| 351 | |
| 352 | void SearchMode::completionChosen() |
| 353 | { |
| 354 | // Choose completion with Enter/ Return -> close bar (the search will have already taken effect at this point), marking as not aborted . |
| 355 | close(wasAborted: false); |
| 356 | } |
| 357 | |
| 358 | CompletionStartParams SearchMode::activateSearchHistoryCompletion() |
| 359 | { |
| 360 | return CompletionStartParams::createModeSpecific(completions: reversed(originalList: viInputModeManager()->globalState()->searchHistory()->items()), wordStartPos: 0); |
| 361 | } |
| 362 | |
| 363 | void SearchMode::setBarBackground(SearchMode::BarBackgroundStatus status) |
| 364 | { |
| 365 | QPalette barBackground(m_edit->palette()); |
| 366 | switch (status) { |
| 367 | case MatchFound: { |
| 368 | KColorScheme::adjustBackground(barBackground, newRole: KColorScheme::PositiveBackground); |
| 369 | break; |
| 370 | } |
| 371 | case NoMatchFound: { |
| 372 | KColorScheme::adjustBackground(barBackground, newRole: KColorScheme::NegativeBackground); |
| 373 | break; |
| 374 | } |
| 375 | case Normal: { |
| 376 | barBackground = QPalette(); |
| 377 | break; |
| 378 | } |
| 379 | } |
| 380 | m_edit->setPalette(barBackground); |
| 381 | } |
| 382 | |