Files
orca/src/shared/quick-open-path-search.ts
T

221 lines
6.0 KiB
TypeScript

import { isClipboardTextByteLengthOverLimit } from './clipboard-text'
import { compareFileNames } from './file-name-sort'
export const QUICK_OPEN_RESULT_LIMIT = 50
export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024
export const QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS = 256
export const QUICK_OPEN_SEARCH_VERSION = 1
export type QuickOpenIndexedFile = {
path: string
lowerPath: string
lowerFilename: string
inputIndex: number
}
export type QuickOpenSearchResult = {
path: string
score: number
}
export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexedFile[] {
return files.map((path, inputIndex) => prepareQuickOpenFile(path, inputIndex))
}
const preparedQuickOpenFiles = new WeakMap<readonly string[], QuickOpenIndexedFile[]>()
export function getPreparedQuickOpenFiles(
files: readonly string[]
): readonly QuickOpenIndexedFile[] {
const cached = preparedQuickOpenFiles.get(files)
if (cached) {
return cached
}
const prepared = prepareQuickOpenFiles(files)
preparedQuickOpenFiles.set(files, prepared)
return prepared
}
export function isQuickOpenQueryTooLarge(
query: string,
maxBytes = QUICK_OPEN_QUERY_MAX_BYTES
): boolean {
return isClipboardTextByteLengthOverLimit(query, maxBytes)
}
export function isQuickOpenRemoteQueryTooLarge(query: string): boolean {
return query.length > QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS || isQuickOpenQueryTooLarge(query)
}
export function rankQuickOpenFiles(
query: string,
files: readonly QuickOpenIndexedFile[],
limit = QUICK_OPEN_RESULT_LIMIT
): QuickOpenSearchResult[] {
if (limit <= 0 || isQuickOpenQueryTooLarge(query)) {
return []
}
const normalizedQuery = normalizeQuickOpenQuery(query)
const results: QuickOpenRankedResult[] = []
for (const file of files) {
const score = normalizedQuery ? fuzzyMatchIndexedFile(normalizedQuery, file) : 0
if (score !== -1) {
retainTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit)
}
}
return finalizeResults(results)
}
export class QuickOpenPathRanker {
private readonly normalizedQuery: string | null
private readonly retained: QuickOpenRankedResult[] = []
private inputIndex = 0
private matchCount = 0
constructor(
query: string,
private readonly limit: number
) {
this.normalizedQuery =
limit <= 0 || isQuickOpenQueryTooLarge(query) ? null : normalizeQuickOpenQuery(query)
}
consider(path: string): void {
const file = prepareQuickOpenFile(path, this.inputIndex++)
if (this.normalizedQuery === null) {
return
}
const score = this.normalizedQuery ? fuzzyMatchIndexedFile(this.normalizedQuery, file) : 0
if (score === -1) {
return
}
this.matchCount++
retainTopResult(
this.retained,
{ path: file.path, score, inputIndex: file.inputIndex },
this.limit
)
}
result(): { paths: string[]; totalCount: number } {
return {
paths: finalizeResults(this.retained).map((result) => result.path),
totalCount: this.matchCount
}
}
}
function normalizeQuickOpenQuery(query: string): string {
return query.trim().replace(/\\/g, '/').toLowerCase()
}
function prepareQuickOpenFile(path: string, inputIndex: number): QuickOpenIndexedFile {
const searchPath = path.replace(/\\/g, '/')
const lastSlash = searchPath.lastIndexOf('/')
return {
path,
lowerPath: searchPath.toLowerCase(),
lowerFilename: searchPath.slice(lastSlash + 1).toLowerCase(),
inputIndex
}
}
function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number {
let qi = 0
let score = 0
let lastMatchIdx = -1
while (qi < query.length) {
const next = lastMatchIdx + 1
const ti =
file.lowerPath[next] === query[qi] ? next : file.lowerPath.indexOf(query[qi], next + 1)
if (ti === -1) {
return -1
}
const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1
score += gap
if (
ti > 0 &&
(file.lowerPath[ti - 1] === '/' ||
file.lowerPath[ti - 1] === '.' ||
file.lowerPath[ti - 1] === '-')
) {
score -= 5
}
lastMatchIdx = ti
qi++
}
if (qi < query.length) {
return -1
}
if (file.lowerFilename.includes(query)) {
score -= 100
}
return score
}
type QuickOpenRankedResult = QuickOpenSearchResult & {
inputIndex: number
}
function retainTopResult(
heap: QuickOpenRankedResult[],
candidate: QuickOpenRankedResult,
limit: number
): void {
if (heap.length === limit && compareRankedResult(candidate, heap[0]) >= 0) {
return
}
if (heap.length < limit) {
heap.push(candidate)
siftResultUp(heap, heap.length - 1)
return
}
heap[0] = candidate
siftResultDown(heap)
}
function siftResultUp(heap: QuickOpenRankedResult[], startIndex: number): void {
let index = startIndex
while (index > 0) {
const parentIndex = Math.floor((index - 1) / 2)
if (compareRankedResult(heap[index], heap[parentIndex]) <= 0) {
return
}
;[heap[index], heap[parentIndex]] = [heap[parentIndex], heap[index]]
index = parentIndex
}
}
function siftResultDown(heap: QuickOpenRankedResult[]): void {
let index = 0
while (true) {
const leftIndex = index * 2 + 1
if (leftIndex >= heap.length) {
return
}
const rightIndex = leftIndex + 1
const worseChildIndex =
rightIndex < heap.length && compareRankedResult(heap[rightIndex], heap[leftIndex]) > 0
? rightIndex
: leftIndex
if (compareRankedResult(heap[worseChildIndex], heap[index]) <= 0) {
return
}
;[heap[index], heap[worseChildIndex]] = [heap[worseChildIndex], heap[index]]
index = worseChildIndex
}
}
function finalizeResults(results: QuickOpenRankedResult[]): QuickOpenSearchResult[] {
return results
.sort(compareRankedResult)
.map(({ path, score }): QuickOpenSearchResult => ({ path, score }))
}
function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number {
return a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex
}