//! Page: code search at `/{owner}/{repo}/search`. Runs `git grep` at one commit (`ref`, default the //! default branch) and groups the hits by file. Each line links to the blob view at `#L`. In //! plain-text mode the matching text is wrapped in ``; regex mode shows the lines unhighlighted. //! `case=1` makes the match case-sensitive; `path` is a git pathspec glob that limits the search. use forge_git::browse; use forge_git::search::{GrepHit, GrepOptions, GrepResult, grep}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::not_found; use topcoat::router::{page, path_param, query_params}; use topcoat::view::{View, view}; use crate::i18n::{ self, code::{self as g, K}, }; use crate::pages::commits::blocking; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::load_repo; use crate::state::AppState; use crate::views::{repo_href, shell}; path_param!(owner); path_param!(repo); #[query_params(error = bad_request)] struct SearchQuery { q: Option, r#ref: Option, case: Option, regex: Option, path: Option, } /// A piece of a result line. `hit` marks the query occurrence to highlight. struct Segment { text: String, hit: bool, } /// One result line, ready to render. struct LineRow { num: String, href: String, segments: Vec, } /// The hits of one file, in git's order. struct FileGroup { path: String, href: String, lines: Vec, } /// Splits `line` around the occurrences of `query`. Matching compares ASCII case-insensitively /// unless `match_case`. Only whole-byte matches are taken, so every slice stays on a char boundary. fn segments(line: &str, query: &str, match_case: bool) -> Vec { let (hay, needle) = (line.as_bytes(), query.as_bytes()); let mut out = Vec::new(); let mut last = 0; let mut i = 0; while !needle.is_empty() && i + needle.len() <= hay.len() { let window = &hay[i..i + needle.len()]; let same = if match_case { window == needle } else { window.eq_ignore_ascii_case(needle) }; if same { let end = i + needle.len(); if last < i { out.push(Segment { text: line.get(last..i).unwrap_or_default().to_owned(), hit: false, }); } out.push(Segment { text: line.get(i..end).unwrap_or_default().to_owned(), hit: true, }); i = end; last = end; } else { i += 1; } } if last < line.len() { out.push(Segment { text: line.get(last..).unwrap_or_default().to_owned(), hit: false, }); } out } /// Groups consecutive hits of the same file, keeping git's order. fn group_hits( hits: &[GrepHit], owner: &str, repo: &str, ref_name: &str, query: Option<(&str, bool)>, ) -> Vec { let mut groups: Vec = Vec::new(); for hit in hits { let blob = repo_href(owner, repo, "blob", &format!("{ref_name}/{}", hit.path)); let segs = match query { Some((q, match_case)) => segments(&hit.line, q, match_case), None => vec![Segment { text: hit.line.clone(), hit: false, }], }; let row = LineRow { num: hit.line_no.to_string(), href: format!("{blob}#L{}", hit.line_no), segments: segs, }; match groups.last_mut() { Some(group) if group.path == hit.path => group.lines.push(row), _ => groups.push(FileGroup { href: blob, path: hit.path.clone(), lines: vec![row], }), } } groups } #[page("/{owner}/{repo}/search")] async fn search_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let query = query_params::(cx)?; let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); let repo_name = ctx.repo.name.clone(); let owner_name = ctx.repo.owner_name.clone(); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let typed_ref = query.r#ref.clone().unwrap_or_default().trim().to_owned(); let repo_dir = ctx.path.clone(); // The commit to search (None for an empty repo), and the ref name shown and used in links. let (commit, ref_name) = if typed_ref.is_empty() { let branch = ctx.repo.default_branch.clone(); let commit = blocking(move || browse::default_branch_commit(&repo_dir, &branch)).await?; (commit, ctx.repo.default_branch.clone()) } else { let name = typed_ref.clone(); let Some(commit) = blocking(move || browse::resolve_commit(&repo_dir, &name)).await? else { return Err(not_found().into()); }; (Some(commit), typed_ref.clone()) }; let q = query.q.clone().unwrap_or_default(); let match_case = query.case.as_deref() == Some("1"); let regex = query.regex.as_deref() == Some("1"); let path_glob = query.path.clone().unwrap_or_default().trim().to_owned(); let mut notice: Option = None; let mut result: Option = None; if let Some(commit) = commit.as_deref().filter(|_| !q.trim().is_empty()) { let opts = GrepOptions { fixed: !regex, ignore_case: !match_case, path_glob: (!path_glob.is_empty()).then(|| path_glob.clone()), ..GrepOptions::default() }; match grep(&state.git, &ctx.path, commit, &q, opts).await { Ok(found) => result = Some(found), Err(forge_git::Error::InvalidInput(msg)) => notice = Some(msg), Err(_) => { notice = Some(g::t(lang, K::SearchFailed).to_owned()); } } } let searched = result.is_some(); let empty_repo = commit.is_none(); let no_query = q.trim().is_empty(); let truncated = result.as_ref().is_some_and(|r| r.truncated); let hits = result.as_ref().map(|r| r.hits.as_slice()).unwrap_or(&[]); let highlight = (!regex).then_some((q.as_str(), match_case)); let groups = group_hits(hits, &owner_name, &repo_name, &ref_name, highlight); let match_count = hits.len(); let file_count = groups.len(); let shown = match_count.to_string(); let summary = g::match_summary(lang, match_count, file_count); let empty_message = g::empty_repo_message(lang, &ctx.repo.default_branch); let has_results = !groups.is_empty(); let action = repo_href(&owner_name, &repo_name, "search", ""); let title = format!( "{owner_name}/{repo_name} \u{b7} {}", g::t(lang, K::SearchWord) ); let ref_value = typed_ref; let path_value = path_glob; let ref_label = ref_name.clone(); let header = ctx.head(state, "code").await?; Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) if empty_repo {

