forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
17.7 KiB
1//! Page: code search at `/{owner}/{repo}/search`. Runs `git grep` at one commit (`ref`, default the
2//! default branch) and groups the hits by file. Each line links to the blob view at `#L<n>`. In
3//! plain-text mode the matching text is wrapped in `<mark>`; regex mode shows the lines unhighlighted.
4//! `case=1` makes the match case-sensitive; `path` is a git pathspec glob that limits the search.
5
6use forge_git::browse;
7use forge_git::search::{GrepHit, GrepOptions, GrepResult, grep};
8use topcoat::Result;
9use topcoat::context::{Cx, app_context};
10use topcoat::router::error::not_found;
11use topcoat::router::{page, path_param, query_params};
12use topcoat::view::{View, view};
13
14use crate::i18n::{
15 self,
16 code::{self as g, K},
17};
18use crate::pages::commits::blocking;
19use crate::pages::repo::repo_head;
20use crate::pages::repo_ctx::load_repo;
21use crate::state::AppState;
22use crate::views::{repo_href, shell};
23
24path_param!(owner);
25path_param!(repo);
26
27#[query_params(error = bad_request)]
28struct SearchQuery {
29 q: Option<String>,
30 r#ref: Option<String>,
31 case: Option<String>,
32 regex: Option<String>,
33 path: Option<String>,
34}
35
36/// A piece of a result line. `hit` marks the query occurrence to highlight.
37struct Segment {
38 text: String,
39 hit: bool,
40}
41
42/// One result line, ready to render.
43struct LineRow {
44 num: String,
45 href: String,
46 segments: Vec<Segment>,
47}
48
49/// The hits of one file, in git's order.
50struct FileGroup {
51 path: String,
52 href: String,
53 lines: Vec<LineRow>,
54}
55
56/// Splits `line` around the occurrences of `query`. Matching compares ASCII case-insensitively
57/// unless `match_case`. Only whole-byte matches are taken, so every slice stays on a char boundary.
58fn segments(line: &str, query: &str, match_case: bool) -> Vec<Segment> {
59 let (hay, needle) = (line.as_bytes(), query.as_bytes());
60 let mut out = Vec::new();
61 let mut last = 0;
62 let mut i = 0;
63 while !needle.is_empty() && i + needle.len() <= hay.len() {
64 let window = &hay[i..i + needle.len()];
65 let same = if match_case {
66 window == needle
67 } else {
68 window.eq_ignore_ascii_case(needle)
69 };
70 if same {
71 let end = i + needle.len();
72 if last < i {
73 out.push(Segment {
74 text: line.get(last..i).unwrap_or_default().to_owned(),
75 hit: false,
76 });
77 }
78 out.push(Segment {
79 text: line.get(i..end).unwrap_or_default().to_owned(),
80 hit: true,
81 });
82 i = end;
83 last = end;
84 } else {
85 i += 1;
86 }
87 }
88 if last < line.len() {
89 out.push(Segment {
90 text: line.get(last..).unwrap_or_default().to_owned(),
91 hit: false,
92 });
93 }
94 out
95}
96
97/// Groups consecutive hits of the same file, keeping git's order.
98fn group_hits(
99 hits: &[GrepHit],
100 owner: &str,
101 repo: &str,
102 ref_name: &str,
103 query: Option<(&str, bool)>,
104) -> Vec<FileGroup> {
105 let mut groups: Vec<FileGroup> = Vec::new();
106 for hit in hits {
107 let blob = repo_href(owner, repo, "blob", &format!("{ref_name}/{}", hit.path));
108 let segs = match query {
109 Some((q, match_case)) => segments(&hit.line, q, match_case),
110 None => vec![Segment {
111 text: hit.line.clone(),
112 hit: false,
113 }],
114 };
115 let row = LineRow {
116 num: hit.line_no.to_string(),
117 href: format!("{blob}#L{}", hit.line_no),
118 segments: segs,
119 };
120 match groups.last_mut() {
121 Some(group) if group.path == hit.path => group.lines.push(row),
122 _ => groups.push(FileGroup {
123 href: blob,
124 path: hit.path.clone(),
125 lines: vec![row],
126 }),
127 }
128 }
129 groups
130}
131
132#[page("/{owner}/{repo}/search")]
133async fn search_page(cx: &Cx) -> Result<impl View> {
134 let owner = path_param::<Owner>(cx);
135 let name = path_param::<Repo>(cx);
136 let query = query_params::<SearchQuery>(cx)?;
137
138 let ctx = load_repo(cx, owner, name).await?;
139 let state = app_context::<AppState>(cx);
140 let repo_name = ctx.repo.name.clone();
141 let owner_name = ctx.repo.owner_name.clone();
142 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
143
144 let typed_ref = query.r#ref.clone().unwrap_or_default().trim().to_owned();
145 let repo_dir = ctx.path.clone();
146 // The commit to search (None for an empty repo), and the ref name shown and used in links.
147 let (commit, ref_name) = if typed_ref.is_empty() {
148 let branch = ctx.repo.default_branch.clone();
149 let commit = blocking(move || browse::default_branch_commit(&repo_dir, &branch)).await?;
150 (commit, ctx.repo.default_branch.clone())
151 } else {
152 let name = typed_ref.clone();
153 let Some(commit) = blocking(move || browse::resolve_commit(&repo_dir, &name)).await? else {
154 return Err(not_found().into());
155 };
156 (Some(commit), typed_ref.clone())
157 };
158
159 let q = query.q.clone().unwrap_or_default();
160 let match_case = query.case.as_deref() == Some("1");
161 let regex = query.regex.as_deref() == Some("1");
162 let path_glob = query.path.clone().unwrap_or_default().trim().to_owned();
163
164 let mut notice: Option<String> = None;
165 let mut result: Option<GrepResult> = None;
166 if let Some(commit) = commit.as_deref().filter(|_| !q.trim().is_empty()) {
167 let opts = GrepOptions {
168 fixed: !regex,
169 ignore_case: !match_case,
170 path_glob: (!path_glob.is_empty()).then(|| path_glob.clone()),
171 ..GrepOptions::default()
172 };
173 match grep(&state.git, &ctx.path, commit, &q, opts).await {
174 Ok(found) => result = Some(found),
175 Err(forge_git::Error::InvalidInput(msg)) => notice = Some(msg),
176 Err(_) => {
177 notice = Some(g::t(lang, K::SearchFailed).to_owned());
178 }
179 }
180 }
181
182 let searched = result.is_some();
183 let empty_repo = commit.is_none();
184 let no_query = q.trim().is_empty();
185 let truncated = result.as_ref().is_some_and(|r| r.truncated);
186 let hits = result.as_ref().map(|r| r.hits.as_slice()).unwrap_or(&[]);
187 let highlight = (!regex).then_some((q.as_str(), match_case));
188 let groups = group_hits(hits, &owner_name, &repo_name, &ref_name, highlight);
189 let match_count = hits.len();
190 let file_count = groups.len();
191 let shown = match_count.to_string();
192 let summary = g::match_summary(lang, match_count, file_count);
193 let empty_message = g::empty_repo_message(lang, &ctx.repo.default_branch);
194 let has_results = !groups.is_empty();
195 let action = repo_href(&owner_name, &repo_name, "search", "");
196 let title = format!(
197 "{owner_name}/{repo_name} \u{b7} {}",
198 g::t(lang, K::SearchWord)
199 );
200 let ref_value = typed_ref;
201 let path_value = path_glob;
202 let ref_label = ref_name.clone();
203
204 let header = ctx.head(state, "code").await?;
205 Ok(view! {
206 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
207 repo_head(head: header)
208 <form method="get" action=(action.as_str()) class="search-form" role="search">
209 <div class="search-row">
210 <input type="search" name="q" value=(q.as_str()) placeholder=(g::t(lang, K::SearchCodePlaceholder)) aria-label=(g::t(lang, K::SearchCodePlaceholder))>
211 <button type="submit">(g::t(lang, K::SearchButton))</button>
212 </div>
213 <div class="search-row search-options">
214 <label>(g::t(lang, K::RefField)) <input type="text" name="ref" value=(ref_value.as_str()) placeholder=(ctx.repo.default_branch.as_str())></label>
215 <label>(g::t(lang, K::PathField)) <input type="text" name="path" value=(path_value.as_str()) placeholder="src/**/*.rs"></label>
216 <label><input type="checkbox" name="case" value="1" checked=(match_case)> (g::t(lang, K::MatchCase))</label>
217 <label><input type="checkbox" name="regex" value="1" checked=(regex)> (g::t(lang, K::RegexOption))</label>
218 </div>
219 </form>
220 if empty_repo {
221 <p class="notice">(empty_message.as_str())</p>
222 } else if no_query {
223 <p class="muted">(g::t(lang, K::SearchFilesAt)) <code>(ref_label.as_str())</code> "."</p>
224 }
225 if let Some(msg) = notice {
226 <p class="notice" role="alert">(msg.as_str())</p>
227 }
228 if !no_query && !empty_repo {
229 if has_results {
230 <p class="muted small">(summary.as_str())
231 if truncated {
232 (g::t(lang, K::ShowingFirst)) (shown.as_str()) (g::t(lang, K::ShowingFirstEnd))
233 }
234 </p>
235 <section class="search-results">
236 for group in groups.iter() {
237 <div class="search-file">
238 <h3 class="search-file-name"><a href=(group.href.as_str())>(group.path.as_str())</a></h3>
239 <ul class="search-hits">
240 for line in group.lines.iter() {
241 <li>
242 <a class="ln" href=(line.href.as_str())>(line.num.as_str())</a>
243 <code class="search-line">
244 for seg in line.segments.iter() {
245 if seg.hit {
246 <mark>(seg.text.as_str())</mark>
247 } else {
248 (seg.text.as_str())
249 }
250 }
251 </code>
252 </li>
253 }
254 </ul>
255 </div>
256 }
257 </section>
258 } else if searched {
259 <p class="muted">(g::t(lang, K::NoMatches))</p>
260 }
261 }
262 )
263 })
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn texts(segs: &[Segment]) -> Vec<(String, bool)> {
271 segs.iter().map(|s| (s.text.clone(), s.hit)).collect()
272 }
273
274 #[test]
275 fn segments_mark_each_occurrence() {
276 let segs = segments("Hello hello HELLO", "hello", false);
277 assert_eq!(
278 texts(&segs),
279 vec![
280 ("Hello".to_owned(), true),
281 (" ".to_owned(), false),
282 ("hello".to_owned(), true),
283 (" ".to_owned(), false),
284 ("HELLO".to_owned(), true),
285 ]
286 );
287 }
288
289 #[test]
290 fn segments_respect_case_and_unicode() {
291 let segs = segments("Hello hello", "hello", true);
292 assert_eq!(
293 texts(&segs),
294 vec![("Hello ".to_owned(), false), ("hello".to_owned(), true)]
295 );
296 // Non-ASCII text is never split inside a character.
297 let segs = segments("café CAFÉ", "é", false);
298 assert_eq!(segs.iter().filter(|s| s.hit).count(), 1);
299 assert_eq!(segments("no match here", "zzz", false).len(), 1);
300 }
301}
302
303#[cfg(test)]
304mod route_tests {
305 use std::path::Path;
306 use std::process::Command;
307
308 use forge_core::Visibility;
309 use forge_git::repo::{init_bare, repo_path};
310 use topcoat::router::request::Request;
311 use topcoat::router::{Body, Router, to_bytes};
312
313 use crate::state::AppState;
314
315 async fn app(dir: &Path) -> (Router, AppState) {
316 let state = crate::test_support::test_state(dir).await;
317 (crate::test_support::app_router(state.clone()), state)
318 }
319
320 fn git(dir: &Path, args: &[&str]) {
321 let status = Command::new("git")
322 .current_dir(dir)
323 .env("GIT_CONFIG_NOSYSTEM", "1")
324 .args([
325 "-c",
326 "user.name=Test",
327 "-c",
328 "user.email=test@example.com",
329 "-c",
330 "commit.gpgsign=false",
331 ])
332 .args(args)
333 .status()
334 .expect("git runs");
335 assert!(status.success(), "git {args:?} failed");
336 }
337
338 /// `alice/demo` (public) and `alice/secret` (private), both with content on `main`, plus
339 /// `alice/empty` (public, no commits).
340 async fn fixture(dir: &Path) -> (Router, AppState) {
341 let (router, state) = app(dir).await;
342 let alice = state
343 .store
344 .create_user("alice", None, "x", false)
345 .await
346 .unwrap();
347 let demo = state
348 .store
349 .create_repo(&alice.id, "demo", "A demo repo", Visibility::Public, "main")
350 .await
351 .unwrap();
352 let secret = state
353 .store
354 .create_repo(&alice.id, "secret", "", Visibility::Private, "main")
355 .await
356 .unwrap();
357 let empty = state
358 .store
359 .create_repo(&alice.id, "empty", "", Visibility::Public, "main")
360 .await
361 .unwrap();
362 for repo in [&demo, &secret, &empty] {
363 let path = repo_path(&state.config.data_dir, &repo.id).unwrap();
364 init_bare(&state.git, &path, "main").await.unwrap();
365 }
366
367 let work = dir.join("work");
368 std::fs::create_dir_all(work.join("src")).unwrap();
369 std::fs::write(work.join("README.md"), "# Demo\n\nHello from the README.\n").unwrap();
370 std::fs::write(
371 work.join("src/main.rs"),
372 "fn main() {\n println!(\"hi\");\n}\n",
373 )
374 .unwrap();
375 git(&work, &["init", "-q", "-b", "main"]);
376 git(&work, &["add", "-A"]);
377 git(&work, &["commit", "-q", "-m", "first commit"]);
378 for repo in [&demo, &secret] {
379 let path = repo_path(&state.config.data_dir, &repo.id).unwrap();
380 git(&work, &["push", "-q", path.to_str().unwrap(), "main:main"]);
381 }
382 (router, state)
383 }
384
385 async fn get(router: &Router, uri: &str) -> (u16, String) {
386 let req = Request::builder()
387 .method("GET")
388 .uri(uri)
389 .body(Body::empty())
390 .unwrap();
391 let res = router.handle(req).await;
392 let status = res.status().as_u16();
393 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
394 (status, String::from_utf8_lossy(&bytes).into_owned())
395 }
396
397 #[tokio::test]
398 async fn results_group_by_file_with_line_links_and_marks() {
399 let dir = tempfile::tempdir().unwrap();
400 let (router, _) = fixture(dir.path()).await;
401 let (status, body) = get(&router, "/alice/demo/search?q=hello").await;
402 assert_eq!(status, 200);
403 assert!(body.contains("README.md"), "file listed");
404 assert!(
405 body.contains("/alice/demo/blob/main/README.md#L3"),
406 "line links to the blob view"
407 );
408 assert!(body.contains("<mark>Hello</mark>"), "match highlighted");
409 assert!(body.contains("1 match in 1 file"), "summary shown");
410 assert!(!body.contains("src/main.rs"), "non-matching file absent");
411 }
412
413 #[tokio::test]
414 async fn match_case_and_regex_and_path_options() {
415 let dir = tempfile::tempdir().unwrap();
416 let (router, _) = fixture(dir.path()).await;
417 let (_, body) = get(&router, "/alice/demo/search?q=HELLO&case=1").await;
418 assert!(body.contains("No matches."), "case-sensitive miss");
419 let (_, body) = get(&router, "/alice/demo/search?q=h.llo&regex=1").await;
420 assert!(body.contains("README.md"), "regex hit");
421 assert!(!body.contains("<mark>"), "regex mode does not highlight");
422 let (_, body) = get(&router, "/alice/demo/search?q=println&path=README.md").await;
423 assert!(body.contains("No matches."), "path glob excludes src");
424 }
425
426 #[tokio::test]
427 async fn bad_regex_and_empty_repo_show_messages() {
428 let dir = tempfile::tempdir().unwrap();
429 let (router, _) = fixture(dir.path()).await;
430 let (status, body) = get(&router, "/alice/demo/search?q=(&regex=1").await;
431 assert_eq!(status, 200);
432 assert!(body.contains("could not run"), "git failure shown");
433 let (status, body) = get(&router, "/alice/empty/search?q=hello").await;
434 assert_eq!(status, 200);
435 assert!(body.contains("no commits"), "empty repo message");
436 let (status, _) = get(&router, "/alice/demo/search?q=hello&ref=nope-branch").await;
437 assert_eq!(status, 404, "unknown ref");
438 }
439
440 #[tokio::test]
441 async fn private_repo_is_404_for_anonymous_viewers() {
442 let dir = tempfile::tempdir().unwrap();
443 let (router, _) = fixture(dir.path()).await;
444 let (status, body) = get(&router, "/alice/secret/search?q=hello").await;
445 assert_eq!(status, 404);
446 assert!(!body.contains("README.md"));
447 }
448
449 #[tokio::test]
450 async fn turkish_viewer_gets_turkish_search_page() {
451 let dir = tempfile::tempdir().unwrap();
452 let (router, _) = fixture(dir.path()).await;
453 let req = Request::builder()
454 .method("GET")
455 .uri("/alice/demo/search?q=hello")
456 .header("accept-language", "tr")
457 .body(Body::empty())
458 .unwrap();
459 let res = router.handle(req).await;
460 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
461 let body = String::from_utf8_lossy(&bytes).into_owned();
462 assert!(body.contains("· arama"), "Turkish page title");
463 assert!(body.contains("Kodda ara"), "Turkish search placeholder");
464 assert!(body.contains("1 eşleşme, 1 dosyada"), "Turkish summary");
465 }
466}