diff --git a/src/1p-filters.html b/src/1p-filters.html index f3479e41f..7d5cb9664 100644 --- a/src/1p-filters.html +++ b/src/1p-filters.html @@ -52,10 +52,10 @@ - + - + diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 7c6d804b5..d4e5b5801 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -1265,6 +1265,10 @@ "message": "Click to load", "description": "Message used in frame placeholders" }, + "linterMainReport": { + "message": "Errors: {{count}}", + "description": "Summary of number of errors as reported by the linter " + }, "dummy": { "message": "This entry must be the last one", "description": "so we dont need to deal with comma for last entry" diff --git a/src/asset-viewer.html b/src/asset-viewer.html index d2ff422fd..70858725a 100644 --- a/src/asset-viewer.html +++ b/src/asset-viewer.html @@ -35,10 +35,10 @@ - + - + diff --git a/src/code-viewer.html b/src/code-viewer.html index 36805b328..3f4896df4 100644 --- a/src/code-viewer.html +++ b/src/code-viewer.html @@ -36,10 +36,10 @@ - + - + diff --git a/src/css/codemirror.css b/src/css/codemirror.css index ed6c51254..c3fecb067 100644 --- a/src/css/codemirror.css +++ b/src/css/codemirror.css @@ -164,6 +164,13 @@ -webkit-user-select: none; z-index: 1000; } +.cm-search-widget > * { + flex-grow: 1; + } +.cm-search-widget > :last-child { + justify-content: flex-end; + } + .cm-search-widget-input { display: inline-flex; flex-grow: 1; @@ -196,8 +203,21 @@ color: #000; } .cm-search-widget .sourceURL[href=""] { - display: none; + visibility: hidden; } + +.cm-linter-widget { + display: none; + flex-grow: 1; + } +.cm-linter-widget.hasErrors { + display: initial; + } +.cm-linter-widget .cm-linter-widget-count { + color: var(--accent-surface-1); + fill: var(--accent-surface-1); + } + .cm-searching.cm-overlay { background-color: var(--cm-searching-surface); border: 0; @@ -247,3 +267,27 @@ .CodeMirror-activeline-background { background-color: var(--cm-active-line); } + +.CodeMirror-lintmarker { + background-color: var(--sf-error-ink); + height: calc(var(--font-size) - 2px); + margin-top: 1px; + position: relative; + } +.CodeMirror-lintmarker > span { + display: none; + } +.CodeMirror-lintmarker > span { + background-color: var(--surface-0); + border: 1px solid var(--sf-error-ink); + color: var(--ink-1); + left: 100%; + padding: var(--default-gap-xsmall); + position: absolute; + top: -2px; + white-space: pre; + } +.CodeMirror-lintmarker:hover > span, +.CodeMirror-lintmarker > span:hover { + display: initial; + } diff --git a/src/js/1p-filters.js b/src/js/1p-filters.js index 097ab2d75..c33acfd32 100644 --- a/src/js/1p-filters.js +++ b/src/js/1p-filters.js @@ -37,7 +37,11 @@ const cmEditor = new CodeMirror(qs$('#userFilters'), { 'Tab': 'toggleComment', }, foldGutter: true, - gutters: [ 'CodeMirror-linenumbers', 'CodeMirror-foldgutter' ], + gutters: [ + 'CodeMirror-foldgutter', + 'CodeMirror-linenumbers', + { className: 'CodeMirror-lintgutter', style: 'width: 10px' }, + ], lineNumbers: true, lineWrapping: true, matchBrackets: true, diff --git a/src/js/asset-viewer.js b/src/js/asset-viewer.js index 5331aad74..20f1cff66 100644 --- a/src/js/asset-viewer.js +++ b/src/js/asset-viewer.js @@ -52,7 +52,11 @@ import './codemirror/ubo-static-filtering.js'; const cmEditor = new CodeMirror(qs$('#content'), { autofocus: true, foldGutter: true, - gutters: [ 'CodeMirror-linenumbers', 'CodeMirror-foldgutter' ], + gutters: [ + 'CodeMirror-foldgutter', + 'CodeMirror-linenumbers', + { className: 'CodeMirror-lintgutter', style: 'width: 10px' }, + ], lineNumbers: true, lineWrapping: true, matchBrackets: true, diff --git a/src/js/codemirror/search.js b/src/js/codemirror/search.js index f05f6d2fc..595345bfc 100644 --- a/src/js/codemirror/search.js +++ b/src/js/codemirror/search.js @@ -27,6 +27,9 @@ 'use strict'; +import { dom } from '../dom.js'; +import { i18n$ } from '../i18n.js'; + { const CodeMirror = self.CodeMirror; @@ -101,6 +104,10 @@ 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(); @@ -137,6 +144,7 @@ this.queryTimer = null; this.dirty = true; this.lines = []; + this.errorLines = []; cm.on('changes', (cm, changes) => { for ( const change of changes ) { if ( change.text.length !== 0 || change.removed !== 0 ) { @@ -365,6 +373,26 @@ }); }; + const findNextError = function(cm, dir) { + const state = getSearchState(cm); + const lines = state.errorLines; + if ( lines.length === 0 ) { return; } + const cursor = cm.getCursor('from'); + const start = cursor.line; + const next = lines.reduce((best, v) => { + if ( dir < 0 ) { + if ( v < start && (best === -1 || v > best) ) { return v; } + return best; + } + if ( v > start && (best === -1 || v < best) ) { return v; } + return best; + }, -1); + if ( next === -1 || next === start ) { return; } + cm.getDoc().setCursor(next); + const { clientHeight } = cm.getScrollInfo(); + cm.scrollIntoView({ line: next, ch: 0 }, clientHeight >>> 1); + }; + const clearSearch = function(cm, hard) { cm.operation(function() { const state = getSearchState(cm); @@ -444,6 +472,11 @@ 'angle-up ' + '' + '' + + '' + + ' ' + + 'angle-up ' + + 'angle-up ' + + '' + 'external-link' + '' + ''; @@ -459,5 +492,14 @@ CodeMirror.defineInitHook(function(cm) { getSearchState(cm); + cm.on('linterDone', details => { + const count = details.lines.length; + getSearchState(cm).errorLines = details.lines; + dom.cl.toggle('.cm-linter-widget', 'hasErrors', count !== 0); + dom.text( + '.cm-linter-widget .cm-linter-widget-count', + i18n$('linterMainReport').replace('{{count}}', count.toLocaleString()) + ); + }); }); } diff --git a/src/js/codemirror/ubo-static-filtering.js b/src/js/codemirror/ubo-static-filtering.js index 04e0abafa..5ea175af1 100644 --- a/src/js/codemirror/ubo-static-filtering.js +++ b/src/js/codemirror/ubo-static-filtering.js @@ -39,7 +39,6 @@ let hintHelperRegistered = false; /******************************************************************************/ CodeMirror.defineMode('ubo-static-filtering', function() { - if ( sfp.AstFilterParser instanceof Object === false ) { return; } const astParser = new sfp.AstFilterParser({ interactive: true, nativeCssHas: vAPI.webextFlavor.env.includes('native_css_has'), @@ -300,8 +299,6 @@ CodeMirror.defineMode('ubo-static-filtering', function() { // https://codemirror.net/demo/complete.html const initHints = function() { - if ( sfp.AstFilterParser instanceof Object === false ) { return; } - const astParser = new sfp.AstFilterParser({ interactive: true, nativeCssHas: vAPI.webextFlavor.env.includes('native_css_has'), @@ -666,6 +663,146 @@ CodeMirror.registerHelper('fold', 'ubo-static-filtering', (( ) => { /******************************************************************************/ +// Linter + +{ + const astParser = new sfp.AstFilterParser({ + interactive: true, + nativeCssHas: vAPI.webextFlavor.env.includes('native_css_has'), + }); + + const markedset = []; + let markedsetStart = 0; + let markedsetTimer; + + const processMarkedsetAsync = doc => { + if ( markedsetTimer !== undefined ) { return; } + markedsetTimer = self.requestIdleCallback(deadline => { + markedsetTimer = undefined; + processMarkedset(doc, deadline); + }); + }; + + const processMarkedset = (doc, deadline) => { + const lineCount = doc.lineCount(); + doc.eachLine(markedsetStart, lineCount, lineHandle => { + const line = markedsetStart++; + const markers = lineHandle.gutterMarkers || null; + if ( markers && markers['CodeMirror-lintgutter'] ) { + markedset.push(lineHandle.lineNo()); + } + if ( (line & 0x0F) === 0 && deadline.timeRemaining() === 0 ) { + processMarkedsetAsync(doc); + return true; + } + if ( markedsetStart === lineCount ) { + CodeMirror.signal( + doc.getEditor(), + 'linterDone', + { lines: markedset } + ); + } + }); + }; + + const changeset = []; + let changesetTimer; + + const addChanges = (doc, change) => { + changeset.push(change); + processChangesetAsync(doc); + }; + + const processChangesetAsync = doc => { + if ( changesetTimer !== undefined ) { return; } + if ( markedsetTimer ) { + self.cancelIdleCallback(markedsetTimer); + markedsetTimer = undefined; + } + changesetTimer = self.requestIdleCallback(deadline => { + changesetTimer = undefined; + processChangeset(doc, deadline); + }); + }; + + const extractError = ( ) => { + if ( astParser.isComment() ) { return; } + if ( astParser.isFilter() === false ) { return; } + if ( astParser.hasError() === false ) { return; } + let error = 'Invalid filter'; + if ( astParser.isCosmeticFilter() && astParser.result.error ) { + error = `${error}: ${astParser.result.error}`; + } + return error; + }; + + const extractMarker = lineInfo => { + if ( lineInfo.gutterMarkers instanceof Object === false ) { return; } + return lineInfo.gutterMarkers['CodeMirror-lintgutter']; + }; + + const markerTemplate = (( ) => { + const marker = document.createElement('div'); + marker.classList.add('CodeMirror-lintmarker'); + marker.textContent = '\xA0'; + const info = document.createElement('span'); + marker.append(info); + return marker; + })(); + + const makeMarker = (doc, line, marker, error) => { + if ( marker === undefined ) { + marker = markerTemplate.cloneNode(true); + doc.setGutterMarker(line, 'CodeMirror-lintgutter', marker); + } + marker.children[0].textContent = error; + }; + + const processChangeset = (doc, deadline) => { + const cm = doc.getEditor(); + cm.startOperation(); + while ( changeset.length !== 0 ) { + const { from, to } = changeset.shift(); + for ( let line = from; line < to; line++ ) { + const lineInfo = doc.lineInfo(line); + if ( lineInfo === null ) { continue; } + astParser.parse(lineInfo.text); + const error = extractError(); + let marker = extractMarker(lineInfo); + if ( error === undefined && marker ) { + doc.setGutterMarker(line, 'CodeMirror-lintgutter', null); + } else if ( error !== undefined ) { + makeMarker(doc, line, marker, error); + } + if ( (line & 0x0F) === 0 && deadline.timeRemaining() === 0 ) { + changeset.unshift({ doc, from: line+1, to }); + break; + } + } + } + cm.endOperation(); + if ( changeset.length !== 0 ) { + return processChangesetAsync(doc); + } + markedset.length = 0; + markedsetStart = 0; + processMarkedsetAsync(doc); + }; + + CodeMirror.defineInitHook(cm => { + cm.on('changes', function(cm, changes) { + const doc = cm.getDoc(); + for ( const change of changes ) { + const from = change.from.line; + const to = from + change.text.length; + addChanges(doc, { from, to }); + } + }); + }); +} + +/******************************************************************************/ + // Enhanced word selection { diff --git a/src/js/static-filtering-parser.js b/src/js/static-filtering-parser.js index 28a6033b9..cc9063db7 100644 --- a/src/js/static-filtering-parser.js +++ b/src/js/static-filtering-parser.js @@ -705,7 +705,7 @@ export class AstFilterParser { this.badTypes = new Set(options.badTypes || []); this.maxTokenLength = options.maxTokenLength || 7; // TODO: rethink this - this.result = { exception: false, raw: '', compiled: '' }; + this.result = { exception: false, raw: '', compiled: '', error: undefined }; this.selectorCompiler = new ExtSelectorCompiler(options); // Regexes this.reWhitespaceStart = /^\s+/; @@ -2967,6 +2967,7 @@ class ExtSelectorCompiler { this.nativeCssHas = instanceOptions.nativeCssHas === true; // https://www.w3.org/TR/css-syntax-3/#typedef-ident-token this.reInvalidIdentifier = /^\d/; + this.error = undefined; } compile(raw, out, compileOptions = {}) { @@ -3016,7 +3017,10 @@ class ExtSelectorCompiler { } out.compiled = this.compileSelector(raw); - if ( out.compiled === undefined ) { return false; } + if ( out.compiled === undefined ) { + out.error = this.error || undefined; + return false; + } if ( out.compiled instanceof Object ) { out.compiled.raw = raw; @@ -3060,6 +3064,7 @@ class ExtSelectorCompiler { parseValue: false, }); } catch(reason) { + this.error = reason && reason.message || undefined; return; } const parts = [];