diff --git a/js/index.js b/js/index.js index e7f389d1..d7e24857 100644 --- a/js/index.js +++ b/js/index.js @@ -28,4 +28,5 @@ exports.FilterFormat = blocker.FilterFormat; exports.FilterSet = FilterSet; exports.RuleTypes = blocker.RuleTypes; exports.Engine = Engine; +exports.parseFilter = blocker.parseFilter; exports.uBlockResources = blocker.uBlockResources; diff --git a/js/src/lib.rs b/js/src/lib.rs index d6577086..20dfdfe1 100644 --- a/js/src/lib.rs +++ b/js/src/lib.rs @@ -129,6 +129,44 @@ fn filter_set_add_filter(mut cx: FunctionContext) -> JsResult { Ok(JsBoolean::new(&mut cx, ok)) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FilterParseResult { + supported: bool, + filter_type: Option<&'static str>, + error: Option, +} + +fn parse_filter(mut cx: FunctionContext) -> JsResult { + let filter: String = cx.argument::(0)?.value(&mut cx); + let parse_opts = match cx.argument_opt(1) { + Some(parse_opts_arg) => json_ffi::from_js(&mut cx, parse_opts_arg)?, + None => ParseOptions::default(), + }; + + // Pure check that does not mutate a FilterSet. On failure, `error` is the Display string of the + // underlying `FilterParseError`. + let result = match adblock::lists::parse_filter(&filter, false, parse_opts) { + Ok(adblock::lists::ParsedFilter::Network(_)) => FilterParseResult { + supported: true, + filter_type: Some("network"), + error: None, + }, + Ok(adblock::lists::ParsedFilter::Cosmetic(_)) => FilterParseResult { + supported: true, + filter_type: Some("cosmetic"), + error: None, + }, + Err(e) => FilterParseResult { + supported: false, + filter_type: None, + error: Some(e.to_string()), + }, + }; + + json_ffi::to_js(&mut cx, &result) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ContentBlockingConversionResult { @@ -411,6 +449,7 @@ register_module!(mut m, { m.export_function("Engine_tagExists", engine_tag_exists)?; m.export_function("Engine_clearTags", engine_clear_tags)?; + m.export_function("parseFilter", parse_filter)?; m.export_function("validateRequest", validate_request)?; m.export_function("uBlockResources", ublock_resources)?; diff --git a/js/test/bindings.test.mjs b/js/test/bindings.test.mjs index 2cca2147..8d3072f8 100644 --- a/js/test/bindings.test.mjs +++ b/js/test/bindings.test.mjs @@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const { FilterSet, Engine, FilterFormat, RuleTypes, uBlockResources } = +const { FilterSet, Engine, FilterFormat, RuleTypes, parseFilter, uBlockResources } = createRequire(import.meta.url)(join(__dirname, '..', 'index.js')); // --------------------------------------------------------------------------- @@ -113,6 +113,58 @@ describe('FilterSet.addFilter', () => { }); }); +// --------------------------------------------------------------------------- +// parseFilter +// --------------------------------------------------------------------------- + +describe('parseFilter', () => { + it('classifies a supported network rule', () => { + const result = parseFilter('||example.com^'); + assert.deepEqual(result, { + supported: true, + filterType: 'network', + error: null, + }); + }); + + it('classifies a supported cosmetic rule', () => { + const result = parseFilter('example.com##.banner'); + assert.deepEqual(result, { + supported: true, + filterType: 'cosmetic', + error: null, + }); + }); + + it('reports the error for an unrecognised network option', () => { + const result = parseFilter('||example.com^$unknown-option'); + assert.equal(result.supported, false); + assert.equal(result.filterType, null); + assert.equal(result.error, 'network filter error: unrecognised option'); + }); + + it('reports the error for an unsupported/garbage rule', () => { + const result = parseFilter('! this is a comment'); + assert.equal(result.supported, false); + assert.equal(result.filterType, null); + assert.equal(result.error, 'unsupported'); + }); + + it('reports the error for an empty rule', () => { + const result = parseFilter(''); + assert.equal(result.supported, false); + assert.equal(result.filterType, null); + assert.equal(result.error, 'empty'); + }); + + it('honours ParseOptions (hosts format)', () => { + const result = parseFilter('127.0.0.1 ads.example.com', { format: FilterFormat.HOSTS }); + assert.equal(result.supported, true); + assert.equal(result.filterType, 'network'); + assert.equal(result.error, null); + }); +}); + // --------------------------------------------------------------------------- // FilterSet.intoContentBlocking // ---------------------------------------------------------------------------