1
0
mirror of https://github.com/gorhill/uBlock.git synced 2024-09-30 14:47:12 +02:00
uBlock/src/js/codemirror/search.js
Raymond Hill 46e90b21e9
Code review of new linter code
As per CodeMirror's documentation, eachLine() iterator is
faster, so use this. Also no need to keep track or marked
lines, we can just find them on demand, this makes the code
simpler.
2023-04-02 09:19:32 -04:00

509 lines
18 KiB
JavaScript

// The following code is heavily based on the standard CodeMirror
// search addon found at: https://codemirror.net/addon/search/search.js
// I added/removed and modified code in order to get a closer match to a
// browser's built-in find-in-page feature which are just enough for
// uBlock Origin.
//
// This file was originally wholly imported from:
// https://github.com/codemirror/CodeMirror/blob/3e1bb5fff682f8f6cbfaef0e56c61d62403d4798/addon/search/search.js
//
// And has been modified over time to better suit uBO's usage and coding style:
// https://github.com/gorhill/uBlock/commits/master/src/js/codemirror/search.js
//
// The original copyright notice is reproduced below:
// =====
// CodeMirror, copyright (c) by Marijn Haverbeke and others
// Distributed under an MIT license: http://codemirror.net/LICENSE
// Define search commands. Depends on dialog.js or another
// implementation of the openDialog method.
// Replace works a little oddly -- it will do the replace on the next
// Ctrl-G (or whatever is bound to findNext) press. You prevent a
// replace by making sure the match is no longer selected when hitting
// Ctrl-G.
// =====
'use strict';
import { dom } from '../dom.js';
import { i18n$ } from '../i18n.js';
{
const CodeMirror = self.CodeMirror;
const searchOverlay = function(query, caseInsensitive) {
if ( typeof query === 'string' )
query = new RegExp(
query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'),
caseInsensitive ? 'gi' : 'g'
);
else if ( !query.global )
query = new RegExp(query.source, query.ignoreCase ? 'gi' : 'g');
return {
token: function(stream) {
query.lastIndex = stream.pos;
const match = query.exec(stream.string);
if ( match && match.index === stream.pos ) {
stream.pos += match[0].length || 1;
return 'searching';
} else if ( match ) {
stream.pos = match.index;
} else {
stream.skipToEnd();
}
}
};
};
const searchWidgetKeydownHandler = function(cm, ev) {
const keyName = CodeMirror.keyName(ev);
if ( !keyName ) { return; }
CodeMirror.lookupKey(
keyName,
cm.getOption('keyMap'),
function(command) {
if ( widgetCommandHandler(cm, command) ) {
ev.preventDefault();
ev.stopPropagation();
}
}
);
};
const searchWidgetInputHandler = function(cm, ev) {
const state = getSearchState(cm);
if ( ev.isTrusted !== true ) {
if ( state.queryText === '' ) {
clearSearch(cm);
} else {
cm.operation(function() {
startSearch(cm, state);
});
}
return;
}
if ( queryTextFromSearchWidget(cm) === state.queryText ) { return; }
if ( state.queryTimer !== null ) {
clearTimeout(state.queryTimer);
}
state.queryTimer = setTimeout(
() => {
state.queryTimer = null;
findCommit(cm, 0);
},
350
);
};
const searchWidgetClickHandler = function(cm, ev) {
const tcl = ev.target.classList;
if ( tcl.contains('cm-search-widget-up') ) {
findNext(cm, -1);
} else if ( tcl.contains('cm-search-widget-down') ) {
findNext(cm, 1);
} else if ( tcl.contains('cm-linter-widget-up') ) {
findNextError(cm, -1);
} else if ( tcl.contains('cm-linter-widget-down') ) {
findNextError(cm, 1);
}
if ( ev.target.localName !== 'input' ) {
ev.preventDefault();
} else {
ev.stopImmediatePropagation();
}
};
const queryTextFromSearchWidget = function(cm) {
return getSearchState(cm).widget.querySelector('input[type="search"]').value;
};
const queryTextToSearchWidget = function(cm, q) {
const input = getSearchState(cm).widget.querySelector('input[type="search"]');
if ( typeof q === 'string' && q !== input.value ) {
input.value = q;
}
input.setSelectionRange(0, input.value.length);
input.focus();
};
const SearchState = function(cm) {
this.query = null;
this.panel = null;
const widgetParent = document.querySelector('.cm-search-widget-template').cloneNode(true);
this.widget = widgetParent.children[0];
this.widget.addEventListener('keydown', searchWidgetKeydownHandler.bind(null, cm));
this.widget.addEventListener('input', searchWidgetInputHandler.bind(null, cm));
this.widget.addEventListener('mousedown', searchWidgetClickHandler.bind(null, cm));
if ( typeof cm.addPanel === 'function' ) {
this.panel = cm.addPanel(this.widget);
}
this.queryText = '';
this.queryTimer = null;
this.dirty = true;
this.lines = [];
cm.on('changes', (cm, changes) => {
for ( const change of changes ) {
if ( change.text.length !== 0 || change.removed !== 0 ) {
this.dirty = true;
break;
}
}
});
cm.on('cursorActivity', cm => {
updateCount(cm);
});
};
// We want the search widget to behave as if the focus was on the
// CodeMirror editor.
const reSearchCommands = /^(?:find|findNext|findPrev|newlineAndIndent)$/;
const widgetCommandHandler = function(cm, command) {
if ( reSearchCommands.test(command) === false ) { return false; }
const queryText = queryTextFromSearchWidget(cm);
if ( command === 'find' ) {
queryTextToSearchWidget(cm);
return true;
}
if ( queryText.length !== 0 ) {
findNext(cm, command === 'findPrev' ? -1 : 1);
}
return true;
};
const getSearchState = function(cm) {
return cm.state.search || (cm.state.search = new SearchState(cm));
};
const queryCaseInsensitive = function(query) {
return typeof query === 'string' && query === query.toLowerCase();
};
// Heuristic: if the query string is all lowercase, do a case insensitive search.
const getSearchCursor = function(cm, query, pos) {
return cm.getSearchCursor(
query,
pos,
{ caseFold: queryCaseInsensitive(query), multiline: false }
);
};
// https://github.com/uBlockOrigin/uBlock-issues/issues/658
// Modified to backslash-escape ONLY widely-used control characters.
const parseString = function(string) {
return string.replace(/\\[nrt\\]/g, match => {
if ( match === '\\n' ) { return '\n'; }
if ( match === '\\r' ) { return '\r'; }
if ( match === '\\t' ) { return '\t'; }
if ( match === '\\\\' ) { return '\\'; }
return match;
});
};
const reEscape = /[.*+\-?^${}()|[\]\\]/g;
// Must always return a RegExp object.
//
// Assume case-sensitivity if there is at least one uppercase in plain
// query text.
const parseQuery = function(query) {
let flags = 'i';
let reParsed = query.match(/^\/(.+)\/([iu]*)$/);
if ( reParsed !== null ) {
try {
const re = new RegExp(reParsed[1], reParsed[2]);
query = re.source;
flags = re.flags;
}
catch (e) {
reParsed = null;
}
}
if ( reParsed === null ) {
if ( /[A-Z]/.test(query) ) { flags = ''; }
query = parseString(query).replace(reEscape, '\\$&');
}
if ( typeof query === 'string' ? query === '' : query.test('') ) {
query = 'x^';
}
return new RegExp(query, 'gm' + flags);
};
let intlNumberFormat;
const formatNumber = function(n) {
if ( intlNumberFormat === undefined ) {
intlNumberFormat = null;
if ( Intl.NumberFormat instanceof Function ) {
const intl = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumSignificantDigits: 3
});
if (
intl.resolvedOptions instanceof Function &&
intl.resolvedOptions().hasOwnProperty('notation')
) {
intlNumberFormat = intl;
}
}
}
return n > 10000 && intlNumberFormat instanceof Object
? intlNumberFormat.format(n)
: n.toLocaleString();
};
const updateCount = function(cm) {
const state = getSearchState(cm);
const lines = state.lines;
const current = cm.getCursor().line;
let l = 0;
let r = lines.length;
let i = -1;
while ( l < r ) {
i = l + r >>> 1;
const candidate = lines[i];
if ( current === candidate ) { break; }
if ( current < candidate ) {
r = i;
} else /* if ( current > candidate ) */ {
l = i + 1;
}
}
let text = '';
if ( i !== -1 ) {
text = formatNumber(i + 1);
if ( lines[i] !== current ) {
text = '~' + text;
}
text = text + '\xA0/\xA0';
}
const count = lines.length;
text += formatNumber(count);
const span = state.widget.querySelector('.cm-search-widget-count');
span.textContent = text;
span.title = count.toLocaleString();
};
const startSearch = function(cm, state) {
state.query = parseQuery(state.queryText);
if ( state.overlay !== undefined ) {
cm.removeOverlay(state.overlay, queryCaseInsensitive(state.query));
}
state.overlay = searchOverlay(state.query, queryCaseInsensitive(state.query));
cm.addOverlay(state.overlay);
if ( state.dirty || self.searchThread.needHaystack() ) {
self.searchThread.setHaystack(cm.getValue());
state.dirty = false;
}
self.searchThread.search(state.query).then(lines => {
if ( Array.isArray(lines) === false ) { return; }
state.lines = lines;
const count = lines.length;
updateCount(cm);
if ( state.annotate !== undefined ) {
state.annotate.clear();
state.annotate = undefined;
}
if ( count === 0 ) { return; }
state.annotate = cm.annotateScrollbar('CodeMirror-search-match');
const annotations = [];
let lineBeg = -1;
let lineEnd = -1;
for ( const line of lines ) {
if ( lineBeg === -1 ) {
lineBeg = line;
lineEnd = line + 1;
continue;
} else if ( line === lineEnd ) {
lineEnd = line + 1;
continue;
}
annotations.push({
from: { line: lineBeg, ch: 0 },
to: { line: lineEnd, ch: 0 }
});
lineBeg = -1;
}
if ( lineBeg !== -1 ) {
annotations.push({
from: { line: lineBeg, ch: 0 },
to: { line: lineEnd, ch: 0 }
});
}
state.annotate.update(annotations);
});
state.widget.setAttribute('data-query', state.queryText);
// Ensure the caret is visible
const input = state.widget.querySelector('.cm-search-widget-input input');
input.selectionStart = input.selectionStart;
};
const findNext = function(cm, dir, callback) {
cm.operation(function() {
const state = getSearchState(cm);
if ( !state.query ) { return; }
let cursor = getSearchCursor(
cm,
state.query,
dir <= 0 ? cm.getCursor('from') : cm.getCursor('to')
);
const previous = dir < 0;
if (!cursor.find(previous)) {
cursor = getSearchCursor(
cm,
state.query,
previous
? CodeMirror.Pos(cm.lastLine())
: CodeMirror.Pos(cm.firstLine(), 0)
);
if (!cursor.find(previous)) return;
}
cm.setSelection(cursor.from(), cursor.to());
const { clientHeight } = cm.getScrollInfo();
cm.scrollIntoView(
{ from: cursor.from(), to: cursor.to() },
clientHeight >>> 1
);
if (callback) callback(cursor.from(), cursor.to());
});
};
const findNextError = function(cm, dir) {
const doc = cm.getDoc();
const cursor = cm.getCursor('from');
const cursorLine = cursor.line;
const start = dir < 0 ? 0 : cursorLine + 1;
const end = dir < 0 ? cursorLine : doc.lineCount();
let found = -1;
doc.eachLine(start, end, lineHandle => {
const markers = lineHandle.gutterMarkers || null;
if ( markers === null ) { return; }
if ( markers['CodeMirror-lintgutter'] === undefined ) { return; }
const line = lineHandle.lineNo();
if ( dir < 0 ) {
found = line;
return;
}
found = line;
return true;
});
if ( found === -1 || found === cursorLine ) { return; }
cm.getDoc().setCursor(found);
const { clientHeight } = cm.getScrollInfo();
cm.scrollIntoView({ line: found, ch: 0 }, clientHeight >>> 1);
};
const clearSearch = function(cm, hard) {
cm.operation(function() {
const state = getSearchState(cm);
if ( state.query ) {
state.query = state.queryText = null;
}
state.lines = [];
if ( state.overlay !== undefined ) {
cm.removeOverlay(state.overlay);
state.overlay = undefined;
}
if ( state.annotate ) {
state.annotate.clear();
state.annotate = undefined;
}
state.widget.removeAttribute('data-query');
if ( hard ) {
state.panel.clear();
state.panel = null;
state.widget = null;
cm.state.search = null;
}
});
};
const findCommit = function(cm, dir) {
const state = getSearchState(cm);
if ( state.queryTimer !== null ) {
clearTimeout(state.queryTimer);
state.queryTimer = null;
}
const queryText = queryTextFromSearchWidget(cm);
if ( queryText === state.queryText ) { return; }
state.queryText = queryText;
if ( state.queryText === '' ) {
clearSearch(cm);
} else {
cm.operation(function() {
startSearch(cm, state);
findNext(cm, dir);
});
}
};
const findCommand = function(cm) {
let queryText = cm.getSelection() || undefined;
if ( !queryText ) {
const word = cm.findWordAt(cm.getCursor());
queryText = cm.getRange(word.anchor, word.head);
if ( /^\W|\W$/.test(queryText) ) {
queryText = undefined;
}
cm.setCursor(word.anchor);
}
queryTextToSearchWidget(cm, queryText);
findCommit(cm, 1);
};
const findNextCommand = function(cm) {
const state = getSearchState(cm);
if ( state.query ) { return findNext(cm, 1); }
};
const findPrevCommand = function(cm) {
const state = getSearchState(cm);
if ( state.query ) { return findNext(cm, -1); }
};
{
const searchWidgetTemplate =
'<div class="cm-search-widget-template" style="display:none;">' +
'<div class="cm-search-widget">' +
'<span class="cm-search-widget-input">' +
'<span class="fa-icon fa-icon-ro">search</span>&ensp;' +
'<input type="search" spellcheck="false">&emsp;' +
'<span class="cm-search-widget-up cm-search-widget-button fa-icon">angle-up</span>&nbsp;' +
'<span class="cm-search-widget-down cm-search-widget-button fa-icon fa-icon-vflipped">angle-up</span>&emsp;' +
'<span class="cm-search-widget-count"></span>' +
'</span>' +
'<span class="cm-linter-widget">' +
'<span class="cm-linter-widget-count"></span>&emsp;' +
'<span class="cm-linter-widget-up cm-search-widget-button fa-icon">angle-up</span>&nbsp;' +
'<span class="cm-linter-widget-down cm-search-widget-button fa-icon fa-icon-vflipped">angle-up</span>&emsp;' +
'</span>' +
'<a class="fa-icon sourceURL" href>external-link</a>' +
'</div>' +
'</div>';
const domParser = new DOMParser();
const doc = domParser.parseFromString(searchWidgetTemplate, 'text/html');
const widgetTemplate = document.adoptNode(doc.body.firstElementChild);
document.body.appendChild(widgetTemplate);
}
CodeMirror.commands.find = findCommand;
CodeMirror.commands.findNext = findNextCommand;
CodeMirror.commands.findPrev = findPrevCommand;
CodeMirror.defineInitHook(function(cm) {
getSearchState(cm);
cm.on('linterDone', details => {
const count = details.errorCount;
dom.cl.toggle('.cm-linter-widget', 'hasErrors', count !== 0);
dom.text(
'.cm-linter-widget .cm-linter-widget-count',
i18n$('linterMainReport').replace('{{count}}', count.toLocaleString())
);
});
});
}