From bde355b7f94a1e8d638856e764702c5029119426 Mon Sep 17 00:00:00 2001 From: Gar Date: Tue, 21 Jun 2022 07:57:26 -0700 Subject: [PATCH] fix: attribute selectors, workspace root, cleanup Giant refactor: - adds case insensitivity to attribute selectors - fixes --include-workspace-root - fixes -w results - docs updates - consolidating state into the `results` object and passing that to the functions that the ast walker functions use. - optimizing and refactoring other loops - code consolidation and consistency between two different attribute selectors - Un-asyncify functions that don't do async operators. We leave the exported fn async so we can add some in the future. - lots of other minor tweaks/cleanups --- docs/content/commands/npm-query.md | 2 +- .../content/using-npm/dependency-selectors.md | 25 +- lib/commands/query.js | 79 +- lib/utils/query-selector-all-response.js | 30 - package-lock.json | 3 +- .../test/lib/commands/query.js.test.cjs | 40 +- .../test/lib/load-all-commands.js.test.cjs | 2 +- tap-snapshots/test/lib/npm.js.test.cjs | 2 +- test/lib/commands/query.js | 51 +- workspaces/arborist/lib/query-selector-all.js | 906 +++++++++--------- .../test/spec-from-lock.js.test.cjs | 9 + .../arborist/test/query-selector-all.js | 54 +- 12 files changed, 664 insertions(+), 539 deletions(-) delete mode 100644 lib/utils/query-selector-all-response.js diff --git a/docs/content/commands/npm-query.md b/docs/content/commands/npm-query.md index c9636bdf3a332..061bf4ce42442 100644 --- a/docs/content/commands/npm-query.md +++ b/docs/content/commands/npm-query.md @@ -11,7 +11,7 @@ description: Dependency selector query ```bash -npm query +npm query ``` diff --git a/docs/content/using-npm/dependency-selectors.md b/docs/content/using-npm/dependency-selectors.md index 997a2459afb30..47d362e831e3d 100644 --- a/docs/content/using-npm/dependency-selectors.md +++ b/docs/content/using-npm/dependency-selectors.md @@ -20,16 +20,29 @@ The `npm query` commmand exposes a new dependency selector syntax (informed by & - there is no "type" or "tag" selectors (ex. `div, h1, a`) as a dependency/target is the only type of `Node` that can be queried - the term "dependencies" is in reference to any `Node` found in a `tree` returned by `Arborist` +#### Combinators + +- `>` direct descendant/child +- ` ` any descendant/child +- `~` sibling + #### Selectors - `*` universal selector - `#` dependency selector (equivalent to `[name="..."]`) - `#@` (equivalent to `[name=]:semver()`) - `,` selector list delimiter -- `.` class selector -- `:` pseudo class selector -- `>` direct decendent/child selector -- `~` sibling selector +- `.` dependency type selector +- `:` pseudo selector + +#### Dependency Type Selectors + +- `.prod` dependency found in the `dependencies` section of `package.json`, or is a child of said dependency +- `.dev` dependency found in the `devDependencies` section of `package.json`, or is a child of said dependency +- `.optional` dependency found in the `optionalDependencies` section of `package.json`, or has `"optional": true` set in its entry in the `peerDependenciesMeta` section of `package.json`, or a child of said dependency +- `.peer` dependency found in the `peerDependencies` section of `package.json` +- `.workspace` dependency found in the `workspaces` section of `package.json` +- `.bundled` dependency found in the `bundleDependencies` section of `package.json`, or is a child of said dependency #### Pseudo Selectors - [`:not()`](https://developer.mozilla.org/en-US/docs/Web/CSS/:not) @@ -58,7 +71,7 @@ The attribute selector evaluates the key/value pairs in `package.json` if they a - `[attribute~=value]` attribute value contains word... - `[attribute*=value]` attribute value contains string... - `[attribute|=value]` attribute value is equal to or starts with... -- `[attribute^=value]` attribute value begins with... +- `[attribute^=value]` attribute value starts with... - `[attribute$=value]` attribute value ends with... #### `Array` & `Object` Attribute Selectors @@ -72,7 +85,7 @@ The generic `:attr()` pseudo selector standardizes a pattern which can be used f *:attr(scripts, [test~=tap]) ``` -#### Nested `Objects` +#### Nested `Objects` Nested objects are expressed as sequential arguments to `:attr()`. diff --git a/lib/commands/query.js b/lib/commands/query.js index 2058124ab7918..2e89985caaf26 100644 --- a/lib/commands/query.js +++ b/lib/commands/query.js @@ -3,28 +3,30 @@ const { resolve } = require('path') const Arborist = require('@npmcli/arborist') const BaseCommand = require('../base-command.js') -const QuerySelectorAllResponse = require('../utils/query-selector-all-response.js') -// retrieves a normalized inventory -const convertInventoryItemsToResponses = inventory => { - const responses = [] - const responsesSeen = new Set() - for (const node of inventory) { - if (!responsesSeen.has(node.target.realpath)) { - const item = new QuerySelectorAllResponse(node) - responses.push(item) - responsesSeen.add(item.path) - } +class QuerySelectorItem { + constructor (node) { + // all enumerable properties from the target + Object.assign(this, node.target.package) + + // append extra info + this.pkgid = node.target.pkgid + this.location = node.target.location + this.path = node.target.path + this.realpath = node.target.realpath + this.resolved = node.target.resolved + this.isLink = node.target.isLink + this.isWorkspace = node.target.isWorkspace } - return responses } class Query extends BaseCommand { + #response = [] // response is the query response + #seen = new Set() // paths we've seen so we can keep response deduped + static description = 'Retrieve a filtered list of packages' static name = 'query' - static usage = [ - '', - ] + static usage = [''] static ignoreImplicitWorkspace = false @@ -35,9 +37,13 @@ class Query extends BaseCommand { 'include-workspace-root', ] - async exec (args, workspaces) { - const globalTop = resolve(this.npm.globalDir, '..') - const where = this.npm.config.get('global') ? globalTop : this.npm.prefix + get parsedResponse () { + return JSON.stringify(this.#response, null, 2) + } + + async exec (args) { + // one dir up from wherever node_modules lives + const where = resolve(this.npm.dir, '..') const opts = { ...this.npm.flatOptions, path: where, @@ -45,33 +51,42 @@ class Query extends BaseCommand { const arb = new Arborist(opts) const tree = await arb.loadActual(opts) const items = await tree.querySelectorAll(args[0]) - const res = - JSON.stringify(convertInventoryItemsToResponses(items), null, 2) + this.buildResponse(items) - return this.npm.output(res) + this.npm.output(this.parsedResponse) } async execWorkspaces (args, filters) { await this.setWorkspaces(filters) - const result = new Set() const opts = { ...this.npm.flatOptions, path: this.npm.prefix, } const arb = new Arborist(opts) const tree = await arb.loadActual(opts) - for (const [, workspacePath] of this.workspaces.entries()) { - this.prefix = workspacePath - const [workspace] = await tree.querySelectorAll(`.workspace:path(${workspacePath})`) - const res = await workspace.querySelectorAll(args[0]) - const converted = convertInventoryItemsToResponses(res) - for (const item of converted) { - result.add(item) + for (const workspacePath of this.workspacePaths) { + let items + if (workspacePath === tree.root.path) { + // include-workspace-root + items = await tree.querySelectorAll(args[0]) + } else { + const [workspace] = await tree.querySelectorAll(`.workspace:path(${workspacePath})`) + items = await workspace.target.querySelectorAll(args[0]) + } + this.buildResponse(items) + } + this.npm.output(this.parsedResponse) + } + + // builds a normalized inventory + buildResponse (items) { + for (const node of items) { + if (!this.#seen.has(node.target.realpath)) { + const item = new QuerySelectorItem(node) + this.#response.push(item) + this.#seen.add(item.realpath) } } - // when running in workspaces names, make sure to key by workspace - // name the results of each value retrieved in each ws - this.npm.output(JSON.stringify([...result], null, 2)) } } diff --git a/lib/utils/query-selector-all-response.js b/lib/utils/query-selector-all-response.js deleted file mode 100644 index 56208a2d41689..0000000000000 --- a/lib/utils/query-selector-all-response.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict' - -class QuerySelectorAllResponse { - #node = null - - constructor (node) { - const { - location, - path, - realpath, - resolved, - isLink, - isWorkspace, - pkgid, - } = node.target - - Object.assign(this, node.target.package) - - // append extra info - this.pkgid = pkgid - this.location = location - this.path = path - this.realpath = realpath - this.resolved = resolved - this.isLink = isLink - this.isWorkspace = isWorkspace - } -} - -module.exports = QuerySelectorAllResponse diff --git a/package-lock.json b/package-lock.json index a31b3476fde3b..136ee7e625850 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5551,8 +5551,9 @@ }, "node_modules/p-map": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", "inBundle": true, - "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" }, diff --git a/tap-snapshots/test/lib/commands/query.js.test.cjs b/tap-snapshots/test/lib/commands/query.js.test.cjs index ee87fd3b32233..946a1d4b2e570 100644 --- a/tap-snapshots/test/lib/commands/query.js.test.cjs +++ b/tap-snapshots/test/lib/commands/query.js.test.cjs @@ -13,8 +13,8 @@ exports[`test/lib/commands/query.js TAP global > should return global package 1` "_id": "lorem@2.0.0", "pkgid": "lorem@2.0.0", "location": "node_modules/lorem", - "path": "{CWD}/test/lib/commands/tap-testdir-query-global/global/lib/node_modules/lorem", - "realpath": "{CWD}/test/lib/commands/tap-testdir-query-global/global/lib/node_modules/lorem", + "path": "{CWD}/test/lib/commands/tap-testdir-query-global/global/node_modules/lorem", + "realpath": "{CWD}/test/lib/commands/tap-testdir-query-global/global/node_modules/lorem", "resolved": null, "isLink": false, "isWorkspace": false @@ -22,6 +22,40 @@ exports[`test/lib/commands/query.js TAP global > should return global package 1` ] ` +exports[`test/lib/commands/query.js TAP include-workspace-root > should return workspace object and root object 1`] = ` +[ + { + "name": "project", + "workspaces": [ + "c" + ], + "dependencies": { + "a": "^1.0.0", + "b": "^1.0.0" + }, + "pkgid": "project@", + "location": "", + "path": "{CWD}/test/lib/commands/tap-testdir-query-include-workspace-root/prefix", + "realpath": "{CWD}/test/lib/commands/tap-testdir-query-include-workspace-root/prefix", + "resolved": null, + "isLink": false, + "isWorkspace": false + }, + { + "name": "c", + "version": "1.0.0", + "_id": "c@1.0.0", + "pkgid": "c@1.0.0", + "location": "c", + "path": "{CWD}/test/lib/commands/tap-testdir-query-include-workspace-root/prefix/c", + "realpath": "{CWD}/test/lib/commands/tap-testdir-query-include-workspace-root/prefix/c", + "resolved": null, + "isLink": false, + "isWorkspace": true + } +] +` + exports[`test/lib/commands/query.js TAP linked node > should return linked node res 1`] = ` [ { @@ -39,7 +73,7 @@ exports[`test/lib/commands/query.js TAP linked node > should return linked node ] ` -exports[`test/lib/commands/query.js TAP simple query > should return root object 1`] = ` +exports[`test/lib/commands/query.js TAP simple query > should return root object and direct children 1`] = ` [ { "name": "project", diff --git a/tap-snapshots/test/lib/load-all-commands.js.test.cjs b/tap-snapshots/test/lib/load-all-commands.js.test.cjs index de34bd81e5c04..a50b1845bc3b4 100644 --- a/tap-snapshots/test/lib/load-all-commands.js.test.cjs +++ b/tap-snapshots/test/lib/load-all-commands.js.test.cjs @@ -687,7 +687,7 @@ exports[`test/lib/load-all-commands.js TAP load each command query > must match Retrieve a filtered list of packages Usage: -npm query +npm query Options: [-g|--global] diff --git a/tap-snapshots/test/lib/npm.js.test.cjs b/tap-snapshots/test/lib/npm.js.test.cjs index c3a7b8fc80608..3fc8c503364b1 100644 --- a/tap-snapshots/test/lib/npm.js.test.cjs +++ b/tap-snapshots/test/lib/npm.js.test.cjs @@ -739,7 +739,7 @@ All commands: query Retrieve a filtered list of packages Usage: - npm query + npm query Options: [-g|--global] diff --git a/test/lib/commands/query.js b/test/lib/commands/query.js index d4e98f28c69b3..f13777f9cd94f 100644 --- a/test/lib/commands/query.js +++ b/test/lib/commands/query.js @@ -7,6 +7,8 @@ t.cleanSnapshot = (str) => { .replace(/\r\n/g, '\n') return normalizePath(str) .replace(new RegExp(normalizePath(process.cwd()), 'g'), '{CWD}') + // normalize between windows and posix + .replace(new RegExp('lib/node_modules', 'g'), 'node_modules') } t.test('simple query', async t => { @@ -32,7 +34,7 @@ t.test('simple query', async t => { }, }) await npm.exec('query', [':root, :root > *']) - t.matchSnapshot(joinedOutput(), 'should return root object') + t.matchSnapshot(joinedOutput(), 'should return root object and direct children') }) t.test('workspace query', async t => { @@ -72,6 +74,43 @@ t.test('workspace query', async t => { t.matchSnapshot(joinedOutput(), 'should return workspace object') }) +t.test('include-workspace-root', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + config: { + 'include-workspace-root': true, + workspaces: ['c'], + }, + prefixDir: { + node_modules: { + a: { + name: 'a', + version: '1.0.0', + }, + b: { + name: 'b', + version: '^2.0.0', + }, + c: t.fixture('symlink', '../c'), + }, + c: { + 'package.json': JSON.stringify({ + name: 'c', + version: '1.0.0', + }), + }, + 'package.json': JSON.stringify({ + name: 'project', + workspaces: ['c'], + dependencies: { + a: '^1.0.0', + b: '^1.0.0', + }, + }), + }, + }) + await npm.exec('query', [':scope'], ['c']) + t.matchSnapshot(joinedOutput(), 'should return workspace object and root object') +}) t.test('linked node', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { prefixDir: { @@ -101,7 +140,17 @@ t.test('global', async t => { config: { global: true, }, + // This is a global dir that works in both windows and non-windows, that's + // why it has two node_modules folders globalPrefixDir: { + node_modules: { + lorem: { + 'package.json': JSON.stringify({ + name: 'lorem', + version: '2.0.0', + }), + }, + }, lib: { node_modules: { lorem: { diff --git a/workspaces/arborist/lib/query-selector-all.js b/workspaces/arborist/lib/query-selector-all.js index e357a660d0258..752d0a2d72bd5 100644 --- a/workspaces/arborist/lib/query-selector-all.js +++ b/workspaces/arborist/lib/query-selector-all.js @@ -7,512 +7,534 @@ const npa = require('npm-package-arg') const minimatch = require('minimatch') const semver = require('semver') -// handle results for parsed query asts, results are stored in a map that -// has a key that points to each ast selector node and stores the resulting -// array of arborist nodes as its value, that is essential to how we handle -// multiple query selectors, e.g: `#a, #b, #c` <- 3 diff ast selector nodes +// handle results for parsed query asts, results are stored in a map that has a +// key that points to each ast selector node and stores the resulting array of +// arborist nodes as its value, that is essential to how we handle multiple +// query selectors, e.g: `#a, #b, #c` <- 3 diff ast selector nodes class Results { - #results = null - #currentAstSelector = null + #currentAstSelector + #initialItems + #inventory + #pendingCombinator + #results = new Map() + #targetNode + + constructor (opts) { + this.#currentAstSelector = opts.rootAstNode.nodes[0] + this.#inventory = opts.inventory + this.#initialItems = opts.initialItems + this.#targetNode = opts.targetNode + + this.currentResults = this.#initialItems + + // reset by rootAstNode walker + this.currentAstNode = opts.rootAstNode + } - constructor (rootAstNode) { - this.#results = new Map() - this.#currentAstSelector = rootAstNode.nodes[0] + get currentResults () { + return this.#results.get(this.#currentAstSelector) } - /* eslint-disable-next-line accessor-pairs */ - set currentAstSelector (value) { - this.#currentAstSelector = value + set currentResults (value) { + this.#results.set(this.#currentAstSelector, value) } - get currentResult () { - return this.#results.get(this.#currentAstSelector) + // retrieves the initial items to which start the filtering / matching + // for most of the different types of recognized ast nodes, e.g: class (aka + // depType), id, *, etc in different contexts we need to start with the + // current list of filtered results, for example a query for `.workspace` + // actually means the same as `*.workspace` so we want to start with the full + // inventory if that's the first ast node we're reading but if it appears in + // the middle of a query it should respect the previous filtered results, + // combinators are a special case in which we always want to have the + // complete inventory list in order to use the left-hand side ast node as a + // filter combined with the element on its right-hand side + get initialItems () { + const firstParsed = + (this.currentAstNode.parent.nodes[0] === this.currentAstNode) && + (this.currentAstNode.parent.parent.type === 'root') + + if (firstParsed) { + return this.#initialItems + } + if (this.currentAstNode.prev().type === 'combinator') { + return this.#inventory + } + return this.currentResults } - set currentResult (value) { - this.#results.set(this.#currentAstSelector, value) + // combinators need information about previously filtered items along + // with info of the items parsed / retrieved from the selector right + // past the combinator, for this reason combinators are stored and + // only ran as the last part of each selector logic + processPendingCombinator (nextResults) { + if (this.#pendingCombinator) { + const res = this.#pendingCombinator(this.currentResults, nextResults) + this.#pendingCombinator = null + this.currentResults = res + } else { + this.currentResults = nextResults + } } - // when collecting results to a root astNode, we traverse the list of - // child selector nodes and collect all of their resulting arborist nodes - // into a single/flat Set of items, this ensures we also deduplicate items + // when collecting results to a root astNode, we traverse the list of child + // selector nodes and collect all of their resulting arborist nodes into a + // single/flat Set of items, this ensures we also deduplicate items collect (rootAstNode) { - const acc = new Set() - for (const n of rootAstNode.nodes) { - for (const node of this.#results.get(n)) { - acc.add(node) - } - } - return acc + return new Set(rootAstNode.nodes.flatMap(n => this.#results.get(n))) } -} -const parentCache = new Map() -const retrieveNodesFromParsedAst = async ({ - initialItems, - inventory, - rootAstNode, - targetNode, -}) => { - if (!rootAstNode.nodes) { - return new Set() + // selector types map to the '.type' property of the ast nodes via `${astNode.type}Type` + // + // attribute selector [name=value], etc + attributeType () { + const nextResults = this.initialItems.filter(node => + attributeMatch(this.currentAstNode, node.package) + ) + this.processPendingCombinator(nextResults) } - const ArboristNode = targetNode.constructor - - const results = new Results(rootAstNode) - let currentAstNode = rootAstNode - let pendingCombinator = null - - results.currentResult = initialItems - - // maps containing the logic to parse each of the supported css selectors - const attributeOperatorsMap = new Map(Object.entries({ - '' ({ attribute, value, pkg }) { - return Boolean(pkg[attribute]) - }, - '=' ({ attribute, value, pkg }) { - return String(pkg[attribute] || '') === value - }, - '~=' ({ attribute, value, pkg }) { - return (String(pkg[attribute] || '').match(/\w+/g) || []).includes(value) - }, - '*=' ({ attribute, value, pkg }) { - return String(pkg[attribute] || '').indexOf(value) > -1 - }, - '|=' ({ attribute, value, pkg }) { - return String(pkg[attribute] || '').split('-')[0] === value - }, - '^=' ({ attribute, value, pkg }) { - return String(pkg[attribute] || '').startsWith(value) - }, - '$=' ({ attribute, value, pkg }) { - return String(pkg[attribute] || '').endsWith(value) - }, - })) - const classesMap = new Map(Object.entries({ - '.prod' (prevResults) { - return Promise.resolve(prevResults.filter(node => - [...node.edgesIn].some(edge => edge.prod))) - }, - '.dev' (prevResults) { - return Promise.resolve(prevResults.filter(node => - [...node.edgesIn].some(edge => edge.dev))) - }, - '.optional' (prevResults) { - return Promise.resolve(prevResults.filter(node => - [...node.edgesIn].some(edge => edge.optional))) - }, - '.peer' (prevResults) { - return Promise.resolve(prevResults.filter(node => - [...node.edgesIn].some(edge => edge.peer))) - }, - '.workspace' (prevResults) { - return Promise.resolve( - prevResults.filter(node => node.isWorkspace)) - }, - '.bundled' (prevResults) { - return Promise.resolve( - prevResults.filter(node => node.inBundle)) - }, - })) - - const hasParent = (node, compareNodes) => { - if (parentCache.has(node) && parentCache.get(node).has(compareNodes)) { - return Promise.resolve(true) + // dependency type selector (i.e. .prod, .dev, etc) + // css calls this class, we interpret is as dependency type + classType () { + const depTypeFn = depTypes[String(this.currentAstNode)] + if (!depTypeFn) { + throw Object.assign( + new Error(`\`${String(this.currentAstNode)}\` is not a supported dependency type.`), + { code: 'EQUERYNODEPTYPE' } + ) } - const parentFound = compareNodes.some(compareNode => { - // follows logical parent for link anscestors - return (node.isTop && node.resolveParent) === compareNode || - // follows edges-in to check if they match a possible parent - [...node.edgesIn].some(edge => - edge && edge.from === compareNode) - }) + const nextResults = depTypeFn(this.initialItems) + this.processPendingCombinator(nextResults) + } - if (parentFound) { - if (!parentCache.has(node)) { - parentCache.set(node, new Set()) - } - parentCache.get(node).add(compareNodes) - } + // combinators (i.e. '>', ' ', '~') + combinatorType () { + this.#pendingCombinator = combinators[String(this.currentAstNode)] + } - return Promise.resolve(parentFound) + // name selectors (i.e. #foo, #foo@1.0.0) + // css calls this id, we interpret it as name + idType () { + const spec = npa(this.currentAstNode.value) + const nextResults = this.initialItems.filter(node => + (node.name === spec.name || node.package.name === spec.name) && + (semver.satisfies(node.version, spec.fetchSpec) || !spec.rawSpec)) + this.processPendingCombinator(nextResults) } - // checks if a given node is a descendant of any - // of the nodes provided in the compare nodes array - const hasAscendant = async (node, compareNodes) => { - const hasP = await hasParent(node, compareNodes) - if (hasP) { - return true + // pseudo selectors (prefixed with :) + pseudoType () { + const pseudoFn = `${this.currentAstNode.value.slice(1)}Pseudo` + if (!this[pseudoFn]) { + throw Object.assign( + new Error(`\`${this.currentAstNode.value + }\` is not a supported pseudo selector.`), + { code: 'EQUERYNOPSEUDO' } + ) } + const nextResults = this[pseudoFn]() + this.processPendingCombinator(nextResults) + } - const lookupEdgesIn = async (node) => { - const edgesIn = [...node.edgesIn] - const p = await Promise.all( - edgesIn.map(edge => - edge && edge.from && hasAscendant(edge.from, compareNodes)) - ) - return edgesIn.some((edge, index) => p[index]) + selectorType () { + this.#currentAstSelector = this.currentAstNode + // starts a new array in which resulting items + // can be stored for each given ast selector + if (!this.currentResults) { + this.currentResults = [] } - const ancestorFound = (node.isTop && node.resolveParent) - ? await hasAscendant(node.resolveParent, compareNodes) - : await lookupEdgesIn(node) + } - return ancestorFound + universalType () { + this.processPendingCombinator(this.initialItems) } - const combinatorsMap = new Map(Object.entries({ - async '>' (prevResults, nextResults) { - const p = await Promise.all( - nextResults.map(i => hasParent(i, prevResults)) - ) - return nextResults.filter((nextItem, index) => p[index]) - }, - async ' ' (prevResults, nextResults) { - const p = await Promise.all( - nextResults.map(i => hasAscendant(i, prevResults)) - ) - return nextResults.filter((nextItem, index) => p[index]) - }, - async '~' (prevResults, nextResults) { - const p = await Promise.all(nextResults.map(nextItem => { - const seenNodes = new Set() - const possibleParentNodes = - prevResults - .flatMap(node => { - seenNodes.add(node) - return [...node.edgesIn] - }) - .map(edge => edge.from) - .filter(Boolean) - - return !seenNodes.has(nextItem) && - hasParent(nextItem, [...possibleParentNodes]) - })) - return nextResults.filter((nextItem, index) => p[index]) - }, - })) - const pseudoMap = new Map(Object.entries({ - async ':attr' () { - const initialItems = getInitialItems() - const { lookupProperties, attributeMatcher } = currentAstNode - - const match = (attributeMatcher, obj) => { - // in case the current object is an array - // then we try to match every item in the array - if (Array.isArray(obj[attributeMatcher.attribute])) { - return obj[attributeMatcher.attribute].find((i, index) => - attributeOperatorsMap.get(attributeMatcher.operator)({ - attribute: index, - value: attributeMatcher.value, - pkg: obj[attributeMatcher.attribute], - }) - ) + // pseudo selectors map to the 'value' property of the pseudo selectors in the ast nodes + // via selectors via `${value.slice(1)}Pseudo` + attrPseudo () { + const { lookupProperties, attributeMatcher } = this.currentAstNode + + return this.initialItems.filter(node => { + let objs = [node.package] + for (const prop of lookupProperties) { + // if an isArray symbol is found that means we'll need to iterate + // over the previous found array to basically make sure we traverse + // all its indexes testing for possible objects that may eventually + // hold more keys specified in a selector + if (prop === arrayDelimiter) { + objs = objs.flat() + continue } - return attributeOperatorsMap.get(attributeMatcher.operator)({ - attribute: attributeMatcher.attribute, - value: attributeMatcher.value, - pkg: obj, - }) + // otherwise just maps all currently found objs + // to the next prop from the lookup properties list, + // filters out any empty key lookup + objs = objs.flatMap(obj => obj[prop] || []) + + // in case there's no property found in the lookup + // just filters that item out + const noAttr = objs.every(obj => !obj) + if (noAttr) { + return false + } } - return initialItems.filter(node => { - let objs = [node.package] - for (const prop of lookupProperties) { - // if an isArray symbol is found that means we'll need to iterate - // over the previous found array to basically make sure we traverse - // all its indexes testing for possible objects that may eventually - // hold more keys specified in a selector - if (prop === arrayDelimiter) { - const newObjs = [] - for (const obj of objs) { - if (Array.isArray(obj)) { - obj.forEach((i, index) => { - newObjs.push(obj[index]) - }) - } else { - newObjs.push(obj) - } - } - objs = newObjs - continue - } else { - // otherwise just maps all currently found objs - // to the next prop from the lookup properties list, - // filters out any empty key lookup - objs = objs.map(obj => obj[prop]).filter(Boolean) - } + // if any of the potential object matches + // that item should be in the final result + return objs.some(obj => attributeMatch(attributeMatcher, obj)) + }) + } - // in case there's no property found in the lookup - // just filters that item out - const noAttr = objs.every(obj => !obj) - if (noAttr) { - return false - } - } + emptyPseudo () { + return this.initialItems.filter(node => node.edgesOut.size === 0) + } - // if any of the potential object matches - // that item should be in the final result - return objs.some(obj => match(attributeMatcher, obj)) + extraneousPseudo () { + return this.initialItems.filter(node => node.extraneous) + } + + hasPseudo () { + const found = [] + for (const item of this.initialItems) { + const res = retrieveNodesFromParsedAst({ + // This is the one time initialItems differs from inventory + initialItems: [item], + inventory: this.#inventory, + rootAstNode: this.currentAstNode.nestedNode, + targetNode: item, }) - }, - async ':empty' () { - return getInitialItems().filter(node => node.edgesOut.size === 0) - }, - async ':extraneous' () { - return getInitialItems().filter(node => node.extraneous) - }, - async ':has' () { - const initialItems = getInitialItems() - const hasResults = new Map() - for (const item of initialItems) { - const res = await retrieveNodesFromParsedAst({ - initialItems: [item], - inventory, - rootAstNode: currentAstNode.nestedNode, - targetNode: item, - }) - hasResults.set(item, res) + if (res.size > 0) { + found.push(item) + break } - return initialItems.filter(node => hasResults.get(node).size > 0) - }, - async ':invalid' () { - return getInitialItems().filter(node => - [...node.edgesIn].some(edge => edge.invalid)) - }, - async ':is' () { - const initialItems = getInitialItems() - const res = await retrieveNodesFromParsedAst({ - initialItems, - inventory, - rootAstNode: currentAstNode.nestedNode, - targetNode: currentAstNode, - }) - return [...res] - }, - async ':link' () { - return getInitialItems().filter(node => node.isLink || (node.isTop && !node.isRoot)) - }, - async ':missing' () { - return inventory.reduce((res, node) => { - for (const edge of node.edgesOut.values()) { - if (edge.missing) { - const pkg = { name: edge.name, version: edge.spec } - res.push(new ArboristNode({ pkg })) - } + } + return found + } + + invalidPseudo () { + const found = [] + for (const node of this.initialItems) { + for (const edge of node.edgesIn) { + if (edge.invalid) { + found.push(node) + break } - return res - }, []) - }, - async ':not' () { - const initialItems = getInitialItems() - const res = await retrieveNodesFromParsedAst({ - initialItems, - inventory: initialItems, - rootAstNode: currentAstNode.nestedNode, - targetNode: currentAstNode, - }) - const internalSelector = new Set(res) - return initialItems.filter(node => - !internalSelector.has(node)) - }, - async ':path' () { - return getInitialItems().filter(node => - currentAstNode.pathValue - ? minimatch( - node.realpath.replace(/\\+/g, '/'), - resolve(node.root.realpath, currentAstNode.pathValue).replace(/\\+/g, '/') - ) - : true - ) - }, - async ':private' () { - return getInitialItems().filter(node => node.package.private) - }, - async ':root' () { - return getInitialItems().filter(node => node === targetNode.root) - }, - async ':scope' () { - return getInitialItems().filter(node => node === targetNode) - }, - async ':semver' () { - return currentAstNode.semverValue - ? getInitialItems().filter(node => - semver.satisfies(node.version, currentAstNode.semverValue)) - : getInitialItems() - }, - async ':type' () { - return currentAstNode.typeValue - ? getInitialItems() - .flatMap(node => [...node.edgesIn]) - .filter(edge => npa(`${edge.name}@${edge.spec}`).type === currentAstNode.typeValue) - .map(edge => edge.to) - .filter(Boolean) - : getInitialItems() - }, - })) + } + } + return found + } - // retrieves the initial items to which start the filtering / matching - // for most of the different types of recognized ast nodes, e.g: class, - // id, *, etc in different contexts we need to start with the current list - // of filtered results, for example a query for `.workspace` actually - // means the same as `*.workspace` so we want to start with the full - // inventory if that's the first ast node we're reading but if it appears - // in the middle of a query it should respect the previous filtered - // results, combinators are a special case in which we always want to - // have the complete inventory list in order to use the left-hand side - // ast node as a filter combined with the element on its right-hand side - const getInitialItems = () => { - const firstParsed = currentAstNode.parent.nodes[0] === currentAstNode && - currentAstNode.parent.parent.type === 'root' - - return firstParsed - ? initialItems - : currentAstNode.prev().type === 'combinator' - ? inventory - : results.currentResult + isPseudo () { + const res = retrieveNodesFromParsedAst({ + initialItems: this.initialItems, + inventory: this.#inventory, + rootAstNode: this.currentAstNode.nestedNode, + targetNode: this.currentAstNode, + }) + return [...res] } - // combinators need information about previously filtered items along - // with info of the items parsed / retrieved from the selector right - // past the combinator, for this reason combinators are stored and - // only ran as the last part of each selector logic - const processPendingCombinator = async (prevResults, nextResults) => { - if (pendingCombinator) { - const res = await pendingCombinator(prevResults, nextResults) - pendingCombinator = null + linkPseudo () { + return this.initialItems.filter(node => node.isLink || (node.isTop && !node.isRoot)) + } + + missingPseudo () { + return this.#inventory.reduce((res, node) => { + for (const edge of node.edgesOut.values()) { + if (edge.missing) { + const pkg = { name: edge.name, version: edge.spec } + res.push(new this.#targetNode.constructor({ pkg })) + } + } return res - } - return nextResults + }, []) } - // below are the functions containing the logic to - // parse each of the recognized css selectors types - const attribute = async () => { - const { - qualifiedAttribute: attribute, - operator = '', - value, - } = currentAstNode - const prevResults = results.currentResult - const nextResults = getInitialItems().filter(node => { - // in case the current obj to check is an array, traverse - // all its items and try to match attributes instead - if (Array.isArray(node.package[attribute])) { - return node.package[attribute].find((i, index) => - attributeOperatorsMap.get(operator)({ - attribute: index, - value, - pkg: node.package[attribute], - }) - ) + notPseudo () { + const res = retrieveNodesFromParsedAst({ + initialItems: this.initialItems, + inventory: this.#inventory, + rootAstNode: this.currentAstNode.nestedNode, + targetNode: this.currentAstNode, + }) + const internalSelector = new Set(res) + return this.initialItems.filter(node => + !internalSelector.has(node)) + } + + pathPseudo () { + return this.initialItems.filter(node => { + if (!this.currentAstNode.pathValue) { + return true } + return minimatch( + node.realpath.replace(/\\+/g, '/'), + resolve(node.root.realpath, this.currentAstNode.pathValue).replace(/\\+/g, '/') + ) + }) + } + + privatePseudo () { + return this.initialItems.filter(node => node.package.private) + } + + rootPseudo () { + return this.initialItems.filter(node => node === this.#targetNode.root) + } + + scopePseudo () { + return this.initialItems.filter(node => node === this.#targetNode) + } + + semverPseudo () { + if (!this.currentAstNode.semverValue) { + return this.initialItems + } + return this.initialItems.filter(node => + semver.satisfies(node.version, this.currentAstNode.semverValue)) + } + + typePseudo () { + if (!this.currentAstNode.typeValue) { + return this.initialItems + } + return this.initialItems + .flatMap(node => { + const found = [] + for (const edge of node.edgesIn) { + if (npa(`${edge.name}@${edge.spec}`).type === this.currentAstNode.typeValue) { + found.push(edge.to) + } + } + return found + }) + } +} - return attributeOperatorsMap.get(operator)({ - attribute, +// TODO attr foo in tests that's a number +// I think attributeMatch should handle it and cast it since the ast parser always returns strings + +// operators for attribute selectors +const attributeOperators = { + // existence of the attribute + '' ({ attr }) { + // TODO existence of attribute in case it's falsey (i.e. 0) + return Boolean(attr) + }, + // attribute value is equivalent + '=' ({ attr, value, insensitive }) { + return attr === value + }, + // attribute value contains word + '~=' ({ attr, value, insensitive }) { + return (attr.match(/\w+/g) || []).includes(value) + }, + // attribute value contains string + '*=' ({ attr, value, insensitive }) { + return attr.includes(value) + }, + // attribute value is equal or starts with + '|=' ({ attr, value, insensitive }) { + return attr.startsWith(`${value}-`) + }, + // attribute value starts with + '^=' ({ attr, value, insensitive }) { + return attr.startsWith(value) + }, + // attribute value ends with + '$=' ({ attr, value, insensitive }) { + return attr.endsWith(value) + }, +} + +const attributeMatch = (matcher, obj) => { + const insensitive = !!matcher.insensitive + const operator = matcher.operator || '' + const attribute = matcher.qualifiedAttribute + let value = matcher.value || '' + if (insensitive) { + value = value.toLowerCase() + } + // in case the current object is an array + // then we try to match every item in the array + if (Array.isArray(obj[attribute])) { + return obj[attribute].find((i, index) => { + let attr = obj[attribute][index] || '' + if (typeof attr !== 'string') { + // It's an object, bail + return false + } + if (insensitive) { + attr = attr.toLowerCase() + } + return attributeOperators[operator]({ + attr, + insensitive, value, - pkg: node.package, }) }) - results.currentResult = - await processPendingCombinator(prevResults, nextResults) + } else { + let attr = obj[attribute] || '' + if (typeof attr !== 'string') { + // It's an object, bail + return false + } + if (insensitive) { + attr = attr.toLowerCase() + } + + return attributeOperators[operator]({ + attr, + value, + insensitive, + }) } - const classType = async () => { - const classFn = classesMap.get(String(currentAstNode)) - if (!classFn) { - throw Object.assign( - new Error(`\`${String(currentAstNode)}\` is not a supported class.`), - { code: 'EQUERYNOCLASS' } - ) +} + +// a dependency is of a given type if any of its edgesIn are also of that type +const filterByType = (nodes, type) => { + const found = [] + for (const node of nodes) { + for (const edge of node.edgesIn) { + if (edge[type]) { + found.push(node) + break + } } - const prevResults = results.currentResult - const nextResults = await classFn(getInitialItems()) - results.currentResult = - await processPendingCombinator(prevResults, nextResults) - } - const combinator = async () => { - pendingCombinator = combinatorsMap.get(String(currentAstNode)) - } - const id = async () => { - const spec = npa(currentAstNode.value) - const prevResults = results.currentResult - const nextResults = getInitialItems().filter(node => - (node.name === spec.name || node.package.name === spec.name) && - (semver.satisfies(node.version, spec.fetchSpec) || !spec.rawSpec)) - results.currentResult = - await processPendingCombinator(prevResults, nextResults) } - const pseudo = async () => { - const pseudoFn = pseudoMap.get(currentAstNode.value) - if (!pseudoFn) { - throw Object.assign( - new Error(`\`${currentAstNode.value - }\` is not a supported pseudo-class.`), - { code: 'EQUERYNOPSEUDOCLASS' } - ) + return found +} + +const depTypes = { + // dependency + '.prod' (prevResults) { + return filterByType(prevResults, 'prod') + }, + // devDependency + '.dev' (prevResults) { + return filterByType(prevResults, 'dev') + }, + // optionalDependency + '.optional' (prevResults) { + return filterByType(prevResults, 'optional') + }, + // peerDependency + '.peer' (prevResults) { + return filterByType(prevResults, 'peer') + }, + // workspace + '.workspace' (prevResults) { + return prevResults.filter(node => node.isWorkspace) + }, + // bundledDependency + '.bundled' (prevResults) { + return prevResults.filter(node => node.inBundle) + }, +} + +// checks if a given node has a direct parent in any of the nodes provided in +// the compare nodes array +const hasParent = (node, compareNodes) => { + // All it takes is one so we loop and return on the first hit + for (const compareNode of compareNodes) { + // follows logical parent for link anscestors + if (node.isTop && (node.resolveParent === compareNode)) { + return true + } + // follows edges-in to check if they match a possible parent + for (const edge of node.edgesIn) { + if (edge && edge.from === compareNode) { + return true + } } - const prevResults = results.currentResult - const nextResults = await pseudoFn() - results.currentResult = - await processPendingCombinator(prevResults, nextResults) } - const selector = async () => { - results.currentAstSelector = currentAstNode - // starts a new array in which resulting items - // can be stored for each given ast selector - if (!results.currentResult) { - results.currentResult = [] + return false +} + +// checks if a given node is a descendant of any of the nodes provided in the +// compareNodes array +const hasAscendant = (node, compareNodes) => { + if (hasParent(node, compareNodes)) { + return true + } + + if (node.isTop && node.resolveParent) { + return hasAscendant(node.resolveParent, compareNodes) + } + for (const edge of node.edgesIn) { + if (edge && edge.from && hasAscendant(edge.from, compareNodes)) { + return true } } - const universal = async () => { - const prevResults = results.currentResult - const nextResults = getInitialItems() - results.currentResult = - await processPendingCombinator(prevResults, nextResults) - } - - // maps each of the recognized css selectors - // to a function that parses it - const retrieveByType = new Map(Object.entries({ - attribute, - class: classType, - combinator, - id, - pseudo, - selector, - universal, - })) - - // walks through the parsed css query and update the - // current result after parsing / executing each ast node - const astNodeQueue = new Set() - rootAstNode.walk((nextAstNode) => { - astNodeQueue.add(nextAstNode) - }) + return false +} - for (const nextAstNode of astNodeQueue) { - currentAstNode = nextAstNode +const combinators = { + // direct descendant + '>' (prevResults, nextResults) { + return nextResults.filter(node => hasParent(node, prevResults)) + }, + // any descendant + ' ' (prevResults, nextResults) { + return nextResults.filter(node => hasAscendant(node, prevResults)) + }, + // sibling + '~' (prevResults, nextResults) { + // Return any node in nextResults that is a sibling of (aka shares a + // parent with) a node in prevResults + const parentNodes = new Set() // Parents of everything in prevResults + for (const node of prevResults) { + for (const edge of node.edgesIn) { + // edge.from always exists cause it's from another node's edgesIn + parentNodes.add(edge.from) + } + } + return nextResults.filter(node => + !prevResults.includes(node) && hasParent(node, [...parentNodes]) + ) + }, +} + +const retrieveNodesFromParsedAst = (opts) => { + // when we first call this it's the parsed query. all other times it's + // results.currentNode.nestedNode + const rootAstNode = opts.rootAstNode - const updateResult = - retrieveByType.get(currentAstNode.type) - await updateResult() + if (!rootAstNode.nodes) { + return new Set() } + const results = new Results(opts) + + rootAstNode.walk((nextAstNode) => { + // This is the only place we reset currentAstNode + results.currentAstNode = nextAstNode + const updateFn = `${results.currentAstNode.type}Type` + if (typeof results[updateFn] !== 'function') { + throw Object.assign( + new Error(`\`${results.currentAstNode.type}\` is not a supported selector.`), + { code: 'EQUERYNOSELECTOR' } + ) + } + results[updateFn]() + }) + return results.collect(rootAstNode) } +// We are keeping this async in the event that we do add async operators, we +// won't have to have a breaking change on this function signature. const querySelectorAll = async (targetNode, query) => { - const rootAstNode = parser(query) - - // results is going to be a Map in which its values are the - // resulting items returned for each parsed css ast selector + // This never changes ever we just pass it around. But we can't scope it to + // this whole file if we ever want to support concurrent calls to this + // function. const inventory = [...targetNode.root.inventory.values()] - const res = await retrieveNodesFromParsedAst({ + // res is a Set of items returned for each parsed css ast selector + const res = retrieveNodesFromParsedAst({ initialItems: inventory, inventory, - rootAstNode, + rootAstNode: parser(query), targetNode, }) diff --git a/workspaces/arborist/tap-snapshots/test/spec-from-lock.js.test.cjs b/workspaces/arborist/tap-snapshots/test/spec-from-lock.js.test.cjs index 840f0eaa15021..c67ddf275ccb5 100644 --- a/workspaces/arborist/tap-snapshots/test/spec-from-lock.js.test.cjs +++ b/workspaces/arborist/tap-snapshots/test/spec-from-lock.js.test.cjs @@ -15,6 +15,7 @@ Result { "fetchSpec": "{..}/some/path", "gitCommittish": undefined, "gitRange": undefined, + "gitSubdir": undefined, "hosted": undefined, "name": "x", "raw": "x@file:../some/path", @@ -33,6 +34,7 @@ Result { "fetchSpec": "{CWD}/x-1.2.3.tgz", "gitCommittish": undefined, "gitRange": undefined, + "gitSubdir": undefined, "hosted": undefined, "name": "x", "raw": "x@x-1.2.3.tgz", @@ -51,6 +53,7 @@ Result { "fetchSpec": "/path/to/x-1.2.3.tgz", "gitCommittish": undefined, "gitRange": undefined, + "gitSubdir": undefined, "hosted": undefined, "name": "x", "raw": "x@/path/to/x-1.2.3.tgz", @@ -69,6 +72,7 @@ Result { "fetchSpec": "/path/to/x-1.2.3.tgz", "gitCommittish": undefined, "gitRange": undefined, + "gitSubdir": undefined, "hosted": undefined, "name": "x", "raw": "x@/path/to/x-1.2.3.tgz", @@ -87,6 +91,7 @@ Result { "fetchSpec": "ssh://git@github.com/isaacs/abbrev-js.git", "gitCommittish": "a9ee72ebc8fe3975f1b0c7aeb3a8f2a806a432eb", "gitRange": undefined, + "gitSubdir": undefined, "hosted": GitHost { "auth": null, "browsefiletemplate": "function browsefiletemplate", @@ -140,6 +145,7 @@ Result { "fetchSpec": "1.2.3", "gitCommittish": undefined, "gitRange": undefined, + "gitSubdir": undefined, "hosted": undefined, "name": "legacy", "raw": "legacy@1.2.3", @@ -158,6 +164,7 @@ Result { "fetchSpec": "{CWD}/foo.tgz", "gitCommittish": undefined, "gitRange": undefined, + "gitSubdir": undefined, "hosted": undefined, "name": "x", "raw": "x@foo.tgz", @@ -176,6 +183,7 @@ Result { "fetchSpec": "1.2.3", "gitCommittish": undefined, "gitRange": undefined, + "gitSubdir": undefined, "hosted": undefined, "name": "x", "raw": "x@1.2.3", @@ -194,6 +202,7 @@ Result { "fetchSpec": "1.2.3", "gitCommittish": undefined, "gitRange": undefined, + "gitSubdir": undefined, "hosted": undefined, "name": "x", "raw": "x@1.2.3", diff --git a/workspaces/arborist/test/query-selector-all.js b/workspaces/arborist/test/query-selector-all.js index 166a654820d78..5d20eaa3823c7 100644 --- a/workspaces/arborist/test/query-selector-all.js +++ b/workspaces/arborist/test/query-selector-all.js @@ -55,6 +55,7 @@ t.test('query-selector-all', async t => { version: '2.0.0', arbitrary: { foo: [ + false, 'foo', 'bar', { funding: { type: 'GH' } }, @@ -178,28 +179,35 @@ t.test('query-selector-all', async t => { const emptyRes = await q(tree, '') t.same(emptyRes, [], 'empty query') - // missing pseudo-class + // missing pseudo selector t.rejects( q(tree, ':foo'), - { code: 'EQUERYNOPSEUDOCLASS' }, - 'should throw on missing pseudo-class' + { code: 'EQUERYNOPSEUDO' }, + 'should throw on missing pseudo selector' ) - // missing class + // missing depType t.rejects( q(tree, '.foo'), - { code: 'EQUERYNOCLASS' }, - 'should throw on missing class' + { code: 'EQUERYNODEPTYPE' }, + 'should throw on missing dep type' ) // missing attribute matcher on :attr t.rejects( q(tree, ':attr(foo, bar)'), { code: 'EQUERYATTR' }, - 'should throw on missing attribute matcher on :attr pseudo-class' + 'should throw on missing attribute matcher on :attr pseudo' ) - // :scope pseudo-class + // no type or tag selectors, as documented + t.rejects( + q(tree, 'node'), + { code: 'EQUERYNOSELECTOR' }, + 'should throw in invalid selector' + ) + + // :scope pseudo const [nodeFoo] = await q(tree, '#foo') const scopeRes = await querySelectorAll(nodeFoo, ':scope') t.same(scopeRes, ['foo@2.2.2'], ':scope') @@ -212,12 +220,14 @@ t.test('query-selector-all', async t => { const runSpecParsing = async testCase => { for (const [selector, expected] of testCase) { - const res = await querySelectorAll(tree, selector) - t.same( - res, - expected, - selector - ) + t.test(selector, async t => { + const res = await querySelectorAll(tree, selector) + t.same( + res, + expected, + selector + ) + }) } } @@ -302,7 +312,7 @@ t.test('query-selector-all', async t => { [':missing', ['missing-dep@^1.0.0']], [':private', ['b@1.0.0']], - // :not pseudo-class + // :not pseudo [':not(#foo)', [ 'query-selector-all-tests@1.0.0', 'a@1.0.0', @@ -330,14 +340,14 @@ t.test('query-selector-all', async t => { 'b@1.0.0', ]], - // has pseudo-class + // has pseudo [':root > *:has(* > #bar@1.4.0)', ['foo@2.2.2']], ['*:has(* > #bar@1.4.0)', ['foo@2.2.2']], ['*:has(> #bar@1.4.0)', ['foo@2.2.2']], ['.workspace:has(> * > #lorem)', ['a@1.0.0']], ['.workspace:has(* #lorem, ~ #b)', ['a@1.0.0']], - // is pseudo-class + // is pseudo [':is(#a, #b) > *', ['bar@2.0.0', 'baz@1.0.0']], // TODO: ipsum is not empty but it's child is missing // so it doesn't return a result here @@ -350,7 +360,7 @@ t.test('query-selector-all', async t => { 'dasher@2.0.0', ]], - // type pseudo-class + // type pseudo [':type()', [ 'query-selector-all-tests@1.0.0', 'a@1.0.0', @@ -378,7 +388,7 @@ t.test('query-selector-all', async t => { ]], [':type(git)', []], - // path pseudo-class + // path pseudo [':path(node_modules/*)', [ 'abbrev@1.1.1', 'bar@2.0.0', @@ -410,7 +420,7 @@ t.test('query-selector-all', async t => { 'lorem@1.0.0', ]], - // semver pseudo-class + // semver pseudo [':semver()', [ 'query-selector-all-tests@1.0.0', 'a@1.0.0', @@ -461,7 +471,7 @@ t.test('query-selector-all', async t => { [':semver(=1.4.0)', ['bar@1.4.0']], [':semver(1.4.0 || 2.2.2)', ['foo@2.2.2', 'bar@1.4.0']], - // attr pseudo-class + // attr pseudo [':attr([name=dasher])', ['dasher@2.0.0']], [':attr(dependencies, [bar="^1.0.0"])', ['foo@2.2.2']], [':attr(dependencies, :attr([bar="^1.0.0"]))', ['foo@2.2.2']], @@ -471,6 +481,7 @@ t.test('query-selector-all', async t => { [':attr(arbitrary, [foo|=oo])', []], [':attr(funding, :attr([type=GitHub]))', ['lorem@1.0.0']], [':attr(arbitrary, foo, :attr(funding, [type=GH]))', ['bar@2.0.0']], + [':attr(arbitrary, foo, :attr(funding, [type=gh i]))', ['bar@2.0.0']], // attribute matchers ['[name]', [ @@ -517,6 +528,7 @@ t.test('query-selector-all', async t => { ]], ['[arbitrary^=foo]', ['foo@2.2.2']], ['[license=ISC]', ['abbrev@1.1.1', 'baz@1.0.0']], + ['[license=isc i]', ['abbrev@1.1.1', 'baz@1.0.0']], // classes ['.workspace', ['a@1.0.0', 'b@1.0.0']],