I created a syntax highlighter in JavaScript. The language it highlights is AdBlock Filter Syntax, the language used to write filters for ad blocker extensions in browsers.
Shown here is the AdBlockSyntaxLine Class, which is the core class that dices a line of text into categories. These categories are used to do highlighting later.
The entire project can be found on my GitHub. And here is a link to the live version.
Screenshot
AdBlockSyntaxLine
"use strict";
import { Helper } from './Helper.js';
export class AdBlockSyntaxLine {
string = "";
toParse = "";
syntax = {
'uboPreParsingDirective': '', // !#
'agHint': '', // !+
'comment': '', // !
'exception': '', // @@
'exceptionRegEx': '', // @@/regex/
'domainRegEx': '', // /regex/
'domain': '',
'option': '', // $
'selectorException': '', // #@#
'selector': '', // ##
'htmlFilter': '', // ##^
'htmlFilterException': '', // #@#^
'abpExtendedSelector': '', // #?#
'uboScriptlet': '', // ##+js()
'uboScriptletException': '', // #@#+js()
'abpSnippet': '', // #$#
'actionOperator': '', // :style() :remove()
};
isValid = "not sure";
errorHint = "";
constructor(s) {
this.string = s;
this.toParse = this.string;
try {
this._categorizeSyntax();
} catch(e) {
// only catch what we want, let actual errors throw to console
if ( e === true || e === false || e === "not sure" ) {
this.isValid = e;
} else {
throw e;
}
}
if ( this.isValid !== true ) {
try {
this._lookForErrors();
} catch(e) {
// only catch what we want, let actual errors throw to console
if ( e === true || e === false || e === "not sure" ) {
this.isValid = e;
} else {
throw e;
}
}
}
this._lookForMismatch();
}
_lookForErrors() {
// no spaces in domains or domain regex
if ( this.syntax['domainRegEx'] && this.syntax['domainRegEx'].search(/ /g) !== -1 ) {
this.errorHint = "no spaces allowed in domains, exceptions, domainRegEx, or exceptionRegEx";
throw false;
}
if ( this.syntax['domain'] && this.syntax['domain'].search(/ /g) !== -1 ) {
this.errorHint = "no spaces allowed in domains, exceptions, domainRegEx, or exceptionRegEx";
throw false;
}
if ( this.syntax['exceptionRegEx'] && this.syntax['exceptionRegEx'].search(/ /g) !== -1 ) {
this.errorHint = "no spaces allowed in domains, exceptions, domainRegEx, or exceptionRegEx";
throw false;
}
if ( this.syntax['exception'] && this.syntax['exception'].search(/ /g) !== -1 ) {
this.errorHint = "no spaces allowed in domains, exceptions, domainRegEx, or exceptionRegEx";
throw false;
}
// Delete regex. Regex is allowed to contain our special chars. When we do our searches, we don't want to get false positives.
let s = this.string;
s = s.replace(/^\/.*?[^\\]\//g, '');
s = s.replace(/^@@\/.*?[^\\]\//g, '@@');
// look for double selectors $ #@# ## ##^ #@#^ #?# ##+js( #@#+js( #$#
// had to take out $, too many false positives, it's used in CSS and +js()
let count = Helper.countRegExMatches(s, /\#@#|##|##\^|#@#\^|#\?#|##\+js\(|#@#\+js\(|#\$#/);
if ( count > 1 ) {
this.errorHint = "selector-ish syntax $ #@# ## ##^ #@#^ #?# ##+js( #@#+js( #$# is only allowed once per filter";
throw false;
}
// look for double actionOperators
count = Helper.countRegExMatches(s, /:style\(|:remove\(/);
if ( count > 1 ) {
this.errorHint = "actionOperators :style() :remove() are only allowed once per filter";
throw false;
}
// actionOperators must be paired with a domain
let domainPresent = (
this.syntax['domain'] ||
this.syntax['exception'] ||
this.syntax['domainRegEx'] ||
this.syntax['exceptionRegEx']
);
if ( this.syntax['actionOperator'] && ! domainPresent ) {
this.errorHint = "actionOperators :style() :remove() must be used with a URL";
throw false;
}
// actionOperators not allowed to be paired with ##+js( #@#+js( #$# $
// TODO: probably also need to ban pairing with #@#|##|##^|#@#^|#?#| but so far :style() passes ubo validator, :remove() fails
let bannedSyntaxPresent = (
this.syntax['uboScriptlet'] ||
this.syntax['uboScriptletException'] ||
this.syntax['abpSnippet'] ||
this.syntax['option']
);
let countActionOperators = Helper.countRegExMatches(s, /:style\(|:remove\(/);
if ( bannedSyntaxPresent && countActionOperators ) {
this.errorHint = "actionOperators :style() :remove() cannot be used with ##+js( #@#+js( #$# $";
throw false;
}
// @@exceptions may not contain any selectors except options
count = Helper.countRegExMatches(s, /\#@#|##|##\^|#@#\^|#\?#|##\+js\(|#@#\+js\(|#\$#|:style\(|:remove\(/);
let exception = ( this.syntax['exception'] || this.syntax['exceptionRegEx'] );
if ( exception && count ) {
this.errorHint = "@@ statements may not contain selector-ish syntax $ #@# ## ##^ #@#^ #?# ##+js( #@#+js( #$# or action operators :style() :remove()"
throw false;
}
// ##+js() #@#+js() :style() :remove() must end in )
let lastChar = s.right(1);
let shouldEndInParenthesis = ( this.syntax['uboScriptlet'] || this.syntax['uboScriptletException'] || this.syntax['actionOperator'] );
if ( shouldEndInParenthesis && lastChar !== ')' ) {
this.errorHint = "##+js() #@#+js() :style() :remove() must end in )"
throw false;
}
}
/** Takes the values in the this.syntax array and builds them into a string. Then makes sure that string matches the input string. If these don't match, this is a pretty sure sign there's a bug. */
_lookForMismatch() {
let lineString = "";
for ( let key in this.syntax ) {
lineString += this.syntax[key];
}
if ( lineString !== this.string ) {
this.isValid = "mismatch";
}
}
/** dice syntax string up into categories: comment !, exception @@, domain, option $, selectorException #@#, selector ##, abpExtendedSelector #?#, actionoperator :style(), abpSnippet #$#, etc. */
_categorizeSyntax() {
this._lookForComments();
this._lookForDomains();
// lookForActionOperators needs to come before lookForSelectors, even though actionOperators appear after selectors in the string.
this._lookForActionOperators();
this._lookForSelectors();
}
_lookForComments() {
// uboPreParsingDirective !#
if ( this.toParse.left(2) === "!#" ) {
this.syntax['uboPreParsingDirective'] = this.string;
throw "not sure";
}
// agHint !+
if ( this.toParse.left(2) === "!+" ) {
this.syntax['agHint'] = this.string;
throw "not sure";
}
// comment ! [
if ( this.string.left(1) === '!' || this.string.left(1) === '[' ) {
this.syntax['comment'] = this.string;
throw true;
}
}
_lookForDomains() {
// domainRegEx /regex/
let matchPos = this.toParse.search(/^\/.*?[^\\]\//);
let regExLookingStringFound = (matchPos !== -1);
let toParse = this.toParse.replace(/^\/.*?[^\\]\//, '');
let regEx = this.toParse.left(this.toParse.length - toParse.length);
let selectorAfterRegEx = (toParse.search(/^(\$|#@#|##|##\^|#@#\^|#\?#|##\+js\(|#@#\+js\(|#\$#)/) !== -1);
let nothingAfterRegEx = (toParse.length === 0);
if ( regExLookingStringFound && (selectorAfterRegEx || nothingAfterRegEx) ) {
this.syntax['domainRegEx'] = regEx;
this.toParse = toParse;
return;
}
// exceptionRegEx @@/regex/
matchPos = this.toParse.search(/^@@\/.*?[^\\]\//);
regExLookingStringFound = (matchPos !== -1);
toParse = this.toParse.replace(/^@@\/.*?[^\\]\//, '');
regEx = this.toParse.left(this.toParse.length - toParse.length);
selectorAfterRegEx = (toParse.search(/^(\$|#@#|##|##\^|#@#\^|#\?#|##\+js\(|#@#\+js\(|#\$#)/) !== -1);
nothingAfterRegEx = (toParse.length === 0);
if ( regExLookingStringFound && (selectorAfterRegEx || nothingAfterRegEx) ) {
this.syntax['domainRegEx'] = regEx;
this.toParse = toParse;
return;
}
// exception @@
let domainException = false;
if ( this.string.left(2) === '@@' ) {
domainException = true;
}
// domain
// parse until $ #@# ## #?# #$#
// str.search returns first position, when searching from left to right (good)
matchPos = this.toParse.search(/#@#|##|#\?#|#\$#|\$/);
// if no categories after the domain
if ( matchPos === -1 ) {
this.syntax['domain'] = this.toParse;
this.toParse = '';
} else {
this.syntax['domain'] = this.toParse.left(matchPos);
this.toParse = this.toParse.slice(matchPos);
}
// exception @@ must have a domain
if ( domainException && ! this.syntax['domain'] ) {
this.errorHint = "exception @@ must have a domain";
throw false;
}
// exception @@
if ( domainException ) {
this.syntax['exception'] = this.syntax['domain'];
this.syntax['domain'] = "";
}
}
_lookForSelectors() {
// option $ (example: image)
if ( this.toParse.left(1) === '$' ) {
this.syntax['option'] = this.toParse;
// OK to have nothing before it
// Nothing allowed after it
throw "not sure";
}
// abpSnippet #$# (example: log hello world!)
if ( this.toParse.left(3) === "#$#" ) {
this.syntax['abpSnippet'] = this.toParse;
// Nothing allowed after it
throw "not sure";
}
// uboScriptletException #@#+js(
if ( this.toParse.left(7) === "#@#+js(" ) {
this.syntax['uboScriptletException'] = this.toParse;
// Nothing allowed after it
throw "not sure";
}
// uboScriptlet ##+js(
if ( this.toParse.left(6) === "##+js(" ) {
this.syntax['uboScriptlet'] = this.toParse;
// per ublock documentation, example.com##+js() when js() is empty is an error
if ( this.syntax['uboScriptlet'] === "##+js()" ) {
this.errorHint = "per ublock documentation, example.com##+js() when js() is empty is an error";
throw false;
}
// Nothing allowed after it
throw "not sure";
}
// htmlFilter ##^
if ( this.toParse.left(3) === "##^" ) {
this.syntax['htmlFilter'] = this.toParse;
return;
}
// htmlFilterException #@#^
if ( this.toParse.left(4) === "#@#^" ) {
this.syntax['htmlFilterException'] = this.toParse;
return;
}
// selectorException #@#
if ( this.toParse.left(3) === "#@#" ) {
this.syntax['selectorException'] = this.toParse;
return;
}
// selector ##
if ( this.toParse.left(2) === "##" ) {
this.syntax['selector'] = this.toParse;
return;
}
// abpExtendedSelector #?#
if ( this.toParse.left(3) === "#?#" ) {
this.syntax['abpExtendedSelector'] = this.toParse;
return;
}
}
_lookForActionOperators() {
let matchPos = this.toParse.search(/(:style\(|:remove\().*\)$/);
if ( matchPos !== -1 ) {
this.syntax['actionOperator'] = this.toParse.slice(matchPos);
this.toParse = this.toParse.left(matchPos);
}
}
/** Gets a string with a JSON representation of the syntax categories. Also prints isValid and errorHint. */
getJSON() {
let s = "";
s += "Filter = " + this.string + "\n";
s += "Valid? = " + this.isValid + "\n";
if ( this.errorHint ) {
s += "Error Hint = " + this.errorHint + "\n";
}
s += JSON.stringify(this.syntax);
// add enters after commas
s = s.replace(/",/g, '",\n');
return s;
}
/** Gets a string of the filter syntax, with HTML <span>s wrapped around each category of syntax. These <span>s will be used to highlight the text the correct color in the richTextBox. */
getRichText() {
let richText = "";
let classes = "";
for ( let key in this.syntax ) {
classes = key;
if ( ! this.isValid || this.isValid === "mismatch" ) {
classes += " error";
}
if ( this.syntax[key] ) {
let s = this.syntax[key];
s = Helper.escapeHTML(s);
s = s.replace(/ /g, " ");
richText += '<span class="' + classes + '">' + s + '</span>';
}
}
return richText;
}
}
Helper
"use strict";
export class Helper {
static countRegExMatches(str, regExPattern) {
regExPattern = new RegExp(regExPattern, "g");
return ((str || '').match(regExPattern) || []).length;
}
static escapeHTML(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
}
String.prototype
// TODO: figure out how to move this into its own file and import/export it. Currently, adding "export" to the beginning of it generates an error.
Object.assign(String.prototype, {
/** @description "Testing 123".left(4) = "Test" */
left(length) {
return this.slice(0, length);
},
/** @description "Testing 123".right(3) = "123" */
right(length) {
return this.substr(this.length - length);
},
});
Possible code smells
- Code needs some optimization. Currently gets pretty slow if parsing more than 500 lines. I imagine use of RegEx is slowing things down a bit.
- I don't use const. So far I am not liking JavaScript's insistence that variables be declared, and having to pick between let/const. Seems to require a lot of thought and require a lot of debugging, without providing much benefit.
- If anybody knows how to put string.prototype in its own file and get it to work with the
export
keyword, that'd be awesome. I couldn't figure it out, so I put it in my main file as a workaround. - Try/Catch true/false/"not sure" feels like a code smell. I couldn't think of a better way to
return
across multiple functions though.