1use std::path::{Path, PathBuf};
11
12pub fn discover_root(start: &Path) -> Option<PathBuf> {
19 let mut cur: PathBuf = if start.is_file() {
20 start.parent()?.to_path_buf()
21 } else {
22 start.to_path_buf()
23 };
24 loop {
25 if cur.join("brief.toml").is_file() {
26 return Some(cur);
27 }
28 if !cur.pop() {
29 return None;
30 }
31 }
32}
33
34use std::collections::{BTreeMap, BTreeSet};
35
36#[derive(Debug)]
39pub struct FileDiagnostics {
40 pub source: crate::span::SourceMap,
41 pub diagnostics: Vec<crate::diag::Diagnostic>,
42}
43
44#[derive(Clone, Debug, Default)]
47pub struct ProjectIndex {
48 pub root: PathBuf,
50 pub anchors: BTreeMap<String, BTreeSet<String>>,
52}
53
54pub fn build_index(root: &Path) -> (ProjectIndex, Vec<FileDiagnostics>) {
67 use crate::lexer;
68 use crate::parser;
69 use crate::span::SourceMap;
70
71 let mut idx = ProjectIndex {
72 root: root.to_path_buf(),
73 anchors: BTreeMap::new(),
74 };
75 let mut all_diags: Vec<FileDiagnostics> = Vec::new();
76
77 walk_brf_files(root, &mut |abs_path: &Path| {
78 let rel: String = match abs_path.strip_prefix(root) {
79 Ok(r) => to_forward_slash(r),
80 Err(_) => return,
81 };
82 let raw = match std::fs::read_to_string(abs_path) {
83 Ok(s) => s,
84 Err(_) => {
85 idx.anchors.insert(rel, BTreeSet::new());
86 return;
87 }
88 };
89 let raw = raw.strip_prefix('\u{feff}').unwrap_or(&raw).to_string();
90 let src = SourceMap::new(abs_path.to_string_lossy(), raw);
91 let tokens = match lexer::lex(&src) {
92 Ok(t) => t,
93 Err(d) => {
94 all_diags.push(FileDiagnostics {
95 source: src,
96 diagnostics: d,
97 });
98 idx.anchors.insert(rel, BTreeSet::new());
99 return;
100 }
101 };
102 let (doc, parse_diags) = parser::parse(tokens, &src);
103 if !parse_diags.is_empty() {
104 all_diags.push(FileDiagnostics {
105 source: src.clone(),
106 diagnostics: parse_diags,
107 });
108 }
109 let anchors = collect_doc_anchors(&doc);
110 idx.anchors.insert(rel, anchors);
111 });
112
113 (idx, all_diags)
114}
115
116fn walk_brf_files(root: &Path, visit: &mut dyn FnMut(&Path)) {
117 let rd = match std::fs::read_dir(root) {
118 Ok(rd) => rd,
119 Err(_) => return,
120 };
121 for entry in rd.flatten() {
122 let path = entry.path();
123 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
124 if name.starts_with('.') {
125 continue;
126 }
127 let ft = match entry.file_type() {
128 Ok(ft) => ft,
129 Err(_) => continue,
130 };
131 if ft.is_dir() {
132 if matches!(name, "target" | "dist" | "node_modules") {
133 continue;
134 }
135 walk_brf_files(&path, visit);
136 } else if ft.is_file() && path.extension().and_then(|s| s.to_str()) == Some("brf") {
137 visit(&path);
138 }
139 }
140}
141
142fn collect_doc_anchors(doc: &crate::ast::Document) -> BTreeSet<String> {
143 let mut out = BTreeSet::new();
144 for b in &doc.blocks {
145 collect_block_anchors(b, &mut out);
146 }
147 out
148}
149
150fn collect_block_anchors(b: &crate::ast::Block, out: &mut BTreeSet<String>) {
151 use crate::ast::Block;
152 match b {
153 Block::Heading {
154 anchor: Some(a), ..
155 } => {
156 out.insert(a.clone());
157 }
158 Block::Heading { .. } => {}
159 Block::Blockquote { children, .. } | Block::BlockShortcode { children, .. } => {
160 for c in children {
161 collect_block_anchors(c, out);
162 }
163 }
164 Block::List { items, .. } => {
165 for it in items {
166 for c in &it.children {
167 collect_block_anchors(c, out);
168 }
169 }
170 }
171 _ => {}
172 }
173}
174
175fn to_forward_slash(p: &Path) -> String {
176 p.components()
177 .map(|c| c.as_os_str().to_string_lossy().into_owned())
178 .collect::<Vec<_>>()
179 .join("/")
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use std::fs;
186 use tempfile::TempDir;
187
188 fn touch(p: &Path) {
189 if let Some(parent) = p.parent() {
190 fs::create_dir_all(parent).unwrap();
191 }
192 fs::write(p, "").unwrap();
193 }
194
195 #[test]
196 fn finds_brief_toml_in_same_directory() {
197 let td = TempDir::new().unwrap();
198 let root = td.path().canonicalize().unwrap();
199 touch(&root.join("brief.toml"));
200 assert_eq!(discover_root(&root).unwrap().canonicalize().unwrap(), root);
201 }
202
203 #[test]
204 fn finds_brief_toml_from_subdirectory() {
205 let td = TempDir::new().unwrap();
206 let root = td.path().canonicalize().unwrap();
207 touch(&root.join("brief.toml"));
208 let sub = root.join("a/b/c");
209 fs::create_dir_all(&sub).unwrap();
210 assert_eq!(discover_root(&sub).unwrap().canonicalize().unwrap(), root);
211 }
212
213 #[test]
214 fn finds_brief_toml_from_a_file() {
215 let td = TempDir::new().unwrap();
216 let root = td.path().canonicalize().unwrap();
217 touch(&root.join("brief.toml"));
218 let f = root.join("docs/intro.brf");
219 touch(&f);
220 assert_eq!(discover_root(&f).unwrap().canonicalize().unwrap(), root);
221 }
222
223 #[test]
224 fn returns_none_when_no_brief_toml() {
225 let td = TempDir::new().unwrap();
226 assert!(discover_root(td.path()).is_none());
227 }
228
229 #[test]
230 fn closest_brief_toml_wins() {
231 let td = TempDir::new().unwrap();
232 let outer = td.path().canonicalize().unwrap();
233 let inner = outer.join("nested");
234 touch(&outer.join("brief.toml"));
235 touch(&inner.join("brief.toml"));
236 let deepest = inner.join("docs");
237 fs::create_dir_all(&deepest).unwrap();
238 assert_eq!(
239 discover_root(&deepest).unwrap().canonicalize().unwrap(),
240 inner.canonicalize().unwrap()
241 );
242 }
243
244 fn write(p: &Path, content: &str) {
245 if let Some(parent) = p.parent() {
246 fs::create_dir_all(parent).unwrap();
247 }
248 fs::write(p, content).unwrap();
249 }
250
251 #[test]
252 fn build_index_collects_anchors_per_file() {
253 let td = TempDir::new().unwrap();
254 let root = td.path();
255 touch(&root.join("brief.toml"));
256 write(
257 &root.join("a.brf"),
258 "# Title {#top}\n\nSome text.\n\n## Subsection {#sub}\n",
259 );
260 write(&root.join("docs/b.brf"), "# Other Title {#other}\n");
261 let (idx, diags) = build_index(root);
262 assert!(
263 diags.iter().all(|fd| fd
264 .diagnostics
265 .iter()
266 .all(|d| d.severity != crate::diag::Severity::Error)),
267 "{:?}",
268 diags,
269 );
270 assert_eq!(
271 idx.root.canonicalize().unwrap(),
272 root.canonicalize().unwrap()
273 );
274 assert_eq!(
275 idx.anchors.get("a.brf").unwrap(),
276 &BTreeSet::from(["top".into(), "sub".into()])
277 );
278 assert_eq!(
279 idx.anchors.get("docs/b.brf").unwrap(),
280 &BTreeSet::from(["other".into()])
281 );
282 }
283
284 #[test]
285 fn build_index_skips_target_dist_and_hidden_dirs() {
286 let td = TempDir::new().unwrap();
287 let root = td.path();
288 touch(&root.join("brief.toml"));
289 write(&root.join("good.brf"), "# G {#g}\n");
290 write(&root.join("target/x.brf"), "# X {#x}\n");
291 write(&root.join("dist/y.brf"), "# Y {#y}\n");
292 write(&root.join(".hidden/z.brf"), "# Z {#z}\n");
293 let (idx, diags) = build_index(root);
294 assert!(
295 diags.iter().all(|fd| fd
296 .diagnostics
297 .iter()
298 .all(|d| d.severity != crate::diag::Severity::Error)),
299 "{:?}",
300 diags,
301 );
302 assert!(idx.anchors.contains_key("good.brf"));
303 assert!(!idx.anchors.contains_key("target/x.brf"));
304 assert!(!idx.anchors.contains_key("dist/y.brf"));
305 assert!(!idx.anchors.contains_key(".hidden/z.brf"));
306 }
307
308 #[test]
309 fn build_index_records_files_with_no_anchors_too() {
310 let td = TempDir::new().unwrap();
311 let root = td.path();
312 touch(&root.join("brief.toml"));
313 write(&root.join("plain.brf"), "Just a paragraph.\n");
314 let (idx, diags) = build_index(root);
315 assert!(
316 diags.iter().all(|fd| fd
317 .diagnostics
318 .iter()
319 .all(|d| d.severity != crate::diag::Severity::Error)),
320 "{:?}",
321 diags,
322 );
323 let entry = idx.anchors.get("plain.brf").unwrap();
324 assert!(entry.is_empty());
325 }
326}