Skip to main content

brink_db/
db.rs

1use std::collections::{BTreeSet, HashMap};
2
3use brink_ir::suppressions::{Suppressions, parse_suppressions};
4use brink_ir::{
5    Diagnostic, DiagnosticCode, FileId, HirFile, SymbolManifest, lower, lower_single_knot,
6    lower_top_level,
7};
8use brink_syntax::ast::AstNode as _;
9use brink_syntax::{Parse, parse_with_cache};
10use rowan::{GreenNode, NodeCache};
11use tracing::debug;
12
13use crate::file_state::{FileState, TopLevelEntry};
14use crate::include_graph::IncludeGraph;
15use crate::knot_cache::KnotEntry;
16
17/// Stateful incremental project database.
18///
19/// Caches parsed trees and lowered HIR per file, enabling efficient re-analysis
20/// when individual files change. Both the compiler (one-shot) and LSP
21/// (long-lived) use this as their project model.
22pub struct ProjectDb {
23    files: HashMap<FileId, FileState>,
24    path_to_id: HashMap<String, FileId>,
25    id_to_path: HashMap<FileId, String>,
26    next_id: u32,
27    include_graph: IncludeGraph,
28    node_cache: NodeCache,
29}
30
31impl ProjectDb {
32    /// Create an empty project database.
33    pub fn new() -> Self {
34        Self {
35            files: HashMap::new(),
36            path_to_id: HashMap::new(),
37            id_to_path: HashMap::new(),
38            next_id: 0,
39            include_graph: IncludeGraph::new(),
40            node_cache: NodeCache::default(),
41        }
42    }
43
44    /// Add or replace a file. Performs full parse + lower + cache.
45    pub fn set_file(&mut self, path: &str, source: String) -> FileId {
46        let file_id = self.get_or_create_id(path);
47
48        let parse = parse_with_cache(&source, &mut self.node_cache);
49        let tree = parse.tree();
50
51        // Per-knot lowering
52        let knot_entries: Vec<KnotEntry> = tree
53            .knots()
54            .map(|knot_ast| {
55                let green = knot_ast.syntax().green().into();
56                let offset = knot_ast.syntax().text_range().start();
57                let (knot, manifest, diagnostics) = lower_single_knot(file_id, &knot_ast);
58                KnotEntry {
59                    green,
60                    offset,
61                    knot,
62                    manifest,
63                    diagnostics,
64                }
65            })
66            .collect();
67
68        // Top-level lowering
69        let top_level = Self::lower_top_level_entry(file_id, &tree);
70
71        // Assemble full HirFile and SymbolManifest
72        let (hir, manifest, mut diagnostics) =
73            Self::assemble(file_id, &knot_entries, &top_level, &tree);
74        // Surface parser/syntax errors as compile diagnostics alongside lowering.
75        diagnostics.extend(Self::syntax_diagnostics(file_id, &parse));
76
77        let suppressions = parse_suppressions(&source);
78
79        let state = FileState {
80            source,
81            parse,
82            knot_entries,
83            top_level,
84            hir,
85            manifest,
86            diagnostics,
87            suppressions,
88        };
89
90        // Update include graph
91        let include_ids: Vec<FileId> = state
92            .hir
93            .includes
94            .iter()
95            .filter_map(|inc| {
96                let resolved = resolve_include_path(path, &inc.file_path);
97                self.path_to_id.get(&resolved).copied()
98            })
99            .collect();
100        self.include_graph.update(file_id, include_ids);
101
102        self.files.insert(file_id, state);
103
104        debug!(path, id = file_id.0, "set_file complete");
105        file_id
106    }
107
108    /// Incrementally update a file. Re-parses, diffs knots by green-node
109    /// identity, and only re-lowers changed knots.
110    pub fn update_file(&mut self, path: &str, source: String) -> FileId {
111        let file_id = self.get_or_create_id(path);
112
113        // If the file doesn't exist yet, fall through to set_file
114        if !self.files.contains_key(&file_id) {
115            return self.set_file(path, source);
116        }
117
118        let parse = parse_with_cache(&source, &mut self.node_cache);
119        let tree = parse.tree();
120
121        // Top-level is always re-lowered (cheap relative to knots)
122        let top_level = Self::lower_top_level_entry(file_id, &tree);
123
124        // Diff knots by green-node identity
125        let new_knot_asts: Vec<_> = tree.knots().collect();
126        let old_state = self.files.get(&file_id);
127
128        let mut knot_entries = Vec::with_capacity(new_knot_asts.len());
129        let mut reused = 0u32;
130
131        for (i, knot_ast) in new_knot_asts.iter().enumerate() {
132            let new_green: GreenNode = knot_ast.syntax().green().into();
133
134            let new_offset = knot_ast.syntax().text_range().start();
135            let reuse_entry = old_state
136                .and_then(|s| s.knot_entries.get(i))
137                .filter(|old| old.green == new_green && old.offset == new_offset);
138
139            if let Some(old_entry) = reuse_entry {
140                knot_entries.push(KnotEntry {
141                    green: new_green,
142                    offset: new_offset,
143                    knot: old_entry.knot.clone(),
144                    manifest: old_entry.manifest.clone(),
145                    diagnostics: old_entry.diagnostics.clone(),
146                });
147                reused += 1;
148            } else {
149                let (knot, manifest, diagnostics) = lower_single_knot(file_id, knot_ast);
150                knot_entries.push(KnotEntry {
151                    green: new_green,
152                    offset: new_offset,
153                    knot,
154                    manifest,
155                    diagnostics,
156                });
157            }
158        }
159
160        debug!(
161            path,
162            total = new_knot_asts.len(),
163            reused,
164            "knot diff complete"
165        );
166
167        let (hir, manifest, mut diagnostics) =
168            Self::assemble(file_id, &knot_entries, &top_level, &tree);
169        diagnostics.extend(Self::syntax_diagnostics(file_id, &parse));
170
171        let suppressions = parse_suppressions(&source);
172
173        let state = FileState {
174            source,
175            parse,
176            knot_entries,
177            top_level,
178            hir,
179            manifest,
180            diagnostics,
181            suppressions,
182        };
183
184        // Update include graph
185        let include_ids: Vec<FileId> = state
186            .hir
187            .includes
188            .iter()
189            .filter_map(|inc| {
190                let resolved = resolve_include_path(path, &inc.file_path);
191                self.path_to_id.get(&resolved).copied()
192            })
193            .collect();
194        self.include_graph.update(file_id, include_ids);
195
196        self.files.insert(file_id, state);
197
198        file_id
199    }
200
201    /// Remove a file from the database.
202    pub fn remove_file(&mut self, path: &str) {
203        if let Some(id) = self.path_to_id.remove(path) {
204            self.id_to_path.remove(&id);
205            self.files.remove(&id);
206            self.include_graph.remove(id);
207        }
208    }
209
210    /// Look up a file's ID by path.
211    pub fn file_id(&self, path: &str) -> Option<FileId> {
212        self.path_to_id.get(path).copied()
213    }
214
215    /// Look up a file's path by ID.
216    pub fn file_path(&self, id: FileId) -> Option<&str> {
217        self.id_to_path.get(&id).map(String::as_str)
218    }
219
220    /// Iterate over all registered file IDs.
221    pub fn file_ids(&self) -> impl Iterator<Item = FileId> + '_ {
222        let mut ids: Vec<_> = self.files.keys().copied().collect();
223        ids.sort_by_key(|id| id.0);
224        ids.into_iter()
225    }
226
227    /// Return file IDs in topological include order (included files before
228    /// the files that include them), matching ink's `INCLUDE` paste semantics.
229    pub fn file_ids_topo(&self, entry: FileId) -> Vec<FileId> {
230        let all: Vec<_> = self.files.keys().copied().collect();
231        self.include_graph.topological_order(entry, &all)
232    }
233
234    /// Get the cached parse tree for a file.
235    pub fn parse(&self, id: FileId) -> Option<&Parse> {
236        self.files.get(&id).map(|s| &s.parse)
237    }
238
239    /// Get the cached HIR for a file.
240    pub fn hir(&self, id: FileId) -> Option<&HirFile> {
241        self.files.get(&id).map(|s| &s.hir)
242    }
243
244    /// Get the cached symbol manifest for a file.
245    pub fn manifest(&self, id: FileId) -> Option<&SymbolManifest> {
246        self.files.get(&id).map(|s| &s.manifest)
247    }
248
249    /// Get the source text for a file.
250    pub fn source(&self, id: FileId) -> Option<&str> {
251        self.files.get(&id).map(|s| s.source.as_str())
252    }
253
254    /// Get per-file diagnostics (parse + lowering).
255    pub fn file_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
256        self.files.get(&id).map(|s| s.diagnostics.as_slice())
257    }
258
259    /// Get parsed suppression directives for a file.
260    pub fn suppressions(&self, id: FileId) -> Option<&Suppressions> {
261        self.files.get(&id).map(|s| &s.suppressions)
262    }
263
264    /// Rebuild include graph edges for all files.
265    ///
266    /// Must be called after batch-loading files (e.g. workspace discovery)
267    /// because `set_file` can only create edges to files already in the db.
268    /// Files loaded before their include targets will have missing edges.
269    pub fn rebuild_include_graph(&mut self) {
270        let file_list: Vec<(FileId, String)> = self
271            .files
272            .keys()
273            .filter_map(|&id| self.id_to_path.get(&id).map(|p| (id, p.clone())))
274            .collect();
275
276        for (file_id, file_path) in &file_list {
277            if let Some(state) = self.files.get(file_id) {
278                let include_ids: Vec<FileId> = state
279                    .hir
280                    .includes
281                    .iter()
282                    .filter_map(|inc| {
283                        let resolved = resolve_include_path(file_path, &inc.file_path);
284                        self.path_to_id.get(&resolved).copied()
285                    })
286                    .collect();
287                self.include_graph.update(*file_id, include_ids);
288            }
289        }
290    }
291
292    /// Detect cycles in the include graph.
293    ///
294    /// Returns the first cycle found as an ordered path of file IDs.
295    pub fn find_cycle(&self) -> Option<Vec<FileId>> {
296        self.include_graph.find_cycle()
297    }
298
299    /// Compute independent projects from include relationships.
300    ///
301    /// Returns `(root, members)` pairs sorted by root `FileId`.
302    pub fn compute_projects(&self) -> Vec<(FileId, Vec<FileId>)> {
303        let all: Vec<_> = self.files.keys().copied().collect();
304        self.include_graph.compute_projects(&all)
305    }
306
307    /// All files reachable from `entry` via the forward `INCLUDE` graph,
308    /// `entry` included.
309    ///
310    /// A forward DFS over `INCLUDE` edges (transitive). The result is a
311    /// [`BTreeSet`], so iteration order is deterministic regardless of graph
312    /// internals — callers that compare or render the set get stable output.
313    pub fn reachable_from(&self, entry: FileId) -> BTreeSet<FileId> {
314        self.include_graph.reachable_from(entry)
315    }
316
317    /// Snapshot analysis inputs for a subset of files.
318    ///
319    /// Like `analysis_inputs()` but filtered to the given set.
320    pub fn analysis_inputs_for(
321        &self,
322        file_ids: &[FileId],
323    ) -> Vec<(FileId, HirFile, SymbolManifest)> {
324        let mut inputs: Vec<_> = file_ids
325            .iter()
326            .filter_map(|&id| {
327                let state = self.files.get(&id)?;
328                Some((id, state.hir.clone(), state.manifest.clone()))
329            })
330            .collect();
331        inputs.sort_by_key(|(id, _, _)| id.0);
332        inputs
333    }
334
335    /// Snapshot all analysis inputs for background analysis.
336    ///
337    /// Returns `(FileId, HirFile, SymbolManifest)` tuples cloned out of the db,
338    /// so the caller can run `brink_analyzer::analyze()` without holding the lock.
339    pub fn analysis_inputs(&self) -> Vec<(FileId, HirFile, SymbolManifest)> {
340        let mut inputs: Vec<_> = self
341            .files
342            .iter()
343            .map(|(&id, state)| (id, state.hir.clone(), state.manifest.clone()))
344            .collect();
345        inputs.sort_by_key(|(id, _, _)| id.0);
346        inputs
347    }
348
349    /// Snapshot file metadata for diagnostic publishing.
350    ///
351    /// Returns `(FileId, path, source)` tuples for all files in the db.
352    pub fn file_metadata(&self) -> Vec<(FileId, String, String)> {
353        let mut meta: Vec<_> = self
354            .files
355            .keys()
356            .filter_map(|&id| {
357                let path = self.id_to_path.get(&id)?.clone();
358                let source = self.files.get(&id)?.source.clone();
359                Some((id, path, source))
360            })
361            .collect();
362        meta.sort_by_key(|(id, _, _)| id.0);
363        meta
364    }
365
366    // ── Internal helpers ──────────────────────────────────────────────
367
368    fn get_or_create_id(&mut self, path: &str) -> FileId {
369        if let Some(&id) = self.path_to_id.get(path) {
370            return id;
371        }
372        let id = FileId(self.next_id);
373        self.next_id += 1;
374        self.path_to_id.insert(path.to_string(), id);
375        self.id_to_path.insert(id, path.to_string());
376        id
377    }
378
379    fn lower_top_level_entry(
380        file_id: FileId,
381        tree: &brink_syntax::ast::SourceFile,
382    ) -> TopLevelEntry {
383        let green_children = Self::collect_top_level_green(tree);
384        let (root_content, top_level_knots, manifest, diagnostics) = lower_top_level(file_id, tree);
385        TopLevelEntry {
386            green_children,
387            root_content,
388            top_level_knots,
389            manifest,
390            diagnostics,
391        }
392    }
393
394    /// Collect green nodes of non-knot direct children for diffing.
395    fn collect_top_level_green(tree: &brink_syntax::ast::SourceFile) -> Vec<GreenNode> {
396        use brink_syntax::SyntaxKind;
397
398        tree.syntax()
399            .children()
400            .filter(|child| child.kind() != SyntaxKind::KNOT_DEF)
401            .map(|child| child.green().into())
402            .collect()
403    }
404
405    /// Assemble a complete `HirFile` and `SymbolManifest` from cached pieces.
406    fn assemble(
407        file_id: FileId,
408        knot_entries: &[KnotEntry],
409        top_level: &TopLevelEntry,
410        tree: &brink_syntax::ast::SourceFile,
411    ) -> (HirFile, SymbolManifest, Vec<Diagnostic>) {
412        // We need declarations from the full lower to build HirFile.
413        // lower_top_level only returns (Block, SymbolManifest, diagnostics).
414        // For the declarations (variables, constants, lists, externals, includes),
415        // we need to call `lower()` or extract them from the AST.
416        //
417        // Approach: use `lower()` to get the full HirFile, then replace knots
418        // with our cached versions. This means top-level lowering happens twice
419        // on change, but it's simple and correct.
420        let (mut full_hir, _full_manifest, _full_diag) = lower(file_id, tree);
421
422        // Replace knots with our cached (possibly reused) versions,
423        // plus any top-level stitches promoted to knots.
424        full_hir.knots = knot_entries.iter().filter_map(|e| e.knot.clone()).collect();
425        full_hir.knots.extend(top_level.top_level_knots.clone());
426        full_hir.root_content = top_level.root_content.clone();
427
428        // Merge manifests: top-level + all knots
429        let mut manifest = top_level.manifest.clone();
430        for entry in knot_entries {
431            merge_manifest_into(&mut manifest, &entry.manifest);
432        }
433
434        // Merge diagnostics
435        let mut diagnostics = top_level.diagnostics.clone();
436        for entry in knot_entries {
437            diagnostics.extend(entry.diagnostics.iter().cloned());
438        }
439
440        (full_hir, manifest, diagnostics)
441    }
442
443    /// Convert parser/syntax errors into compile diagnostics (`E037`), so
444    /// malformed source fails the compile instead of being silently ignored.
445    fn syntax_diagnostics(file_id: FileId, parse: &Parse) -> Vec<Diagnostic> {
446        parse
447            .errors()
448            .iter()
449            .map(|e| Diagnostic {
450                file: file_id,
451                range: e.range,
452                message: e.message.clone(),
453                code: DiagnosticCode::E037,
454            })
455            .collect()
456    }
457}
458
459impl Default for ProjectDb {
460    fn default() -> Self {
461        Self::new()
462    }
463}
464
465/// Merge `src` manifest fields into `dst`.
466fn merge_manifest_into(dst: &mut SymbolManifest, src: &SymbolManifest) {
467    dst.knots.extend(src.knots.iter().cloned());
468    dst.stitches.extend(src.stitches.iter().cloned());
469    dst.variables.extend(src.variables.iter().cloned());
470    dst.constants.extend(src.constants.iter().cloned());
471    dst.lists.extend(src.lists.iter().cloned());
472    dst.externals.extend(src.externals.iter().cloned());
473    dst.labels.extend(src.labels.iter().cloned());
474    dst.list_items.extend(src.list_items.iter().cloned());
475    dst.locals.extend(src.locals.iter().cloned());
476    dst.unresolved.extend(src.unresolved.iter().cloned());
477    dst.docs
478        .extend(src.docs.iter().map(|(k, v)| (k.clone(), v.clone())));
479}
480
481/// Resolve an INCLUDE path relative to the including file's directory.
482///
483/// Uses string-based path manipulation (`rfind('/')`) rather than
484/// `std::path::Path` to avoid platform-specific separator issues and
485/// to work in WASM contexts. The joined path is normalized so `.`/`..`
486/// segments collapse to a clean project-relative key (e.g.
487/// `a/b/../d.ink` → `a/d.ink`) — consistent across the compiler, runtime,
488/// and IDE so upward-relative includes resolve to real files.
489pub fn resolve_include_path(from_file: &str, include_path: &str) -> String {
490    let joined = match from_file.rfind('/') {
491        Some(i) => format!("{}/{include_path}", &from_file[..i]),
492        None => include_path.to_string(),
493    };
494    normalize_path(&joined)
495}
496
497/// Collapse `.` and `..` segments in a `/`-separated path. A `..` pops the
498/// previous real segment; a `..` with nothing to pop (or above an existing
499/// `..`) is kept literally rather than escaping the root. A leading `/`
500/// (absolute path) is preserved — the test harness and disk-backed compiles
501/// resolve against absolute filesystem paths.
502fn normalize_path(path: &str) -> String {
503    let absolute = path.starts_with('/');
504    let mut out: Vec<&str> = Vec::new();
505    for seg in path.split('/') {
506        match seg {
507            "" | "." => {}
508            ".." if matches!(out.last(), Some(&s) if s != "..") => {
509                out.pop();
510            }
511            s => out.push(s),
512        }
513    }
514    let joined = out.join("/");
515    if absolute {
516        format!("/{joined}")
517    } else {
518        joined
519    }
520}
521
522/// Compute the relative INCLUDE target to reach `to_file` from `from_file`'s
523/// directory — the inverse of [`resolve_include_path`]:
524/// `normalize(resolve_include_path(from_file, compute_relative_path(from_file, to_file))) == to_file`
525/// for both forward and `..`-traversing layouts. Used when a file is
526/// renamed/moved to rewrite every `INCLUDE` that points at it (and the moved
527/// file's own includes).
528pub fn compute_relative_path(from_file: &str, to_file: &str) -> String {
529    let mut from_dirs: Vec<&str> = from_file.split('/').collect();
530    from_dirs.pop(); // drop the including file's own name
531    let to_all: Vec<&str> = to_file.split('/').collect();
532    let Some((to_name, to_dirs)) = to_all.split_last() else {
533        return to_file.to_owned();
534    };
535
536    // Longest common directory prefix.
537    let mut k = 0;
538    while k < from_dirs.len() && k < to_dirs.len() && from_dirs[k] == to_dirs[k] {
539        k += 1;
540    }
541
542    let mut parts: Vec<&str> = Vec::new();
543    parts.extend(std::iter::repeat_n("..", from_dirs.len() - k));
544    parts.extend_from_slice(&to_dirs[k..]);
545    parts.push(to_name);
546    parts.join("/")
547}
548
549#[cfg(test)]
550mod path_tests {
551    use super::{compute_relative_path, resolve_include_path};
552
553    #[test]
554    fn resolve_forward_includes() {
555        assert_eq!(
556            resolve_include_path("src/main.ink", "utils.ink"),
557            "src/utils.ink"
558        );
559        assert_eq!(resolve_include_path("story.ink", "other.ink"), "other.ink");
560        assert_eq!(resolve_include_path("a/b/c.ink", "d/e.ink"), "a/b/d/e.ink");
561    }
562
563    #[test]
564    fn resolve_normalizes_dot_and_dotdot() {
565        assert_eq!(resolve_include_path("a/b/c.ink", "../d.ink"), "a/d.ink");
566        assert_eq!(resolve_include_path("a/b/c.ink", "./d.ink"), "a/b/d.ink");
567        assert_eq!(resolve_include_path("a/b/c.ink", "../../d.ink"), "d.ink");
568        assert_eq!(
569            resolve_include_path("a/b/c.ink", "../x/../d.ink"),
570            "a/d.ink"
571        );
572    }
573
574    #[test]
575    fn compute_relative_is_inverse_of_resolve() {
576        // (from including file, target file) round-trips through resolve.
577        let cases = [
578            ("main.ink", "scenes/intro.ink"), // move into a subdir
579            ("a/b/c.ink", "a/d.ink"),         // sibling dir (needs ..)
580            ("a/b/c.ink", "a/b/renamed.ink"), // rename in place
581            ("scenes/intro.ink", "lib.ink"),  // up to root (needs ..)
582            ("a/b/c.ink", "x/y/z.ink"),       // fully divergent
583            ("main.ink", "other.ink"),        // both at root
584        ];
585        for (from, to) in cases {
586            let rel = compute_relative_path(from, to);
587            assert_eq!(
588                resolve_include_path(from, &rel),
589                to,
590                "round-trip failed for from={from} to={to} rel={rel}",
591            );
592        }
593    }
594
595    #[test]
596    fn resolve_preserves_absolute_paths() {
597        // The test harness / disk-backed compiles pass absolute paths — the
598        // leading slash must survive normalization.
599        assert_eq!(
600            resolve_include_path("/proj/tier3/main.ink", "included.ink"),
601            "/proj/tier3/included.ink",
602        );
603        assert_eq!(
604            resolve_include_path("/proj/a/b/c.ink", "../d.ink"),
605            "/proj/a/d.ink"
606        );
607    }
608
609    #[test]
610    fn compute_relative_rename_in_place_is_bare_name() {
611        assert_eq!(
612            compute_relative_path("a/b/c.ink", "a/b/renamed.ink"),
613            "renamed.ink"
614        );
615        assert_eq!(
616            compute_relative_path("main.ink", "renamed.ink"),
617            "renamed.ink"
618        );
619    }
620
621    #[test]
622    fn compute_relative_move_shallower_is_bare_name() {
623        // Regression (#318): after a shallower move (chapters/main.ink →
624        // main.ink), the outbound-INCLUDE rewrite relativizes the resolved
625        // target against the NEW path. From the root, a root-level sibling is a
626        // bare name — no stale `chapters/` or `../` prefix.
627        assert_eq!(compute_relative_path("main.ink", "host.ink"), "host.ink");
628        // And from the OLD subdir location, the same root-level target is
629        // `../host.ink` — relative to `chapters/`, reaching the root needs `..`.
630        // (This is the value resolve→compute round-trips through; the rewrite
631        // uses the NEW path above to drop the prefix.)
632        assert_eq!(
633            compute_relative_path("chapters/main.ink", "host.ink"),
634            "../host.ink"
635        );
636        assert_eq!(
637            resolve_include_path("chapters/main.ink", "../host.ink"),
638            "host.ink"
639        );
640    }
641}
642
643#[cfg(test)]
644mod reachable_tests {
645    use super::ProjectDb;
646
647    /// Load files in dependency order then rebuild the graph so every edge is
648    /// linked regardless of insertion order.
649    fn db_with(files: &[(&str, &str)]) -> ProjectDb {
650        let mut db = ProjectDb::new();
651        for (path, src) in files {
652            db.set_file(path, (*src).to_owned());
653        }
654        db.rebuild_include_graph();
655        db
656    }
657
658    #[test]
659    fn entry_is_always_reachable_from_itself() {
660        let db = db_with(&[("main.ink", "== hub ==\ntext\n")]);
661        let main = db.file_id("main.ink").expect("main");
662        let reachable = db.reachable_from(main);
663        assert_eq!(reachable.into_iter().collect::<Vec<_>>(), vec![main]);
664    }
665
666    #[test]
667    fn direct_includes_are_reachable() {
668        let db = db_with(&[
669            ("main.ink", "INCLUDE a.ink\nINCLUDE b.ink\n"),
670            ("a.ink", "== a ==\n"),
671            ("b.ink", "== b ==\n"),
672        ]);
673        let main = db.file_id("main.ink").expect("main");
674        let a = db.file_id("a.ink").expect("a");
675        let b = db.file_id("b.ink").expect("b");
676        let reachable: Vec<_> = db.reachable_from(main).into_iter().collect();
677        assert!(reachable.contains(&main));
678        assert!(reachable.contains(&a));
679        assert!(reachable.contains(&b));
680        assert_eq!(reachable.len(), 3);
681    }
682
683    #[test]
684    fn transitive_includes_are_reachable() {
685        let db = db_with(&[
686            ("main.ink", "INCLUDE a.ink\n"),
687            ("a.ink", "INCLUDE b.ink\n"),
688            ("b.ink", "== b ==\n"),
689            ("unrelated.ink", "== x ==\n"),
690        ]);
691        let main = db.file_id("main.ink").expect("main");
692        let a = db.file_id("a.ink").expect("a");
693        let b = db.file_id("b.ink").expect("b");
694        let unrelated = db.file_id("unrelated.ink").expect("unrelated");
695        let reachable = db.reachable_from(main);
696        assert!(reachable.contains(&main));
697        assert!(reachable.contains(&a));
698        assert!(reachable.contains(&b));
699        assert!(
700            !reachable.contains(&unrelated),
701            "unrelated file is not reachable"
702        );
703    }
704
705    #[test]
706    fn reachable_terminates_on_cycles() {
707        // a -> b -> a; reachability must not loop forever.
708        let db = db_with(&[("a.ink", "INCLUDE b.ink\n"), ("b.ink", "INCLUDE a.ink\n")]);
709        let a = db.file_id("a.ink").expect("a");
710        let b = db.file_id("b.ink").expect("b");
711        let reachable: Vec<_> = db.reachable_from(a).into_iter().collect();
712        assert!(reachable.contains(&a));
713        assert!(reachable.contains(&b));
714        assert_eq!(reachable.len(), 2);
715    }
716}