Skip to content

Commit 09c46e8

Browse files
nlfwraithgar
authored andcommitted
feat(arborist): allow for selectors and function names with :semver pseudo selector
1 parent 926f0ad commit 09c46e8

File tree

2 files changed

+218
-4
lines changed

2 files changed

+218
-4
lines changed

workspaces/arborist/lib/query-selector-all.js

+110-4
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
const { resolve } = require('path')
44
const { parser, arrayDelimiter } = require('@npmcli/query')
55
const localeCompare = require('@isaacs/string-locale-compare')('en')
6-
const npa = require('npm-package-arg')
6+
const log = require('proc-log')
77
const minimatch = require('minimatch')
8+
const npa = require('npm-package-arg')
89
const semver = require('semver')
910

1011
// handle results for parsed query asts, results are stored in a map that has a
@@ -291,11 +292,115 @@ class Results {
291292
}
292293

293294
semverPseudo () {
294-
if (!this.currentAstNode.semverValue) {
295+
const {
296+
attributeMatcher,
297+
lookupProperties,
298+
semverFunc = 'infer',
299+
semverValue,
300+
} = this.currentAstNode
301+
const { qualifiedAttribute } = attributeMatcher
302+
303+
if (!semverValue) {
304+
// DEPRECATED: remove this warning and throw an error as part of @npmcli/arborist@6
305+
log.warn('query', 'usage of :semver() with no parameters is deprecated')
295306
return this.initialItems
296307
}
297-
return this.initialItems.filter(node =>
298-
semver.satisfies(node.version, this.currentAstNode.semverValue))
308+
309+
if (!semver.valid(semverValue) && !semver.validRange(semverValue)) {
310+
throw Object.assign(
311+
new Error(`\`${semverValue}\` is not a valid semver version or range`),
312+
{ code: 'EQUERYINVALIDSEMVER' })
313+
}
314+
315+
const valueIsVersion = !!semver.valid(semverValue)
316+
317+
const nodeMatches = (node, obj) => {
318+
// if we already have an operator, the user provided some test as part of the selector
319+
// we evaluate that first because if it fails we don't want this node anyway
320+
if (attributeMatcher.operator) {
321+
if (!attributeMatch(attributeMatcher, obj)) {
322+
// if the initial operator doesn't match, we're done
323+
return false
324+
}
325+
}
326+
327+
const attrValue = obj[qualifiedAttribute]
328+
// both valid and validRange return null for undefined, so this will skip both nodes that
329+
// do not have the attribute defined as well as those where the attribute value is invalid
330+
// and those where the value from the package.json is not a string
331+
if ((!semver.valid(attrValue) && !semver.validRange(attrValue)) ||
332+
typeof attrValue !== 'string') {
333+
return false
334+
}
335+
336+
const attrIsVersion = !!semver.valid(attrValue)
337+
338+
let actualFunc = semverFunc
339+
340+
// if we're asked to infer, we examine outputs to make a best guess
341+
if (actualFunc === 'infer') {
342+
if (valueIsVersion && attrIsVersion) {
343+
// two versions -> semver.eq
344+
actualFunc = 'eq'
345+
} else if (!valueIsVersion && !attrIsVersion) {
346+
// two ranges -> semver.intersects
347+
actualFunc = 'intersects'
348+
} else {
349+
// anything else -> semver.satisfies
350+
actualFunc = 'satisfies'
351+
}
352+
}
353+
354+
if (['eq', 'neq', 'gt', 'gte', 'lt', 'lte'].includes(actualFunc)) {
355+
// both sides must be versions, but one is not
356+
if (!valueIsVersion || !attrIsVersion) {
357+
return false
358+
}
359+
360+
return semver[actualFunc](attrValue, semverValue)
361+
} else if (['gtr', 'ltr', 'satisfies'].includes(actualFunc)) {
362+
// at least one side must be a version, but neither is
363+
if (!valueIsVersion && !attrIsVersion) {
364+
return false
365+
}
366+
367+
return valueIsVersion
368+
? semver[actualFunc](semverValue, attrValue)
369+
: semver[actualFunc](attrValue, semverValue)
370+
} else if (['intersects', 'subset'].includes(actualFunc)) {
371+
// these accept two ranges and since a version is also a range, anything goes
372+
return semver[actualFunc](attrValue, semverValue)
373+
} else {
374+
// user provided a function we don't know about, throw an error
375+
throw Object.assign(new Error(`\`semver.${actualFunc}\` is not a supported operator.`),
376+
{ code: 'EQUERYINVALIDOPERATOR' })
377+
}
378+
}
379+
380+
return this.initialItems.filter((node) => {
381+
// no lookupProperties just means its a top level property, see if it matches
382+
if (!lookupProperties.length) {
383+
return nodeMatches(node, node.package)
384+
}
385+
386+
// this code is mostly duplicated from attrPseudo to traverse into the package until we get
387+
// to our deepest requested object
388+
let objs = [node.package]
389+
for (const prop of lookupProperties) {
390+
if (prop === arrayDelimiter) {
391+
objs = objs.flat()
392+
continue
393+
}
394+
395+
objs = objs.flatMap(obj => obj[prop] || [])
396+
const noAttr = objs.every(obj => !obj)
397+
if (noAttr) {
398+
return false
399+
}
400+
401+
return objs.some(obj => nodeMatches(node, obj))
402+
}
403+
})
299404
}
300405

301406
typePseudo () {
@@ -358,6 +463,7 @@ const attributeOperator = ({ attr, value, insensitive, operator }) => {
358463
if (insensitive) {
359464
attr = attr.toLowerCase()
360465
}
466+
361467
return attributeOperators[operator]({
362468
attr,
363469
insensitive,

workspaces/arborist/test/query-selector-all.js

+108
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ t.test('query-selector-all', async t => {
5252
name: 'abbrev',
5353
version: '1.1.1',
5454
license: 'ISC',
55+
engines: {
56+
node: '^16.0.0',
57+
},
5558
}),
5659
},
5760
b: t.fixture('symlink', '../b'),
@@ -62,6 +65,9 @@ t.test('query-selector-all', async t => {
6265
dependencies: {
6366
moo: '3.0.0',
6467
},
68+
engines: {
69+
node: '>= 14.0.0',
70+
},
6571
arbitrary: {
6672
foo: [
6773
false,
@@ -89,6 +95,10 @@ t.test('query-selector-all', async t => {
8995
scripts: {
9096
test: 'tap',
9197
},
98+
engines: {
99+
// intentionally invalid range
100+
node: 'nope',
101+
},
92102
}),
93103
},
94104
foo: {
@@ -254,6 +264,18 @@ t.test('query-selector-all', async t => {
254264
'should throw in invalid selector'
255265
)
256266

267+
t.rejects(
268+
q(tree, ':semver(1.0.0, [version], eqqq)'),
269+
{ code: 'EQUERYINVALIDOPERATOR' },
270+
'should throw on invalid semver operator'
271+
)
272+
273+
t.rejects(
274+
q(tree, ':semver(nope)'),
275+
{ code: 'EQUERYINVALIDSEMVER' },
276+
'should throw on invalid semver value'
277+
)
278+
257279
// :scope pseudo
258280
const [nodeFoo] = await q(tree, '#foo')
259281
const scopeRes = await querySelectorAll(nodeFoo, ':scope')
@@ -559,6 +581,92 @@ t.test('query-selector-all', async t => {
559581
]],
560582
[':semver(=1.4.0)', ['bar@1.4.0']],
561583
[':semver(1.4.0 || 2.2.2)', ['foo@2.2.2', 'bar@1.4.0']],
584+
[':semver(^16.0.0, :attr(engines, [node]))', ['abbrev@1.1.1', 'bar@2.0.0']],
585+
[':semver(18.0.0, :attr(engines, [node]))', ['bar@2.0.0']],
586+
[':semver(^16.0.0, :attr(engines, [node^=">="]))', ['bar@2.0.0']],
587+
[':semver(3.0.0, [version], eq)', ['moo@3.0.0']],
588+
[':semver(^3.0.0, [version], eq)', []],
589+
[':semver(1.0.0, [version], neq)', [
590+
'@npmcli/abbrev@2.0.0-beta.45',
591+
'abbrev@1.1.1',
592+
'bar@2.0.0',
593+
'dasher@2.0.0',
594+
'foo@2.2.2',
595+
'bar@1.4.0',
596+
'moo@3.0.0',
597+
]],
598+
[':semver(^1.0.0, [version], neq)', []],
599+
[':semver(2.0.0, [version], gt)', ['foo@2.2.2', 'moo@3.0.0']],
600+
[':semver(^2.0.0, [version], gt)', []],
601+
[':semver(2.0.0, [version], gte)', [
602+
'bar@2.0.0',
603+
'dasher@2.0.0',
604+
'foo@2.2.2',
605+
'moo@3.0.0',
606+
]],
607+
[':semver(^2.0.0, [version], gte)', []],
608+
[':semver(1.1.1, [version], lt)', [
609+
'query-selector-all-tests@1.0.0',
610+
'a@1.0.0',
611+
'b@1.0.0',
612+
'baz@1.0.0',
613+
'dash-separated-pkg@1.0.0',
614+
'ipsum@npm:sit@1.0.0',
615+
'lorem@1.0.0',
616+
'recur@1.0.0',
617+
'sive@1.0.0',
618+
]],
619+
[':semver(^1.1.1, [version], lt)', []],
620+
[':semver(1.1.1, [version], lte)', [
621+
'query-selector-all-tests@1.0.0',
622+
'a@1.0.0',
623+
'b@1.0.0',
624+
'abbrev@1.1.1',
625+
'baz@1.0.0',
626+
'dash-separated-pkg@1.0.0',
627+
'ipsum@npm:sit@1.0.0',
628+
'lorem@1.0.0',
629+
'recur@1.0.0',
630+
'sive@1.0.0',
631+
]],
632+
[':semver(^1.1.1, [version], lte)', []],
633+
[':semver(^14.0.0, :attr(engines, [node]), intersects)', ['bar@2.0.0']],
634+
[':semver(>=14, :attr(engines, [node]), subset)', ['abbrev@1.1.1', 'bar@2.0.0']],
635+
[':semver(^2.0.0, [version], gtr)', ['moo@3.0.0']],
636+
[':semver(^2.0.0, :attr(engines, [node]), gtr)', []],
637+
[':semver(20.0.0, :attr(engines, [node]), gtr)', ['abbrev@1.1.1']],
638+
[':semver(1.0.1, [version], gtr)', [
639+
'query-selector-all-tests@1.0.0',
640+
'a@1.0.0',
641+
'b@1.0.0',
642+
'baz@1.0.0',
643+
'dash-separated-pkg@1.0.0',
644+
'ipsum@npm:sit@1.0.0',
645+
'lorem@1.0.0',
646+
'recur@1.0.0',
647+
'sive@1.0.0',
648+
]],
649+
[':semver(^1.1.1, [version], ltr)', [
650+
'query-selector-all-tests@1.0.0',
651+
'a@1.0.0',
652+
'b@1.0.0',
653+
'baz@1.0.0',
654+
'dash-separated-pkg@1.0.0',
655+
'ipsum@npm:sit@1.0.0',
656+
'lorem@1.0.0',
657+
'recur@1.0.0',
658+
'sive@1.0.0',
659+
]],
660+
[':semver(^1.1.1, :attr(engines, [node]), ltr)', []],
661+
[':semver(0.0.1, :attr(engines, [node]), ltr)', ['abbrev@1.1.1', 'bar@2.0.0']],
662+
[':semver(1.1.1, [version], ltr)', [
663+
'@npmcli/abbrev@2.0.0-beta.45',
664+
'bar@2.0.0',
665+
'dasher@2.0.0',
666+
'foo@2.2.2',
667+
'bar@1.4.0',
668+
'moo@3.0.0',
669+
]],
562670

563671
// attr pseudo
564672
[':attr([name=dasher])', ['dasher@2.0.0']],

0 commit comments

Comments
 (0)