@@ -6,6 +6,7 @@ const localeCompare = require('@isaacs/string-locale-compare')('en')
66const log = require ( 'proc-log' )
77const minimatch = require ( 'minimatch' )
88const npa = require ( 'npm-package-arg' )
9+ const pacote = require ( 'pacote' )
910const semver = require ( 'semver' )
1011
1112// handle results for parsed query asts, results are stored in a map that has a
@@ -16,6 +17,7 @@ class Results {
1617 #currentAstSelector
1718 #initialItems
1819 #inventory
20+ #outdatedCache = new Map ( )
1921 #pendingCombinator
2022 #results = new Map ( )
2123 #targetNode
@@ -28,6 +30,9 @@ class Results {
2830
2931 this . currentResults = this . #initialItems
3032
33+ // We get this when first called and need to pass it to pacote
34+ this . flatOptions = opts . flatOptions || { }
35+
3136 // reset by rootAstNode walker
3237 this . currentAstNode = opts . rootAstNode
3338 }
@@ -58,6 +63,7 @@ class Results {
5863 if ( firstParsed ) {
5964 return this . #initialItems
6065 }
66+
6167 if ( this . currentAstNode . prev ( ) . type === 'combinator' ) {
6268 return this . #inventory
6369 }
@@ -125,7 +131,7 @@ class Results {
125131 }
126132
127133 // pseudo selectors (prefixed with :)
128- pseudoType ( ) {
134+ async pseudoType ( ) {
129135 const pseudoFn = `${ this . currentAstNode . value . slice ( 1 ) } Pseudo`
130136 if ( ! this [ pseudoFn ] ) {
131137 throw Object . assign (
@@ -134,7 +140,7 @@ class Results {
134140 { code : 'EQUERYNOPSEUDO' }
135141 )
136142 }
137- const nextResults = this [ pseudoFn ] ( )
143+ const nextResults = await this [ pseudoFn ] ( )
138144 this . processPendingCombinator ( nextResults )
139145 }
140146
@@ -195,11 +201,12 @@ class Results {
195201 return this . initialItems . filter ( node => node . extraneous )
196202 }
197203
198- hasPseudo ( ) {
204+ async hasPseudo ( ) {
199205 const found = [ ]
200206 for ( const item of this . initialItems ) {
201- const res = retrieveNodesFromParsedAst ( {
202- // This is the one time initialItems differs from inventory
207+ // This is the one time initialItems differs from inventory
208+ const res = await retrieveNodesFromParsedAst ( {
209+ flatOptions : this . flatOptions ,
203210 initialItems : [ item ] ,
204211 inventory : this . #inventory,
205212 rootAstNode : this . currentAstNode . nestedNode ,
@@ -225,8 +232,9 @@ class Results {
225232 return found
226233 }
227234
228- isPseudo ( ) {
229- const res = retrieveNodesFromParsedAst ( {
235+ async isPseudo ( ) {
236+ const res = await retrieveNodesFromParsedAst ( {
237+ flatOptions : this . flatOptions ,
230238 initialItems : this . initialItems ,
231239 inventory : this . #inventory,
232240 rootAstNode : this . currentAstNode . nestedNode ,
@@ -251,8 +259,9 @@ class Results {
251259 } , [ ] )
252260 }
253261
254- notPseudo ( ) {
255- const res = retrieveNodesFromParsedAst ( {
262+ async notPseudo ( ) {
263+ const res = await retrieveNodesFromParsedAst ( {
264+ flatOptions : this . flatOptions ,
256265 initialItems : this . initialItems ,
257266 inventory : this . #inventory,
258267 rootAstNode : this . currentAstNode . nestedNode ,
@@ -422,6 +431,135 @@ class Results {
422431 dedupedPseudo ( ) {
423432 return this . initialItems . filter ( node => node . target . edgesIn . size > 1 )
424433 }
434+
435+ async outdatedPseudo ( ) {
436+ const { outdatedKind = 'any' } = this . currentAstNode
437+
438+ // filter the initialItems
439+ // NOTE: this uses a Promise.all around a map without in-line concurrency handling
440+ // since the only async action taken is retrieving the packument, which is limited
441+ // based on the max-sockets config in make-fetch-happen
442+ const initialResults = await Promise . all ( this . initialItems . map ( async ( node ) => {
443+ // the root can't be outdated, skip it
444+ if ( node . isProjectRoot ) {
445+ return false
446+ }
447+
448+ // we cache the promise representing the full versions list, this helps reduce the
449+ // number of requests we send by keeping population of the cache in a single tick
450+ // making it less likely that multiple requests for the same package will be inflight
451+ if ( ! this . #outdatedCache. has ( node . name ) ) {
452+ this . #outdatedCache. set ( node . name , getPackageVersions ( node . name , this . flatOptions ) )
453+ }
454+ const availableVersions = await this . #outdatedCache. get ( node . name )
455+
456+ // we attach _all_ versions to the queryContext to allow consumers to do their own
457+ // filtering and comparisons
458+ node . queryContext . versions = availableVersions
459+
460+ // next we further reduce the set to versions that are greater than the current one
461+ const greaterVersions = availableVersions . filter ( ( available ) => {
462+ return semver . gt ( available , node . version )
463+ } )
464+
465+ // no newer versions than the current one, drop this node from the result set
466+ if ( ! greaterVersions . length ) {
467+ return false
468+ }
469+
470+ // if we got here, we know that newer versions exist, if the kind is 'any' we're done
471+ if ( outdatedKind === 'any' ) {
472+ return node
473+ }
474+
475+ // look for newer versions that differ from current by a specific part of the semver version
476+ if ( [ 'major' , 'minor' , 'patch' ] . includes ( outdatedKind ) ) {
477+ // filter the versions greater than our current one based on semver.diff
478+ const filteredVersions = greaterVersions . filter ( ( version ) => {
479+ return semver . diff ( node . version , version ) === outdatedKind
480+ } )
481+
482+ // no available versions are of the correct diff type
483+ if ( ! filteredVersions . length ) {
484+ return false
485+ }
486+
487+ return node
488+ }
489+
490+ // look for newer versions that satisfy at least one edgeIn to this node
491+ if ( outdatedKind === 'in-range' ) {
492+ const inRangeContext = [ ]
493+ for ( const edge of node . edgesIn ) {
494+ const inRangeVersions = greaterVersions . filter ( ( version ) => {
495+ return semver . satisfies ( version , edge . spec )
496+ } )
497+
498+ // this edge has no in-range candidates, just move on
499+ if ( ! inRangeVersions . length ) {
500+ continue
501+ }
502+
503+ inRangeContext . push ( {
504+ from : edge . from . location ,
505+ versions : inRangeVersions ,
506+ } )
507+ }
508+
509+ // if we didn't find at least one match, drop this node
510+ if ( ! inRangeContext . length ) {
511+ return false
512+ }
513+
514+ // now add to the context each version that is in-range for each edgeIn
515+ node . queryContext . outdated = {
516+ ...node . queryContext . outdated ,
517+ inRange : inRangeContext ,
518+ }
519+
520+ return node
521+ }
522+
523+ // look for newer versions that _do not_ satisfy at least one edgeIn
524+ if ( outdatedKind === 'out-of-range' ) {
525+ const outOfRangeContext = [ ]
526+ for ( const edge of node . edgesIn ) {
527+ const outOfRangeVersions = greaterVersions . filter ( ( version ) => {
528+ return ! semver . satisfies ( version , edge . spec )
529+ } )
530+
531+ // this edge has no out-of-range candidates, skip it
532+ if ( ! outOfRangeVersions . length ) {
533+ continue
534+ }
535+
536+ outOfRangeContext . push ( {
537+ from : edge . from . location ,
538+ versions : outOfRangeVersions ,
539+ } )
540+ }
541+
542+ // if we didn't add at least one thing to the context, this node is not a match
543+ if ( ! outOfRangeContext . length ) {
544+ return false
545+ }
546+
547+ // attach the out-of-range context to the node
548+ node . queryContext . outdated = {
549+ ...node . queryContext . outdated ,
550+ outOfRange : outOfRangeContext ,
551+ }
552+
553+ return node
554+ }
555+
556+ // any other outdatedKind is unknown and will never match
557+ return false
558+ } ) )
559+
560+ // return an array with the holes for non-matching nodes removed
561+ return initialResults . filter ( Boolean )
562+ }
425563}
426564
427565// operators for attribute selectors
@@ -622,7 +760,41 @@ const combinators = {
622760 } ,
623761}
624762
625- const retrieveNodesFromParsedAst = ( opts ) => {
763+ // get a list of available versions of a package filtered to respect --before
764+ // NOTE: this runs over each node and should not throw
765+ const getPackageVersions = async ( name , opts ) => {
766+ let packument
767+ try {
768+ packument = await pacote . packument ( name , {
769+ ...opts ,
770+ fullMetadata : false , // we only need the corgi
771+ } )
772+ } catch ( err ) {
773+ // if the fetch fails, log a warning and pretend there are no versions
774+ log . warn ( 'query' , `could not retrieve packument for ${ name } : ${ err . message } ` )
775+ return [ ]
776+ }
777+
778+ // start with a sorted list of all versions (lowest first)
779+ let candidates = Object . keys ( packument . versions ) . sort ( semver . compare )
780+
781+ // if the packument has a time property, and the user passed a before flag, then
782+ // we filter this list down to only those versions that existed before the specified date
783+ if ( packument . time && opts . before ) {
784+ candidates = candidates . filter ( ( version ) => {
785+ // this version isn't found in the times at all, drop it
786+ if ( ! packument . time [ version ] ) {
787+ return false
788+ }
789+
790+ return Date . parse ( packument . time [ version ] ) <= opts . before
791+ } )
792+ }
793+
794+ return candidates
795+ }
796+
797+ const retrieveNodesFromParsedAst = async ( opts ) => {
626798 // when we first call this it's the parsed query. all other times it's
627799 // results.currentNode.nestedNode
628800 const rootAstNode = opts . rootAstNode
@@ -633,7 +805,13 @@ const retrieveNodesFromParsedAst = (opts) => {
633805
634806 const results = new Results ( opts )
635807
808+ const astNodeQueue = new Set ( )
809+ // walk is sync, so we have to build up our async functions and then await them later
636810 rootAstNode . walk ( ( nextAstNode ) => {
811+ astNodeQueue . add ( nextAstNode )
812+ } )
813+
814+ for ( const nextAstNode of astNodeQueue ) {
637815 // This is the only place we reset currentAstNode
638816 results . currentAstNode = nextAstNode
639817 const updateFn = `${ results . currentAstNode . type } Type`
@@ -643,23 +821,24 @@ const retrieveNodesFromParsedAst = (opts) => {
643821 { code : 'EQUERYNOSELECTOR' }
644822 )
645823 }
646- results [ updateFn ] ( )
647- } )
824+ await results [ updateFn ] ( )
825+ }
648826
649827 return results . collect ( rootAstNode )
650828}
651829
652830// We are keeping this async in the event that we do add async operators, we
653831// won't have to have a breaking change on this function signature.
654- const querySelectorAll = async ( targetNode , query ) => {
832+ const querySelectorAll = async ( targetNode , query , flatOptions ) => {
655833 // This never changes ever we just pass it around. But we can't scope it to
656834 // this whole file if we ever want to support concurrent calls to this
657835 // function.
658836 const inventory = [ ...targetNode . root . inventory . values ( ) ]
659837 // res is a Set of items returned for each parsed css ast selector
660- const res = retrieveNodesFromParsedAst ( {
838+ const res = await retrieveNodesFromParsedAst ( {
661839 initialItems : inventory ,
662840 inventory,
841+ flatOptions,
663842 rootAstNode : parser ( query ) ,
664843 targetNode,
665844 } )
0 commit comments