// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Search: in-repo and global, over a linear (index-free) scan (§10). //! //! Queries carry a tiny hand-written qualifier syntax (`repo:`, `user:`, `path:`, //! `lang:`, `case:`, `regex:`) plus bare literal terms. Content matching uses //! ripgrep's engine. Global search is bounded by a worker-pool size, a total time //! budget, and a result cap; exhausting the budget returns partial results with an //! explicit notice rather than truncating silently. use std::path::Path as FsPath; use std::sync::Arc; use std::time::{Duration, Instant}; use axum::extract::{Query, State}; use axum::http::Uri; use axum::response::{IntoResponse, Response}; use axum_extra::extract::cookie::CookieJar; use grep::regex::RegexMatcherBuilder; use grep::searcher::Searcher; use grep::searcher::sinks::UTF8; use maud::{Markup, html}; use model::{Repo, User}; use serde::Deserialize; use tokio::sync::Semaphore; use tokio::task::JoinSet; use crate::AppState; use crate::error::AppResult; use crate::layout::page; use crate::pages::build_chrome; use crate::session::MaybeUser; /// Max matching lines kept per file, and max files per repo, to bound one repo's /// contribution. const LINES_PER_FILE: usize = 10; const FILES_PER_REPO: usize = 50; /// Files larger than this are skipped by the content scan. const MAX_FILE_BYTES: u64 = 1_048_576; /// A parsed query: the free text plus any qualifiers. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ParsedQuery { /// The literal or regex text to match. pub text: String, /// `repo:owner/path`. pub repo: Option, /// `user:name`. pub user: Option, /// `path:glob` (matched as a substring for the MVP). pub path: Option, /// `lang:name`. pub lang: Option, /// `case:yes` — case-sensitive matching. pub case_sensitive: bool, /// `regex:yes` — treat `text` as a regex rather than a literal. pub regex: bool, } /// Parse a raw query string into qualifiers and free text. #[must_use] pub fn parse(raw: &str) -> ParsedQuery { let mut query = ParsedQuery::default(); let mut terms: Vec<&str> = Vec::new(); for token in raw.split_whitespace() { match token.split_once(':') { Some(("repo", v)) => query.repo = Some(v.to_string()), Some(("user", v)) => query.user = Some(v.to_string()), Some(("path", v)) => query.path = Some(v.to_string()), Some(("lang", v)) => query.lang = Some(v.to_string()), Some(("case", v)) => query.case_sensitive = is_yes(v), Some(("regex", v)) => query.regex = is_yes(v), _ => terms.push(token), } } query.text = terms.join(" "); query } /// Whether a qualifier value means "yes". fn is_yes(v: &str) -> bool { matches!(v.to_ascii_lowercase().as_str(), "yes" | "true" | "1" | "on") } /// One matching line within a file. #[derive(Debug, Clone)] pub struct LineMatch { /// 1-based line number. pub line: u64, /// The (trimmed) line text. pub text: String, } /// A file's matches within a repo. #[derive(Debug, Clone)] pub struct FileResult { /// Repo-relative path. pub path: String, /// Matching lines (may be empty for a path-only match). pub lines: Vec, } /// Search one already-open repository at `rev_name`. Returns matched files. fn search_repo_blocking(repo: &git::Repo, rev_name: &str, query: &ParsedQuery) -> Vec { let Ok(rev) = repo.resolve_ref(rev_name) else { return Vec::new(); }; let Ok(paths) = repo.file_paths(&rev) else { return Vec::new(); }; let matcher = (!query.text.is_empty()) .then(|| { RegexMatcherBuilder::new() .case_insensitive(!query.case_sensitive) .fixed_strings(!query.regex) .build(&query.text) .ok() }) .flatten(); let mut searcher = Searcher::new(); let mut results = Vec::new(); for path in paths { if results.len() >= FILES_PER_REPO { break; } if let Some(filter) = &query.path && !path.to_lowercase().contains(&filter.to_lowercase()) { continue; } let Ok(blob) = repo.blob(&rev, FsPath::new(&path)) else { continue; }; if blob.is_binary || blob.size > MAX_FILE_BYTES { continue; } if let Some(lang) = &query.lang { let resolved = highlight::resolve(FsPath::new(&path), &blob.content, None); if resolved.name().map(str::to_ascii_lowercase) != Some(lang.to_ascii_lowercase()) { continue; } } let mut lines = Vec::new(); if let Some(matcher) = &matcher { let _ = searcher.search_slice( matcher, &blob.content, UTF8(|lnum, line| { if lines.len() < LINES_PER_FILE { lines.push(LineMatch { line: lnum, text: line.trim_end().to_string(), }); } Ok(true) }), ); if !lines.is_empty() { results.push(FileResult { path, lines }); } } else if query.path.is_some() || query.lang.is_some() { // No text term but a path/lang filter matched: report the file itself. results.push(FileResult { path, lines }); } } results } /// Query parameters for the global search route. #[derive(Debug, Default, Deserialize)] pub struct SearchParams { /// The raw query. #[serde(default)] q: String, } /// `GET /search` — global search across visible repos. pub async fn global( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, uri: Uri, Query(params): Query, ) -> AppResult { let query = parse(¶ms.q); let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string()); let body = if params.q.trim().is_empty() { html! { h1 { "Search" } (search_box(¶ms.q, None)) } } else { let entities = entity_matches(&state, &query, viewer.as_ref()).await?; let (content, timed_out, truncated) = content_matches(&state, &query, viewer.as_ref()).await?; html! { h1 { "Search" } (search_box(¶ms.q, None)) @if timed_out { div class="notice notice-error" { "Search timed out; showing partial results." } } @else if truncated { div class="notice" { "Showing the first results; refine your query for more." } } (entity_section(&entities)) (content_section(&content)) @if entities.is_empty() && content.is_empty() { p class="muted" { "No results." } } } }; Ok((jar, page(&chrome, "Search", body)).into_response()) } /// A repo/user/group name-or-description match. struct EntityMatch { kind: &'static str, label: String, href: String, } /// Match repo/user names and descriptions (the "first" pass of global search). async fn entity_matches( state: &AppState, query: &ParsedQuery, viewer: Option<&User>, ) -> AppResult> { let needle = query.text.to_lowercase(); if needle.is_empty() { return Ok(Vec::new()); } let mut out = Vec::new(); for user in state.store.list_users().await? { if user.username.to_lowercase().contains(&needle) { out.push(EntityMatch { kind: "user", label: user.username.clone(), href: format!("/{}", user.username), }); } } for repo in visible_repos(state, viewer).await? { let owner = owner_name(state, &repo.owner_id).await; let hay = format!( "{} {} {}", repo.path, repo.name, repo.description.clone().unwrap_or_default() ) .to_lowercase(); if hay.contains(&needle) { out.push(EntityMatch { kind: "repo", label: format!("{owner}/{}", repo.path), href: format!("/{owner}/{}", repo.path), }); } } Ok(out) } /// Run the bounded content search across visible repos. Returns matches plus /// whether the time budget or result cap was hit. /// A repo's content matches: its `owner/path` label, its default branch (for /// blob links), and the per-file results. type RepoMatches = (String, String, Vec); async fn content_matches( state: &AppState, query: &ParsedQuery, viewer: Option<&User>, ) -> AppResult<(Vec, bool, bool)> { let mut repos = visible_repos(state, viewer).await?; // Honour a repo: qualifier by filtering the candidate set. if let Some(repo_q) = &query.repo { let want = repo_q.to_lowercase(); // Resolve owner names to filter by "owner/path". let mut kept = Vec::new(); for repo in repos { let owner = owner_name(state, &repo.owner_id).await; if format!("{owner}/{}", repo.path).to_lowercase() == want { kept.push(repo); } } repos = kept; } let deadline = Instant::now() + Duration::from_secs(state.config.search.timeout_secs.max(1)); let semaphore = Arc::new(Semaphore::new(state.config.search.concurrency.max(1))); let mut set: JoinSet> = JoinSet::new(); for repo in repos { let state = state.clone(); let query = query.clone(); let semaphore = Arc::clone(&semaphore); set.spawn(async move { let _permit = semaphore.acquire().await.ok()?; let owner = owner_name(&state, &repo.owner_id).await; let branch = repo.default_branch.clone(); let rev = branch.clone(); let files = crate::repo::git_read(&state, &repo.id, move |repo| { Ok(search_repo_blocking(repo, &rev, &query)) }) .await .ok()?; (!files.is_empty()).then(|| (format!("{owner}/{}", repo.path), branch, files)) }); } let cap = state.config.search.max_results; let mut results = Vec::new(); let mut total = 0usize; let mut timed_out = false; let mut truncated = false; loop { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { timed_out = true; break; } match tokio::time::timeout(remaining, set.join_next()).await { Ok(Some(Ok(Some(entry)))) => { total += entry.2.iter().map(|f| f.lines.len().max(1)).sum::(); results.push(entry); if total >= cap { truncated = true; break; } } Ok(Some(_)) => {} // a repo errored or had no matches Ok(None) => break, // all done Err(_) => { timed_out = true; break; } } } set.abort_all(); Ok((results, timed_out, truncated)) } /// `search` view for a single repo (called from the repo dispatcher). pub async fn in_repo( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: &crate::repo::RepoCtx, raw_query: &str, header: Markup, ) -> AppResult { let owner = &ctx.owner.username; let repo = &ctx.repo; let default_branch = &ctx.repo.default_branch; let query = parse(raw_query); let rev = default_branch.clone(); let files = if raw_query.trim().is_empty() { Vec::new() } else { let query = query.clone(); let rev = rev.clone(); crate::repo::git_read(state, &repo.id, move |repo| { Ok(search_repo_blocking(repo, &rev, &query)) }) .await? }; let base = format!("/{owner}/{}", repo.path); let body = html! { (header) h2 { "Search this repository" } (search_box(raw_query, Some(&format!("{base}/-/search")))) @if raw_query.trim().is_empty() { p class="muted" { "Enter a query to search the code." } } @else if files.is_empty() { p class="muted" { "No matches." } } @else { (repo_content(owner, &repo.path, default_branch, &files)) } }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{owner}/{}: search", repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } // ---- components ---- /// The search input form. fn search_box(value: &str, action: Option<&str>) -> Markup { html! { form class="search-row" method="get" action=[action] { input type="search" name="q" value=(value) placeholder="Search…" data-search-input aria-label="Search query" autofocus; button class="btn btn-primary" type="submit" { "Search" } } } } /// Render entity (name) matches. fn entity_section(entities: &[EntityMatch]) -> Markup { html! { @if !entities.is_empty() { section { h2 { "Repositories & users" } ul class="repo-list" { @for entity in entities { li { span class="badge" { (entity.kind) } " " a href=(entity.href) { (entity.label) } } } } } } } } /// Render global content matches, grouped by repo then file, each linking into /// the blob at the repo's default branch. fn content_section(content: &[RepoMatches]) -> Markup { html! { @if !content.is_empty() { section { h2 { "Code" } @for (repo, rev, files) in content { h3 class="search-repo" { a href={ "/" (repo) } { (repo) } } @let base = format!("/{repo}/-/blob/{rev}"); @for file in files { (file_result(&base, Some(rev), file)) } } } } } } /// Render in-repo content matches with links into the blob view. fn repo_content(owner: &str, repo_path: &str, rev: &str, files: &[FileResult]) -> Markup { let base = format!("/{owner}/{repo_path}/-/blob/{rev}"); html! { @for file in files { (file_result(&base, Some(rev), file)) } } } /// One file's matches. `blob_base` links each line to the blob (with `#L{n}`). fn file_result(blob_base: &str, in_repo: Option<&str>, file: &FileResult) -> Markup { html! { div class="search-hit card" { div class="search-hit-path mono" { @if in_repo.is_some() { a href=(format!("{blob_base}/{}", file.path)) { (file.path) } } @else { (file.path) } } @if !file.lines.is_empty() { div class="search-hit-lines" { @for m in &file.lines { div class="search-hit-line" { @if in_repo.is_some() { a href=(format!("{blob_base}/{}#L{}", file.path, m.line)) class="lineno" { (m.line) } } @else { span class="lineno" { (m.line) } } code { (m.text) } } } } } } } } // ---- shared repo visibility ---- /// Every repo the viewer may read (across owners), for global search. async fn visible_repos(state: &AppState, viewer: Option<&User>) -> AppResult> { let all = state.store.list_repos().await?; let mut out = Vec::new(); for repo in all { if can_read(state, &repo, viewer).await? { out.push(repo); } } Ok(out) } /// Whether the viewer can read `repo`. async fn can_read(state: &AppState, repo: &Repo, viewer: Option<&User>) -> AppResult { let viewer_id = viewer.map(|u| auth::Viewer { id: u.id.clone(), is_admin: u.is_admin, }); let collaborator = match viewer { Some(u) => state .store .effective_permission(&repo.id, &u.id) .await? .and_then(|p| auth::Permission::parse(&p)), None => None, }; let access = auth::access( viewer_id.as_ref(), repo, collaborator, state.allow_anonymous(), ); Ok(access >= auth::Access::Read) } /// Resolve an owner id to a username, falling back to the id. async fn owner_name(state: &AppState, owner_id: &str) -> String { state .store .user_by_id(owner_id) .await .ok() .flatten() .map_or_else(|| owner_id.to_string(), |u| u.username) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_qualifiers_and_free_text() { let q = parse("foo bar repo:ada/api lang:rust case:yes regex:no path:src"); assert_eq!(q.text, "foo bar"); assert_eq!(q.repo.as_deref(), Some("ada/api")); assert_eq!(q.lang.as_deref(), Some("rust")); assert_eq!(q.path.as_deref(), Some("src")); assert!(q.case_sensitive); assert!(!q.regex); } #[test] fn bare_query_has_no_qualifiers() { let q = parse("hello world"); assert_eq!(q.text, "hello world"); assert!(q.repo.is_none()); assert!(!q.case_sensitive); assert!(!q.regex); } }