import { all, first } from "../db"; import { visibleReposClause } from "../repos"; import { CODE_MAX_PAGES, CODE_PAGE_SIZE, matchingLines } from "~/lib/code-search"; export { CODE_MAX_PAGES }; import type { CodeSearchHit, 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; /** Counting stops at this many matching files so a broad query stays cheap. */ const COUNT_CAP = 1000; const LINES_PER_HIT = 3; const SNIPPET_TOKENS = 24; const PATH_PREFIX = "path:"; const SNIPPET = `snippet(code_fts, 1, char(1), char(2), '…', ${SNIPPET_TOKENS})`; 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 ?? []; } const FROM = `FROM code_fts JOIN code_files f ON f.id = code_fts.rowid JOIN repos r ON r.id = f.repo_id JOIN accounts a ON a.id = r.owner_id`; type Row = Omit & { content: string }; export type CodeSearchOptions = { page?: number; terms?: string[] }; async function runSearch( where: string, params: Array, options: CodeSearchOptions, ): Promise { const page = Math.min(Math.max(1, Math.floor(options.page ?? 1)), CODE_MAX_PAGES); const offset = (page - 1) * CODE_PAGE_SIZE; const [rows, count] = await Promise.all([ all( `SELECT a.name AS owner, r.name AS repo, r.default_branch AS ref, f.path, ${SNIPPET} AS snippet, code_fts.content AS content ${FROM} WHERE ${where} ORDER BY rank LIMIT ${CODE_PAGE_SIZE + 1} OFFSET ${offset}`, ...params, ), first<{ total: number }>( `SELECT COUNT(*) AS total FROM (SELECT 1 ${FROM} WHERE ${where} LIMIT ${COUNT_CAP + 1})`, ...params, ), ]); const terms = options.terms ?? []; const hits = rows.slice(0, CODE_PAGE_SIZE).map(({ content, ...row }) => ({ ...row, ...matchingLines(content, terms, LINES_PER_HIT), })); const counted = count?.total ?? 0; return { hits, page, hasMore: rows.length > CODE_PAGE_SIZE && page < CODE_MAX_PAGES, total: Math.min(counted, COUNT_CAP), totalCapped: counted > COUNT_CAP, }; } export async function searchRepoCode( repoId: string, query: string, options: CodeSearchOptions = {}, ): Promise { return runSearch("code_fts MATCH ?1 AND f.repo_id = ?2", [query, repoId], options); } export async function searchAllCode( viewer: Viewer | null, query: string, options: CodeSearchOptions = {}, ): Promise { const { clause, viewerId } = visibleReposClause(viewer); return runSearch(`code_fts MATCH ?2 AND ${clause}`, [viewerId, query], options); } export type IndexStatus = { commitSha: string; fileCount: number; truncated: boolean } | null; export async function codeIndexStatus(repoId: string): Promise { const row = await first<{ commit_sha: string; file_count: number; truncated: number }>( "SELECT commit_sha, file_count, truncated FROM code_index_state WHERE repo_id = ?", repoId, ); return row ? { commitSha: row.commit_sha, fileCount: row.file_count, truncated: row.truncated === 1 } : null; }