Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 31 additions & 31 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ Query contexts default to the factory document when omitted. `closest()`, `first

| Method | Result |
| --- | --- |
| [`byClass(cls, context)`](../src/nwsapi.mts#L3865) | Returns elements with the class name. |
| [`byId(id, context)`](../src/nwsapi.mts#L3863) | Returns elements with the ID. Duplicate IDs are allowed by default. |
| [`byTag(tag, context)`](../src/nwsapi.mts#L3864) | Returns elements with the tag name. Use `*` for all elements. |
| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L3871) | Returns the nearest match, starting with the element, or `null`. |
| [`compile(selector, mode, callback, relative)`](../src/nwsapi.mts#L3873) | Compiles a selector into a resolver function. This is an advanced API. |
| [`configure(option, clear)`](../src/nwsapi.mts#L3874) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. |
| [`emit(message, proto)`](../src/nwsapi.mts#L3876) | Reports an error using the configured error policy. |
| [`first(selectors, context, callback)`](../src/nwsapi.mts#L3867) | Returns the first matching descendant, or `null`. |
| [`install(all)`](../src/nwsapi.mts#L3882) | Replaces native selector methods. Pass `true` to also replace collection methods. |
| [`match(selectors, element, callback)`](../src/nwsapi.mts#L3868) | Returns whether the element matches. |
| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3889) | Adds a relationship between elements using trusted resolver code. |
| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3914) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. |
| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3936) | Adds a selector pattern and a compiler callback that returns `source` and `status`. |
| [`select(selectors, context, callback)`](../src/nwsapi.mts#L3869) | Returns an array of matching descendants, or an empty array. |
| [`uninstall()`](../src/nwsapi.mts#L3883) | Restores the native methods saved by `install()`. |
| [`byClass(cls, context)`](../src/nwsapi.mts#L3933) | Returns elements with the class name. |
| [`byId(id, context)`](../src/nwsapi.mts#L3931) | Returns elements with the ID. Duplicate IDs are allowed by default. |
| [`byTag(tag, context)`](../src/nwsapi.mts#L3932) | Returns elements with the tag name. Use `*` for all elements. |
| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L3939) | Returns the nearest match, starting with the element, or `null`. |
| [`compile(selector, mode, callback, relative)`](../src/nwsapi.mts#L3941) | Compiles a selector into a resolver function. This is an advanced API. |
| [`configure(option, clear)`](../src/nwsapi.mts#L3942) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. |
| [`emit(message, proto)`](../src/nwsapi.mts#L3944) | Reports an error using the configured error policy. |
| [`first(selectors, context, callback)`](../src/nwsapi.mts#L3935) | Returns the first matching descendant, or `null`. |
| [`install(all)`](../src/nwsapi.mts#L3950) | Replaces native selector methods. Pass `true` to also replace collection methods. |
| [`match(selectors, element, callback)`](../src/nwsapi.mts#L3936) | Returns whether the element matches. |
| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3957) | Adds a relationship between elements using trusted resolver code. |
| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3982) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. |
| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L4004) | Adds a selector pattern and a compiler callback that returns `source` and `status`. |
| [`select(selectors, context, callback)`](../src/nwsapi.mts#L3937) | Returns an array of matching descendants, or an empty array. |
| [`uninstall()`](../src/nwsapi.mts#L3951) | Restores the native methods saved by `install()`. |

<details>
<summary>Configuration</summary>
Expand Down Expand Up @@ -60,22 +60,22 @@ These exports support extensions and debugging. Prefer query methods and `config

