11# Usage: scoop search <query>
22# Summary: Search available apps
33# Help: Searches for apps that are available to install.
4- #
5- # If used with [query], shows app names that match the query.
64# - With 'use_sqlite_cache' enabled, [query] is partially matched against app names, binaries, and shortcuts.
7- # - Without 'use_sqlite_cache', [query] can be a regular expression to match against app names and binaries.
5+ # - Without 'use_sqlite_cache', [query] is matched against app names and binaries via:
6+ # * A JSON-based binary-index cache (when fresh) for fast substring matching (~200ms), or
7+ # * The original regex full-scan as fallback (~3s).
8+ # - Queries containing regex metacharacters (.+*?|[](){}^$\ ) skip the cache and use regex directly.
89# Without [query], shows all the available apps.
910param ($query )
1011
@@ -14,6 +15,160 @@ param($query)
1415
1516$list = [System.Collections.Generic.List [PSCustomObject ]]::new()
1617
18+ # === Search index cache (JSON-based, faster than SQLite) ===
19+ $searchCachePath = Join-Path $scoopdir ' search-cache.json'
20+ $searchIndexApps = $null
21+ $searchIndexBins = $null
22+
23+ function init_search_cache {
24+ if (-not (Test-Path $searchCachePath )) { return $false }
25+ try {
26+ $cache = Get-Content $searchCachePath - Raw | ConvertFrom-Json - ErrorAction Stop
27+ $cacheAge = (Get-Date ) - [datetime ]::Parse($cache.timestamp )
28+ if ($cacheAge.TotalHours -ge 24 ) { return $false }
29+ # Cross-check file count and last-write fingerprint for staleness
30+ $currentCount = 0 ; $currentMaxWrite = [datetime ]::MinValue
31+ Get-LocalBucket | ForEach-Object {
32+ $dir = Find-BucketDirectory $_
33+ $items = Get-ChildItem $dir - Filter ' *.json' - Recurse - ErrorAction SilentlyContinue
34+ $currentCount += $items.Count
35+ foreach ($item in $items ) {
36+ if ($item.LastWriteTimeUtc -gt $currentMaxWrite ) { $currentMaxWrite = $item.LastWriteTimeUtc }
37+ }
38+ }
39+ if ($cache.fileCount -ne $currentCount ) { return $false }
40+ if ($cache.maxWriteUtc -ne $currentMaxWrite.ToString (' o' )) { return $false }
41+ $script :searchIndexApps = @ {}
42+ foreach ($prop in $cache.apps.PSObject.Properties ) {
43+ $entries = @ ()
44+ foreach ($entry in $prop.Value ) {
45+ $entries += @ { path = $entry.path ; bucket = $entry.bucket }
46+ }
47+ $script :searchIndexApps [$prop.Name ] = $entries
48+ }
49+ $script :searchIndexBins = @ {}
50+ foreach ($prop in $cache.bins.PSObject.Properties ) {
51+ $script :searchIndexBins [$prop.Name ] = @ ($prop.Value )
52+ }
53+ return $true
54+ } catch { return $false }
55+ }
56+
57+ function build_search_cache {
58+ $allPathsByBucket = @ {}
59+ $maxWriteUtc = [datetime ]::MinValue
60+ Get-LocalBucket | ForEach-Object {
61+ $dir = Find-BucketDirectory $_
62+ $items = Get-ChildItem $dir - Filter ' *.json' - Recurse - ErrorAction SilentlyContinue
63+ $paths = @ ($items | ForEach-Object { $_.FullName })
64+ foreach ($item in $items ) {
65+ if ($item.LastWriteTimeUtc -gt $maxWriteUtc ) { $maxWriteUtc = $item.LastWriteTimeUtc }
66+ }
67+ if ($paths.Count -gt 0 ) { $allPathsByBucket [$_ ] = $paths }
68+ }
69+ $totalCount = ($allPathsByBucket.Values | ForEach-Object { $_.Count } | Measure-Object - Sum).Sum
70+ $newApps = @ {}
71+ $newBins = @ {}
72+
73+ foreach ($bucket in $allPathsByBucket.Keys ) {
74+ foreach ($filePath in $allPathsByBucket [$bucket ]) {
75+ $appName = [System.IO.Path ]::GetFileNameWithoutExtension($filePath )
76+ if (-not $newApps.ContainsKey ($appName )) { $newApps [$appName ] = @ () }
77+ $newApps [$appName ] += @ { path = $filePath ; bucket = $bucket }
78+ try {
79+ $manifest = Get-Content - Path $filePath - Raw | ConvertFrom-Json - ErrorAction Stop
80+ if (-not $manifest.bin ) { continue }
81+ foreach ($binEntry in $manifest.bin ) {
82+ $exe = $null ; $alias = $null
83+ if ($binEntry -is [System.Object []]) {
84+ $exe = $binEntry [0 ]
85+ $alias = if ($binEntry.Count -gt 1 ) { $binEntry [1 ] } else { $null }
86+ } else { $exe = $binEntry }
87+ $exeName = [System.IO.Path ]::GetFileNameWithoutExtension([string ]$exe ).ToLower()
88+ if ($exeName ) {
89+ if (-not $newBins.ContainsKey ($exeName )) { $newBins [$exeName ] = @ () }
90+ if ($appName -notin $newBins [$exeName ]) { $newBins [$exeName ] += $appName }
91+ }
92+ if ($alias ) {
93+ $aliasName = $alias.ToLower ()
94+ if ($aliasName ) {
95+ if (-not $newBins.ContainsKey ($aliasName )) { $newBins [$aliasName ] = @ () }
96+ if ($appName -notin $newBins [$aliasName ]) { $newBins [$aliasName ] += $appName }
97+ }
98+ }
99+ }
100+ } catch { Write-Debug " cache-build parse failed for $ ( $filePath ) : $ ( $_.Exception.Message ) " }
101+ }
102+ }
103+ $cacheData = [PSCustomObject ]@ { timestamp = (Get-Date ).ToString(' o' ); fileCount = $totalCount ; maxWriteUtc = $maxWriteUtc.ToString (' o' ); apps = [PSCustomObject ]$newApps ; bins = [PSCustomObject ]$newBins }
104+ $cacheData | ConvertTo-Json - Compress - Depth 4 | Set-Content $searchCachePath - Encoding UTF8
105+ $script :searchIndexApps = $newApps
106+ $script :searchIndexBins = $newBins
107+ return $true
108+ }
109+
110+ function search_by_index ($query ) {
111+ if (-not $query ) { return }
112+ $matchedByName = @ {}
113+ $matchedByBin = @ {}
114+
115+ # Phase 1: Search app names (case-insensitive substring)
116+ foreach ($appName in $searchIndexApps.Keys ) {
117+ if ($appName.IndexOf ($query , [StringComparison ]::OrdinalIgnoreCase) -ge 0 ) {
118+ $matchedByName [$appName ] = $true
119+ }
120+ }
121+
122+ # Phase 2: Search binary names — only for apps not already matched by name
123+ foreach ($binName in $searchIndexBins.Keys ) {
124+ if ($binName.IndexOf ($query , [StringComparison ]::OrdinalIgnoreCase) -ge 0 ) {
125+ foreach ($appName in $searchIndexBins [$binName ]) {
126+ if (-not $matchedByName.ContainsKey ($appName ) -and -not $matchedByBin.ContainsKey ($appName )) {
127+ $matchedByBin [$appName ] = $true
128+ }
129+ }
130+ }
131+ }
132+
133+ # Display: name-matched apps get empty binaries (matching stock), binary-only get matching binaries
134+ foreach ($appName in $matchedByName.Keys ) {
135+ foreach ($entry in $searchIndexApps [$appName ]) {
136+ try {
137+ $manifest = Get-Content - Path $entry.path - Raw | ConvertFrom-Json - ErrorAction Stop
138+ if (-not $manifest ) { continue }
139+ $list.Add ([PSCustomObject ]@ { Name = $appName ; Version = $manifest.version ; Source = $entry.bucket ; Binaries = ' ' })
140+ } catch { Write-Debug " index search parse failed for $ ( $entry.path ) : $ ( $_.Exception.Message ) " }
141+ }
142+ }
143+
144+ foreach ($appName in $matchedByBin.Keys ) {
145+ foreach ($entry in $searchIndexApps [$appName ]) {
146+ try {
147+ $manifest = Get-Content - Path $entry.path - Raw | ConvertFrom-Json - ErrorAction Stop
148+ $binaries = ' '
149+ $binMatches = @ ()
150+ if (-not $manifest ) { continue }
151+ if ($manifest.bin ) {
152+ foreach ($binEntry in $manifest.bin ) {
153+ $exe = $null ; $alias = $null
154+ if ($binEntry -is [System.Object []]) {
155+ $exe = $binEntry [0 ]
156+ $alias = if ($binEntry.Count -gt 1 ) { $binEntry [1 ] } else { $null }
157+ } else { $exe = $binEntry }
158+ $exeName = [System.IO.Path ]::GetFileNameWithoutExtension([string ]$exe )
159+ if ($exeName.IndexOf ($query , [StringComparison ]::OrdinalIgnoreCase) -ge 0 -or
160+ ($alias -and $alias.IndexOf ($query , [StringComparison ]::OrdinalIgnoreCase) -ge 0 )) {
161+ $binMatches += [System.IO.Path ]::GetFileName([string ]$exe )
162+ }
163+ }
164+ if ($binMatches ) { $binaries = $binMatches -join ' | ' }
165+ }
166+ $list.Add ([PSCustomObject ]@ { Name = $appName ; Version = $manifest.version ; Source = $entry.bucket ; Binaries = $binaries })
167+ } catch { Write-Debug " index search parse failed for $ ( $entry.path ) : $ ( $_.Exception.Message ) " }
168+ }
169+ }
170+ }
171+
17172function bin_match ($manifest , $query ) {
18173 if (! $manifest.bin ) { return $false }
19174 $bins = foreach ($bin in $manifest.bin ) {
@@ -169,20 +324,34 @@ if (get_config USE_SQLITE_CACHE) {
169324 })
170325 }
171326} else {
172- try {
173- $query = New-Object Regex $query , ' IgnoreCase'
174- } catch {
175- abort " Invalid regular expression: $ ( $_.Exception.InnerException.Message ) "
327+ # Try search index cache first for literal queries (fast, substring matching).
328+ # Queries with regex metacharacters skip the cache and go straight to regex.
329+ $cacheWasFresh = $false
330+ if ($query -notmatch ' [.+*?|\[\](){}^$\\]' ) {
331+ $cacheWasFresh = init_search_cache
332+ if ($cacheWasFresh ) { search_by_index $query }
176333 }
177334
178- $jsonTextAvailable = [System.AppDomain ]::CurrentDomain.GetAssemblies() | Where-Object { [System.IO.Path ]::GetFileNameWithoutExtension($_.Location ) -eq ' System.Text.Json' }
335+ # Fall back to regex full-scan when cache produced no results
336+ if ($list.Count -eq 0 ) {
337+ try {
338+ $regex = New-Object Regex $query , ' IgnoreCase'
339+ } catch {
340+ abort " Invalid regular expression: $ ( $_.Exception.InnerException.Message ) "
341+ }
342+
343+ $jsonTextAvailable = [System.AppDomain ]::CurrentDomain.GetAssemblies() | Where-Object { [System.IO.Path ]::GetFileNameWithoutExtension($_.Location ) -eq ' System.Text.Json' }
179344
180- Get-LocalBucket | ForEach-Object {
181- if ($jsonTextAvailable ) {
182- search_bucket $_ $query
183- } else {
184- search_bucket_legacy $_ $query
345+ Get-LocalBucket | ForEach-Object {
346+ if ($jsonTextAvailable ) {
347+ search_bucket $_ $regex
348+ } else {
349+ search_bucket_legacy $_ $regex
350+ }
185351 }
352+
353+ # Build cache for next time only if it wasn't already fresh
354+ if (-not $cacheWasFresh ) { build_search_cache }
186355 }
187356}
188357
0 commit comments