Krolyxon
Commit 8f664fb

ferreniumsrchandlersfind.rs

170 lines · 4.7 KB · Rust

1//! `/{repo}/find/{rev}?q=` — find files by path.
2//!
3//! Every term must appear in the path (case-insensitively); matches in the
4//! file name rank first, then shorter paths.
5
6use askama::Template;
7use axum::http::StatusCode;
8use axum::response::Response;
9
10use super::repo::{Active, Crumb, Ctx, RepoNav, set_cache_headers};
11use super::{Layout, render};
12use crate::git::tree::{self, PathTarget};
13
14/// Paths indexed per tree; larger trees are searched partially.
15const MAX_PATHS: usize = 200_000;
16const MAX_RESULTS: usize = 100;
17
18pub struct Hit {
19 pub dir: String,
20 pub name: String,
21 pub url: String,
22}
23
24#[derive(Template)]
25#[template(path = "find.html")]
26pub struct FindPage {
27 pub layout: Layout,
28 pub nav: RepoNav,
29 pub crumbs: Vec<Crumb>,
30 pub here: Option<String>,
31 pub action: String,
32 pub q: String,
33 pub hits: Vec<Hit>,
34 pub matched: usize,
35 pub total: usize,
36 pub truncated: bool,
37}
38
39pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
40 let (rev, path) = match ctx.resolve(segments) {
41 Ok(found) => found,
42 Err(res) => return *res,
43 };
44 if !path.is_empty() {
45 return ctx.not_found("PAGE NOT FOUND");
46 }
47 let q = ctx
48 .query
49 .q
50 .as_deref()
51 .unwrap_or_default()
52 .trim()
53 .to_string();
54 let etag = ctx.etag(("find", rev.commit, &q));
55 let immutable = rev.is_immutable();
56 if let Some(res) = ctx.not_modified(&etag, immutable) {
57 return res;
58 }
59 let root = match tree::lookup(&ctx.git, rev.commit, b"") {
60 Ok(Some(PathTarget::Tree(id))) => id,
61 Ok(_) => return ctx.not_found("COMMIT HAS NO TREE"),
62 Err(e) => return ctx.internal_error(e),
63 };
64 let paths = match ctx
65 .state
66 .cache
67 .paths(root, || tree::all_paths(&ctx.git, root, MAX_PATHS))
68 {
69 Ok(paths) => paths,
70 Err(e) => return ctx.internal_error(e),
71 };
72 let (all, truncated) = (&paths.0, paths.1);
73 let ranked = search(all, &q);
74 let hits = ranked
75 .iter()
76 .take(MAX_RESULTS)
77 .map(|&i| {
78 let path = &all[i];
79 let (dir, name) = match path.rsplit_once('/') {
80 Some((dir, name)) => (format!("{dir}/"), name.to_string()),
81 None => (String::new(), path.clone()),
82 };
83 Hit {
84 dir,
85 name,
86 url: ctx.url("blob", &rev.name, path.as_bytes()),
87 }
88 })
89 .collect();
90
91 let page = FindPage {
92 layout: Layout::new(
93 ctx.state,
94 ctx.config,
95 Some(&format!("Find · {}", ctx.repo.name)),
96 ),
97 nav: ctx.nav(Active::Find, Some(&rev), |name| ctx.url("find", name, b"")),
98 crumbs: vec![Crumb {
99 name: ctx.repo.name.clone(),
100 url: ctx.url("tree", &rev.name, b""),
101 }],
102 here: Some("Find".into()),
103 action: ctx.url("find", &rev.name, b""),
104 hits,
105 matched: ranked.len(),
106 total: all.len(),
107 truncated,
108 q,
109 };
110 let mut res = render(StatusCode::OK, &page);
111 set_cache_headers(&mut res, &etag, immutable);
112 res
113}
114
115/// Indexes of the paths matching every term of `q`, best first.
116pub fn search(paths: &[String], q: &str) -> Vec<usize> {
117 let terms: Vec<String> = q.split_whitespace().map(str::to_lowercase).collect();
118 if terms.is_empty() {
119 return Vec::new();
120 }
121 let mut hits: Vec<(bool, usize, usize)> = paths
122 .iter()
123 .enumerate()
124 .filter_map(|(i, path)| {
125 let lower = path.to_lowercase();
126 if !terms.iter().all(|t| lower.contains(t.as_str())) {
127 return None;
128 }
129 let name = lower.rsplit('/').next().unwrap_or(&lower);
130 let in_name = terms.iter().any(|t| name.contains(t.as_str()));
131 Some((!in_name, path.len(), i))
132 })
133 .collect();
134 hits.sort();
135 hits.into_iter().map(|(_, _, i)| i).collect()
136}
137
138#[cfg(test)]
139mod tests {
140 use super::search;
141
142 #[test]
143 fn ranking() {
144 let paths: Vec<String> = [
145 "src/main.rs",
146 "main/readme.md",
147 "src/very/deep/main.rs",
148 "docs/Main.md",
149 "src/lib.rs",
150 ]
151 .iter()
152 .map(|s| s.to_string())
153 .collect();
154 let hits: Vec<&str> = search(&paths, "MAIN")
155 .into_iter()
156 .map(|i| paths[i].as_str())
157 .collect();
158 assert_eq!(
159 hits,
160 [
161 "src/main.rs",
162 "docs/Main.md",
163 "src/very/deep/main.rs",
164 "main/readme.md"
165 ]
166 );
167 assert_eq!(search(&paths, "src rs").len(), 3);
168 assert!(search(&paths, " ").is_empty());
169 }
170}