Skip to content

Commit

Permalink
feat(arborist): allow for selectors and function names with :semver p…
Browse files Browse the repository at this point in the history
…seudo selector
  • Loading branch information
nlf authored and wraithgar committed Sep 8, 2022
1 parent 926f0ad commit 09c46e8
Show file tree
Hide file tree
Showing 2 changed files with 218 additions and 4 deletions.
114 changes: 110 additions & 4 deletions workspaces/arborist/lib/query-selector-all.js
Expand Up @@ -3,8 +3,9 @@
const { resolve } = require('path')
const { parser, arrayDelimiter } = require('@npmcli/query')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const npa = require('npm-package-arg')
const log = require('proc-log')
const minimatch = require('minimatch')
const npa = require('npm-package-arg')
const semver = require('semver')

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

semverPseudo () {
if (!this.currentAstNode.semverValue) {
const {
attributeMatcher,
lookupProperties,
semverFunc = 'infer',
semverValue,
} = this.currentAstNode
const { qualifiedAttribute } = attributeMatcher

if (!semverValue) {
// DEPRECATED: remove this warning and throw an error as part of @npmcli/arborist@6
log.warn('query', 'usage of :semver() with no parameters is deprecated')
return this.initialItems
}
return this.initialItems.filter(node =>
semver.satisfies(node.version, this.currentAstNode.semverValue))

if (!semver.valid(semverValue) && !semver.validRange(semverValue)) {
throw Object.assign(
new Error(`\`${semverValue}\` is not a valid semver version or range`),
{ code: 'EQUERYINVALIDSEMVER' })
}

const valueIsVersion = !!semver.valid(semverValue)

const nodeMatches = (node, obj) => {
// if we already have an operator, the user provided some test as part of the selector
// we evaluate that first because if it fails we don't want this node anyway
if (attributeMatcher.operator) {
if (!attributeMatch(attributeMatcher, obj)) {
// if the initial operator doesn't match, we're done
return false
}
}

const attrValue = obj[qualifiedAttribute]
// both valid and validRange return null for undefined, so this will skip both nodes that
// do not have the attribute defined as well as those where the attribute value is invalid
// and those where the value from the package.json is not a string
if ((!semver.valid(attrValue) && !semver.validRange(attrValue)) ||
typeof attrValue !== 'string') {
return false
}

const attrIsVersion = !!semver.valid(attrValue)

let actualFunc = semverFunc

// if we're asked to infer, we examine outputs to make a best guess
if (actualFunc === 'infer') {
if (valueIsVersion && attrIsVersion) {
// two versions -> semver.eq
actualFunc = 'eq'
} else if (!valueIsVersion && !attrIsVersion) {
// two ranges -> semver.intersects
actualFunc = 'intersects'
} else {
// anything else -> semver.satisfies
actualFunc = 'satisfies'
}
}

if (['eq', 'neq', 'gt', 'gte', 'lt', 'lte'].includes(actualFunc)) {
// both sides must be versions, but one is not
if (!valueIsVersion || !attrIsVersion) {
return false
}

return semver[actualFunc](attrValue, semverValue)
} else if (['gtr', 'ltr', 'satisfies'].includes(actualFunc)) {
// at least one side must be a version, but neither is
if (!valueIsVersion && !attrIsVersion) {
return false
}

return valueIsVersion
? semver[actualFunc](semverValue, attrValue)
: semver[actualFunc](attrValue, semverValue)
} else if (['intersects', 'subset'].includes(actualFunc)) {
// these accept two ranges and since a version is also a range, anything goes
return semver[actualFunc](attrValue, semverValue)
} else {
// user provided a function we don't know about, throw an error
throw Object.assign(new Error(`\`semver.${actualFunc}\` is not a supported operator.`),
{ code: 'EQUERYINVALIDOPERATOR' })
}
}

return this.initialItems.filter((node) => {
// no lookupProperties just means its a top level property, see if it matches
if (!lookupProperties.length) {
return nodeMatches(node, node.package)
}

// this code is mostly duplicated from attrPseudo to traverse into the package until we get
// to our deepest requested object
let objs = [node.package]
for (const prop of lookupProperties) {
if (prop === arrayDelimiter) {
objs = objs.flat()
continue
}

objs = objs.flatMap(obj => obj[prop] || [])
const noAttr = objs.every(obj => !obj)
if (noAttr) {
return false
}

return objs.some(obj => nodeMatches(node, obj))
}
})
}

typePseudo () {
Expand Down Expand Up @@ -358,6 +463,7 @@ const attributeOperator = ({ attr, value, insensitive, operator }) => {
if (insensitive) {
attr = attr.toLowerCase()
}

return attributeOperators[operator]({
attr,
insensitive,
Expand Down
108 changes: 108 additions & 0 deletions workspaces/arborist/test/query-selector-all.js
Expand Up @@ -52,6 +52,9 @@ t.test('query-selector-all', async t => {
name: 'abbrev',
version: '1.1.1',
license: 'ISC',
engines: {
node: '^16.0.0',
},
}),
},
b: t.fixture('symlink', '../b'),
Expand All @@ -62,6 +65,9 @@ t.test('query-selector-all', async t => {
dependencies: {
moo: '3.0.0',
},
engines: {
node: '>= 14.0.0',
},
arbitrary: {
foo: [
false,
Expand Down Expand Up @@ -89,6 +95,10 @@ t.test('query-selector-all', async t => {
scripts: {
test: 'tap',
},
engines: {
// intentionally invalid range
node: 'nope',
},
}),
},
foo: {
Expand Down Expand Up @@ -254,6 +264,18 @@ t.test('query-selector-all', async t => {
'should throw in invalid selector'
)

t.rejects(
q(tree, ':semver(1.0.0, [version], eqqq)'),
{ code: 'EQUERYINVALIDOPERATOR' },
'should throw on invalid semver operator'
)

t.rejects(
q(tree, ':semver(nope)'),
{ code: 'EQUERYINVALIDSEMVER' },
'should throw on invalid semver value'
)

// :scope pseudo
const [nodeFoo] = await q(tree, '#foo')
const scopeRes = await querySelectorAll(nodeFoo, ':scope')
Expand Down Expand Up @@ -559,6 +581,92 @@ 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']],
[':semver(^16.0.0, :attr(engines, [node]))', ['abbrev@1.1.1', 'bar@2.0.0']],
[':semver(18.0.0, :attr(engines, [node]))', ['bar@2.0.0']],
[':semver(^16.0.0, :attr(engines, [node^=">="]))', ['bar@2.0.0']],
[':semver(3.0.0, [version], eq)', ['moo@3.0.0']],
[':semver(^3.0.0, [version], eq)', []],
[':semver(1.0.0, [version], neq)', [
'@npmcli/abbrev@2.0.0-beta.45',
'abbrev@1.1.1',
'bar@2.0.0',
'dasher@2.0.0',
'foo@2.2.2',
'bar@1.4.0',
'moo@3.0.0',
]],
[':semver(^1.0.0, [version], neq)', []],
[':semver(2.0.0, [version], gt)', ['foo@2.2.2', 'moo@3.0.0']],
[':semver(^2.0.0, [version], gt)', []],
[':semver(2.0.0, [version], gte)', [
'bar@2.0.0',
'dasher@2.0.0',
'foo@2.2.2',
'moo@3.0.0',
]],
[':semver(^2.0.0, [version], gte)', []],
[':semver(1.1.1, [version], lt)', [
'query-selector-all-tests@1.0.0',
'a@1.0.0',
'b@1.0.0',
'baz@1.0.0',
'dash-separated-pkg@1.0.0',
'ipsum@npm:sit@1.0.0',
'lorem@1.0.0',
'recur@1.0.0',
'sive@1.0.0',
]],
[':semver(^1.1.1, [version], lt)', []],
[':semver(1.1.1, [version], lte)', [
'query-selector-all-tests@1.0.0',
'a@1.0.0',
'b@1.0.0',
'abbrev@1.1.1',
'baz@1.0.0',
'dash-separated-pkg@1.0.0',
'ipsum@npm:sit@1.0.0',
'lorem@1.0.0',
'recur@1.0.0',
'sive@1.0.0',
]],
[':semver(^1.1.1, [version], lte)', []],
[':semver(^14.0.0, :attr(engines, [node]), intersects)', ['bar@2.0.0']],
[':semver(>=14, :attr(engines, [node]), subset)', ['abbrev@1.1.1', 'bar@2.0.0']],
[':semver(^2.0.0, [version], gtr)', ['moo@3.0.0']],
[':semver(^2.0.0, :attr(engines, [node]), gtr)', []],
[':semver(20.0.0, :attr(engines, [node]), gtr)', ['abbrev@1.1.1']],
[':semver(1.0.1, [version], gtr)', [
'query-selector-all-tests@1.0.0',
'a@1.0.0',
'b@1.0.0',
'baz@1.0.0',
'dash-separated-pkg@1.0.0',
'ipsum@npm:sit@1.0.0',
'lorem@1.0.0',
'recur@1.0.0',
'sive@1.0.0',
]],
[':semver(^1.1.1, [version], ltr)', [
'query-selector-all-tests@1.0.0',
'a@1.0.0',
'b@1.0.0',
'baz@1.0.0',
'dash-separated-pkg@1.0.0',
'ipsum@npm:sit@1.0.0',
'lorem@1.0.0',
'recur@1.0.0',
'sive@1.0.0',
]],
[':semver(^1.1.1, :attr(engines, [node]), ltr)', []],
[':semver(0.0.1, :attr(engines, [node]), ltr)', ['abbrev@1.1.1', 'bar@2.0.0']],
[':semver(1.1.1, [version], ltr)', [
'@npmcli/abbrev@2.0.0-beta.45',
'bar@2.0.0',
'dasher@2.0.0',
'foo@2.2.2',
'bar@1.4.0',
'moo@3.0.0',
]],

// attr pseudo
[':attr([name=dasher])', ['dasher@2.0.0']],
Expand Down

0 comments on commit 09c46e8

Please sign in to comment.