Skip to content

Commit

Permalink
feat: add :outdated pseudo selector
Browse files Browse the repository at this point in the history
  • Loading branch information
nlf authored and lukekarrys committed Sep 21, 2022
1 parent d030f10 commit ebf167b
Show file tree
Hide file tree
Showing 5 changed files with 598 additions and 22 deletions.
7 changes: 5 additions & 2 deletions workspaces/arborist/lib/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ class Node {
dummy = false,
sourceReference = null,
} = options
// this object gives querySelectorAll somewhere to stash context about a node
// while processing a query
this.queryContext = {}

// true if part of a global install
this[_global] = global
Expand Down Expand Up @@ -1455,8 +1458,8 @@ class Node {

// maybe accept both string value or array of strings
// seems to be what dom API does
querySelectorAll (query) {
return querySelectorAll(this, query)
querySelectorAll (query, opts) {
return querySelectorAll(this, query, opts)
}

toJSON () {
Expand Down
207 changes: 193 additions & 14 deletions workspaces/arborist/lib/query-selector-all.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const localeCompare = require('@isaacs/string-locale-compare')('en')
const log = require('proc-log')
const minimatch = require('minimatch')
const npa = require('npm-package-arg')
const pacote = require('pacote')
const semver = require('semver')

// handle results for parsed query asts, results are stored in a map that has a
Expand All @@ -16,6 +17,7 @@ class Results {
#currentAstSelector
#initialItems
#inventory
#outdatedCache = new Map()
#pendingCombinator
#results = new Map()
#targetNode
Expand All @@ -28,6 +30,9 @@ class Results {

this.currentResults = this.#initialItems

// We get this when first called and need to pass it to pacote
this.flatOptions = opts.flatOptions || {}

// reset by rootAstNode walker
this.currentAstNode = opts.rootAstNode
}
Expand Down Expand Up @@ -58,6 +63,7 @@ class Results {
if (firstParsed) {
return this.#initialItems
}

if (this.currentAstNode.prev().type === 'combinator') {
return this.#inventory
}
Expand Down Expand Up @@ -125,7 +131,7 @@ class Results {
}

// pseudo selectors (prefixed with :)
pseudoType () {
async pseudoType () {
const pseudoFn = `${this.currentAstNode.value.slice(1)}Pseudo`
if (!this[pseudoFn]) {
throw Object.assign(
Expand All @@ -134,7 +140,7 @@ class Results {
{ code: 'EQUERYNOPSEUDO' }
)
}
const nextResults = this[pseudoFn]()
const nextResults = await this[pseudoFn]()
this.processPendingCombinator(nextResults)
}

Expand Down Expand Up @@ -195,11 +201,12 @@ class Results {
return this.initialItems.filter(node => node.extraneous)
}

hasPseudo () {
async hasPseudo () {
const found = []
for (const item of this.initialItems) {
const res = retrieveNodesFromParsedAst({
// This is the one time initialItems differs from inventory
// This is the one time initialItems differs from inventory
const res = await retrieveNodesFromParsedAst({
flatOptions: this.flatOptions,
initialItems: [item],
inventory: this.#inventory,
rootAstNode: this.currentAstNode.nestedNode,
Expand All @@ -225,8 +232,9 @@ class Results {
return found
}

isPseudo () {
const res = retrieveNodesFromParsedAst({
async isPseudo () {
const res = await retrieveNodesFromParsedAst({
flatOptions: this.flatOptions,
initialItems: this.initialItems,
inventory: this.#inventory,
rootAstNode: this.currentAstNode.nestedNode,
Expand All @@ -251,8 +259,9 @@ class Results {
}, [])
}

notPseudo () {
const res = retrieveNodesFromParsedAst({
async notPseudo () {
const res = await retrieveNodesFromParsedAst({
flatOptions: this.flatOptions,
initialItems: this.initialItems,
inventory: this.#inventory,
rootAstNode: this.currentAstNode.nestedNode,
Expand Down Expand Up @@ -422,6 +431,135 @@ class Results {
dedupedPseudo () {
return this.initialItems.filter(node => node.target.edgesIn.size > 1)
}

async outdatedPseudo () {
const { outdatedKind = 'any' } = this.currentAstNode

// filter the initialItems
// NOTE: this uses a Promise.all around a map without in-line concurrency handling
// since the only async action taken is retrieving the packument, which is limited
// based on the max-sockets config in make-fetch-happen
const initialResults = await Promise.all(this.initialItems.map(async (node) => {
// the root can't be outdated, skip it
if (node.isProjectRoot) {
return false
}

// we cache the promise representing the full versions list, this helps reduce the
// number of requests we send by keeping population of the cache in a single tick
// making it less likely that multiple requests for the same package will be inflight
if (!this.#outdatedCache.has(node.name)) {
this.#outdatedCache.set(node.name, getPackageVersions(node.name, this.flatOptions))
}
const availableVersions = await this.#outdatedCache.get(node.name)

// we attach _all_ versions to the queryContext to allow consumers to do their own
// filtering and comparisons
node.queryContext.versions = availableVersions

// next we further reduce the set to versions that are greater than the current one
const greaterVersions = availableVersions.filter((available) => {
return semver.gt(available, node.version)
})

// no newer versions than the current one, drop this node from the result set
if (!greaterVersions.length) {
return false
}

// if we got here, we know that newer versions exist, if the kind is 'any' we're done
if (outdatedKind === 'any') {
return node
}

// look for newer versions that differ from current by a specific part of the semver version
if (['major', 'minor', 'patch'].includes(outdatedKind)) {
// filter the versions greater than our current one based on semver.diff
const filteredVersions = greaterVersions.filter((version) => {
return semver.diff(node.version, version) === outdatedKind
})

// no available versions are of the correct diff type
if (!filteredVersions.length) {
return false
}

return node
}

// look for newer versions that satisfy at least one edgeIn to this node
if (outdatedKind === 'in-range') {
const inRangeContext = []
for (const edge of node.edgesIn) {
const inRangeVersions = greaterVersions.filter((version) => {
return semver.satisfies(version, edge.spec)
})

// this edge has no in-range candidates, just move on
if (!inRangeVersions.length) {
continue
}

inRangeContext.push({
from: edge.from.location,
versions: inRangeVersions,
})
}

// if we didn't find at least one match, drop this node
if (!inRangeContext.length) {
return false
}

// now add to the context each version that is in-range for each edgeIn
node.queryContext.outdated = {
...node.queryContext.outdated,
inRange: inRangeContext,
}

return node
}

// look for newer versions that _do not_ satisfy at least one edgeIn
if (outdatedKind === 'out-of-range') {
const outOfRangeContext = []
for (const edge of node.edgesIn) {
const outOfRangeVersions = greaterVersions.filter((version) => {
return !semver.satisfies(version, edge.spec)
})

// this edge has no out-of-range candidates, skip it
if (!outOfRangeVersions.length) {
continue
}

outOfRangeContext.push({
from: edge.from.location,
versions: outOfRangeVersions,
})
}

// if we didn't add at least one thing to the context, this node is not a match
if (!outOfRangeContext.length) {
return false
}

// attach the out-of-range context to the node
node.queryContext.outdated = {
...node.queryContext.outdated,
outOfRange: outOfRangeContext,
}

return node
}

// any other outdatedKind is unknown and will never match
return false
}))

// return an array with the holes for non-matching nodes removed
return initialResults.filter(Boolean)
}
}

// operators for attribute selectors
Expand Down Expand Up @@ -622,7 +760,41 @@ const combinators = {
},
}

const retrieveNodesFromParsedAst = (opts) => {
// get a list of available versions of a package filtered to respect --before
// NOTE: this runs over each node and should not throw
const getPackageVersions = async (name, opts) => {
let packument
try {
packument = await pacote.packument(name, {
...opts,
fullMetadata: false, // we only need the corgi
})
} catch (err) {
// if the fetch fails, log a warning and pretend there are no versions
log.warn('query', `could not retrieve packument for ${name}: ${err.message}`)
return []
}

// start with a sorted list of all versions (lowest first)
let candidates = Object.keys(packument.versions).sort(semver.compare)

// if the packument has a time property, and the user passed a before flag, then
// we filter this list down to only those versions that existed before the specified date
if (packument.time && opts.before) {
candidates = candidates.filter((version) => {
// this version isn't found in the times at all, drop it
if (!packument.time[version]) {
return false
}

return Date.parse(packument.time[version]) <= opts.before
})
}

return candidates
}

const retrieveNodesFromParsedAst = async (opts) => {
// when we first call this it's the parsed query. all other times it's
// results.currentNode.nestedNode
const rootAstNode = opts.rootAstNode
Expand All @@ -633,7 +805,13 @@ const retrieveNodesFromParsedAst = (opts) => {

const results = new Results(opts)

const astNodeQueue = new Set()
// walk is sync, so we have to build up our async functions and then await them later
rootAstNode.walk((nextAstNode) => {
astNodeQueue.add(nextAstNode)
})

for (const nextAstNode of astNodeQueue) {
// This is the only place we reset currentAstNode
results.currentAstNode = nextAstNode
const updateFn = `${results.currentAstNode.type}Type`
Expand All @@ -643,23 +821,24 @@ const retrieveNodesFromParsedAst = (opts) => {
{ code: 'EQUERYNOSELECTOR' }
)
}
results[updateFn]()
})
await 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 querySelectorAll = async (targetNode, query, flatOptions) => {
// 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()]
// res is a Set of items returned for each parsed css ast selector
const res = retrieveNodesFromParsedAst({
const res = await retrieveNodesFromParsedAst({
initialItems: inventory,
inventory,
flatOptions,
rootAstNode: parser(query),
targetNode,
})
Expand Down
4 changes: 4 additions & 0 deletions workspaces/arborist/tap-snapshots/test/link.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Link {
"optional": true,
"path": "/home/user/some/other/path",
"peer": true,
"queryContext": Object {},
"realpath": "/home/user/projects/some/kind/of/path",
"sourceReference": null,
"tops": Set {},
Expand Down Expand Up @@ -70,6 +71,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = `
"optional": true,
"path": "/home/user/projects/some/kind/of/path",
"peer": true,
"queryContext": Object {},
"realpath": "/home/user/projects/some/kind/of/path",
"resolved": null,
"sourceReference": null,
Expand All @@ -83,6 +85,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = `
"optional": true,
"path": "/home/user/some/other/path",
"peer": true,
"queryContext": Object {},
"realpath": "/home/user/projects/some/kind/of/path",
"sourceReference": null,
"tops": Set {
Expand All @@ -109,6 +112,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = `
"optional": true,
"path": "/home/user/projects/some/kind/of/path",
"peer": true,
"queryContext": Object {},
"realpath": "/home/user/projects/some/kind/of/path",
"resolved": null,
"sourceReference": null,
Expand Down
Loading

0 comments on commit ebf167b

Please sign in to comment.