Skip to main content

brief/
project.rs

1//! Project-wide compile state: root discovery and the cross-document
2//! reference index.
3//!
4//! `@ref` validation is the first multi-pass element of the Brief
5//! compiler. `discover_root` walks up the directory tree from a starting
6//! point looking for `brief.toml`; `build_index` (Task 3) walks the
7//! discovered project tree and collects the heading anchors that `@ref`
8//! is allowed to point at.
9
10use std::path::{Path, PathBuf};
11
12/// Walk up from `start`, returning the first ancestor directory that
13/// contains `brief.toml`. Returns `None` if no such ancestor exists.
14///
15/// `start` may be a file or directory. If a file, its parent is the
16/// first directory considered. The search includes `start` itself if
17/// `start` is a directory.
18pub 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/// Diagnostics emitted while indexing one project file, paired with the
37/// source so the consumer can render them at correct positions.
38#[derive(Debug)]
39pub struct FileDiagnostics {
40    pub source: crate::span::SourceMap,
41    pub diagnostics: Vec<crate::diag::Diagnostic>,
42}
43
44/// Snapshot of the project's `@ref` targets. Built once per compilation
45/// (or watch tick). Read-only after construction.
46#[derive(Clone, Debug, Default)]
47pub struct ProjectIndex {
48    /// Absolute, canonicalized project root (the directory holding `brief.toml`).
49    pub root: PathBuf,
50    /// Project-relative `.brf` path (forward-slash, ASCII) → set of heading anchors that exist in it.
51    pub anchors: BTreeMap<String, BTreeSet<String>>,
52}
53
54/// Walk the project tree and collect heading anchors per file.
55///
56/// Skips:
57///   * hidden directories (name starts with `.`),
58///   * the `target/`, `dist/`, `node_modules/` directories.
59///
60/// Files that fail to lex or parse are still indexed with an empty anchor
61/// set — references to anchors in such files surface as `B0602`. Returns
62/// the index along with per-file diagnostics, each paired with the
63/// `SourceMap` of the file that produced them so callers can render
64/// diagnostics at the correct positions. The caller decides whether to
65/// treat any errors as fatal.
66pub 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}