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(c: 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 | |