diff --git a/packages/agtree/CHANGELOG.md b/packages/agtree/CHANGELOG.md index 9447d5632c..ebd6c9cf3f 100644 --- a/packages/agtree/CHANGELOG.md +++ b/packages/agtree/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog][keepachangelog], and this project adhe [keepachangelog]: https://keepachangelog.com/en/1.0.0/ [semver]: https://semver.org/spec/v2.0.0.html +## [2.3.0] - 2024-12-18 + +### Added + +- Support for ABP-syntax CSS injection rules [tsurlfilter#143]. + +[2.3.0]: https://github.com/AdguardTeam/tsurlfilter/releases/tag/agtree-v2.3.0 +[tsurlfilter#143]: https://github.com/AdguardTeam/tsurlfilter/issues/143 + ## [2.2.0] - 2024-11-27 ### Removed diff --git a/packages/agtree/src/parser/cosmetic/index.ts b/packages/agtree/src/parser/cosmetic/index.ts index f54fef0606..ad562966af 100644 --- a/packages/agtree/src/parser/cosmetic/index.ts +++ b/packages/agtree/src/parser/cosmetic/index.ts @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ import { sprintf } from 'sprintf-js'; +import { TokenType } from '@adguard/css-tokenizer'; import { CosmeticRuleSeparatorUtils } from '../../utils/cosmetic-rule-separator'; import { AdblockSyntax } from '../../utils/adblockers'; @@ -7,6 +8,8 @@ import { DomainListParser } from '../misc/domain-list'; import { ModifierListParser } from '../misc/modifier-list'; import { ADG_SCRIPTLET_MASK, + CSS_BLOCK_OPEN, + CSS_BLOCK_CLOSE, CLOSE_PARENTHESIS, CLOSE_SQUARE_BRACKET, COLON, @@ -56,6 +59,7 @@ import { type InputByteBuffer } from '../../utils/input-byte-buffer'; import { ValueParser } from '../misc/value'; import { isUndefined } from '../../utils/type-guards'; import { BINARY_SCHEMA_VERSION } from '../../utils/binary-schema-version'; +import { hasToken } from '../css/has-token'; /** * Value map for binary serialization. This helps to reduce the size of the serialized data, @@ -535,6 +539,37 @@ export class CosmeticRuleParser extends ParserBase { }; }; + /** + * Parses Adb CSS injection rules + * eg: example.com##.foo { display: none; } + * + * @returns parsed rule + */ + const parseAbpCssInjection = (): Pick | null => { + if (!options.parseAbpSpecificRules) { + return null; + } + + // check if the rule contains both CSS block open and close characters + // if none of them is present we can stop parsing + if (rawBody.indexOf(CSS_BLOCK_OPEN) === -1 && rawBody.indexOf(CSS_BLOCK_CLOSE) === -1) { + return null; + } + + if (!hasToken(rawBody, new Set([TokenType.OpenCurlyBracket, TokenType.CloseCurlyBracket]))) { + return null; + } + + // try to parse the raw body as an AdGuard CSS injection rule + const body = AdgCssInjectionParser.parse(rawBody, options, baseOffset + bodyStart); + // if the parsed rule type is a 'CssInjectionRuleBody', return the parsed rule + return { + syntax: AdblockSyntax.Abp, + type: CosmeticRuleType.CssInjectionRule, + body, + }; + }; + const parseAbpSnippetInjection = (): Pick | null => { if (!options.parseAbpSpecificRules) { throw new AdblockSyntaxError( @@ -687,11 +722,23 @@ export class CosmeticRuleParser extends ParserBase { // the next function is called, and so on. // If all functions return null, an error should be thrown. const separatorMap = { - '##': [parseUboHtmlFiltering, parseUboScriptletInjection, parseUboCssInjection, parseElementHiding], - '#@#': [parseUboHtmlFiltering, parseUboScriptletInjection, parseUboCssInjection, parseElementHiding], - - '#?#': [parseUboCssInjection, parseElementHiding], - '#@?#': [parseUboCssInjection, parseElementHiding], + '##': [ + parseUboHtmlFiltering, + parseUboScriptletInjection, + parseUboCssInjection, + parseAbpCssInjection, + parseElementHiding, + ], + '#@#': [ + parseUboHtmlFiltering, + parseUboScriptletInjection, + parseUboCssInjection, + parseAbpCssInjection, + parseElementHiding, + ], + + '#?#': [parseUboCssInjection, parseAbpCssInjection, parseElementHiding], + '#@?#': [parseUboCssInjection, parseAbpCssInjection, parseElementHiding], '#$#': [parseAdgCssInjection, parseAbpSnippetInjection], '#@$#': [parseAdgCssInjection, parseAbpSnippetInjection], @@ -772,7 +819,6 @@ export class CosmeticRuleParser extends ParserBase { */ public static generateBody(node: AnyCosmeticRule): string { let result = EMPTY; - // Body switch (node.type) { case CosmeticRuleType.ElementHidingRule: @@ -780,7 +826,7 @@ export class CosmeticRuleParser extends ParserBase { break; case CosmeticRuleType.CssInjectionRule: - if (node.syntax === AdblockSyntax.Adg) { + if (node.syntax === AdblockSyntax.Adg || node.syntax === AdblockSyntax.Abp) { result = AdgCssInjectionParser.generate(node.body); } else if (node.syntax === AdblockSyntax.Ubo) { if (node.body.mediaQueryList) { diff --git a/packages/agtree/src/parser/css/css-token-stream.ts b/packages/agtree/src/parser/css/css-token-stream.ts index 106dc9a9d5..d3f464786c 100644 --- a/packages/agtree/src/parser/css/css-token-stream.ts +++ b/packages/agtree/src/parser/css/css-token-stream.ts @@ -112,14 +112,23 @@ export class CssTokenStream { // - end: end index of the token // - props: additional properties of the token, if any (we don't use it here, this is why we use underscore) // - balance: balance level of the token - tokenizeBalanced(source, (type, start, end, _, balance) => { - this.tokens.push({ - type, - start, - end, - balance, + try { + tokenizeBalanced(source, (type, start, end, _, balance) => { + this.tokens.push({ + type, + start, + end, + balance, + }); }); - }); + } catch (error) { + // If the error is an AdblockSyntaxError, adjust the error positions to the base offset + if (error instanceof AdblockSyntaxError) { + error.start += baseOffset; + error.end += baseOffset; + throw error; + } + } this.index = 0; diff --git a/packages/agtree/src/parser/css/has-token.ts b/packages/agtree/src/parser/css/has-token.ts new file mode 100644 index 0000000000..72150f0a71 --- /dev/null +++ b/packages/agtree/src/parser/css/has-token.ts @@ -0,0 +1,38 @@ +import { type TokenType, tokenizeExtended } from '@adguard/css-tokenizer'; + +/** + * Represents an error that occurs when an operation is aborted. + */ +class AbortError extends Error { + constructor() { + super('Aborted'); + } +} + +// TODO: AG-38480 add a stop function to the tokenizers callback and move `hasToken` to CSS Tokenizer as well +/** + * Checks if the given raw string contains any of the specified tokens. + * This function uses error throwing inside the abort tokenization process. + * + * @param raw - The raw string to be tokenized and checked. + * @param tokens - A set of token types to check for in the raw string. + * @returns `true` if any of the specified tokens are found in the raw string, otherwise `false`. + */ +export const hasToken = (raw: string, tokens: Set): boolean => { + try { + tokenizeExtended( + raw, + (type: TokenType) => { + if (tokens.has(type)) { + throw new AbortError(); + } + }, + ); + } catch (e) { + if (e instanceof AbortError) { + return true; + } + throw e; + } + return false; +}; diff --git a/packages/agtree/test/converter/converter.test.ts b/packages/agtree/test/converter/converter.test.ts index 3983a068f8..491e307a7e 100644 --- a/packages/agtree/test/converter/converter.test.ts +++ b/packages/agtree/test/converter/converter.test.ts @@ -658,6 +658,32 @@ describe('Converter integration tests', () => { expected: ['#?#.banner'], shouldConvert: false, }, + { + actual: '##.banner { display: none; }', + expected: ['#$#.banner { display: none; }'], + shouldConvert: true, + }, + { + actual: '##.banner { remove: true; }', + expected: ['#$?#.banner { remove: true; }'], + shouldConvert: true, + }, + // case without spaces in css pseudo property + { + actual: '##.banner {remove:true;}', + expected: ['#$?#.banner { remove: true; }'], + shouldConvert: true, + }, + { + actual: '##div[foo="yay{"]', + expected: ['##div[foo="yay{"]'], + shouldConvert: false, + }, + { + actual: '##div[foo="yay{"][href="yay}"]', + expected: ['##div[foo="yay{"][href="yay}"]'], + shouldConvert: false, + }, ])('should convert \'$actual\' to \'$expected\'', (testData) => { expect(testData).toBeConvertedProperly(RuleConverter, 'convertToAdg'); }); diff --git a/packages/agtree/test/helpers/node-utils.ts b/packages/agtree/test/helpers/node-utils.ts index 5df2ca60b1..3e7ced3721 100644 --- a/packages/agtree/test/helpers/node-utils.ts +++ b/packages/agtree/test/helpers/node-utils.ts @@ -88,6 +88,18 @@ export class NodeExpectContext { }; } + /** + * Gets the range for the last slot in the actual string. + * + * @returns Location range. See {@link Range}. + */ + public getLastSlotRange(): Range { + return { + start: this.actual.length - 1, + end: this.actual.length, + }; + } + /** * Converts the given range to a tuple. * diff --git a/packages/agtree/test/parser/cosmetic/abp-css-injection.test.ts b/packages/agtree/test/parser/cosmetic/abp-css-injection.test.ts new file mode 100644 index 0000000000..b9a860782f --- /dev/null +++ b/packages/agtree/test/parser/cosmetic/abp-css-injection.test.ts @@ -0,0 +1,557 @@ +import { sprintf } from 'sprintf-js'; +import { getFormattedTokenName, TokenType } from '@adguard/css-tokenizer'; + +import { NodeExpectContext, type NodeExpectFn } from '../../helpers/node-utils'; +import { + CosmeticRuleType, + RuleCategory, + type CssInjectionRule, + type ElementHidingRule, +} from '../../../src/parser/common'; +import { CosmeticRuleParser } from '../../../src/parser/cosmetic'; +import { AdblockSyntax } from '../../../src/utils/adblockers'; +import { DomainListParser } from '../../../src/parser/misc/domain-list'; +import { AdblockSyntaxError } from '../../../src/errors/adblock-syntax-error'; +import { ERROR_MESSAGES as CSS_TOKEN_STREAM_ERROR_MESSAGES, END_OF_INPUT } from '../../../src/parser/css/constants'; + +describe('CosmeticRuleParser', () => { + describe('CosmeticRuleParser.parse - valid AdblockPlus CSS injection rules', () => { + test.each<{ actual: string; expected: NodeExpectFn }>([ + // generic cosmetic rule - without domains + { + actual: '##div { display: none !important; }', + expected: (context: NodeExpectContext): CssInjectionRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.CssInjectionRule, + syntax: AdblockSyntax.Abp, + exception: false, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '##', + ...context.getRangeFor('##'), + }, + body: { + type: 'CssInjectionRuleBody', + selectorList: { + type: 'Value', + value: 'div', + ...context.getRangeFor('div'), + }, + declarationList: { + type: 'Value', + value: 'display: none !important;', + ...context.getRangeFor('display: none !important;'), + }, + ...context.getRangeFor('div { display: none !important; }'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '#@#div { display: none !important; }', + expected: (context: NodeExpectContext): CssInjectionRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.CssInjectionRule, + syntax: AdblockSyntax.Abp, + exception: true, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '#@#', + ...context.getRangeFor('#@#'), + }, + body: { + type: 'CssInjectionRuleBody', + selectorList: { + type: 'Value', + value: 'div', + ...context.getRangeFor('div'), + }, + declarationList: { + type: 'Value', + value: 'display: none !important;', + ...context.getRangeFor('display: none !important;'), + }, + ...context.getRangeFor('div { display: none !important; }'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '##.banner { remove: true; }', + expected: (context: NodeExpectContext): CssInjectionRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.CssInjectionRule, + syntax: AdblockSyntax.Abp, + exception: false, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '##', + ...context.getRangeFor('##'), + }, + body: { + type: 'CssInjectionRuleBody', + selectorList: { + type: 'Value', + value: '.banner', + ...context.getRangeFor('.banner'), + }, + declarationList: { + type: 'Value', + value: 'remove: true;', + ...context.getRangeFor('remove: true;'), + }, + ...context.getRangeFor('.banner { remove: true; }'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '#@#.banner { remove: true; }', + expected: (context: NodeExpectContext): CssInjectionRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.CssInjectionRule, + syntax: AdblockSyntax.Abp, + exception: true, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '#@#', + ...context.getRangeFor('#@#'), + }, + body: { + type: 'CssInjectionRuleBody', + selectorList: { + type: 'Value', + value: '.banner', + ...context.getRangeFor('.banner'), + }, + declarationList: { + type: 'Value', + value: 'remove: true;', + ...context.getRangeFor('remove: true;'), + }, + ...context.getRangeFor('.banner { remove: true; }'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '#?#.banner:has(.foo) { display: none !important; }', + expected: (context: NodeExpectContext): CssInjectionRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.CssInjectionRule, + syntax: AdblockSyntax.Abp, + exception: false, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '#?#', + ...context.getRangeFor('#?#'), + }, + body: { + type: 'CssInjectionRuleBody', + selectorList: { + type: 'Value', + value: '.banner:has(.foo)', + ...context.getRangeFor('.banner:has(.foo)'), + }, + declarationList: { + type: 'Value', + value: 'display: none !important;', + ...context.getRangeFor('display: none !important;'), + }, + ...context.getRangeFor('.banner:has(.foo) { display: none !important; }'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '#@?#.banner:has(.foo) { display: none !important; }', + expected: (context: NodeExpectContext): CssInjectionRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.CssInjectionRule, + syntax: AdblockSyntax.Abp, + exception: true, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '#@?#', + ...context.getRangeFor('#@?#'), + }, + body: { + type: 'CssInjectionRuleBody', + selectorList: { + type: 'Value', + value: '.banner:has(.foo)', + ...context.getRangeFor('.banner:has(.foo)'), + }, + declarationList: { + type: 'Value', + value: 'display: none !important;', + ...context.getRangeFor('display: none !important;'), + }, + ...context.getRangeFor('.banner:has(.foo) { display: none !important; }'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '#?#.banner:contains({) { display: none !important; }', + expected: (context: NodeExpectContext): CssInjectionRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.CssInjectionRule, + syntax: AdblockSyntax.Abp, + exception: false, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '#?#', + ...context.getRangeFor('#?#'), + }, + body: { + type: 'CssInjectionRuleBody', + selectorList: { + type: 'Value', + value: '.banner:contains({)', + ...context.getRangeFor('.banner:contains({)'), + }, + declarationList: { + type: 'Value', + value: 'display: none !important;', + ...context.getRangeFor('display: none !important;'), + }, + ...context.getRangeFor('.banner:contains({) { display: none !important; }'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '#@?#.banner:contains({) { display: none !important; }', + expected: (context: NodeExpectContext): CssInjectionRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.CssInjectionRule, + syntax: AdblockSyntax.Abp, + exception: true, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '#@?#', + ...context.getRangeFor('#@?#'), + }, + body: { + type: 'CssInjectionRuleBody', + selectorList: { + type: 'Value', + value: '.banner:contains({)', + ...context.getRangeFor('.banner:contains({)'), + }, + declarationList: { + type: 'Value', + value: 'display: none !important;', + ...context.getRangeFor('display: none !important;'), + }, + ...context.getRangeFor('.banner:contains({) { display: none !important; }'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '##.banner { padding: 10px !important; background: black !important; }', + expected: (context: NodeExpectContext): CssInjectionRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.CssInjectionRule, + syntax: AdblockSyntax.Abp, + exception: false, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '##', + ...context.getRangeFor('##'), + }, + body: { + type: 'CssInjectionRuleBody', + selectorList: { + type: 'Value', + value: '.banner', + ...context.getRangeFor('.banner'), + }, + declarationList: { + type: 'Value', + value: 'padding: 10px !important; background: black !important;', + ...context.getRangeFor('padding: 10px !important; background: black !important;'), + }, + ...context.getRangeFor( + '.banner { padding: 10px !important; background: black !important; }', + ), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '##div[class^="foo{"]', + expected: (context: NodeExpectContext): ElementHidingRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.ElementHidingRule, + syntax: AdblockSyntax.Common, + exception: false, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '##', + ...context.getRangeFor('##'), + }, + body: { + type: 'ElementHidingRuleBody', + selectorList: { + type: 'Value', + value: 'div[class^="foo{"]', + ...context.getRangeFor('div[class^="foo{"]'), + }, + ...context.getRangeFor('div[class^="foo{"]'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '#@#div[class^="foo{"]', + expected: (context: NodeExpectContext): ElementHidingRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.ElementHidingRule, + syntax: AdblockSyntax.Common, + exception: true, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '#@#', + ...context.getRangeFor('#@#'), + }, + body: { + type: 'ElementHidingRuleBody', + selectorList: { + type: 'Value', + value: 'div[class^="foo{"]', + ...context.getRangeFor('div[class^="foo{"]'), + }, + ...context.getRangeFor('div[class^="foo{"]'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '#?#.foo:contains({)', + expected: (context: NodeExpectContext): ElementHidingRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.ElementHidingRule, + syntax: AdblockSyntax.Common, + exception: false, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '#?#', + ...context.getRangeFor('#?#'), + }, + body: { + type: 'ElementHidingRuleBody', + selectorList: { + type: 'Value', + value: '.foo:contains({)', + ...context.getRangeFor('.foo:contains({)'), + }, + ...context.getRangeFor('.foo:contains({)'), + }, + ...context.getFullRange(), + }; + }, + }, + { + actual: '#?#.foo[class^="foo{"][href*="}"]', + expected: (context: NodeExpectContext): ElementHidingRule => { + return { + category: RuleCategory.Cosmetic, + type: CosmeticRuleType.ElementHidingRule, + syntax: AdblockSyntax.Common, + exception: false, + modifiers: undefined, + domains: DomainListParser.parse(''), + separator: { + type: 'Value', + value: '#?#', + ...context.getRangeFor('#?#'), + }, + body: { + type: 'ElementHidingRuleBody', + selectorList: { + type: 'Value', + value: '.foo[class^="foo{"][href*="}"]', + ...context.getRangeFor('.foo[class^="foo{"][href*="}"]'), + }, + ...context.getRangeFor('.foo[class^="foo{"][href*="}"]'), + }, + ...context.getFullRange(), + }; + }, + }, + ])("should parse '$actual'", ({ actual, expected: expectedFn }) => { + expect(CosmeticRuleParser.parse(actual)).toMatchObject(expectedFn(new NodeExpectContext(actual))); + }); + }); + + describe('CosmeticRuleParser.generate - AdblockPlus CSS injection rules', () => { + test.each<{ actual: string; expected: string }>([ + // simple cases - without domains + { + actual: '##div { display: none !important; }', + expected: '##div { display: none !important; }', + }, + { + actual: '#@#div { display: none !important; }', + expected: '#@#div { display: none !important; }', + }, + { + actual: '##.banner { remove: true; }', + expected: '##.banner { remove: true; }', + }, + { + actual: '#@#.banner { remove: true; }', + expected: '#@#.banner { remove: true; }', + }, + { + actual: '#?#.banner:has(.foo) { display: none !important; }', + expected: '#?#.banner:has(.foo) { display: none !important; }', + }, + { + actual: '#@?#.banner:has(.foo) { display: none !important; }', + expected: '#@?#.banner:has(.foo) { display: none !important; }', + }, + { + actual: '#?#.banner:contains({) { display: none !important; }', + expected: '#?#.banner:contains({) { display: none !important; }', + }, + { + actual: '#@?#.banner:contains({) { display: none !important; }', + expected: '#@?#.banner:contains({) { display: none !important; }', + }, + { + actual: '##.banner { padding: 10px !important; background: black !important; }', + expected: '##.banner { padding: 10px !important; background: black !important; }', + }, + { + actual: '##div[class^="foo{"]', + expected: '##div[class^="foo{"]', + }, + { + actual: '#@#div[class^="foo{"]', + expected: '#@#div[class^="foo{"]', + }, + { + actual: '#?#.foo:contains({)', + expected: '#?#.foo:contains({)', + }, + ])("should generate '$expected' from '$actual'", ({ actual, expected }) => { + const ruleNode = CosmeticRuleParser.parse(actual); + + if (ruleNode === null) { + throw new Error(`Failed to parse '${actual}' as cosmetic rule`); + } + + expect(CosmeticRuleParser.generate(ruleNode)).toBe(expected); + }); + }); + + describe('AdgScriptletInjectionBodyParser.parse - invalid cases', () => { + test.each<{ actual: string; expected: NodeExpectFn }>([ + { + actual: String.raw`##div { display: none !important`, + expected: (context: NodeExpectContext): AdblockSyntaxError => { + return new AdblockSyntaxError( + "Expected '<}-token>', but got 'end of input'", + ...context.toTuple(context.getLastSlotRange()), + ); + }, + }, + { + actual: String.raw`#@#div { display: none !important`, + expected: (context: NodeExpectContext): AdblockSyntaxError => { + return new AdblockSyntaxError( + sprintf( + CSS_TOKEN_STREAM_ERROR_MESSAGES.EXPECTED_TOKEN_BUT_GOT, + getFormattedTokenName(TokenType.CloseCurlyBracket), + END_OF_INPUT, + ), + ...context.toTuple(context.getLastSlotRange()), + ); + }, + }, + { + actual: String.raw`#@#div display: none !important }`, + expected: (context: NodeExpectContext): AdblockSyntaxError => { + return new AdblockSyntaxError( + "Expected '', but got '<}-token>'", + ...context.toTuple(context.getLastSlotRange()), + ); + }, + }, + { + actual: String.raw`##div display: none !important }`, + expected: (context: NodeExpectContext): AdblockSyntaxError => { + return new AdblockSyntaxError( + "Expected '', but got '<}-token>'", + ...context.toTuple(context.getLastSlotRange()), + ); + }, + }, + ])("should throw on input: '$actual'", ({ actual, expected: expectedFn }) => { + const fn = jest.fn(() => CosmeticRuleParser.parse(actual)); + + // parse should throw + expect(fn).toThrow(); + + const expected = expectedFn(new NodeExpectContext(actual)); + + // check the thrown error + const error = fn.mock.results[0].value; + expect(error).toBeInstanceOf(AdblockSyntaxError); + expect(error).toHaveProperty('message', expected.message); + expect(error).toHaveProperty('start', (expected.start)); + expect(error).toHaveProperty('end', expected.end); + }); + }); +}); diff --git a/packages/agtree/test/parser/css/css-token-stream.test.ts b/packages/agtree/test/parser/css/css-token-stream.test.ts index 2908d67286..1f30fab3bf 100644 --- a/packages/agtree/test/parser/css/css-token-stream.test.ts +++ b/packages/agtree/test/parser/css/css-token-stream.test.ts @@ -3,6 +3,7 @@ import { sprintf } from 'sprintf-js'; import { END_OF_INPUT, ERROR_MESSAGES } from '../../../src/parser/css/constants'; import { CssTokenStream } from '../../../src/parser/css/css-token-stream'; +import { AdblockSyntaxError } from '../../../src/errors/adblock-syntax-error'; describe('CssTokenStream', () => { test('length', () => { @@ -181,4 +182,20 @@ describe('CssTokenStream', () => { expect(stream.hasAnySelectorExtendedCssNode()).toBe(expected); }); }); + + test('if balanced tokenizer throws, offsets should be added to the error', () => { + const actual = 'div { display: none !important'; + const offset = 2; + + const fn = jest.fn(() => new CssTokenStream(actual, offset)); + + expect(() => fn()).toThrow(); + + // check the thrown error + const error = fn.mock.results[0].value; + expect(error).toBeInstanceOf(AdblockSyntaxError); + expect(error).toHaveProperty('message', "Expected '<}-token>', but got 'end of input'"); + expect(error).toHaveProperty('start', offset + actual.length - 1); + expect(error).toHaveProperty('end', offset + actual.length); + }); });