| Member | Purpose |
| --- | --- |
| [`CFG`](../src/nwsapi.mts#L3851) | Contains the compiler syntax settings. |
| [`Config`](../src/nwsapi.mts#L3877) | Contains the active options. Use `configure()` to change them. |
| [`M_BODY`](../src/nwsapi.mts#L3854) | Contains the matching resolver body template. |
| [`M_TEST`](../src/nwsapi.mts#L3858) | Contains the matching resolver test template. |
| [`matchLambdas`](../src/nwsapi.mts#L3843) | Caches compiled matching functions, not DOM results. |
| [`matchResolvers`](../src/nwsapi.mts#L3846) | Caches matching plans, not DOM results. |
| [`N_BODY`](../src/nwsapi.mts#L3855) | Exposes the matching resolver body template. |
| [`N_TEST`](../src/nwsapi.mts#L3859) | Contains the alternate resolver test template. |
| [`Operators`](../src/nwsapi.mts#L3885) | Contains registered attribute operators. |
| [`S_BODY`](../src/nwsapi.mts#L3853) | Contains the selection resolver body template. |
| [`S_TEST`](../src/nwsapi.mts#L3857) | Contains the selection resolver test template. |
| [`selectLambdas`](../src/nwsapi.mts#L3844) | Caches compiled selection functions, not DOM results. |
| [`Selectors`](../src/nwsapi.mts#L3886) | Contains registered selector extensions. |
| [`selectResolvers`](../src/nwsapi.mts#L3847) | Caches selection plans, not DOM results. |
| [`Snapshot`](../src/nwsapi.mts#L3878) | Contains the document state and helpers used by compiled selectors. |
| [`Version`](../src/nwsapi.mts#L3880) | Contains the engine version string. |
| [`CFG`](../src/nwsapi.mts#L3919) | Contains the compiler syntax settings. |
| [`Config`](../src/nwsapi.mts#L3945) | Contains the active options. Use `configure()` to change them. |
| [`M_BODY`](../src/nwsapi.mts#L3922) | Contains the matching resolver body template. |
| [`M_TEST`](../src/nwsapi.mts#L3926) | Contains the matching resolver test template. |
| [`matchLambdas`](../src/nwsapi.mts#L3911) | Caches compiled matching functions, not DOM results. |
| [`matchResolvers`](../src/nwsapi.mts#L3914) | Caches matching plans, not DOM results. |
| [`N_BODY`](../src/nwsapi.mts#L3923) | Exposes the matching resolver body template. |
| [`N_TEST`](../src/nwsapi.mts#L3927) | Contains the alternate resolver test template. |
| [`Operators`](../src/nwsapi.mts#L3953) | Contains registered attribute operators. |
| [`S_BODY`](../src/nwsapi.mts#L3921) | Contains the selection resolver body template. |
| [`S_TEST`](../src/nwsapi.mts#L3925) | Contains the selection resolver test template. |
| [`selectLambdas`](../src/nwsapi.mts#L3912) | Caches compiled selection functions, not DOM results. |
| [`Selectors`](../src/nwsapi.mts#L3954) | Contains registered selector extensions. |
| [`selectResolvers`](../src/nwsapi.mts#L3915) | Caches selection plans, not DOM results. |
| [`Snapshot`](../src/nwsapi.mts#L3946) | Contains the document state and helpers used by compiled selectors. |
| [`Version`](../src/nwsapi.mts#L3948) | Contains the engine version string. |

</details>

Expand Down
39 changes: 39 additions & 0 deletions docs/compound-negation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Regression tests

Inline compound `:not()` arguments; keep lists and combinators on the general matcher path.

Run with Node.js 26.

```sh
pnpm install
pnpm run test:unit
```

Extracted from #167. Assertions cover both results and the optimized route.
These tests do not establish an end-to-end speedup.

## Integration with ancestor filtering

When combining this PR with #189, preserve the outer compiler's filter state
around the nested `compileSelector()` call. Declare `A_KEEP`, `A_HOLD`, and
`A_MOVE` as locals of the outer compiler, then wrap that call:

```js
A_KEEP = A_REQD.slice();
A_HOLD = A_PEND.slice();
A_MOVE = A_WALK;
nested = compileSelector(argument, flag + '=true;', mode, callback);
A_REQD.length = 0;
A_REQD.push.apply(A_REQD, A_KEEP);
A_PEND.length = 0;
A_PEND.push.apply(A_PEND, A_HOLD);
A_WALK = A_MOVE;
```

Negated tags are not positive ancestor requirements. The nested compile must
also retain the outer helper-alias set when combined with legacy DOM support.
Reset `H_USED` in `compile()`, not in `compileSelector()`.

Integration tests should verify results and the ancestor-filter guard for
`main section p:not(article)`, including legacy attribute and sibling reads
inside a compound negation.
70 changes: 69 additions & 1 deletion src/nwsapi.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2230,6 +2230,51 @@
return factory
},
// build conditional code to check components of selector strings
isCompound = function (text) {
var chr,
depth = 0,
escaped,
i = 0,
l = text.length,
quote = ''

for (; l > i; ++i) {
chr = text.charAt(i)
if (escaped) {
escaped = false
continue
}
if (chr == '\\') {
escaped = true
} else if (quote) {
if (chr == quote) {
quote = ''
}
} else if (chr == '\x22' || chr == '\x27') {
quote = chr
} else if (chr == '\x28' || chr == '\x5b') {
++depth
} else if (chr == '\x29' || chr == '\x5d') {
--depth
} else if (
depth === 0 &&
(chr == ',' ||
chr == '>' ||
chr == '+' ||
chr == '~' ||
chr == ' ' ||
chr == '\t' ||
chr == '\n' ||
chr == '\f' ||
chr == '\r')
) {
return false
}
}

return l > 0
},
notFlag = 0,
compileSelector = function (expression, source, mode, callback) {
var a,
b,
Expand All @@ -2250,6 +2295,9 @@
type,
selector = expression,
vars,
argument,
flag,
nested,
read

read = Config.LEGACY
Expand Down Expand Up @@ -2700,7 +2748,27 @@
source = 'if(s.match("' + expr + '",e)){' + source + '}'
break
case 'not':
source = 'if(!s.match("' + expr + '",e)){' + source + '}'
if (isCompound((argument = match[2]))) {
flag = '_n' + notFlag++
nested = compileSelector(
argument,
flag + '=true;',
mode,
callback,
)
source =
'var ' +
flag +
'=false;' +
nested +
'if(!' +
flag +
'){' +
source +
'}'
} else {
source = 'if(!s.match("' + expr + '",e)){' + source + '}'
}
break
case 'has':
source =
Expand Down
33 changes: 33 additions & 0 deletions test/repo/e2e/upstream/fixtures/compound-negation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<meta charset="utf-8">
<title>Compound negation</title>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script type="module">
import { legacyHost } from '/_repo/test/repo/fixtures/legacy-host.mts';
test(t => {
const engine = NW.Dom;
const doc = document.implementation.createHTMLDocument('negation');
doc.body.innerHTML = '<main><div id="d1" class="x"><p id="p1" class="a">1</p><p id="p2" class="b">2</p><p id="p3">3</p></div><div id="d2"><span id="s1"></span></div><div id="d3"><div id="d4"><p id="p4" class="a">4</p></div></div></main>';
const host = legacyHost(doc);
const selectors = [
'p:not(.a)', 'p:not(#p1)', 'p:not([class])', 'p:not([class="a"])',
'p:not(:first-child)', 'p:not(:nth-of-type(2n))', 'div:not(:nth-of-type(2n))',
'p:not(:is(.a, .b))', 'p:not(:not(.a))', 'div:not(:has(p))', 'p:not(.a):not(.b)',
'div p:not(.a)', 'div:not(.x) p', 'p:not(.a, .b)', 'div:not(p > span)', 'div:not(div p)',
'p:not([title="a,b > c"])', 'p:not(.a\\.b)', "p:not([title='x'])",
];
t.add_cleanup(() => { engine.configure({ LEGACY: false }, true); });
for (const legacy of [false, true, false]) {
engine.configure({ LEGACY: legacy }, true);
const context = legacy ? host : doc;
for (const selector of selectors) {
const expected = Array.from(doc.querySelectorAll(selector), element => element.id);
assert_array_equals(engine.select(selector, context).map(element => element.id), expected, selector);
assert_array_equals(engine.select(selector, context).map(element => element.id), expected, selector);
const target = context.getElementById('p2');
assert_equals(engine.match(selector, target), expected.includes('p2'), selector);
}
}
}, 'Inline negations agree with native selectors and preserve legacy helper reads');
</script>
5 changes: 5 additions & 0 deletions test/repo/e2e/upstream/manifest.mts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export const manifest: Array<{
install?: boolean
legacyMap?: boolean
}> = [
{
path: '/_repo/test/repo/e2e/upstream/fixtures/compound-negation.html',
note: 'local WPT regression: compound and general negation paths with modern and legacy hosts',
install: false,
},
{
path: '/_repo/test/repo/e2e/upstream/fixtures/property-reads.html',
note: 'local WPT regression: reflected classes, SVG base values, attribute fallbacks, and escaped IDs',
Expand Down
104 changes: 104 additions & 0 deletions test/repo/unit/compound-negation.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict'
import { test, describe, afterEach } from 'vitest'
import { JSDOM } from 'jsdom'
import factory from '../../../src/nwsapi.js'
const windows = []
afterEach(() => {
for (const window of windows.splice(0)) {
window.close()
}
})
function build(html) {
const { window } = new JSDOM(html)
windows.push(window)
const NW = factory(window)
return { window, document: window.document, NW }
}

describe(':not() with a compound argument', () => {
// The argument compiles in place rather than going back out through match()
// once per candidate, so these cover the shapes that inline, the shapes that
// must not, and that an inlined argument leaves the surrounding walk alone.
function fixture() {
return build(
'<!doctype html><body>' +
'<div id=d1 class=x><p id=p1 class=a>1</p><p id=p2 class=b>2</p><p id=p3>3</p></div>' +
'<div id=d2><span id=s1></span></div>' +
'<div id=d3><div id=d4><p id=p4 class=a>4</p></div></div>' +
'</body>',
)
}

test('the same answer as the reference engine', () => {
const { document, NW } = fixture()
for (const selector of [
// compound arguments, which compile in place
'p:not(.a)',
'p:not(#p1)',
'p:not([class])',
'p:not([class="a"])',
'p:not(:first-child)',
'p:not(:nth-of-type(2n))',
'div:not(:nth-of-type(2n))',
'p:not(:is(.a, .b))',
'p:not(:not(.a))',
'div:not(:has(p))',
'p:not(.a):not(.b)',
'div p:not(.a)',
'div:not(.x) p',
// arguments that keep the call: a list, and a combinator
'p:not(.a, .b)',
'div:not(p > span)',
'div:not(div p)',
]) {
const mine = NW.select(selector, document).map(node => node.id)
const reference = Array.from(
document.querySelectorAll(selector),
(node: Element) => node.id,
)
assert.deepEqual(mine, reference, selector)
}
})

test('match() agrees with select() on the same element', () => {
const { document, NW } = fixture()
const p2 = document.getElementById('p2')
assert.equal(NW.match('p:not(.a)', p2), true)
assert.equal(NW.match('p:not(.b)', p2), false)
assert.equal(NW.match('p:not(:nth-of-type(2n))', p2), false)
assert.equal(NW.match('div p:not(.a)', p2), true)
})

test('compound arguments avoid per-candidate match delegation', () => {
const { document, NW } = fixture()
let calls = 0
const original = Reflect.get(NW, 'Snapshot').match
Reflect.get(NW, 'Snapshot').match = function (...args) {
calls++
return original(...args)
}
assert.deepEqual(
NW.select('p:not(.a)', document).map(e => e.id),
['p2', 'p3'],
)
assert.equal(calls, 0)
assert.deepEqual(
NW.select('p:not(.a, .b)', document).map(e => e.id),
['p3'],
)
assert.ok(calls > 0, 'selector lists retain the general matcher')
})

test('an argument the engine cannot read is a syntax error', () => {
const { document, NW } = fixture()
for (const selector of ['p:not(@@)', 'p:not()', 'div:not(svg|div)']) {
assert.throws(() => NW.select(selector, document), selector)
assert.throws(() => document.querySelectorAll(selector), selector)
}
// an argument left unclosed is closed by EOF, as the syntax parser does
assert.deepEqual(
NW.select('p:not(.a', document).map(node => node.id),
['p2', 'p3'],
)
})
})