diff options
Diffstat (limited to 'src/js/codemirror')
-rw-r--r-- | src/js/codemirror/search-thread.js | 199 | ||||
-rw-r--r-- | src/js/codemirror/search.js | 504 | ||||
-rw-r--r-- | src/js/codemirror/ubo-dynamic-filtering.js | 239 | ||||
-rw-r--r-- | src/js/codemirror/ubo-static-filtering.js | 1200 |
4 files changed, 2142 insertions, 0 deletions
diff --git a/src/js/codemirror/search-thread.js b/src/js/codemirror/search-thread.js new file mode 100644 index 0000000..3a4416f --- /dev/null +++ b/src/js/codemirror/search-thread.js @@ -0,0 +1,199 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2020-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +/******************************************************************************/ + +(( ) => { +// >>>>> start of local scope + +/******************************************************************************/ + +// Worker context + +if ( + self.WorkerGlobalScope instanceof Object && + self instanceof self.WorkerGlobalScope +) { + let content = ''; + + const doSearch = function(details) { + const reEOLs = /\n\r|\r\n|\n|\r/g; + const t1 = Date.now() + 750; + + let reSearch; + try { + reSearch = new RegExp(details.pattern, details.flags); + } catch(ex) { + return; + } + + const response = []; + const maxOffset = content.length; + let iLine = 0; + let iOffset = 0; + let size = 0; + while ( iOffset < maxOffset ) { + // Find next match + const match = reSearch.exec(content); + if ( match === null ) { break; } + // Find number of line breaks between last and current match. + reEOLs.lastIndex = 0; + const eols = content.slice(iOffset, match.index).match(reEOLs); + if ( Array.isArray(eols) ) { + iLine += eols.length; + } + // Store line + response.push(iLine); + size += 1; + // Find next line break. + reEOLs.lastIndex = reSearch.lastIndex; + const eol = reEOLs.exec(content); + iOffset = eol !== null + ? reEOLs.lastIndex + : content.length; + reSearch.lastIndex = iOffset; + iLine += 1; + // Quit if this takes too long + if ( (size & 0x3FF) === 0 && Date.now() >= t1 ) { break; } + } + + return response; + }; + + self.onmessage = function(e) { + const msg = e.data; + + switch ( msg.what ) { + case 'setHaystack': + content = msg.content; + break; + + case 'doSearch': + const response = doSearch(msg); + self.postMessage({ id: msg.id, response }); + break; + } + }; + + return; +} + +/******************************************************************************/ + +// Main context + +{ + const workerTTL = { min: 5 }; + const pendingResponses = new Map(); + const workerTTLTimer = vAPI.defer.create(( ) => { + shutdown(); + }); + + let worker; + let messageId = 1; + + const onWorkerMessage = function(e) { + const msg = e.data; + const resolver = pendingResponses.get(msg.id); + if ( resolver === undefined ) { return; } + pendingResponses.delete(msg.id); + resolver(msg.response); + }; + + const cancelPendingTasks = function() { + for ( const resolver of pendingResponses.values() ) { + resolver(); + } + pendingResponses.clear(); + }; + + const destroy = function() { + shutdown(); + self.searchThread = undefined; + }; + + const shutdown = function() { + if ( worker === undefined ) { return; } + workerTTLTimer.off(); + worker.terminate(); + worker.onmessage = undefined; + worker = undefined; + cancelPendingTasks(); + }; + + const init = function() { + if ( self.searchThread instanceof Object === false ) { return; } + if ( worker === undefined ) { + worker = new Worker('js/codemirror/search-thread.js'); + worker.onmessage = onWorkerMessage; + } + workerTTLTimer.offon(workerTTL); + }; + + const needHaystack = function() { + return worker instanceof Object === false; + }; + + const setHaystack = function(content) { + init(); + worker.postMessage({ what: 'setHaystack', content }); + }; + + const search = function(query, overwrite = true) { + init(); + if ( worker instanceof Object === false ) { + return Promise.resolve(); + } + if ( overwrite ) { + cancelPendingTasks(); + } + const id = messageId++; + worker.postMessage({ + what: 'doSearch', + id, + pattern: query.source, + flags: query.flags, + isRE: query instanceof RegExp + }); + return new Promise(resolve => { + pendingResponses.set(id, resolve); + }); + }; + + self.addEventListener( + 'beforeunload', + ( ) => { destroy(); }, + { once: true } + ); + + self.searchThread = { needHaystack, setHaystack, search, shutdown }; +} + +/******************************************************************************/ + +// <<<<< end of local scope +})(); + +/******************************************************************************/ + +void 0; diff --git a/src/js/codemirror/search.js b/src/js/codemirror/search.js new file mode 100644 index 0000000..477e9cc --- /dev/null +++ b/src/js/codemirror/search.js @@ -0,0 +1,504 @@ +// 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, qs$ } 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; } + state.queryTimer.offon(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.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); + }); + this.queryTimer = vAPI.defer.create(( ) => { + findCommit(cm, 0); + }); + }; + + // 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; } + const marker = markers['CodeMirror-lintgutter']; + if ( marker === undefined ) { return; } + if ( marker.dataset.error !== 'y' ) { 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); + state.queryTimer.off(); + 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> ' + + '<input type="search" spellcheck="false"> ' + + '<span class="cm-search-widget-up cm-search-widget-button fa-icon">angle-up</span> ' + + '<span class="cm-search-widget-down cm-search-widget-button fa-icon fa-icon-vflipped">angle-up</span> ' + + '<span class="cm-search-widget-count"></span>' + + '</span>' + + '<span class="cm-linter-widget" data-lint="0">' + + '<span class="cm-linter-widget-count"></span> ' + + '<span class="cm-linter-widget-up cm-search-widget-button fa-icon">angle-up</span> ' + + '<span class="cm-linter-widget-down cm-search-widget-button fa-icon fa-icon-vflipped">angle-up</span> ' + + '</span>' + + '<span>' + + '<a class="fa-icon sourceURL" href>external-link</a>' + + '</span>' + + '</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 linterWidget = qs$('.cm-linter-widget'); + const count = details.errorCount; + if ( linterWidget.dataset.lint === `${count}` ) { return; } + linterWidget.dataset.lint = `${count}`; + dom.text( + qs$(linterWidget, '.cm-linter-widget-count'), + i18n$('linterMainReport').replace('{{count}}', count.toLocaleString()) + ); + }); + }); +} diff --git a/src/js/codemirror/ubo-dynamic-filtering.js b/src/js/codemirror/ubo-dynamic-filtering.js new file mode 100644 index 0000000..d0709a4 --- /dev/null +++ b/src/js/codemirror/ubo-dynamic-filtering.js @@ -0,0 +1,239 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2019-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +/* global CodeMirror */ + +'use strict'; + +CodeMirror.defineMode('ubo-dynamic-filtering', ( ) => { + + const validSwitches = new Set([ + 'no-strict-blocking:', + 'no-popups:', + 'no-cosmetic-filtering:', + 'no-remote-fonts:', + 'no-large-media:', + 'no-csp-reports:', + 'no-scripting:', + ]); + const validSwitcheStates = new Set([ + 'true', + 'false', + ]); + const validHnRuleTypes = new Set([ + '*', + '3p', + 'image', + 'inline-script', + '1p-script', + '3p-script', + '3p-frame', + ]); + const invalidURLRuleTypes = new Set([ + 'doc', + 'main_frame', + ]); + const validActions = new Set([ + 'block', + 'allow', + 'noop', + ]); + const hnValidator = new URL(self.location.href); + const reBadHn = /[%]|^\.|\.$/; + const slices = []; + let sliceIndex = 0; + let sliceCount = 0; + let hostnameToDomainMap = new Map(); + let psl; + + const isValidHostname = hnin => { + if ( hnin === '*' ) { return true; } + hnValidator.hostname = '_'; + try { + hnValidator.hostname = hnin; + } catch(_) { + return false; + } + const hnout = hnValidator.hostname; + return hnout !== '_' && hnout !== '' && reBadHn.test(hnout) === false; + }; + + const addSlice = (len, style = null) => { + let i = sliceCount; + if ( i === slices.length ) { + slices[i] = { len: 0, style: null }; + } + const entry = slices[i]; + entry.len = len; + entry.style = style; + sliceCount += 1; + }; + + const addMatchSlice = (match, style = null) => { + const len = match !== null ? match[0].length : 0; + addSlice(len, style); + return match !== null ? match.input.slice(len) : ''; + }; + + const addMatchHnSlices = (match, style = null) => { + const hn = match[0]; + if ( hn === '*' ) { + return addMatchSlice(match, style); + } + let dn = hostnameToDomainMap.get(hn) || ''; + if ( dn === '' && psl !== undefined ) { + dn = /(\d|\])$/.test(hn) ? hn : (psl.getDomain(hn) || hn); + } + const entityBeg = hn.length - dn.length; + if ( entityBeg !== 0 ) { + addSlice(entityBeg, style); + } + let entityEnd = dn.indexOf('.'); + if ( entityEnd === -1 ) { entityEnd = dn.length; } + addSlice(entityEnd, style !== null ? `${style} strong` : 'strong'); + if ( entityEnd < dn.length ) { + addSlice(dn.length - entityEnd, style); + } + return match.input.slice(hn.length); + }; + + const makeSlices = (stream, opts) => { + sliceIndex = 0; + sliceCount = 0; + let { string } = stream; + if ( string === '...' ) { return; } + const { sortType } = opts; + const reNotToken = /^\s+/; + const reToken = /^\S+/; + const tokens = []; + // leading whitespaces + let match = reNotToken.exec(string); + if ( match !== null ) { + string = addMatchSlice(match); + } + // first token + match = reToken.exec(string); + if ( match === null ) { return; } + tokens.push(match[0]); + // hostname or switch + const isSwitchRule = validSwitches.has(match[0]); + if ( isSwitchRule ) { + string = addMatchSlice(match, sortType === 0 ? 'sortkey' : null); + } else if ( isValidHostname(match[0]) ) { + if ( sortType === 1 ) { + string = addMatchHnSlices(match, 'sortkey'); + } else { + string = addMatchHnSlices(match, null); + } + } else { + string = addMatchSlice(match, 'error'); + } + // whitespaces before second token + match = reNotToken.exec(string); + if ( match === null ) { return; } + string = addMatchSlice(match); + // second token + match = reToken.exec(string); + if ( match === null ) { return; } + tokens.push(match[0]); + // hostname or url + const isURLRule = isSwitchRule === false && match[0].indexOf('://') > 0; + if ( isURLRule ) { + string = addMatchSlice(match, sortType === 2 ? 'sortkey' : null); + } else if ( isValidHostname(match[0]) === false ) { + string = addMatchSlice(match, 'error'); + } else if ( sortType === 1 && isSwitchRule || sortType === 2 ) { + string = addMatchHnSlices(match, 'sortkey'); + } else { + string = addMatchHnSlices(match, null); + } + // whitespaces before third token + match = reNotToken.exec(string); + if ( match === null ) { return; } + string = addMatchSlice(match); + // third token + match = reToken.exec(string); + if ( match === null ) { return; } + tokens.push(match[0]); + // rule type or switch state + if ( isSwitchRule ) { + string = validSwitcheStates.has(match[0]) + ? addMatchSlice(match, match[0] === 'true' ? 'blockrule' : 'allowrule') + : addMatchSlice(match, 'error'); + } else if ( isURLRule ) { + string = invalidURLRuleTypes.has(match[0]) + ? addMatchSlice(match, 'error') + : addMatchSlice(match); + } else if ( tokens[1] === '*' ) { + string = validHnRuleTypes.has(match[0]) + ? addMatchSlice(match) + : addMatchSlice(match, 'error'); + } else { + string = match[0] === '*' + ? addMatchSlice(match) + : addMatchSlice(match, 'error'); + } + // whitespaces before fourth token + match = reNotToken.exec(string); + if ( match === null ) { return; } + string = addMatchSlice(match); + // fourth token + match = reToken.exec(string); + if ( match === null ) { return; } + tokens.push(match[0]); + string = isSwitchRule || validActions.has(match[0]) === false + ? addMatchSlice(match, 'error') + : addMatchSlice(match, `${match[0]}rule`); + // whitespaces before end of line + match = reNotToken.exec(string); + if ( match === null ) { return; } + string = addMatchSlice(match); + // any token beyond fourth token is invalid + match = reToken.exec(string); + if ( match !== null ) { + string = addMatchSlice(null, 'error'); + } + }; + + const token = function(stream) { + if ( stream.sol() ) { + makeSlices(stream, this); + } + if ( sliceIndex >= sliceCount ) { + stream.skipToEnd(stream); + return null; + } + const { len, style } = slices[sliceIndex++]; + if ( len === 0 ) { + stream.skipToEnd(); + } else { + stream.pos += len; + } + return style; + }; + + return { + token, + sortType: 1, + setHostnameToDomainMap: a => { hostnameToDomainMap = a; }, + setPSL: a => { psl = a; }, + }; +}); diff --git a/src/js/codemirror/ubo-static-filtering.js b/src/js/codemirror/ubo-static-filtering.js new file mode 100644 index 0000000..ac1b048 --- /dev/null +++ b/src/js/codemirror/ubo-static-filtering.js @@ -0,0 +1,1200 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2018-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +/* global CodeMirror */ + +'use strict'; + +/******************************************************************************/ + +import * as sfp from '../static-filtering-parser.js'; +import { dom, qs$ } from '../dom.js'; + +/******************************************************************************/ + +const redirectNames = new Map(); +const scriptletNames = new Map(); +const preparseDirectiveEnv = []; +const preparseDirectiveHints = []; +const originHints = []; +let hintHelperRegistered = false; + +/******************************************************************************/ + +CodeMirror.defineOption('trustedSource', false, (cm, state) => { + if ( typeof state !== 'boolean' ) { return; } + self.dispatchEvent(new CustomEvent('trustedSource', { + detail: state, + })); +}); + +CodeMirror.defineOption('trustedScriptletTokens', undefined, (cm, tokens) => { + if ( tokens === undefined || tokens === null ) { return; } + if ( typeof tokens[Symbol.iterator] !== 'function' ) { return; } + self.dispatchEvent(new CustomEvent('trustedScriptletTokens', { + detail: new Set(tokens), + })); +}); + +/******************************************************************************/ + +CodeMirror.defineMode('ubo-static-filtering', function() { + const astParser = new sfp.AstFilterParser({ + interactive: true, + nativeCssHas: vAPI.webextFlavor.env.includes('native_css_has'), + }); + const astWalker = astParser.getWalker(); + let currentWalkerNode = 0; + let lastNetOptionType = 0; + + const redirectTokenStyle = node => { + const rawToken = astParser.getNodeString(node || currentWalkerNode); + const { token } = sfp.parseRedirectValue(rawToken); + return redirectNames.has(token) ? 'value' : 'value warning'; + }; + + const nodeHasError = node => { + return astParser.getNodeFlags( + node || currentWalkerNode, sfp.NODE_FLAG_ERROR + ) !== 0; + }; + + const colorFromAstNode = function() { + if ( astParser.nodeIsEmptyString(currentWalkerNode) ) { return '+'; } + if ( nodeHasError() ) { return 'error'; } + const nodeType = astParser.getNodeType(currentWalkerNode); + switch ( nodeType ) { + case sfp.NODE_TYPE_WHITESPACE: + return ''; + case sfp.NODE_TYPE_COMMENT: + if ( astWalker.canGoDown() ) { break; } + return 'comment'; + case sfp.NODE_TYPE_COMMENT_URL: + return 'comment link'; + case sfp.NODE_TYPE_IGNORE: + return 'comment'; + case sfp.NODE_TYPE_PREPARSE_DIRECTIVE: + case sfp.NODE_TYPE_PREPARSE_DIRECTIVE_VALUE: + return 'directive'; + case sfp.NODE_TYPE_PREPARSE_DIRECTIVE_IF_VALUE: { + const raw = astParser.getNodeString(currentWalkerNode); + const state = sfp.utils.preparser.evaluateExpr(raw, preparseDirectiveEnv); + return state ? 'positive strong' : 'negative strong'; + } + case sfp.NODE_TYPE_EXT_OPTIONS_ANCHOR: + return astParser.getFlags(sfp.AST_FLAG_IS_EXCEPTION) + ? 'tag strong' + : 'def strong'; + case sfp.NODE_TYPE_EXT_DECORATION: + return 'def'; + case sfp.NODE_TYPE_EXT_PATTERN_RAW: + if ( astWalker.canGoDown() ) { break; } + return 'variable'; + case sfp.NODE_TYPE_EXT_PATTERN_COSMETIC: + case sfp.NODE_TYPE_EXT_PATTERN_HTML: + return 'variable'; + case sfp.NODE_TYPE_EXT_PATTERN_RESPONSEHEADER: + case sfp.NODE_TYPE_EXT_PATTERN_SCRIPTLET: + if ( astWalker.canGoDown() ) { break; } + return 'variable'; + case sfp.NODE_TYPE_EXT_PATTERN_SCRIPTLET_TOKEN: { + const token = astParser.getNodeString(currentWalkerNode); + if ( scriptletNames.has(token) === false ) { + return 'warning'; + } + return 'variable'; + } + case sfp.NODE_TYPE_EXT_PATTERN_SCRIPTLET_ARG: + return 'variable'; + case sfp.NODE_TYPE_NET_EXCEPTION: + return 'tag strong'; + case sfp.NODE_TYPE_NET_PATTERN: + if ( astWalker.canGoDown() ) { break; } + if ( astParser.isRegexPattern() ) { + if ( astParser.getNodeFlags(currentWalkerNode, sfp.NODE_FLAG_PATTERN_UNTOKENIZABLE) !== 0 ) { + return 'variable warning'; + } + return 'variable notice'; + } + return 'variable'; + case sfp.NODE_TYPE_NET_PATTERN_PART: + return 'variable'; + case sfp.NODE_TYPE_NET_PATTERN_PART_SPECIAL: + return 'keyword strong'; + case sfp.NODE_TYPE_NET_PATTERN_PART_UNICODE: + return 'variable unicode'; + case sfp.NODE_TYPE_NET_PATTERN_LEFT_HNANCHOR: + case sfp.NODE_TYPE_NET_PATTERN_LEFT_ANCHOR: + case sfp.NODE_TYPE_NET_PATTERN_RIGHT_ANCHOR: + case sfp.NODE_TYPE_NET_OPTION_NAME_NOT: + return 'keyword strong'; + case sfp.NODE_TYPE_NET_OPTIONS_ANCHOR: + case sfp.NODE_TYPE_NET_OPTION_SEPARATOR: + lastNetOptionType = 0; + return 'def strong'; + case sfp.NODE_TYPE_NET_OPTION_NAME_UNKNOWN: + lastNetOptionType = 0; + return 'error'; + case sfp.NODE_TYPE_NET_OPTION_NAME_1P: + case sfp.NODE_TYPE_NET_OPTION_NAME_STRICT1P: + case sfp.NODE_TYPE_NET_OPTION_NAME_3P: + case sfp.NODE_TYPE_NET_OPTION_NAME_STRICT3P: + case sfp.NODE_TYPE_NET_OPTION_NAME_ALL: + case sfp.NODE_TYPE_NET_OPTION_NAME_BADFILTER: + case sfp.NODE_TYPE_NET_OPTION_NAME_CNAME: + case sfp.NODE_TYPE_NET_OPTION_NAME_CSP: + case sfp.NODE_TYPE_NET_OPTION_NAME_CSS: + case sfp.NODE_TYPE_NET_OPTION_NAME_DENYALLOW: + case sfp.NODE_TYPE_NET_OPTION_NAME_DOC: + case sfp.NODE_TYPE_NET_OPTION_NAME_EHIDE: + case sfp.NODE_TYPE_NET_OPTION_NAME_EMPTY: + case sfp.NODE_TYPE_NET_OPTION_NAME_FONT: + case sfp.NODE_TYPE_NET_OPTION_NAME_FRAME: + case sfp.NODE_TYPE_NET_OPTION_NAME_FROM: + case sfp.NODE_TYPE_NET_OPTION_NAME_GENERICBLOCK: + case sfp.NODE_TYPE_NET_OPTION_NAME_GHIDE: + case sfp.NODE_TYPE_NET_OPTION_NAME_HEADER: + case sfp.NODE_TYPE_NET_OPTION_NAME_IMAGE: + case sfp.NODE_TYPE_NET_OPTION_NAME_IMPORTANT: + case sfp.NODE_TYPE_NET_OPTION_NAME_INLINEFONT: + case sfp.NODE_TYPE_NET_OPTION_NAME_INLINESCRIPT: + case sfp.NODE_TYPE_NET_OPTION_NAME_MATCHCASE: + case sfp.NODE_TYPE_NET_OPTION_NAME_MEDIA: + case sfp.NODE_TYPE_NET_OPTION_NAME_METHOD: + case sfp.NODE_TYPE_NET_OPTION_NAME_MP4: + case sfp.NODE_TYPE_NET_OPTION_NAME_NOOP: + case sfp.NODE_TYPE_NET_OPTION_NAME_OBJECT: + case sfp.NODE_TYPE_NET_OPTION_NAME_OTHER: + case sfp.NODE_TYPE_NET_OPTION_NAME_PING: + case sfp.NODE_TYPE_NET_OPTION_NAME_POPUNDER: + case sfp.NODE_TYPE_NET_OPTION_NAME_POPUP: + case sfp.NODE_TYPE_NET_OPTION_NAME_REDIRECT: + case sfp.NODE_TYPE_NET_OPTION_NAME_REDIRECTRULE: + case sfp.NODE_TYPE_NET_OPTION_NAME_REMOVEPARAM: + case sfp.NODE_TYPE_NET_OPTION_NAME_SCRIPT: + case sfp.NODE_TYPE_NET_OPTION_NAME_SHIDE: + case sfp.NODE_TYPE_NET_OPTION_NAME_TO: + case sfp.NODE_TYPE_NET_OPTION_NAME_XHR: + case sfp.NODE_TYPE_NET_OPTION_NAME_WEBRTC: + case sfp.NODE_TYPE_NET_OPTION_NAME_WEBSOCKET: + lastNetOptionType = nodeType; + return 'def'; + case sfp.NODE_TYPE_NET_OPTION_ASSIGN: + return 'def'; + case sfp.NODE_TYPE_NET_OPTION_VALUE: + if ( astWalker.canGoDown() ) { break; } + switch ( lastNetOptionType ) { + case sfp.NODE_TYPE_NET_OPTION_NAME_REDIRECT: + case sfp.NODE_TYPE_NET_OPTION_NAME_REDIRECTRULE: + return redirectTokenStyle(); + default: + break; + } + return 'value'; + case sfp.NODE_TYPE_OPTION_VALUE_NOT: + return 'keyword strong'; + case sfp.NODE_TYPE_OPTION_VALUE_DOMAIN: + return 'value'; + case sfp.NODE_TYPE_OPTION_VALUE_SEPARATOR: + return 'def'; + default: + break; + } + return '+'; + }; + + self.addEventListener('trustedSource', ev => { + astParser.options.trustedSource = ev.detail; + }); + + self.addEventListener('trustedScriptletTokens', ev => { + astParser.options.trustedScriptletTokens = ev.detail; + }); + + return { + lineComment: '!', + token: function(stream) { + if ( stream.sol() ) { + astParser.parse(stream.string); + if ( astParser.getFlags(sfp.AST_FLAG_UNSUPPORTED) !== 0 ) { + stream.skipToEnd(); + return 'error'; + } + if ( astParser.getType() === sfp.AST_TYPE_NONE ) { + stream.skipToEnd(); + return 'comment'; + } + currentWalkerNode = astWalker.reset(); + } else if ( nodeHasError() ) { + currentWalkerNode = astWalker.right(); + } else { + currentWalkerNode = astWalker.next(); + } + let style = ''; + while ( currentWalkerNode !== 0 ) { + style = colorFromAstNode(stream); + if ( style !== '+' ) { break; } + currentWalkerNode = astWalker.next(); + } + if ( style === '+' ) { + stream.skipToEnd(); + return null; + } + stream.pos = astParser.getNodeStringEnd(currentWalkerNode); + if ( astParser.isNetworkFilter() ) { + return style ? `line-cm-net ${style}` : 'line-cm-net'; + } + if ( astParser.isExtendedFilter() ) { + let flavor = ''; + if ( astParser.isCosmeticFilter() ) { + flavor = 'line-cm-ext-dom'; + } else if ( astParser.isScriptletFilter() ) { + flavor = 'line-cm-ext-js'; + } else if ( astParser.isHtmlFilter() ) { + flavor = 'line-cm-ext-html'; + } + if ( flavor !== '' ) { + style = `${flavor} ${style}`; + } + } + style = style.trim(); + return style !== '' ? style : null; + }, + parser: astParser, + }; +}); + +/******************************************************************************/ + +// Following code is for auto-completion. Reference: +// https://codemirror.net/demo/complete.html + +CodeMirror.defineOption('uboHints', null, (cm, hints) => { + if ( hints instanceof Object === false ) { return; } + if ( Array.isArray(hints.redirectResources) ) { + for ( const [ name, desc ] of hints.redirectResources ) { + const displayText = desc.aliasOf !== '' + ? `${name} (${desc.aliasOf})` + : ''; + if ( desc.canRedirect ) { + redirectNames.set(name, displayText); + } + if ( desc.canInject && name.endsWith('.js') ) { + scriptletNames.set(name.slice(0, -3), displayText); + } + } + } + if ( Array.isArray(hints.preparseDirectiveEnv)) { + preparseDirectiveEnv.length = 0; + preparseDirectiveEnv.push(...hints.preparseDirectiveEnv); + } + if ( Array.isArray(hints.preparseDirectiveHints)) { + preparseDirectiveHints.push(...hints.preparseDirectiveHints); + } + if ( Array.isArray(hints.originHints) ) { + originHints.length = 0; + for ( const hint of hints.originHints ) { + originHints.push(hint); + } + } + if ( hintHelperRegistered ) { return; } + hintHelperRegistered = true; + initHints(); +}); + +function initHints() { + const astParser = new sfp.AstFilterParser({ + interactive: true, + nativeCssHas: vAPI.webextFlavor.env.includes('native_css_has'), + }); + const proceduralOperatorNames = new Map( + Array.from(sfp.proceduralOperatorTokens) + .filter(item => (item[1] & 0b01) !== 0) + ); + const excludedHints = new Set([ + 'genericblock', + 'object-subrequest', + 'rewrite', + 'webrtc', + ]); + + const pickBestHints = function(cursor, seedLeft, seedRight, hints) { + const seed = (seedLeft + seedRight).trim(); + const out = []; + // First, compare against whole seed + for ( const hint of hints ) { + const text = hint instanceof Object + ? hint.displayText || hint.text + : hint; + if ( text.startsWith(seed) === false ) { continue; } + out.push(hint); + } + if ( out.length !== 0 ) { + return { + from: { line: cursor.line, ch: cursor.ch - seedLeft.length }, + to: { line: cursor.line, ch: cursor.ch + seedRight.length }, + list: out, + }; + } + // If no match, try again with a different heuristic: valid hints are + // those matching left seed, not matching right seed but right seed is + // found to be a valid hint. This is to take care of cases like: + // + // *$script,redomain=example.org + // ^ + // + cursor position + // + // In such case, [ redirect=, redirect-rule= ] should be returned + // as valid hints. + for ( const hint of hints ) { + const text = hint instanceof Object + ? hint.displayText || hint.text + : hint; + if ( seedLeft.length === 0 ) { continue; } + if ( text.startsWith(seedLeft) === false ) { continue; } + if ( hints.includes(seedRight) === false ) { continue; } + out.push(hint); + } + if ( out.length !== 0 ) { + return { + from: { line: cursor.line, ch: cursor.ch - seedLeft.length }, + to: { line: cursor.line, ch: cursor.ch }, + list: out, + }; + } + // If no match, try again with a different heuristic: valid hints are + // those containing seed as a substring. This is to take care of cases + // like: + // + // *$script,redirect=gif + // ^ + // + cursor position + // + // In such case, [ 1x1.gif, 1x1-transparent.gif ] should be returned + // as valid hints. + for ( const hint of hints ) { + const text = hint instanceof Object + ? hint.displayText || hint.text + : hint; + if ( seedLeft.length === 1 ) { + if ( text.startsWith(seedLeft) === false ) { continue; } + } else if ( text.includes(seed) === false ) { continue; } + out.push(hint); + } + if ( out.length !== 0 ) { + return { + from: { line: cursor.line, ch: cursor.ch - seedLeft.length }, + to: { line: cursor.line, ch: cursor.ch + seedRight.length }, + list: out, + }; + } + // If still no match, try again with a different heuristic: valid hints + // are those containing left seed as a substring. This is to take care + // of cases like: + // + // *$script,redirect=gifdomain=example.org + // ^ + // + cursor position + // + // In such case, [ 1x1.gif, 1x1-transparent.gif ] should be returned + // as valid hints. + for ( const hint of hints ) { + const text = hint instanceof Object + ? hint.displayText || hint.text + : hint; + if ( text.includes(seedLeft) === false ) { continue; } + out.push(hint); + } + if ( out.length !== 0 ) { + return { + from: { line: cursor.line, ch: cursor.ch - seedLeft.length }, + to: { line: cursor.line, ch: cursor.ch }, + list: out, + }; + } + }; + + const getOriginHints = function(cursor, line, suffix = '') { + const beg = cursor.ch; + const matchLeft = /[^,|=~]*$/.exec(line.slice(0, beg)); + const matchRight = /^[^#,|]*/.exec(line.slice(beg)); + if ( matchLeft === null || matchRight === null ) { return; } + const hints = []; + for ( const text of originHints ) { + hints.push(text + suffix); + } + return pickBestHints(cursor, matchLeft[0], matchRight[0], hints); + }; + + const getNetPatternHints = function(cursor, line) { + if ( /\|\|[\w.-]*$/.test(line.slice(0, cursor.ch)) ) { + return getOriginHints(cursor, line, '^'); + } + // Maybe a static extended filter is meant to be crafted. + if ( /[^\w\x80-\xF4#,.-]/.test(line) === false ) { + return getOriginHints(cursor, line); + } + }; + + const getNetOptionHints = function(cursor, seedLeft, seedRight) { + const isNegated = seedLeft.startsWith('~'); + if ( isNegated ) { + seedLeft = seedLeft.slice(1); + } + const assignPos = seedRight.indexOf('='); + if ( assignPos !== -1 ) { seedRight = seedRight.slice(0, assignPos); } + const isException = astParser.isException(); + const hints = []; + for ( let [ text, desc ] of sfp.netOptionTokenDescriptors ) { + if ( excludedHints.has(text) ) { continue; } + if ( isNegated && desc.canNegate !== true ) { continue; } + if ( isException ) { + if ( desc.blockOnly ) { continue; } + } else { + if ( desc.allowOnly ) { continue; } + if ( (assignPos === -1) && desc.mustAssign ) { + text += '='; + } + } + hints.push(text); + } + return pickBestHints(cursor, seedLeft, seedRight, hints); + }; + + const getNetRedirectHints = function(cursor, seedLeft, seedRight) { + const hints = []; + for ( const text of redirectNames.keys() ) { + if ( text.startsWith('abp-resource:') ) { continue; } + hints.push(text); + } + return pickBestHints(cursor, seedLeft, seedRight, hints); + }; + + const getNetHints = function(cursor, line) { + const patternNode = astParser.getBranchFromType(sfp.NODE_TYPE_NET_PATTERN_RAW); + if ( patternNode === 0 ) { return; } + const patternEnd = astParser.getNodeStringEnd(patternNode); + const beg = cursor.ch; + if ( beg <= patternEnd ) { + return getNetPatternHints(cursor, line); + } + const lineBefore = line.slice(0, beg); + const lineAfter = line.slice(beg); + let matchLeft = /[^$,]*$/.exec(lineBefore); + let matchRight = /^[^,]*/.exec(lineAfter); + if ( matchLeft === null || matchRight === null ) { return; } + const assignPos = matchLeft[0].indexOf('='); + if ( assignPos === -1 ) { + return getNetOptionHints(cursor, matchLeft[0], matchRight[0]); + } + if ( /^(redirect(-rule)?|rewrite)=/.test(matchLeft[0]) ) { + return getNetRedirectHints( + cursor, + matchLeft[0].slice(assignPos + 1), + matchRight[0] + ); + } + if ( /^(domain|from)=/.test(matchLeft[0]) ) { + return getOriginHints(cursor, line); + } + }; + + const getExtSelectorHints = function(cursor, line) { + const beg = cursor.ch; + // Special selector case: `^responseheader` + { + const match = /#\^([a-z]+)$/.exec(line.slice(0, beg)); + if ( + match !== null && + 'responseheader'.startsWith(match[1]) && + line.slice(beg) === '' + ) { + return pickBestHints( + cursor, + match[1], + '', + [ 'responseheader()' ] + ); + } + } + // Procedural operators + const matchLeft = /#\^?.*:([^:]*)$/.exec(line.slice(0, beg)); + const matchRight = /^([a-z-]*)\(?/.exec(line.slice(beg)); + if ( matchLeft === null || matchRight === null ) { return; } + const isStaticDOM = matchLeft[0].indexOf('^') !== -1; + const hints = []; + for ( let [ text, bits ] of proceduralOperatorNames ) { + if ( isStaticDOM && (bits & 0b10) !== 0 ) { continue; } + hints.push(text); + } + return pickBestHints(cursor, matchLeft[1], matchRight[1], hints); + }; + + const getExtHeaderHints = function(cursor, line) { + const beg = cursor.ch; + const matchLeft = /#\^responseheader\((.*)$/.exec(line.slice(0, beg)); + const matchRight = /^([^)]*)/.exec(line.slice(beg)); + if ( matchLeft === null || matchRight === null ) { return; } + const hints = []; + for ( const hint of sfp.removableHTTPHeaders ) { + hints.push(hint); + } + return pickBestHints(cursor, matchLeft[1], matchRight[1], hints); + }; + + const getExtScriptletHints = function(cursor, line) { + const beg = cursor.ch; + const matchLeft = /#\+\js\(([^,]*)$/.exec(line.slice(0, beg)); + const matchRight = /^([^,)]*)/.exec(line.slice(beg)); + if ( matchLeft === null || matchRight === null ) { return; } + const hints = []; + for ( const [ text, displayText ] of scriptletNames ) { + const hint = { text }; + if ( displayText !== '' ) { + hint.displayText = displayText; + } + hints.push(hint); + } + return pickBestHints(cursor, matchLeft[1], matchRight[1], hints); + }; + + const getCommentHints = function(cursor, line) { + const beg = cursor.ch; + if ( line.startsWith('!#if ') ) { + const matchLeft = /^!#if !?(\w*)$/.exec(line.slice(0, beg)); + const matchRight = /^\w*/.exec(line.slice(beg)); + if ( matchLeft === null || matchRight === null ) { return; } + return pickBestHints( + cursor, + matchLeft[1], + matchRight[0], + preparseDirectiveHints + ); + } + if ( line.startsWith('!#') && line !== '!#endif' ) { + const matchLeft = /^!#(\w*)$/.exec(line.slice(0, beg)); + const matchRight = /^\w*/.exec(line.slice(beg)); + if ( matchLeft === null || matchRight === null ) { return; } + const hints = [ 'if ', 'endif\n', 'include ' ]; + return pickBestHints(cursor, matchLeft[1], matchRight[0], hints); + } + }; + + CodeMirror.registerHelper('hint', 'ubo-static-filtering', function(cm) { + const cursor = cm.getCursor(); + const line = cm.getLine(cursor.line); + astParser.parse(line); + if ( astParser.isExtendedFilter() ) { + const anchorNode = astParser.getBranchFromType(sfp.NODE_TYPE_EXT_OPTIONS_ANCHOR); + if ( anchorNode === 0 ) { return; } + let hints; + if ( cursor.ch <= astParser.getNodeStringBeg(anchorNode) ) { + hints = getOriginHints(cursor, line); + } else if ( astParser.isScriptletFilter() ) { + hints = getExtScriptletHints(cursor, line); + } else if ( astParser.isResponseheaderFilter() ) { + hints = getExtHeaderHints(cursor, line); + } else { + hints = getExtSelectorHints(cursor, line); + } + return hints; + } + if ( astParser.isNetworkFilter() ) { + return getNetHints(cursor, line); + } + if ( astParser.isComment() ) { + return getCommentHints(cursor, line); + } + return getOriginHints(cursor, line); + }); +} + +/******************************************************************************/ + +CodeMirror.registerHelper('fold', 'ubo-static-filtering', (( ) => { + const foldIfEndif = function(startLineNo, startLine, cm) { + const lastLineNo = cm.lastLine(); + let endLineNo = startLineNo; + let depth = 1; + while ( endLineNo < lastLineNo ) { + endLineNo += 1; + const line = cm.getLine(endLineNo); + if ( line.startsWith('!#endif') ) { + depth -= 1; + if ( depth === 0 ) { + return { + from: CodeMirror.Pos(startLineNo, startLine.length), + to: CodeMirror.Pos(endLineNo, 0) + }; + } + } + if ( line.startsWith('!#if') ) { + depth += 1; + } + } + }; + + const foldInclude = function(startLineNo, startLine, cm) { + const lastLineNo = cm.lastLine(); + let endLineNo = startLineNo + 1; + if ( endLineNo >= lastLineNo ) { return; } + if ( cm.getLine(endLineNo).startsWith('! >>>>>>>> ') === false ) { + return; + } + while ( endLineNo < lastLineNo ) { + endLineNo += 1; + const line = cm.getLine(endLineNo); + if ( line.startsWith('! <<<<<<<< ') ) { + return { + from: CodeMirror.Pos(startLineNo, startLine.length), + to: CodeMirror.Pos(endLineNo, line.length) + }; + } + } + }; + + return function(cm, start) { + const startLineNo = start.line; + const startLine = cm.getLine(startLineNo); + if ( startLine.startsWith('!#if') ) { + return foldIfEndif(startLineNo, startLine, cm); + } + if ( startLine.startsWith('!#include ') ) { + return foldInclude(startLineNo, startLine, cm); + } + }; +})()); + +/******************************************************************************/ + +// Linter + +{ + const astParser = new sfp.AstFilterParser({ + interactive: true, + nativeCssHas: vAPI.webextFlavor.env.includes('native_css_has'), + }); + + const changeset = []; + let changesetTimer; + + const includeset = new Set(); + let errorCount = 0; + + const ifendifSet = new Set(); + let ifendifSetChanged = false; + + const extractMarkerDetails = (doc, lineHandle) => { + if ( astParser.isUnsupported() ) { + return { lint: 'error', msg: 'Unsupported filter syntax' }; + } + if ( astParser.hasError() ) { + let msg = 'Invalid filter'; + switch ( astParser.astError ) { + case sfp.AST_ERROR_UNSUPPORTED: + msg = `${msg}: Unsupported filter syntax`; + break; + case sfp.AST_ERROR_REGEX: + msg = `${msg}: Bad regular expression`; + break; + case sfp.AST_ERROR_PATTERN: + msg = `${msg}: Bad pattern`; + break; + case sfp.AST_ERROR_DOMAIN_NAME: + msg = `${msg}: Bad domain name`; + break; + case sfp.AST_ERROR_OPTION_BADVALUE: + msg = `${msg}: Bad value assigned to a valid option`; + break; + case sfp.AST_ERROR_OPTION_DUPLICATE: + msg = `${msg}: Duplicate filter option`; + break; + case sfp.AST_ERROR_OPTION_UNKNOWN: + msg = `${msg}: Unsupported filter option`; + break; + case sfp.AST_ERROR_IF_TOKEN_UNKNOWN: + msg = `${msg}: Unknown preparsing token`; + break; + case sfp.AST_ERROR_UNTRUSTED_SOURCE: + msg = `${msg}: Filter requires trusted source`; + break; + default: + if ( astParser.isCosmeticFilter() && astParser.result.error ) { + msg = `${msg}: ${astParser.result.error}`; + } + break; + } + return { lint: 'error', msg }; + } + if ( astParser.astType !== sfp.AST_TYPE_COMMENT ) { return; } + if ( astParser.astTypeFlavor !== sfp.AST_TYPE_COMMENT_PREPARSER ) { + if ( astParser.raw.startsWith('! <<<<<<<< ') === false ) { return; } + for ( const include of includeset ) { + if ( astParser.raw.endsWith(include) === false ) { continue; } + includeset.delete(include); + return { lint: 'include-end' }; + } + return; + } + if ( /^\s*!#if \S+/.test(astParser.raw) ) { + return { + lint: 'if-start', + data: { + state: sfp.utils.preparser.evaluateExpr( + astParser.getTypeString(sfp.NODE_TYPE_PREPARSE_DIRECTIVE_IF_VALUE), + preparseDirectiveEnv + ) ? 'y' : 'n' + } + }; + } + if ( /^\s*!#endif\b/.test(astParser.raw) ) { + return { lint: 'if-end' }; + } + const match = /^\s*!#include\s*(\S+)/.exec(astParser.raw); + if ( match === null ) { return; } + const nextLineHandle = doc.getLineHandle(lineHandle.lineNo() + 1); + if ( nextLineHandle === undefined ) { return; } + if ( nextLineHandle.text.startsWith('! >>>>>>>> ') === false ) { return; } + const includeToken = `/${match[1]}`; + if ( nextLineHandle.text.endsWith(includeToken) === false ) { return; } + includeset.add(includeToken); + return { lint: 'include-start' }; + }; + + const extractMarker = lineHandle => { + const markers = lineHandle.gutterMarkers || null; + return markers !== null + ? markers['CodeMirror-lintgutter'] || null + : null; + }; + + const markerTemplates = { + 'error': { + node: null, + html: [ + '<div class="CodeMirror-lintmarker" data-lint="error" data-error="y"> ', + '<span class="msg"></span>', + '</div>', + ], + }, + 'if-start': { + node: null, + html: [ + '<div class="CodeMirror-lintmarker" data-lint="if" data-fold="start" data-state=""> ', + '<svg viewBox="0 0 100 100">', + '<polygon points="0,0 100,0 50,100" />', + '</svg>', + '<span class="msg">Mismatched if-endif directive</span>', + '</div>', + ], + }, + 'if-end': { + node: null, + html: [ + '<div class="CodeMirror-lintmarker" data-lint="if" data-fold="end"> ', + '<svg viewBox="0 0 100 100">', + '<polygon points="50,0 100,100 0,100" />', + '</svg>', + '<span class="msg">Mismatched if-endif directive</span>', + '</div>', + ], + }, + 'include-start': { + node: null, + html: [ + '<div class="CodeMirror-lintmarker" data-lint="include" data-fold="start"> ', + '<svg viewBox="0 0 100 100">', + '<polygon points="0,0 100,0 50,100" />', + '</svg>', + '</div>', + ], + }, + 'include-end': { + node: null, + html: [ + '<div class="CodeMirror-lintmarker" data-lint="include" data-fold="end"> ', + '<svg viewBox="0 0 100 100">', + '<polygon points="50,0 100,100 0,100" />', + '</svg>', + '</div>', + ], + }, + }; + + const markerFromTemplate = details => { + const template = markerTemplates[details.lint]; + if ( template.node === null ) { + const domParser = new DOMParser(); + const doc = domParser.parseFromString(template.html.join(''), 'text/html'); + template.node = document.adoptNode(qs$(doc, '.CodeMirror-lintmarker')); + } + const node = template.node.cloneNode(true); + if ( details.data instanceof Object ) { + for ( const [ k, v ] of Object.entries(details.data) ) { + node.dataset[k] = `${v}`; + } + } + return node; + }; + + const addMarker = (doc, lineHandle, marker, details) => { + if ( marker && marker.dataset.lint !== details.lint ) { + doc.setGutterMarker(lineHandle, 'CodeMirror-lintgutter', null); + if ( marker.dataset.error === 'y' ) { + errorCount -= 1; + } + if ( marker.dataset.lint === 'if' ) { + ifendifSet.delete(lineHandle); + ifendifSetChanged = true; + } + marker = null; + } + if ( marker === null ) { + marker = markerFromTemplate(details); + doc.setGutterMarker(lineHandle, 'CodeMirror-lintgutter', marker); + if ( marker.dataset.error === 'y' ) { + errorCount += 1; + } + if ( marker.dataset.lint === 'if' ) { + ifendifSet.add(lineHandle); + ifendifSetChanged = true; + } + } + if ( typeof details.msg !== 'string' || details.msg === '' ) { return; } + const msgElem = qs$(marker, '.msg'); + if ( msgElem === null ) { return; } + msgElem.textContent = details.msg; + }; + + const removeMarker = (doc, lineHandle, marker) => { + doc.setGutterMarker(lineHandle, 'CodeMirror-lintgutter', null); + if ( marker.dataset.error === 'y' ) { + errorCount -= 1; + } + if ( marker.dataset.lint === 'if' ) { + ifendifSet.delete(lineHandle); + ifendifSetChanged = true; + } + }; + + // Analyze whether all if-endif are properly paired + const processIfendifs = ( ) => { + if ( ifendifSet.size === 0 ) { return; } + if ( ifendifSetChanged !== true ) { return; } + const sortFn = (a, b) => a.lineNo() - b.lineNo(); + const sorted = Array.from(ifendifSet).sort(sortFn); + const bad = []; + const stack = []; + for ( const line of sorted ) { + const marker = extractMarker(line); + const fold = marker.dataset.fold; + if ( fold === 'start' ) { + stack.push(line); + } else if ( fold === 'end' ) { + if ( stack.length !== 0 ) { + if ( marker.dataset.error === 'y' ) { + marker.dataset.error = ''; + errorCount -= 1; + } + const ifstart = extractMarker(stack.pop()); + if ( ifstart.dataset.error === 'y' ) { + ifstart.dataset.error = ''; + errorCount -= 1; + } + } else { + bad.push(line); + } + } + } + bad.push(...stack); + for ( const line of bad ) { + const marker = extractMarker(line); + marker.dataset.error = 'y'; + errorCount += 1; + } + ifendifSetChanged = false; + }; + + const processDeletion = (doc, change) => { + let { from, to } = change; + doc.eachLine(from.line, to.line, lineHandle => { + const marker = extractMarker(lineHandle); + if ( marker === null ) { return; } + if ( marker.dataset.error === 'y' ) { + marker.dataset.error = ''; + errorCount -= 1; + } + ifendifSet.delete(lineHandle); + ifendifSetChanged = true; + }); + }; + + const processInsertion = (doc, deadline, change) => { + let { from, to } = change; + doc.eachLine(from, to, lineHandle => { + astParser.parse(lineHandle.text); + const markerDetails = extractMarkerDetails(doc, lineHandle); + const marker = extractMarker(lineHandle); + if ( markerDetails !== undefined ) { + addMarker(doc, lineHandle, marker, markerDetails); + } else if ( marker !== null ) { + removeMarker(doc, lineHandle, marker); + } + from += 1; + if ( (from & 0x0F) !== 0 ) { return; } + if ( deadline.timeRemaining() !== 0 ) { return; } + return true; + }); + if ( from !== to ) { + return { from, to }; + } + }; + + const processChangeset = (doc, deadline) => { + const cm = doc.getEditor(); + cm.startOperation(); + while ( changeset.length !== 0 ) { + const change = processInsertion(doc, deadline, changeset.shift()); + if ( change === undefined ) { continue; } + changeset.unshift(change); + break; + } + cm.endOperation(); + if ( changeset.length !== 0 ) { + return processChangesetAsync(doc); + } + includeset.clear(); + processIfendifs(doc); + CodeMirror.signal(doc.getEditor(), 'linterDone', { errorCount }); + }; + + const processChangesetAsync = doc => { + if ( changesetTimer !== undefined ) { return; } + changesetTimer = self.requestIdleCallback(deadline => { + changesetTimer = undefined; + processChangeset(doc, deadline); + }); + }; + + const onChanges = (cm, changes) => { + if ( changes.length === 0 ) { return; } + const doc = cm.getDoc(); + for ( const change of changes ) { + const from = change.from.line; + const to = from + change.text.length; + changeset.push({ from, to }); + } + processChangesetAsync(doc); + }; + + const onBeforeChanges = (cm, change) => { + const doc = cm.getDoc(); + processDeletion(doc, change); + }; + + const foldRangeFinder = (cm, from) => { + const lineNo = from.line; + const lineHandle = cm.getDoc().getLineHandle(lineNo); + const marker = extractMarker(lineHandle); + if ( marker === null ) { return; } + if ( marker.dataset.fold === undefined ) { return; } + const foldName = marker.dataset.lint; + from.ch = lineHandle.text.length; + const to = { line: 0, ch: 0 }; + const doc = cm.getDoc(); + let depth = 0; + doc.eachLine(from.line, doc.lineCount(), lineHandle => { + const marker = extractMarker(lineHandle); + if ( marker === null ) { return; } + if ( marker.dataset.lint === foldName && marker.dataset.fold === 'start' ) { + depth += 1; + return; + } + if ( marker.dataset.lint !== foldName ) { return; } + if ( marker.dataset.fold !== 'end' ) { return; } + depth -= 1; + if ( depth !== 0 ) { return; } + to.line = lineHandle.lineNo(); + return true; + }); + return { from, to }; + }; + + const onGutterClick = (cm, lineNo, gutterId, ev) => { + if ( ev.button !== 0 ) { return; } + if ( gutterId !== 'CodeMirror-lintgutter' ) { return; } + const doc = cm.getDoc(); + const lineHandle = doc.getLineHandle(lineNo); + const marker = extractMarker(lineHandle); + if ( marker === null ) { return; } + if ( marker.dataset.fold === 'start' ) { + if ( ev.ctrlKey ) { + if ( dom.cl.has(marker, 'folded') ) { + CodeMirror.commands.unfoldAll(cm); + } else { + CodeMirror.commands.foldAll(cm); + } + doc.setCursor(lineNo); + return; + } + cm.foldCode(lineNo, { + widget: '\u00A0\u22EF\u00A0', + rangeFinder: foldRangeFinder, + }); + return; + } + if ( marker.dataset.fold === 'end' ) { + let depth = 1; + let lineNo = lineHandle.lineNo(); + while ( lineNo-- ) { + const prevLineHandle = doc.getLineHandle(lineNo); + const markerFrom = extractMarker(prevLineHandle); + if ( markerFrom === null ) { continue; } + if ( markerFrom.dataset.fold === 'end' ) { + depth += 1; + } else if ( markerFrom.dataset.fold === 'start' ) { + depth -= 1; + if ( depth === 0 ) { + doc.setCursor(lineNo); + break; + } + } + } + return; + } + }; + + self.addEventListener('trustedSource', ev => { + astParser.options.trustedSource = ev.detail; + }); + + self.addEventListener('trustedScriptletTokens', ev => { + astParser.options.trustedScriptletTokens = ev.detail; + }); + + CodeMirror.defineInitHook(cm => { + cm.on('changes', onChanges); + cm.on('beforeChange', onBeforeChanges); + cm.on('gutterClick', onGutterClick); + cm.on('fold', function(cm, from) { + const doc = cm.getDoc(); + const lineHandle = doc.getLineHandle(from.line); + const marker = extractMarker(lineHandle); + dom.cl.add(marker, 'folded'); + }); + cm.on('unfold', function(cm, from) { + const doc = cm.getDoc(); + const lineHandle = doc.getLineHandle(from.line); + const marker = extractMarker(lineHandle); + dom.cl.remove(marker, 'folded'); + }); + }); +} + +/******************************************************************************/ + +// Enhanced word selection + +{ + const selectWordAt = function(cm, pos) { + const { line, ch } = pos; + const s = cm.getLine(line); + const { type: token } = cm.getTokenAt(pos); + let beg, end; + + // Select URL in comments + if ( /\bcomment\b/.test(token) && /\blink\b/.test(token) ) { + const l = /\S+$/.exec(s.slice(0, ch)); + if ( l && /^https?:\/\//.test(s.slice(l.index)) ) { + const r = /^\S+/.exec(s.slice(ch)); + if ( r ) { + beg = l.index; + end = ch + r[0].length; + } + } + } + + // Better word selection for extended filters: prefix + else if ( + /\bline-cm-ext-(?:dom|html|js)\b/.test(token) && + /\bvalue\b/.test(token) + ) { + const l = /[^,.]*$/i.exec(s.slice(0, ch)); + const r = /^[^#,]*/i.exec(s.slice(ch)); + if ( l && r ) { + beg = l.index; + end = ch + r[0].length; + } + } + + // Better word selection for cosmetic and HTML filters: suffix + else if ( /\bline-cm-ext-(?:dom|html)\b/.test(token) ) { + const l = /[#.]?[a-z0-9_-]+$/i.exec(s.slice(0, ch)); + const r = /^[a-z0-9_-]+/i.exec(s.slice(ch)); + if ( l && r ) { + beg = l.index; + end = ch + r[0].length; + if ( /\bdef\b/.test(cm.getTokenTypeAt({ line, ch: beg + 1 })) ) { + beg += 1; + } + } + } + + // Better word selection for network filters + else if ( /\bline-cm-net\b/.test(token) ) { + if ( /\bvalue\b/.test(token) ) { + const l = /[^ ,.=|]*$/i.exec(s.slice(0, ch)); + const r = /^[^ #,|]*/i.exec(s.slice(ch)); + if ( l && r ) { + beg = l.index; + end = ch + r[0].length; + } + } else if ( /\bdef\b/.test(token) ) { + const l = /[a-z0-9-]+$/i.exec(s.slice(0, ch)); + const r = /^[^,]*=[^,]+/i.exec(s.slice(ch)); + if ( l && r ) { + beg = l.index; + end = ch + r[0].length; + } + } + } + + if ( beg === undefined ) { + const { anchor, head } = cm.findWordAt(pos); + return { from: anchor, to: head }; + } + + return { + from: { line, ch: beg }, + to: { line, ch: end }, + }; + }; + + CodeMirror.defineInitHook(cm => { + cm.setOption('configureMouse', function(cm, repeat) { + return { + unit: repeat === 'double' ? selectWordAt : null, + }; + }); + }); +} + +/******************************************************************************/ |