import { all } from "../db"; import { repoSearch, type RepoSearchStatus } from "./repo-search-do"; import { visibleReposClause } from "../repos"; import { CODE_MAX_PAGES, CODE_PAGE_SIZE } from "~/lib/code-search"; export { CODE_MAX_PAGES }; import type { CodeSearchPage } from "~/lib/code-intel"; import type { Viewer } from "~/lib/types"; const MIN_TERM_LENGTH = 3; const MAX_TERMS = 8; const MAX_TERM_LENGTH = 100; const PATH_PREFIX = "path:"; function quote(term: string): string { return `"${term.replaceAll('"', '""')}"`; } type ParsedQuery = { fts: string; contentTerms: string[] }; function parse(input: string): ParsedQuery | null { const terms: string[] = []; const contentTerms: string[] = []; for (const raw of input.trim().split(/\s+/)) { const isPath = raw.startsWith(PATH_PREFIX); const term = (isPath ? raw.slice(PATH_PREFIX.length) : raw).slice(0, MAX_TERM_LENGTH); if (term.length < MIN_TERM_LENGTH) continue; terms.push(isPath ? `path : ${quote(term)}` : quote(term)); if (!isPath) contentTerms.push(term); if (terms.length >= MAX_TERMS) break; } return terms.length > 0 ? { fts: terms.join(" "), contentTerms } : null; } /** Turns free text into a safe FTS5 expression: every term is quoted (trigram needs 3+ chars); `path:x` filters by path. */ export function ftsQuery(input: string): string | null { return parse(input)?.fts ?? null; } /** The plain (non-`path:`) terms of a query: what gets highlighted inside file contents. */ export function contentTerms(input: string): string[] { return parse(input)?.contentTerms ?? []; } export type CodeSearchOptions = { page?: number; terms?: string[] }; type RepoRef = { id: string; owner: string; name: string; ref: string }; function clampPage(page: number | undefined, max: number): number { return Math.min(Math.max(1, Math.floor(page ?? 1)), max); } /** One repository's index, served by its own RepoSearch object. */ export async function searchRepoCode( repo: RepoRef, query: string, options: CodeSearchOptions = {}, ): Promise { const page = clampPage(options.page, CODE_MAX_PAGES); const result = await repoSearch(repo.id).search(query, options.terms ?? [], (page - 1) * CODE_PAGE_SIZE); return { hits: result.hits.map(({ rank: _rank, ...hit }) => ({ ...hit, owner: repo.owner, repo: repo.name, ref: repo.ref })), page, hasMore: result.hasMore && page < CODE_MAX_PAGES, total: result.total, totalCapped: result.totalCapped, }; } /** An owner search asks this many of the owner's repositories (most recently pushed first). */ export const OWNER_SEARCH_REPOS = 50; /** Every page re-asks each repository for all earlier results, so owner paging stops early. */ export const OWNER_MAX_PAGES = 5; /** * Searches every repository of one owner the viewer can read by asking each repo's RepoSearch object for its * best files and merging them by rank. No shared index: cost is one object call per repository. */ export async function searchOwnerCode( viewer: Viewer | null, owner: string, query: string, options: CodeSearchOptions = {}, ): Promise { const page = clampPage(options.page, OWNER_MAX_PAGES); const { clause, viewerId } = visibleReposClause(viewer); const repos = await all<{ id: string; name: string; default_branch: string; owner_name: string }>( `SELECT r.id, r.name, r.default_branch, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.name = ?2 AND r.status = 'ready' AND ${clause} ORDER BY COALESCE(r.pushed_at, r.created_at) DESC LIMIT ${OWNER_SEARCH_REPOS + 1}`, viewerId, owner, ); const searched = repos.slice(0, OWNER_SEARCH_REPOS); const wanted = page * CODE_PAGE_SIZE; const terms = options.terms ?? []; const results = await Promise.all( searched.map(async (repo) => { try { return { repo, result: await repoSearch(repo.id).search(query, terms, 0, wanted) }; } catch (error) { console.warn("Owner code search: repository failed", repo.id, error); return null; } }), ); const merged = results .flatMap((entry) => entry ? entry.result.hits.map(({ rank, ...hit }) => ({ rank, hit: { ...hit, owner: entry.repo.owner_name, repo: entry.repo.name, ref: entry.repo.default_branch }, })) : [], ) .sort((left, right) => left.rank - right.rank); const counted = results.reduce((sum, entry) => sum + (entry?.result.total ?? 0), 0); const capped = results.some((entry) => entry?.result.totalCapped); const start = (page - 1) * CODE_PAGE_SIZE; return { hits: merged.slice(start, start + CODE_PAGE_SIZE).map((entry) => entry.hit), page, hasMore: merged.length > start + CODE_PAGE_SIZE && page < OWNER_MAX_PAGES, total: counted, totalCapped: capped, reposSearched: searched.length, reposTruncated: repos.length > OWNER_SEARCH_REPOS, }; } export type IndexStatus = RepoSearchStatus; export async function codeIndexStatus(repoId: string): Promise<{ indexed: IndexStatus; indexing: boolean }> { return repoSearch(repoId).status(); }