(empty_message.as_str())

} else if no_query {

(g::t(lang, K::SearchFilesAt)) (ref_label.as_str()) "."

} if let Some(msg) = notice { } if !no_query && !empty_repo { if has_results {

(summary.as_str()) if truncated { (g::t(lang, K::ShowingFirst)) (shown.as_str()) (g::t(lang, K::ShowingFirstEnd)) }

for group in groups.iter() {

(group.path.as_str())

    for line in group.lines.iter() {
  • (line.num.as_str()) for seg in line.segments.iter() { if seg.hit { (seg.text.as_str()) } else { (seg.text.as_str()) } }
  • }
}
} else if searched {

(g::t(lang, K::NoMatches))

} } ) }) } #[cfg(test)] mod tests { use super::*; fn texts(segs: &[Segment]) -> Vec<(String, bool)> { segs.iter().map(|s| (s.text.clone(), s.hit)).collect() } #[test] fn segments_mark_each_occurrence() { let segs = segments("Hello hello HELLO", "hello", false); assert_eq!( texts(&segs), vec![ ("Hello".to_owned(), true), (" ".to_owned(), false), ("hello".to_owned(), true), (" ".to_owned(), false), ("HELLO".to_owned(), true), ] ); } #[test] fn segments_respect_case_and_unicode() { let segs = segments("Hello hello", "hello", true); assert_eq!( texts(&segs), vec![("Hello ".to_owned(), false), ("hello".to_owned(), true)] ); // Non-ASCII text is never split inside a character. let segs = segments("café CAFÉ", "é", false); assert_eq!(segs.iter().filter(|s| s.hit).count(), 1); assert_eq!(segments("no match here", "zzz", false).len(), 1); } } #[cfg(test)] mod route_tests { use std::path::Path; use std::process::Command; use forge_core::Visibility; use forge_git::repo::{init_bare, repo_path}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::state::AppState; async fn app(dir: &Path) -> (Router, AppState) { let state = crate::test_support::test_state(dir).await; (crate::test_support::app_router(state.clone()), state) } fn git(dir: &Path, args: &[&str]) { let status = Command::new("git") .current_dir(dir) .env("GIT_CONFIG_NOSYSTEM", "1") .args([ "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", ]) .args(args) .status() .expect("git runs"); assert!(status.success(), "git {args:?} failed"); } /// `alice/demo` (public) and `alice/secret` (private), both with content on `main`, plus /// `alice/empty` (public, no commits). async fn fixture(dir: &Path) -> (Router, AppState) { let (router, state) = app(dir).await; let alice = state .store .create_user("alice", None, "x", false) .await .unwrap(); let demo = state .store .create_repo(&alice.id, "demo", "A demo repo", Visibility::Public, "main") .await .unwrap(); let secret = state .store .create_repo(&alice.id, "secret", "", Visibility::Private, "main") .await .unwrap(); let empty = state .store .create_repo(&alice.id, "empty", "", Visibility::Public, "main") .await .unwrap(); for repo in [&demo, &secret, &empty] { let path = repo_path(&state.config.data_dir, &repo.id).unwrap(); init_bare(&state.git, &path, "main").await.unwrap(); } let work = dir.join("work"); std::fs::create_dir_all(work.join("src")).unwrap(); std::fs::write(work.join("README.md"), "# Demo\n\nHello from the README.\n").unwrap(); std::fs::write( work.join("src/main.rs"), "fn main() {\n println!(\"hi\");\n}\n", ) .unwrap(); git(&work, &["init", "-q", "-b", "main"]); git(&work, &["add", "-A"]); git(&work, &["commit", "-q", "-m", "first commit"]); for repo in [&demo, &secret] { let path = repo_path(&state.config.data_dir, &repo.id).unwrap(); git(&work, &["push", "-q", path.to_str().unwrap(), "main:main"]); } (router, state) } async fn get(router: &Router, uri: &str) -> (u16, String) { let req = Request::builder() .method("GET") .uri(uri) .body(Body::empty()) .unwrap(); let res = router.handle(req).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); (status, String::from_utf8_lossy(&bytes).into_owned()) } #[tokio::test] async fn results_group_by_file_with_line_links_and_marks() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let (status, body) = get(&router, "/alice/demo/search?q=hello").await; assert_eq!(status, 200); assert!(body.contains("README.md"), "file listed"); assert!( body.contains("/alice/demo/blob/main/README.md#L3"), "line links to the blob view" ); assert!(body.contains("Hello"), "match highlighted"); assert!(body.contains("1 match in 1 file"), "summary shown"); assert!(!body.contains("src/main.rs"), "non-matching file absent"); } #[tokio::test] async fn match_case_and_regex_and_path_options() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let (_, body) = get(&router, "/alice/demo/search?q=HELLO&case=1").await; assert!(body.contains("No matches."), "case-sensitive miss"); let (_, body) = get(&router, "/alice/demo/search?q=h.llo®ex=1").await; assert!(body.contains("README.md"), "regex hit"); assert!(!body.contains(""), "regex mode does not highlight"); let (_, body) = get(&router, "/alice/demo/search?q=println&path=README.md").await; assert!(body.contains("No matches."), "path glob excludes src"); } #[tokio::test] async fn bad_regex_and_empty_repo_show_messages() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let (status, body) = get(&router, "/alice/demo/search?q=(®ex=1").await; assert_eq!(status, 200); assert!(body.contains("could not run"), "git failure shown"); let (status, body) = get(&router, "/alice/empty/search?q=hello").await; assert_eq!(status, 200); assert!(body.contains("no commits"), "empty repo message"); let (status, _) = get(&router, "/alice/demo/search?q=hello&ref=nope-branch").await; assert_eq!(status, 404, "unknown ref"); } #[tokio::test] async fn private_repo_is_404_for_anonymous_viewers() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let (status, body) = get(&router, "/alice/secret/search?q=hello").await; assert_eq!(status, 404); assert!(!body.contains("README.md")); } #[tokio::test] async fn turkish_viewer_gets_turkish_search_page() { let dir = tempfile::tempdir().unwrap(); let (router, _) = fixture(dir.path()).await; let req = Request::builder() .method("GET") .uri("/alice/demo/search?q=hello") .header("accept-language", "tr") .body(Body::empty()) .unwrap(); let res = router.handle(req).await; let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); let body = String::from_utf8_lossy(&bytes).into_owned(); assert!(body.contains("· arama"), "Turkish page title"); assert!(body.contains("Kodda ara"), "Turkish search placeholder"); assert!(body.contains("1 eşleşme, 1 dosyada"), "Turkish summary"); } }