diff --git a/lib/attribute.js b/lib/attribute.js index 49b5b1d..8979cf2 100644 --- a/lib/attribute.js +++ b/lib/attribute.js @@ -4,35 +4,25 @@ * @typedef {import('./types.js').Node} Node */ -import {unreachable} from 'devlop' -import {zwitch} from 'zwitch' +import {ok as assert} from 'devlop' import {indexable} from './util.js' -/** @type {(query: AstAttribute, node: Node) => boolean} */ -const handle = zwitch('operator', { - unknown: unknownOperator, - // @ts-expect-error: hush. - invalid: exists, - handlers: { - '=': exact, - '^=': begins, - '$=': ends, - '*=': containsString, - '~=': containsArray - } -}) - /** * @param {AstRule} query + * Query. * @param {Node} node + * Node. * @returns {boolean} + * Whether `node` matches `query`. */ -export function attribute(query, node) { +export function attributes(query, node) { let index = -1 if (query.attributes) { while (++index < query.attributes.length) { - if (!handle(query.attributes[index], node)) return false + if (!attribute(query.attributes[index], node)) { + return false + } } } @@ -40,150 +30,67 @@ export function attribute(query, node) { } /** - * Check whether an attribute exists. - * - * `[attr]` - * * @param {AstAttribute} query + * Query. * @param {Node} node + * Node. * @returns {boolean} + * Whether `node` matches `query`. */ -function exists(query, node) { - indexable(node) - return node[query.name] !== null && node[query.name] !== undefined -} -/** - * Check whether an attribute has an exact value. - * - * `[attr=value]` - * - * @param {AstAttribute} query - * @param {Node} node - * @returns {boolean} - */ -function exact(query, node) { - const queryValue = attributeValue(query) - indexable(node) - return exists(query, node) && String(node[query.name]) === queryValue -} - -/** - * Check whether an attribute, as a list, contains a value. - * - * When the attribute value is not a list, checks that the serialized value - * is the queried one. - * - * `[attr~=value]` - * - * @param {AstAttribute} query - * @param {Node} node - * @returns {boolean} - */ -function containsArray(query, node) { +function attribute(query, node) { indexable(node) const value = node[query.name] - if (value === null || value === undefined) return false - - const queryValue = attributeValue(query) - - // If this is an array, and the query is contained in it, return `true`. - // Coverage comment in place because TS turns `Array.isArray(unknown)` - // into `Array` instead of `Array`. - // type-coverage:ignore-next-line - if (Array.isArray(value) && value.includes(queryValue)) { - return true + // Exists. + if (!query.value) { + return value !== null && value !== undefined } - // For all other values, return whether this is an exact match. - return String(value) === queryValue -} + assert(query.value.type === 'String', 'expected plain string') + let key = query.value.value + let normal = value === null || value === undefined ? undefined : String(value) -/** - * Check whether an attribute has a substring as its start. - * - * `[attr^=value]` - * - * @param {AstAttribute} query - * @param {Node} node - * @returns {boolean} - */ -function begins(query, node) { - indexable(node) - const value = node[query.name] - const queryValue = attributeValue(query) + // Case-sensitivity. + if (query.caseSensitivityModifier === 'i') { + key = key.toLowerCase() - return Boolean( - query.value && - typeof value === 'string' && - value.slice(0, queryValue.length) === queryValue - ) -} - -/** - * Check whether an attribute has a substring as its end. - * - * `[attr$=value]` - * - * @param {AstAttribute} query - * @param {Node} node - * @returns {boolean} - */ -function ends(query, node) { - indexable(node) - const value = node[query.name] - const queryValue = attributeValue(query) - - return Boolean( - query.value && - typeof value === 'string' && - value.slice(-queryValue.length) === queryValue - ) -} - -/** - * Check whether an attribute contains a substring. - * - * `[attr*=value]` - * - * @param {AstAttribute} query - * @param {Node} node - * @returns {boolean} - */ -function containsString(query, node) { - indexable(node) - const value = node[query.name] - const queryValue = attributeValue(query) - - return Boolean( - typeof value === 'string' && queryValue && value.includes(queryValue) - ) -} - -// Shouldn’t be called, parser throws an error instead. -/** - * @param {unknown} query - * @returns {never} - */ -/* c8 ignore next 4 */ -function unknownOperator(query) { - // @ts-expect-error: `operator` guaranteed. - throw new Error('Unknown operator `' + query.operator + '`') -} - -/** - * @param {AstAttribute} query - * @returns {string} - */ -function attributeValue(query) { - const queryValue = query.value + if (normal) { + normal = normal.toLowerCase() + } + } - /* c8 ignore next 4 -- never happens with our config */ - if (!queryValue) unreachable('Attribute values should be defined') - if (queryValue.type === 'Substitution') { - unreachable('Substitutions are not enabled') + if (value !== undefined) { + switch (query.operator) { + // Exact. + case '=': { + return typeof normal === 'string' && key === normal + } + + // Ends. + case '$=': { + return typeof value === 'string' && value.slice(-key.length) === key + } + + // Contains. + case '*=': { + return typeof value === 'string' && value.includes(key) + } + + // Begins. + case '^=': { + return typeof value === 'string' && key === value.slice(0, key.length) + } + + // Space-separated list. + case '~=': { + // type-coverage:ignore-next-line -- some bug with TS. + return (Array.isArray(value) && value.includes(key)) || normal === key + } + // Other values are not yet supported by CSS. + // No default + } } - return queryValue.value + return false } diff --git a/lib/test.js b/lib/test.js index 065656d..53c6774 100644 --- a/lib/test.js +++ b/lib/test.js @@ -5,7 +5,7 @@ * @typedef {import('./types.js').SelectState} SelectState */ -import {attribute} from './attribute.js' +import {attributes} from './attribute.js' import {pseudo} from './pseudo.js' /** @@ -28,7 +28,7 @@ export function test(query, node, index, parent, state) { (!query.tag || query.tag.type === 'WildcardTag' || query.tag.name === node.type) && - (!query.attributes || attribute(query, node)) && + (!query.attributes || attributes(query, node)) && (!query.pseudoClasses || pseudo(query, node, index, parent, state)) ) } diff --git a/test/matches.js b/test/matches.js index 69538ea..4ab950d 100644 --- a/test/matches.js +++ b/test/matches.js @@ -639,6 +639,56 @@ test('select.matches()', async function (t) { } ) + await t.test('attributes, case modifiers `[attr i]`', async function (t) { + await t.test( + 'should throw when using a modifier in a wrong place', + async function () { + assert.throws(function () { + matches('[x y]', u('a')) + }, /Expected a valid attribute selector operator/) + } + ) + + await t.test( + 'should throw when using an unknown modifier', + async function () { + assert.throws(function () { + matches('[x=y z]', u('a')) + }, /Unknown attribute case sensitivity modifier/) + } + ) + + await t.test( + 'should match sensitively (default) with `s` (#1)', + async function () { + assert.ok(matches('[x=y s]', u('a', {x: 'y'}))) + } + ) + + await t.test( + 'should match sensitively (default) with `s` (#2)', + async function () { + assert.ok(!matches('[x=y s]', u('a', {x: 'Y'}))) + } + ) + + await t.test('should match insensitively with `i` (#1)', async function () { + assert.ok(matches('[x=y i]', u('a', {x: 'y'}))) + }) + + await t.test('should match insensitively with `i` (#2)', async function () { + assert.ok(matches('[x=y i]', u('a', {x: 'Y'}))) + }) + + await t.test('should match insensitively with `i` (#3)', async function () { + assert.ok(matches('[x=Y i]', u('a', {x: 'y'}))) + }) + + await t.test('should match insensitively with `i` (#4)', async function () { + assert.ok(matches('[x=Y i]', u('a', {x: 'Y'}))) + }) + }) + await t.test('pseudo-classes', async function (t) { await t.test(':is', async function (t) { await t.test(