fabrica

hanna/fabrica

18235 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Search: in-repo and global, over a linear (index-free) scan (§10).
6//!
7//! Queries carry a tiny hand-written qualifier syntax (`repo:`, `user:`, `path:`,
8//! `lang:`, `case:`, `regex:`) plus bare literal terms. Content matching uses
9//! ripgrep's engine. Global search is bounded by a worker-pool size, a total time
10//! budget, and a result cap; exhausting the budget returns partial results with an
11//! explicit notice rather than truncating silently.
12
13use std::path::Path as FsPath;
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use axum::extract::{Query, State};
18use axum::http::Uri;
19use axum::response::{IntoResponse, Response};
20use axum_extra::extract::cookie::CookieJar;
21use grep::regex::RegexMatcherBuilder;
22use grep::searcher::Searcher;
23use grep::searcher::sinks::UTF8;
24use maud::{Markup, html};
25use model::{Repo, User};
26use serde::Deserialize;
27use tokio::sync::Semaphore;
28use tokio::task::JoinSet;
29
30use crate::AppState;
31use crate::error::AppResult;
32use crate::layout::page;
33use crate::pages::build_chrome;
34use crate::session::MaybeUser;
35
36/// Max matching lines kept per file, and max files per repo, to bound one repo's
37/// contribution.
38const LINES_PER_FILE: usize = 10;
39const FILES_PER_REPO: usize = 50;
40/// Files larger than this are skipped by the content scan.
41const MAX_FILE_BYTES: u64 = 1_048_576;
42
43/// A parsed query: the free text plus any qualifiers.
44#[derive(Debug, Default, Clone, PartialEq, Eq)]
45pub struct ParsedQuery {
46 /// The literal or regex text to match.
47 pub text: String,
48 /// `repo:owner/path`.
49 pub repo: Option<String>,
50 /// `user:name`.
51 pub user: Option<String>,
52 /// `path:glob` (matched as a substring for the MVP).
53 pub path: Option<String>,
54 /// `lang:name`.
55 pub lang: Option<String>,
56 /// `case:yes` — case-sensitive matching.
57 pub case_sensitive: bool,
58 /// `regex:yes` — treat `text` as a regex rather than a literal.
59 pub regex: bool,
60}
61
62/// Parse a raw query string into qualifiers and free text.
63#[must_use]
64pub fn parse(raw: &str) -> ParsedQuery {
65 let mut query = ParsedQuery::default();
66 let mut terms: Vec<&str> = Vec::new();
67 for token in raw.split_whitespace() {
68 match token.split_once(':') {
69 Some(("repo", v)) => query.repo = Some(v.to_string()),
70 Some(("user", v)) => query.user = Some(v.to_string()),
71 Some(("path", v)) => query.path = Some(v.to_string()),
72 Some(("lang", v)) => query.lang = Some(v.to_string()),
73 Some(("case", v)) => query.case_sensitive = is_yes(v),
74 Some(("regex", v)) => query.regex = is_yes(v),
75 _ => terms.push(token),
76 }
77 }
78 query.text = terms.join(" ");
79 query
80}
81
82/// Whether a qualifier value means "yes".
83fn is_yes(v: &str) -> bool {
84 matches!(v.to_ascii_lowercase().as_str(), "yes" | "true" | "1" | "on")
85}
86
87/// One matching line within a file.
88#[derive(Debug, Clone)]
89pub struct LineMatch {
90 /// 1-based line number.
91 pub line: u64,
92 /// The (trimmed) line text.
93 pub text: String,
94}
95
96/// A file's matches within a repo.
97#[derive(Debug, Clone)]
98pub struct FileResult {
99 /// Repo-relative path.
100 pub path: String,
101 /// Matching lines (may be empty for a path-only match).
102 pub lines: Vec<LineMatch>,
103}
104
105/// Search one already-open repository at `rev_name`. Returns matched files.
106fn search_repo_blocking(repo: &git::Repo, rev_name: &str, query: &ParsedQuery) -> Vec<FileResult> {
107 let Ok(rev) = repo.resolve_ref(rev_name) else {
108 return Vec::new();
109 };
110 let Ok(paths) = repo.file_paths(&rev) else {
111 return Vec::new();
112 };
113 let matcher = (!query.text.is_empty())
114 .then(|| {
115 RegexMatcherBuilder::new()
116 .case_insensitive(!query.case_sensitive)
117 .fixed_strings(!query.regex)
118 .build(&query.text)
119 .ok()
120 })
121 .flatten();
122
123 let mut searcher = Searcher::new();
124 let mut results = Vec::new();
125 for path in paths {
126 if results.len() >= FILES_PER_REPO {
127 break;
128 }
129 if let Some(filter) = &query.path
130 && !path.to_lowercase().contains(&filter.to_lowercase())
131 {
132 continue;
133 }
134 let Ok(blob) = repo.blob(&rev, FsPath::new(&path)) else {
135 continue;
136 };
137 if blob.is_binary || blob.size > MAX_FILE_BYTES {
138 continue;
139 }
140 if let Some(lang) = &query.lang {
141 let resolved = highlight::resolve(FsPath::new(&path), &blob.content, None);
142 if resolved.name().map(str::to_ascii_lowercase) != Some(lang.to_ascii_lowercase()) {
143 continue;
144 }
145 }
146
147 let mut lines = Vec::new();
148 if let Some(matcher) = &matcher {
149 let _ = searcher.search_slice(
150 matcher,
151 &blob.content,
152 UTF8(|lnum, line| {
153 if lines.len() < LINES_PER_FILE {
154 lines.push(LineMatch {
155 line: lnum,
156 text: line.trim_end().to_string(),
157 });
158 }
159 Ok(true)
160 }),
161 );
162 if !lines.is_empty() {
163 results.push(FileResult { path, lines });
164 }
165 } else if query.path.is_some() || query.lang.is_some() {
166 // No text term but a path/lang filter matched: report the file itself.
167 results.push(FileResult { path, lines });
168 }
169 }
170 results
171}
172
173/// Query parameters for the global search route.
174#[derive(Debug, Default, Deserialize)]
175pub struct SearchParams {
176 /// The raw query.
177 #[serde(default)]
178 q: String,
179}
180
181/// `GET /search` — global search across visible repos.
182pub async fn global(
183 State(state): State<AppState>,
184 MaybeUser(viewer): MaybeUser,
185 jar: CookieJar,
186 uri: Uri,
187 Query(params): Query<SearchParams>,
188) -> AppResult<Response> {
189 let query = parse(&params.q);
190 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
191
192 let body = if params.q.trim().is_empty() {
193 html! { h1 { "Search" } (search_box(&params.q, None)) }
194 } else {
195 let entities = entity_matches(&state, &query, viewer.as_ref()).await?;
196 let (content, timed_out, truncated) =
197 content_matches(&state, &query, viewer.as_ref()).await?;
198 html! {
199 h1 { "Search" }
200 (search_box(&params.q, None))
201 @if timed_out {
202 div class="notice notice-error" { "Search timed out; showing partial results." }
203 } @else if truncated {
204 div class="notice" { "Showing the first results; refine your query for more." }
205 }
206 (entity_section(&entities))
207 (content_section(&content))
208 @if entities.is_empty() && content.is_empty() {
209 p class="muted" { "No results." }
210 }
211 }
212 };
213 Ok((jar, page(&chrome, "Search", body)).into_response())
214}
215
216/// A repo/user/group name-or-description match.
217struct EntityMatch {
218 kind: &'static str,
219 label: String,
220 href: String,
221}
222
223/// Match repo/user names and descriptions (the "first" pass of global search).
224async fn entity_matches(
225 state: &AppState,
226 query: &ParsedQuery,
227 viewer: Option<&User>,
228) -> AppResult<Vec<EntityMatch>> {
229 let needle = query.text.to_lowercase();
230 if needle.is_empty() {
231 return Ok(Vec::new());
232 }
233 let mut out = Vec::new();
234
235 for user in state.store.list_users().await? {
236 if user.username.to_lowercase().contains(&needle) {
237 out.push(EntityMatch {
238 kind: "user",
239 label: user.username.clone(),
240 href: format!("/{}", user.username),
241 });
242 }
243 }
244 for repo in visible_repos(state, viewer).await? {
245 let owner = owner_name(state, &repo.owner_id).await;
246 let hay = format!(
247 "{} {} {}",
248 repo.path,
249 repo.name,
250 repo.description.clone().unwrap_or_default()
251 )
252 .to_lowercase();
253 if hay.contains(&needle) {
254 out.push(EntityMatch {
255 kind: "repo",
256 label: format!("{owner}/{}", repo.path),
257 href: format!("/{owner}/{}", repo.path),
258 });
259 }
260 }
261 Ok(out)
262}
263
264/// Run the bounded content search across visible repos. Returns matches plus
265/// whether the time budget or result cap was hit.
266/// A repo's content matches: its `owner/path` label, its default branch (for
267/// blob links), and the per-file results.
268type RepoMatches = (String, String, Vec<FileResult>);
269
270async fn content_matches(
271 state: &AppState,
272 query: &ParsedQuery,
273 viewer: Option<&User>,
274) -> AppResult<(Vec<RepoMatches>, bool, bool)> {
275 let mut repos = visible_repos(state, viewer).await?;
276 // Honour a repo: qualifier by filtering the candidate set.
277 if let Some(repo_q) = &query.repo {
278 let want = repo_q.to_lowercase();
279 // Resolve owner names to filter by "owner/path".
280 let mut kept = Vec::new();
281 for repo in repos {
282 let owner = owner_name(state, &repo.owner_id).await;
283 if format!("{owner}/{}", repo.path).to_lowercase() == want {
284 kept.push(repo);
285 }
286 }
287 repos = kept;
288 }
289
290 let deadline = Instant::now() + Duration::from_secs(state.config.search.timeout_secs.max(1));
291 let semaphore = Arc::new(Semaphore::new(state.config.search.concurrency.max(1)));
292 let mut set: JoinSet<Option<RepoMatches>> = JoinSet::new();
293
294 for repo in repos {
295 let state = state.clone();
296 let query = query.clone();
297 let semaphore = Arc::clone(&semaphore);
298 set.spawn(async move {
299 let _permit = semaphore.acquire().await.ok()?;
300 let owner = owner_name(&state, &repo.owner_id).await;
301 let branch = repo.default_branch.clone();
302 let rev = branch.clone();
303 let files = crate::repo::git_read(&state, &repo.id, move |repo| {
304 Ok(search_repo_blocking(repo, &rev, &query))
305 })
306 .await
307 .ok()?;
308 (!files.is_empty()).then(|| (format!("{owner}/{}", repo.path), branch, files))
309 });
310 }
311
312 let cap = state.config.search.max_results;
313 let mut results = Vec::new();
314 let mut total = 0usize;
315 let mut timed_out = false;
316 let mut truncated = false;
317
318 loop {
319 let remaining = deadline.saturating_duration_since(Instant::now());
320 if remaining.is_zero() {
321 timed_out = true;
322 break;
323 }
324 match tokio::time::timeout(remaining, set.join_next()).await {
325 Ok(Some(Ok(Some(entry)))) => {
326 total += entry.2.iter().map(|f| f.lines.len().max(1)).sum::<usize>();
327 results.push(entry);
328 if total >= cap {
329 truncated = true;
330 break;
331 }
332 }
333 Ok(Some(_)) => {} // a repo errored or had no matches
334 Ok(None) => break, // all done
335 Err(_) => {
336 timed_out = true;
337 break;
338 }
339 }
340 }
341 set.abort_all();
342 Ok((results, timed_out, truncated))
343}
344
345/// `search` view for a single repo (called from the repo dispatcher).
346pub async fn in_repo(
347 state: &AppState,
348 viewer: Option<User>,
349 jar: CookieJar,
350 uri: &Uri,
351 ctx: &crate::repo::RepoCtx,
352 raw_query: &str,
353 header: Markup,
354) -> AppResult<Response> {
355 let owner = &ctx.owner.username;
356 let repo = &ctx.repo;
357 let default_branch = &ctx.repo.default_branch;
358 let query = parse(raw_query);
359 let rev = default_branch.clone();
360
361 let files = if raw_query.trim().is_empty() {
362 Vec::new()
363 } else {
364 let query = query.clone();
365 let rev = rev.clone();
366 crate::repo::git_read(state, &repo.id, move |repo| {
367 Ok(search_repo_blocking(repo, &rev, &query))
368 })
369 .await?
370 };
371
372 let base = format!("/{owner}/{}", repo.path);
373 let body = html! {
374 (header)
375 h2 { "Search this repository" }
376 (search_box(raw_query, Some(&format!("{base}/-/search"))))
377 @if raw_query.trim().is_empty() {
378 p class="muted" { "Enter a query to search the code." }
379 } @else if files.is_empty() {
380 p class="muted" { "No matches." }
381 } @else {
382 (repo_content(owner, &repo.path, default_branch, &files))
383 }
384 };
385
386 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
387 let title = format!("{owner}/{}: search", repo.path);
388 Ok((jar, page(&chrome, &title, body)).into_response())
389}
390
391// ---- components ----
392
393/// The search input form.
394fn search_box(value: &str, action: Option<&str>) -> Markup {
395 html! {
396 form class="search-row" method="get" action=[action] {
397 input type="search" name="q" value=(value) placeholder="Search…"
398 data-search-input aria-label="Search query" autofocus;
399 button class="btn btn-primary" type="submit" { "Search" }
400 }
401 }
402}
403
404/// Render entity (name) matches.
405fn entity_section(entities: &[EntityMatch]) -> Markup {
406 html! {
407 @if !entities.is_empty() {
408 section {
409 h2 { "Repositories & users" }
410 ul class="repo-list" {
411 @for entity in entities {
412 li {
413 span class="badge" { (entity.kind) }
414 " " a href=(entity.href) { (entity.label) }
415 }
416 }
417 }
418 }
419 }
420 }
421}
422
423/// Render global content matches, grouped by repo then file, each linking into
424/// the blob at the repo's default branch.
425fn content_section(content: &[RepoMatches]) -> Markup {
426 html! {
427 @if !content.is_empty() {
428 section {
429 h2 { "Code" }
430 @for (repo, rev, files) in content {
431 h3 class="search-repo" { a href={ "/" (repo) } { (repo) } }
432 @let base = format!("/{repo}/-/blob/{rev}");
433 @for file in files {
434 (file_result(&base, Some(rev), file))
435 }
436 }
437 }
438 }
439 }
440}
441
442/// Render in-repo content matches with links into the blob view.
443fn repo_content(owner: &str, repo_path: &str, rev: &str, files: &[FileResult]) -> Markup {
444 let base = format!("/{owner}/{repo_path}/-/blob/{rev}");
445 html! {
446 @for file in files {
447 (file_result(&base, Some(rev), file))
448 }
449 }
450}
451
452/// One file's matches. `blob_base` links each line to the blob (with `#L{n}`).
453fn file_result(blob_base: &str, in_repo: Option<&str>, file: &FileResult) -> Markup {
454 html! {
455 div class="search-hit card" {
456 div class="search-hit-path mono" {
457 @if in_repo.is_some() {
458 a href=(format!("{blob_base}/{}", file.path)) { (file.path) }
459 } @else {
460 (file.path)
461 }
462 }
463 @if !file.lines.is_empty() {
464 div class="search-hit-lines" {
465 @for m in &file.lines {
466 div class="search-hit-line" {
467 @if in_repo.is_some() {
468 a href=(format!("{blob_base}/{}#L{}", file.path, m.line))
469 class="lineno" { (m.line) }
470 } @else {
471 span class="lineno" { (m.line) }
472 }
473 code { (m.text) }
474 }
475 }
476 }
477 }
478 }
479 }
480}
481
482// ---- shared repo visibility ----
483
484/// Every repo the viewer may read (across owners), for global search.
485async fn visible_repos(state: &AppState, viewer: Option<&User>) -> AppResult<Vec<Repo>> {
486 let all = state.store.list_repos().await?;
487 let mut out = Vec::new();
488 for repo in all {
489 if can_read(state, &repo, viewer).await? {
490 out.push(repo);
491 }
492 }
493 Ok(out)
494}
495
496/// Whether the viewer can read `repo`.
497async fn can_read(state: &AppState, repo: &Repo, viewer: Option<&User>) -> AppResult<bool> {
498 let viewer_id = viewer.map(|u| auth::Viewer {
499 id: u.id.clone(),
500 is_admin: u.is_admin,
501 });
502 let collaborator = match viewer {
503 Some(u) => state
504 .store
505 .effective_permission(&repo.id, &u.id)
506 .await?
507 .and_then(|p| auth::Permission::parse(&p)),
508 None => None,
509 };
510 let access = auth::access(
511 viewer_id.as_ref(),
512 repo,
513 collaborator,
514 state.allow_anonymous(),
515 );
516 Ok(access >= auth::Access::Read)
517}
518
519/// Resolve an owner id to a username, falling back to the id.
520async fn owner_name(state: &AppState, owner_id: &str) -> String {
521 state
522 .store
523 .user_by_id(owner_id)
524 .await
525 .ok()
526 .flatten()
527 .map_or_else(|| owner_id.to_string(), |u| u.username)
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn parses_qualifiers_and_free_text() {
536 let q = parse("foo bar repo:ada/api lang:rust case:yes regex:no path:src");
537 assert_eq!(q.text, "foo bar");
538 assert_eq!(q.repo.as_deref(), Some("ada/api"));
539 assert_eq!(q.lang.as_deref(), Some("rust"));
540 assert_eq!(q.path.as_deref(), Some("src"));
541 assert!(q.case_sensitive);
542 assert!(!q.regex);
543 }
544
545 #[test]
546 fn bare_query_has_no_qualifiers() {
547 let q = parse("hello world");
548 assert_eq!(q.text, "hello world");
549 assert!(q.repo.is_none());
550 assert!(!q.case_sensitive);
551 assert!(!q.regex);
552 }
553}