diff --git a/src/edit_session.js b/src/edit_session.js index f9717aa477..491a2eec29 100644 --- a/src/edit_session.js +++ b/src/edit_session.js @@ -192,7 +192,7 @@ class EditSession { /** * Get "widgetManager" from EditSession - * + * * @returns {LineWidgets} object */ get widgetManager() { @@ -202,18 +202,18 @@ class EditSession { if (this.$editor) widgetManager.attach(this.$editor); - + return widgetManager; } /** * Set "widgetManager" in EditSession - * + * * @returns void */ set widgetManager(value) { Object.defineProperty(this, "widgetManager", { - writable: true, + writable: true, enumerable: true, configurable: true, value: value, @@ -2747,4 +2747,3 @@ config.defineOptions(EditSession.prototype, "session", { }); exports.EditSession = EditSession; - diff --git a/src/ext/searchbox.js b/src/ext/searchbox.js index e26a900391..2a34ecce97 100644 --- a/src/ext/searchbox.js +++ b/src/ext/searchbox.js @@ -51,6 +51,7 @@ class SearchBox { this.element = div.firstChild; this.setSession = this.setSession.bind(this); + this.$onEditorInput = this.onEditorInput.bind(this); this.$init(); this.setEditor(editor); @@ -72,6 +73,11 @@ class SearchBox { this.$syncOptions(true); } + // Auto update "updateCounter" and "ace_nomatch" + onEditorInput() { + this.find(false, false, true); + } + /** * @param {HTMLElement} sb */ @@ -214,6 +220,15 @@ class SearchBox { ? editor.session.getTextRange(this.searchRange) : editor.getValue(); + /** + * Convert all line ending variations to Unix-style = \n + * Windows (\r\n), MacOS Classic (\r), and Unix (\n) + */ + if (editor.$search.$isMultilineSearch(editor.getLastSearchOptions())) { + value = value.replace(/\r\n|\r|\n/g, "\n"); + editor.session.doc.$autoNewLine = "\n"; + } + var offset = editor.session.doc.positionToIndex(editor.selection.anchor); if (this.searchRange) offset -= editor.session.doc.positionToIndex(this.searchRange.start); @@ -274,6 +289,7 @@ class SearchBox { this.active = false; this.setSearchRange(null); this.editor.off("changeSession", this.setSession); + this.editor.off("input", this.$onEditorInput); this.element.style.display = "none"; this.editor.keyBinding.removeKeyboardHandler(this.$closeSearchBarKb); @@ -287,9 +303,13 @@ class SearchBox { show(value, isReplace) { this.active = true; this.editor.on("changeSession", this.setSession); + this.editor.on("input", this.$onEditorInput); this.element.style.display = ""; this.replaceOption.checked = isReplace; + if (this.editor.$search.$options.regExp) + value = lang.escapeRegExp(value); + if (value) this.searchInput.value = value; diff --git a/src/search.js b/src/search.js index 17a7c63222..93bce2a943 100644 --- a/src/search.js +++ b/src/search.js @@ -116,11 +116,23 @@ class Search { row = row + len - 2; } } else { - for (var i = 0; i < lines.length; i++) { - var matches = lang.getMatchOffsets(lines[i], re); - for (var j = 0; j < matches.length; j++) { - var match = matches[j]; - ranges.push(new Range(i, match.offset, i, match.offset + match.length)); + for (var matches, i = 0; i < lines.length; i++) { + if (this.$isMultilineSearch(options)) { + var lng = lines.length - 1; + matches = this.$multiLineForward(session, re, i, lng); + if (matches) { + var end_row = matches.endRow <= lng ? matches.endRow - 1 : lng; + if (end_row > i) + i = end_row; + ranges.push(new Range(matches.startRow, matches.startCol, matches.endRow, matches.endCol)); + } + } + else { + matches = lang.getMatchOffsets(lines[i], re); + for (var j = 0; j < matches.length; j++) { + var match = matches[j]; + ranges.push(new Range(i, match.offset, i, match.offset + match.length)); + } } } } @@ -146,6 +158,79 @@ class Search { return ranges; } + parseReplaceString(replaceString) { + var CharCode = { + DollarSign: 36, + Ampersand: 38, + Digit0: 48, + Digit1: 49, + Digit9: 57, + Backslash: 92, + n: 110, + t: 116 + }; + + var replacement = ''; + for (var i = 0, len = replaceString.length; i < len; i++) { + var chCode = replaceString.charCodeAt(i); + if (chCode === CharCode.Backslash) { + // move to next char + i++; + if (i >= len) { + // string ends with a \ + replacement += "\\"; + break; + } + var nextChCode = replaceString.charCodeAt(i); + switch (nextChCode) { + case CharCode.Backslash: + // \\ => inserts a "\" + replacement += "\\"; + break; + case CharCode.n: + // \n => inserts a LF + replacement += "\n"; + break; + case CharCode.t: + // \t => inserts a TAB + replacement += "\t"; + break; + } + continue; + } + + if (chCode === CharCode.DollarSign) { + // move to next char + i++; + if (i >= len) { + // string ends with a $ + replacement += "$"; + break; + } + const nextChCode = replaceString.charCodeAt(i); + if (nextChCode === CharCode.DollarSign) { + // $$ => inserts a "$" + replacement += "$$"; + continue; + } + if (nextChCode === CharCode.Digit0 || nextChCode === CharCode.Ampersand) { + // replace $0 to $&, making it compatible with JavaScript + // $0 and $& => inserts the matched substring. + replacement += "$&"; + continue; + } + if (CharCode.Digit1 <= nextChCode && nextChCode <= CharCode.Digit9) { + // $n + replacement += "$" + replaceString[i]; + continue; + } + } + + replacement += replaceString[i]; + } + return replacement || replaceString; + } + /** * Searches for `options.needle` in `input`, and, if found, replaces it with `replacement`. * @param {String} input The text to search in @@ -166,12 +251,21 @@ class Search { if (!re) return; + /** + * Convert all line ending variations to Unix-style = \n + * Windows (\r\n), MacOS Classic (\r), and Unix (\n) + */ + var mtSearch = this.$isMultilineSearch(options); + if (mtSearch) + input = input.replace(/\r\n|\r|\n/g, "\n"); + var match = re.exec(input); - if (!match || match[0].length != input.length) + if (!match || (!mtSearch && match[0].length != input.length)) return null; - if (!options.regExp) { - replacement = replacement.replace(/\$/g, "$$$$"); - } + + replacement = options.regExp + ? this.parseReplaceString(replacement) + : replacement.replace(/\$/g, "$$$$"); replacement = input.replace(re, replacement); if (options.preserveCase) { @@ -248,6 +342,78 @@ class Search { return re; } + $isMultilineSearch(options) { + return options.re && /\\r\\n|\\r|\\n/.test(options.re.source) && options.regExp && !options.$isMultiLine; + } + + $multiLineForward(session, re, start, last) { + var line, + chunk = chunkEnd(session, start); + + for (var row = start; row <= last;) { + for (var i = 0; i < chunk; i++) { + if (row > last) + break; + var next = session.getLine(row++); + line = line == null ? next : line + "\n" + next; + } + + var match = re.exec(line); + re.lastIndex = 0; + if (match) { + var beforeMatch = line.slice(0, match.index).split("\n"); + var matchedText = match[0].split("\n"); + var startRow = start + beforeMatch.length - 1; + var startCol = beforeMatch[beforeMatch.length - 1].length; + var endRow = startRow + matchedText.length - 1; + var endCol = matchedText.length == 1 + ? startCol + matchedText[0].length + : matchedText[matchedText.length - 1].length; + + return { + startRow: startRow, + startCol: startCol, + endRow: endRow, + endCol: endCol + }; + } + } + return null; + } + + $multiLineBackward(session, re, endIndex, start, first) { + var line, + chunk = chunkEnd(session, start), + endMargin = session.getLine(start).length - endIndex; + + for (var row = start; row >= first;) { + for (var i = 0; i < chunk && row >= first; i++) { + var next = session.getLine(row--); + line = line == null ? next : next + "\n" + line; + } + + var match = multiLineBackwardMatch(line, re, endMargin); + if (match) { + var beforeMatch = line.slice(0, match.index).split("\n"); + var matchedText = match[0].split("\n"); + var startRow = row + beforeMatch.length; + var startCol = beforeMatch[beforeMatch.length - 1].length; + var endRow = startRow + matchedText.length - 1; + var endCol = matchedText.length == 1 + ? startCol + matchedText[0].length + : matchedText[matchedText.length - 1].length; + + return { + startRow: startRow, + startCol: startCol, + endRow: endRow, + endCol: endCol + }; + } + } + return null; + } + /** * @param {EditSession} session */ @@ -255,6 +421,11 @@ class Search { var re = this.$assembleRegExp(options); if (!re) return false; + + var mtSearch = this.$isMultilineSearch(options); + var mtForward = this.$multiLineForward; + var mtBackward = this.$multiLineBackward; + var backwards = options.backwards == true; var skipCurrent = options.skipCurrent != false; var supportsUnicodeFlag = re.unicode; @@ -322,43 +493,66 @@ class Search { } else if (backwards) { var forEachInLine = function(row, endIndex, callback) { - var line = session.getLine(row); - var matches = []; - var m, last = 0; - re.lastIndex = 0; - while((m = re.exec(line))) { - var length = m[0].length; - last = m.index; - if (!length) { - if (last >= line.length) break; - re.lastIndex = last += lang.skipEmptyMatch(line, last, supportsUnicodeFlag); - } - if (m.index + length > endIndex) - break; - matches.push(m.index, length); - } - for (var i = matches.length - 1; i >= 0; i -= 2) { - var column = matches[i - 1]; - var length = matches[i]; - if (callback(row, column, row, column + length)) + if (mtSearch) { + var pos = mtBackward(session, re, endIndex, row, firstRow); + if (!pos) + return false; + if (callback(pos.startRow, pos.startCol, pos.endRow, pos.endCol)) return true; } + else { + var line = session.getLine(row); + var matches = []; + var m, last = 0; + re.lastIndex = 0; + while((m = re.exec(line))) { + var length = m[0].length; + last = m.index; + if (!length) { + if (last >= line.length) break; + re.lastIndex = last += lang.skipEmptyMatch(line, last, supportsUnicodeFlag); + } + if (m.index + length > endIndex) + break; + matches.push(m.index, length); + } + for (var i = matches.length - 1; i >= 0; i -= 2) { + var column = matches[i - 1]; + var length = matches[i]; + if (callback(row, column, row, column + length)) + return true; + } + } }; } else { var forEachInLine = function(row, startIndex, callback) { - var line = session.getLine(row); - var last; - var m; re.lastIndex = startIndex; - while((m = re.exec(line))) { - var length = m[0].length; - last = m.index; - if (callback(row, last, row,last + length)) + if (mtSearch) { + var pos = mtForward(session, re, row, lastRow); + if (pos) { + var end_row = pos.endRow <= lastRow ? pos.endRow - 1 : lastRow; + if (end_row > row) + row = end_row; + } + if (!pos) + return false; + if (callback(pos.startRow, pos.startCol, pos.endRow, pos.endCol)) return true; - if (!length) { - re.lastIndex = last += lang.skipEmptyMatch(line, last, supportsUnicodeFlag); - if (last >= line.length) return false; + } + else { + var line = session.getLine(row); + var last; + var m; + while((m = re.exec(line))) { + var length = m[0].length; + last = m.index; + if (callback(row, last, row, last + length)) + return true; + if (!length) { + re.lastIndex = last += lang.skipEmptyMatch(line, last, supportsUnicodeFlag); + if (last >= line.length) return false; + } } } }; @@ -397,4 +591,32 @@ function addWordBoundary(needle, options) { return wordBoundary(firstChar) + needle + wordBoundary(lastChar, false); } +function multiLineBackwardMatch(line, re, endMargin) { + var match = null; + var from = 0; + while (from <= line.length) { + re.lastIndex = from; + var newMatch = re.exec(line); + if (!newMatch) + break; + var end = newMatch.index + newMatch[0].length; + if (end > line.length - endMargin) + break; + if (!match || end > match.index + match[0].length) + match = newMatch; + from = newMatch.index + 1; + } + return match; +} + +function chunkEnd(session, start) { + var base = 5000, + startPosition = { row: start, column: 0 }, + startIndex = session.doc.positionToIndex(startPosition), + targetIndex = startIndex + base, + targetPosition = session.doc.indexToPosition(targetIndex), + targetLine = targetPosition.row; + return targetLine + 1; +} + exports.Search = Search; diff --git a/src/search_highlight.js b/src/search_highlight.js index 6a5e864c74..cd9f0f643c 100644 --- a/src/search_highlight.js +++ b/src/search_highlight.js @@ -15,8 +15,9 @@ class SearchHighlight { this.setRegexp(regExp); this.clazz = clazz; this.type = type; + this.docLen = 0; } - + setRegexp(regExp) { if (this.regExp+"" == regExp+"") return; @@ -33,21 +34,40 @@ class SearchHighlight { update(html, markerLayer, session, config) { if (!this.regExp) return; - var start = config.firstRow, end = config.lastRow; + var start = config.firstRow; + var end = config.lastRow; var renderedMarkerRanges = {}; + var _search = session.$editor.$search; + var mtSearch = _search.$isMultilineSearch(session.$editor.getLastSearchOptions()); for (var i = start; i <= end; i++) { var ranges = this.cache[i]; - if (ranges == null) { - ranges = lang.getMatchOffsets(session.getLine(i), this.regExp); - if (ranges.length > this.MAX_RANGES) - ranges = ranges.slice(0, this.MAX_RANGES); - ranges = ranges.map(function(match) { - return new Range(i, match.offset, i, match.offset + match.length); - }); + if (ranges == null || session.getValue().length != this.docLen) { + if (mtSearch) { + ranges = []; + var match = _search.$multiLineForward(session, this.regExp, i, end); + if (match) { + var end_row = match.endRow <= end ? match.endRow - 1 : end; + if (end_row > i) + i = end_row; + ranges.push(new Range(match.startRow, match.startCol, match.endRow, match.endCol)); + } + if (ranges.length > this.MAX_RANGES) + ranges = ranges.slice(0, this.MAX_RANGES); + } + else { + ranges = lang.getMatchOffsets(session.getLine(i), this.regExp); + if (ranges.length > this.MAX_RANGES) + ranges = ranges.slice(0, this.MAX_RANGES); + ranges = ranges.map(function(match) { + return new Range(i, match.offset, i, match.offset + match.length); + }); + } this.cache[i] = ranges.length ? ranges : ""; } + if (ranges.length === 0) continue; + for (var j = ranges.length; j --; ) { var rangeToAddMarkerTo = ranges[j].toScreenRange(session); var rangeAsString = rangeToAddMarkerTo.toString(); @@ -58,8 +78,8 @@ class SearchHighlight { html, rangeToAddMarkerTo, this.clazz, config); } } + this.docLen = session.getValue().length; } - } // needed to prevent long lines from freezing the browser diff --git a/src/search_test.js b/src/search_test.js index 0ac18959e7..f1e7674d3c 100644 --- a/src/search_test.js +++ b/src/search_test.js @@ -153,7 +153,7 @@ module.exports = { var range = search.find(session); assert.position(range.start, 0, 12); assert.position(range.end, 0, 13); - + search.set({ needle: "ab\\{2}" }); range = search.find(session); assert.position(range.start, 1, 8); @@ -369,8 +369,8 @@ module.exports = { assert.position(ranges[1].start, 2, 1); assert.position(ranges[1].end, 2, 3); }, - - + + "test: find all multiline matches" : function() { var session = new EditSession(["juhu", "juhu", "juhu", "juhu"]); @@ -420,13 +420,17 @@ module.exports = { "test: replace with RegExp match and capture groups" : function() { var search = new Search().set({ - needle: "ab(\\d\\d)", + needle: "ab((\\d)\\d)", regExp: true }); assert.equal(search.replace("ab12", "cd$1"), "cd12"); + assert.equal(search.replace("ab56", "pr$17$2"), "pr5675"); assert.equal(search.replace("ab12", "-$&-"), "-ab12-"); - assert.equal(search.replace("ab12", "$$"), "$"); + assert.equal(search.replace("ab12", "_$0_"), "_ab12_"); + + search.set({ needle: "(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)(k)(l)" }); + assert.equal(search.replace("abcdefghijkl", "$2$9$7_$11$9$4_$8$9$4$5_$10$1$3$11_$6$12$1$7_$13"), "big_kid_hide_jack_flag_a3"); }, "test: replace() should correctly handle $$ in the replacement string": function () { @@ -435,11 +439,11 @@ module.exports = { }); // Expecting $$ to be preserved in the output + assert.equal(search.replace("example", "$test"), "$test"); assert.equal(search.replace("example", "$$test"), "$$test"); - - // Expecting $$$$ to be preserved as $$$$ + assert.equal(search.replace("example", "$$$test"), "$$$test"); assert.equal(search.replace("example", "$$$$test"), "$$$$test"); - + search.set({ regExp: true, needle: "(example)" @@ -448,11 +452,40 @@ module.exports = { // Tests that $1 is replaced by the text that matches the capturing group. assert.equal(search.replace("example", "$1test"), "exampletest"); - search.set({regExp: false}); + assert.equal(search.replace("example", "$"), "$"); + assert.equal(search.replace("example", "$$"), "$"); + assert.equal(search.replace("example", "$$$"), "$$"); + assert.equal(search.replace("example", "$$$$"), "$$"); + assert.equal(search.replace("example", "$$$$$"), "$$$"); + assert.equal(search.replace("example", "$$$$$$"), "$$$"); + assert.equal(search.replace("example", "$$$$$$$"), "$$$$"); + + search.set({ regExp: false }); // Tests that without regular expression, "$1test" is treated as a literal string with $ escape. assert.equal(search.replace("(example)", "$1test"), "$1test"); }, + "test: replace() should correctly handle \\\\ in the replacement string": function () { + var search = new Search().set({ + needle: "example" + }); + + // Expecting \\ to be preserved in the output + assert.equal(search.replace("example", "\\test"), "\\test"); + assert.equal(search.replace("example", "\\\\test"), "\\\\test"); + assert.equal(search.replace("example", "\\\\\\test"), "\\\\\\test"); + + search.set({ regExp: true }); + + assert.equal(search.replace("example", "\\"), "\\"); + assert.equal(search.replace("example", "\\\\"), "\\"); + assert.equal(search.replace("example", "\\\\\\"), "\\\\"); + assert.equal(search.replace("example", "\\\\\\\\"), "\\\\"); + assert.equal(search.replace("example", "\\\\\\\\\\"), "\\\\\\"); + assert.equal(search.replace("example", "\\\\\\\\\\\\"), "\\\\\\"); + assert.equal(search.replace("example", "\\\\\\\\\\\\\\"), "\\\\\\\\"); + }, + "test: find all using regular expresion containing $" : function() { var session = new EditSession(["a", " b", "c ", "d"]); @@ -518,7 +551,7 @@ module.exports = { "test: find next empty range" : function() { var session = new EditSession("foo foobar foo"); var editor = new Editor(new MockRenderer(), session); - + var options = { needle: "o*", wrap: true, @@ -526,7 +559,7 @@ module.exports = { backwards: false }; var positions = [4, 5.2, 7, 8, 9, 10, 11, 12.2, 14, 0, 1.2, 3]; - + session.selection.moveCursorTo(0, 3); for (var i = 0; i < positions.length; i++) { editor.find(options); @@ -545,10 +578,11 @@ module.exports = { assert.equal(start + 0.1 * len, positions[i]); } }, + "test: repeating text": function() { var session = new EditSession("tttttt\ntttttt\ntttttt\ntttttt\ntttttt\ntttttt"); var editor = new Editor(new MockRenderer(), session); - + var options = { needle: "^", wrap: true, @@ -560,15 +594,15 @@ module.exports = { var range = editor.selection.getRange(); assert.range(range, sl, sc, el, ec); } - + session.selection.moveCursorTo(1, 3); check(2, 0, 2, 0); - + options.needle = "tttt\ntttt"; check(2, 2, 3, 4); check(4, 2, 5, 4); check(0, 2, 1, 4); - + options.backwards = true; check(4, 2, 5, 4); check(2, 2, 3, 4); @@ -601,6 +635,72 @@ module.exports = { assert.position(ranges[1].end, 1, 39); assert.position(ranges[2].start, 2, 4); assert.position(ranges[2].end, 2, 7); + }, + + "test: find all line breaks (\\r\\n, \\n) using regular expression" : function() { + var session = new EditSession('\nfunction foo(items, nada) {\n for (var i=0; i