Adds Find and Replace commands
Originally based on the code in CodeMirror/lib/util/search.js.
If the number of matches exceeds this limit, inline text highlighting and scroll-track tickmarks are disabled
var FIND_HIGHLIGHT_MAX = 2000;
Maximum file size to search within (in chars)
var FIND_MAX_FILE_SIZE = 500000;
Currently open Find or Find/Replace bar, if any
var findBar;
function SearchState() {
this.searchStartPos = null;
this.parsedQuery = null;
this.queryInfo = null;
this.foundAny = false;
this.marked = [];
this.resultSet = [];
this.matchIndex = -1;
this.markedCurrent = null;
}
function getSearchState(cm) {
if (!cm._searchState) {
cm._searchState = new SearchState();
}
return cm._searchState;
}
function getSearchCursor(cm, state, pos) {
// Heuristic: if the query string is all lowercase, do a case insensitive search.
return cm.getSearchCursor(state.parsedQuery, pos, !state.queryInfo.isCaseSensitive);
}
function parseQuery(queryInfo) {
if (findBar) {
findBar.showError(null);
}
var parsed = FindUtils.parseQueryInfo(queryInfo);
if (parsed.empty === true) {
return "";
}
if (!parsed.valid) {
if (findBar) {
findBar.showError(parsed.error);
}
return "";
}
return parsed.queryExpr;
}
Expands each empty range in the selection to the nearest word boundaries. Then, if the primary selection was already a range (even a non-word range), adds the next instance of the contents of that range as a selection.
function _expandWordAndAddNextToSelection(editor, removePrimary) {
editor = editor || EditorManager.getActiveEditor();
if (!editor) {
return;
}
var selections = editor.getSelections(),
primarySel,
primaryIndex,
searchText,
added = false;
_.each(selections, function (sel, index) {
var isEmpty = (CodeMirror.cmpPos(sel.start, sel.end) === 0);
if (sel.primary) {
primarySel = sel;
primaryIndex = index;
if (!isEmpty) {
searchText = editor.document.getRange(primarySel.start, primarySel.end);
}
}
if (isEmpty) {
var wordInfo = _getWordAt(editor, sel.start);
sel.start = wordInfo.start;
sel.end = wordInfo.end;
if (sel.primary && removePrimary) {
// Get the expanded text, even though we're going to remove this selection,
// since in this case we still want to select the next match.
searchText = wordInfo.text;
}
}
});
if (searchText && searchText.length) {
// We store this as a query in the state so that if the user next does a "Find Next",
// it will use the same query (but throw away the existing selection).
var state = getSearchState(editor._codeMirror);
setQueryInfo(state, { query: searchText, isCaseSensitive: false, isRegexp: false });
// Skip over matches that are already in the selection.
var searchStart = primarySel.end,
nextMatch,
isInSelection;
do {
nextMatch = _getNextMatch(editor, false, searchStart);
if (nextMatch) {
// This is a little silly, but if we just stick the equivalence test function in here
// JSLint complains about creating a function in a loop, even though it's safe in this case.
isInSelection = _.find(selections, _.partial(_selEq, nextMatch));
searchStart = nextMatch.end;
// If we've gone all the way around, then all instances must have been selected already.
if (CodeMirror.cmpPos(searchStart, primarySel.end) === 0) {
nextMatch = null;
break;
}
}
} while (nextMatch && isInSelection);
if (nextMatch) {
nextMatch.primary = true;
selections.push(nextMatch);
added = true;
}
}
if (removePrimary) {
selections.splice(primaryIndex, 1);
}
if (added) {
// Center the new match, but avoid scrolling to matches that are already on screen.
_selectAndScrollTo(editor, selections, true, true);
} else {
// If all we did was expand some selections, don't center anything.
_selectAndScrollTo(editor, selections, false);
}
}
function _skipCurrentMatch(editor) {
return _expandWordAndAddNextToSelection(editor, true);
}
Takes the primary selection, expands it to a word range if necessary, then sets the selection to include all instances of that range. Removes all other selections. Does nothing if the selection is not a range after expansion.
function _findAllAndSelect(editor) {
editor = editor || EditorManager.getActiveEditor();
if (!editor) {
return;
}
var sel = editor.getSelection(),
newSelections = [];
if (CodeMirror.cmpPos(sel.start, sel.end) === 0) {
sel = _getWordAt(editor, sel.start);
}
if (CodeMirror.cmpPos(sel.start, sel.end) !== 0) {
var searchStart = {line: 0, ch: 0},
state = getSearchState(editor._codeMirror),
nextMatch;
setQueryInfo(state, { query: editor.document.getRange(sel.start, sel.end), isCaseSensitive: false, isRegexp: false });
while ((nextMatch = _getNextMatch(editor, false, searchStart, false)) !== null) {
if (_selEq(sel, nextMatch)) {
nextMatch.primary = true;
}
newSelections.push(nextMatch);
searchStart = nextMatch.end;
}
// This should find at least the original selection, but just in case...
if (newSelections.length) {
// Don't change the scroll position.
editor.setSelections(newSelections, false);
}
}
}
function _getNextMatch(editor, searchBackwards, pos, wrap) {
var cm = editor._codeMirror;
var state = getSearchState(cm);
var cursor = getSearchCursor(cm, state, pos || editor.getCursorPos(false, searchBackwards ? "start" : "end"));
state.lastMatch = cursor.find(searchBackwards);
if (!state.lastMatch && wrap !== false) {
// If no result found before hitting edge of file, try wrapping around
cursor = getSearchCursor(cm, state, searchBackwards ? {line: cm.lineCount() - 1} : {line: 0, ch: 0});
state.lastMatch = cursor.find(searchBackwards);
}
if (!state.lastMatch) {
// No result found, period: clear selection & bail
cm.setCursor(editor.getCursorPos()); // collapses selection, keeping cursor in place to avoid scrolling
return null;
}
return {start: cursor.from(), end: cursor.to()};
}
Returns the range of the word surrounding the given editor position. Similar to getWordAt() from CodeMirror.
function _getWordAt(editor, pos) {
var cm = editor._codeMirror,
start = pos.ch,
end = start,
line = cm.getLine(pos.line);
while (start && CodeMirror.isWordChar(line.charAt(start - 1))) {
--start;
}
while (end < line.length && CodeMirror.isWordChar(line.charAt(end))) {
++end;
}
return {start: {line: pos.line, ch: start}, end: {line: pos.line, ch: end}, text: line.slice(start, end)};
}
function _handleFileChanged() {
if (findBar) {
findBar.close();
}
}
function doReplace(editor, all) {
var cm = editor._codeMirror,
state = getSearchState(cm),
replaceText = findBar.getReplaceText();
// Do not replace if editor is set to read only
if (cm.options.readOnly) {
return;
}
if (all === null) {
findBar.close();
FindInFilesUI.searchAndReplaceResults(state.queryInfo, editor.document.file, null, replaceText);
} else if (all) {
findBar.close();
// Delegate to Replace in Files.
FindInFilesUI.searchAndShowResults(state.queryInfo, editor.document.file, null, replaceText);
} else {
cm.replaceSelection(state.queryInfo.isRegexp ? FindUtils.parseDollars(replaceText, state.lastMatch) : replaceText);
updateResultSet(editor); // we updated the text, so result count & tickmarks must be refreshed
findNext(editor);
if (!state.lastMatch) {
// No more matches, so destroy find bar
findBar.close();
}
}
}
function replace(editor) {
// If Replace bar already open, treat the shortcut as a hotkey for the Replace button
if (findBar && findBar.getOptions().replace && findBar.isReplaceEnabled()) {
doReplace(editor, false);
return;
}
openSearchBar(editor, true);
findBar
.on("doReplace.FindReplace", function (e) {
doReplace(editor, false);
})
.on("doReplaceBatch.FindReplace", function (e) {
doReplace(editor, true);
})
.on("doReplaceAll.FindReplace", function (e) {
doReplace(editor, null);
});
}
function _launchFind() {
var editor = EditorManager.getActiveEditor();
if (editor) {
// Create a new instance of the search bar UI
clearSearch(editor._codeMirror);
doSearch(editor, false);
}
}
function _findNext() {
var editor = EditorManager.getActiveEditor();
if (editor) {
doSearch(editor);
}
}
function _findPrevious() {
var editor = EditorManager.getActiveEditor();
if (editor) {
doSearch(editor, true);
}
}
function _replace() {
var editor = EditorManager.getActiveEditor();
if (editor) {
replace(editor);
}
}
MainViewManager.on("currentFileChange", _handleFileChanged);
CommandManager.register(Strings.CMD_FIND, Commands.CMD_FIND, _launchFind);
CommandManager.register(Strings.CMD_FIND_NEXT, Commands.CMD_FIND_NEXT, _findNext);
CommandManager.register(Strings.CMD_REPLACE, Commands.CMD_REPLACE, _replace);
CommandManager.register(Strings.CMD_FIND_PREVIOUS, Commands.CMD_FIND_PREVIOUS, _findPrevious);
CommandManager.register(Strings.CMD_FIND_ALL_AND_SELECT, Commands.CMD_FIND_ALL_AND_SELECT, _findAllAndSelect);
CommandManager.register(Strings.CMD_ADD_NEXT_MATCH, Commands.CMD_ADD_NEXT_MATCH, _expandWordAndAddNextToSelection);
CommandManager.register(Strings.CMD_SKIP_CURRENT_MATCH, Commands.CMD_SKIP_CURRENT_MATCH, _skipCurrentMatch);
// For unit testing
exports._getWordAt = _getWordAt;
exports._expandWordAndAddNextToSelection = _expandWordAndAddNextToSelection;
exports._findAllAndSelect = _findAllAndSelect;
});
function _selEq(sel1, sel2) {
return (CodeMirror.cmpPos(sel1.start, sel2.start) === 0 && CodeMirror.cmpPos(sel1.end, sel2.end) === 0);
}
function _selectAndScrollTo(editor, selections, center, preferNoScroll) {
var primarySelection = _.find(selections, function (sel) { return sel.primary; }) || _.last(selections),
resultVisible = editor.isLineVisible(primarySelection.start.line),
centerOptions = Editor.BOUNDARY_CHECK_NORMAL;
if (preferNoScroll && resultVisible) {
// no need to scroll if the line with the match is in view
centerOptions = Editor.BOUNDARY_IGNORE_TOP;
}
// Make sure the primary selection is fully visible on screen.
var primary = _.find(selections, function (sel) {
return sel.primary;
});
if (!primary) {
primary = _.last(selections);
}
editor._codeMirror.scrollIntoView({from: primary.start, to: primary.end});
editor.setSelections(selections, center, centerOptions);
}
function _updateFindBarWithMatchInfo(state, matchRange, searchBackwards) {
// Bail if there is no result set.
if (!state.foundAny) {
return;
}
if (findBar) {
if (state.matchIndex === -1) {
state.matchIndex = _.findIndex(state.resultSet, matchRange);
} else {
state.matchIndex = searchBackwards ? state.matchIndex - 1 : state.matchIndex + 1;
// Adjust matchIndex for modulo wraparound
state.matchIndex = (state.matchIndex + state.resultSet.length) % state.resultSet.length;
// Confirm that we find the right matchIndex. If not, then search
// matchRange in the entire resultSet.
if (!_.isEqual(state.resultSet[state.matchIndex], matchRange)) {
state.matchIndex = _.findIndex(state.resultSet, matchRange);
}
}
console.assert(state.matchIndex !== -1);
if (state.matchIndex !== -1) {
// Convert to 1-based by adding one before showing the index.
findBar.showFindCount(StringUtils.format(Strings.FIND_MATCH_INDEX,
state.matchIndex + 1, state.resultSet.length));
}
}
}
Removes the current-match highlight, leaving all matches marked in the generic highlight style
function clearCurrentMatchHighlight(cm, state) {
if (state.markedCurrent) {
state.markedCurrent.clear();
ScrollTrackMarkers.markCurrent(-1);
}
}
Clears all match highlights, including the current match
function clearHighlights(cm, state) {
cm.operation(function () {
state.marked.forEach(function (markedRange) {
markedRange.clear();
});
clearCurrentMatchHighlight(cm, state);
});
state.marked.length = 0;
state.markedCurrent = null;
ScrollTrackMarkers.clear();
state.resultSet = [];
state.matchIndex = -1;
}
function clearSearch(cm) {
cm.operation(function () {
var state = getSearchState(cm);
if (!state.parsedQuery) {
return;
}
setQueryInfo(state, null);
clearHighlights(cm, state);
});
}
function toggleHighlighting(editor, enabled) {
// Temporarily change selection color to improve highlighting - see LESS code for details
if (enabled) {
$(editor.getRootElement()).addClass("find-highlighting");
} else {
$(editor.getRootElement()).removeClass("find-highlighting");
}
ScrollTrackMarkers.setVisible(editor, enabled);
}
If no search pending, opens the Find dialog. If search bar already open, moves to next/prev result (depending on 'searchBackwards')
function doSearch(editor, searchBackwards) {
var state = getSearchState(editor._codeMirror);
if (state.parsedQuery) {
findNext(editor, searchBackwards);
} else {
openSearchBar(editor, false);
}
}
Selects the next match (or prev match, if searchBackwards==true) starting from either the current position (if pos unspecified) or the given position (if pos specified explicitly). The starting position need not be an existing match. If a new match is found, sets to state.lastMatch either the regex match result, or simply true for a plain-string match. If no match found, sets state.lastMatch to false.
function findNext(editor, searchBackwards, preferNoScroll, pos) {
var cm = editor._codeMirror;
cm.operation(function () {
var state = getSearchState(cm);
clearCurrentMatchHighlight(cm, state);
var nextMatch = _getNextMatch(editor, searchBackwards, pos);
if (nextMatch) {
// Update match index indicators - only possible if we have resultSet saved (missing if FIND_MAX_FILE_SIZE threshold hit)
if (state.resultSet.length) {
_updateFindBarWithMatchInfo(state,
{from: nextMatch.start, to: nextMatch.end}, searchBackwards);
// Update current-tickmark indicator - only if highlighting enabled (disabled if FIND_HIGHLIGHT_MAX threshold hit)
if (state.marked.length) {
ScrollTrackMarkers.markCurrent(state.matchIndex); // _updateFindBarWithMatchInfo() has updated this index
}
}
_selectAndScrollTo(editor, [nextMatch], true, preferNoScroll);
// Only mark text with "current match" highlight if search bar still open
if (findBar && !findBar.isClosed()) {
// If highlighting disabled, apply both match AND current-match styles for correct appearance
var curentMatchClassName = state.marked.length ? "searching-current-match" : "CodeMirror-searching searching-current-match";
state.markedCurrent = cm.markText(nextMatch.start, nextMatch.end,
{ className: curentMatchClassName, startStyle: "searching-first", endStyle: "searching-last" });
}
} else {
cm.setCursor(editor.getCursorPos()); // collapses selection, keeping cursor in place to avoid scrolling
// (nothing more to do: previous highlights already cleared above)
}
});
}
Called each time the search query field changes. Updates state.parsedQuery (parsedQuery will be falsy if the field was blank OR contained a regexp with invalid syntax). Then calls updateResultSet(), and then jumps to the first matching result, starting from the original cursor position.
function handleQueryChange(editor, state, initial) {
setQueryInfo(state, findBar.getQueryInfo());
updateResultSet(editor);
if (state.parsedQuery) {
// 3rd arg: prefer to avoid scrolling if result is anywhere within view, since in this case user
// is in the middle of typing, not navigating explicitly; viewport jumping would be distracting.
findNext(editor, false, true, state.searchStartPos);
} else if (!initial) {
// Blank or invalid query: just jump back to initial pos
editor._codeMirror.setCursor(state.searchStartPos);
}
editor.lastParsedQuery = state.parsedQuery;
}
Creates a Find bar for the current search session.
function openSearchBar(editor, replace) {
var cm = editor._codeMirror,
state = getSearchState(cm);
// Use the selection start as the searchStartPos. This way if you
// start with a pre-populated search and enter an additional character,
// it will extend the initial selection instead of jumping to the next
// occurrence.
state.searchStartPos = editor.getCursorPos(false, "start");
// Prepopulate the search field
var initialQuery = FindBar.getInitialQuery(findBar, editor);
if (initialQuery.query === "" && editor.lastParsedQuery !== "") {
initialQuery.query = editor.lastParsedQuery;
}
// Close our previous find bar, if any. (The open() of the new findBar will
// take care of closing any other find bar instances.)
if (findBar) {
findBar.close();
}
// Create the search bar UI (closing any previous find bar in the process)
findBar = new FindBar({
multifile: false,
replace: replace,
initialQuery: initialQuery.query,
initialReplaceText: initialQuery.replaceText,
queryPlaceholder: Strings.FIND_QUERY_PLACEHOLDER
});
findBar.open();
findBar
.on("queryChange.FindReplace", function (e) {
handleQueryChange(editor, state);
})
.on("doFind.FindReplace", function (e, searchBackwards) {
findNext(editor, searchBackwards);
})
.on("close.FindReplace", function (e) {
editor.lastParsedQuery = state.parsedQuery;
// Clear highlights but leave search state in place so Find Next/Previous work after closing
clearHighlights(cm, state);
// Dispose highlighting UI (important to restore normal selection color as soon as focus goes back to the editor)
toggleHighlighting(editor, false);
findBar.off(".FindReplace");
findBar = null;
});
handleQueryChange(editor, state, true);
}
function setQueryInfo(state, queryInfo) {
state.queryInfo = queryInfo;
if (!queryInfo) {
state.parsedQuery = null;
} else {
state.parsedQuery = parseQuery(queryInfo);
}
}
Called each time the search query changes or document is modified (via Replace). Updates the match count, match highlights and scrollbar tickmarks. Does not change the cursor pos.
function updateResultSet(editor) {
var cm = editor._codeMirror,
state = getSearchState(cm);
function indicateHasMatches(numResults) {
// Make the field red if it's not blank and it has no matches (which also covers invalid regexes)
findBar.showNoResults(!state.foundAny && findBar.getQueryInfo().query);
// Navigation buttons enabled if we have a query and more than one match
findBar.enableNavigation(state.foundAny && numResults > 1);
findBar.enableReplace(state.foundAny);
}
cm.operation(function () {
// Clear old highlights
if (state.marked) {
clearHighlights(cm, state);
}
if (!state.parsedQuery) {
// Search field is empty - no results
findBar.showFindCount("");
state.foundAny = false;
indicateHasMatches();
return;
}
// Find *all* matches, searching from start of document
// (Except on huge documents, where this is too expensive)
var cursor = getSearchCursor(cm, state);
if (cm.getValue().length <= FIND_MAX_FILE_SIZE) {
// FUTURE: if last query was prefix of this one, could optimize by filtering last result set
state.resultSet = [];
while (cursor.findNext()) {
state.resultSet.push(cursor.pos); // pos is unique obj per search result
}
// Highlight all matches if there aren't too many
if (state.resultSet.length <= FIND_HIGHLIGHT_MAX) {
toggleHighlighting(editor, true);
state.resultSet.forEach(function (result) {
state.marked.push(cm.markText(result.from, result.to,
{ className: "CodeMirror-searching", startStyle: "searching-first", endStyle: "searching-last" }));
});
var scrollTrackPositions = state.resultSet.map(function (result) {
return result.from;
});
ScrollTrackMarkers.addTickmarks(editor, scrollTrackPositions);
}
// Here we only update find bar with no result. In the case of a match
// a findNext() call is guaranteed to be followed by this function call,
// and findNext() in turn calls _updateFindBarWithMatchInfo() to show the
// match index.
if (state.resultSet.length === 0) {
findBar.showFindCount(Strings.FIND_NO_RESULTS);
}
state.foundAny = (state.resultSet.length > 0);
indicateHasMatches(state.resultSet.length);
} else {
// On huge documents, just look for first match & then stop
findBar.showFindCount("");
state.foundAny = cursor.findNext();
indicateHasMatches();
}
});
}