Skip to main content

brink_db/
db.rs

1use std::collections::HashMap;
2
3use brink_ir::suppressions::{Suppressions, parse_suppressions};
4use brink_ir::{
5    Diagnostic, FileId, HirFile, SymbolManifest, lower, lower_single_knot, lower_top_level,
6};
7use brink_syntax::ast::AstNode as _;
8use brink_syntax::{Parse, parse_with_cache};
9use rowan::{GreenNode, NodeCache};
10use tracing::debug;
11
12use crate::file_state::{FileState, TopLevelEntry};
13use crate::include_graph::IncludeGraph;
14use crate::knot_cache::KnotEntry;
15
16/// Stateful incremental project database.
17///
18/// Caches parsed trees and lowered HIR per file, enabling efficient re-analysis
19/// when individual files change. Both the compiler (one-shot) and LSP
20/// (long-lived) use this as their project model.
21pub struct ProjectDb {
22    files: HashMap<FileId, FileState>,
23    path_to_id: HashMap<String, FileId>,
24    id_to_path: HashMap<FileId, String>,
25    next_id: u32,
26    include_graph: IncludeGraph,
27    node_cache: NodeCache,
28}
29
30impl ProjectDb {
31    /// Create an empty project database.
32    pub fn new() -> Self {
33        Self {
34            files: HashMap::new(),
35            path_to_id: HashMap::new(),
36            id_to_path: HashMap::new(),
37            next_id: 0,
38            include_graph: IncludeGraph::new(),
39            node_cache: NodeCache::default(),
40        }
41    }
42
43    /// Add or replace a file. Performs full parse + lower + cache.
44    pub fn set_file(&mut self, path: &str, source: String) -> FileId {
45        let file_id = self.get_or_create_id(path);
46
47        let parse = parse_with_cache(&source, &mut self.node_cache);
48        let tree = parse.tree();
49
50        // Per-knot lowering
51        let knot_entries: Vec<KnotEntry> = tree
52            .knots()
53            .map(|knot_ast| {
54                let green = knot_ast.syntax().green().into();
55                let offset = knot_ast.syntax().text_range().start();
56                let (knot, manifest, diagnostics) = lower_single_knot(file_id, &knot_ast);
57                KnotEntry {
58                    green,
59                    offset,
60                    knot,
61                    manifest,
62                    diagnostics,
63                }
64            })
65            .collect();
66
67        // Top-level lowering
68        let top_level = Self::lower_top_level_entry(file_id, &tree);
69
70        // Assemble full HirFile and SymbolManifest
71        let (hir, manifest, diagnostics) =
72            Self::assemble(file_id, &knot_entries, &top_level, &tree);
73
74        let suppressions = parse_suppressions(&source);
75
76        let state = FileState {
77            source,
78            parse,
79            knot_entries,
80            top_level,
81            hir,
82            manifest,
83            diagnostics,
84            suppressions,
85        };
86
87        // Update include graph
88        let include_ids: Vec<FileId> = state
89            .hir
90            .includes
91            .iter()
92            .filter_map(|inc| {
93                let resolved = resolve_include_path(path, &inc.file_path);
94                self.path_to_id.get(&resolved).copied()
95            })
96            .collect();
97        self.include_graph.update(file_id, include_ids);
98
99        self.files.insert(file_id, state);
100
101        debug!(path, id = file_id.0, "set_file complete");
102        file_id
103    }
104
105    /// Incrementally update a file. Re-parses, diffs knots by green-node
106    /// identity, and only re-lowers changed knots.
107    pub fn update_file(&mut self, path: &str, source: String) -> FileId {
108        let file_id = self.get_or_create_id(path);
109
110        // If the file doesn't exist yet, fall through to set_file
111        if !self.files.contains_key(&file_id) {
112            return self.set_file(path, source);
113        }
114
115        let parse = parse_with_cache(&source, &mut self.node_cache);
116        let tree = parse.tree();
117
118        // Top-level is always re-lowered (cheap relative to knots)
119        let top_level = Self::lower_top_level_entry(file_id, &tree);
120
121        // Diff knots by green-node identity
122        let new_knot_asts: Vec<_> = tree.knots().collect();
123        let old_state = self.files.get(&file_id);
124
125        let mut knot_entries = Vec::with_capacity(new_knot_asts.len());
126        let mut reused = 0u32;
127
128        for (i, knot_ast) in new_knot_asts.iter().enumerate() {
129            let new_green: GreenNode = knot_ast.syntax().green().into();
130
131            let new_offset = knot_ast.syntax().text_range().start();
132            let reuse_entry = old_state
133                .and_then(|s| s.knot_entries.get(i))
134                .filter(|old| old.green == new_green && old.offset == new_offset);
135
136            if let Some(old_entry) = reuse_entry {
137                knot_entries.push(KnotEntry {
138                    green: new_green,
139                    offset: new_offset,
140                    knot: old_entry.knot.clone(),
141                    manifest: old_entry.manifest.clone(),
142                    diagnostics: old_entry.diagnostics.clone(),
143                });
144                reused += 1;
145            } else {
146                let (knot, manifest, diagnostics) = lower_single_knot(file_id, knot_ast);
147                knot_entries.push(KnotEntry {
148                    green: new_green,
149                    offset: new_offset,
150                    knot,
151                    manifest,
152                    diagnostics,
153                });
154            }
155        }
156
157        debug!(
158            path,
159            total = new_knot_asts.len(),
160            reused,
161            "knot diff complete"
162        );
163
164        let (hir, manifest, diagnostics) =
165            Self::assemble(file_id, &knot_entries, &top_level, &tree);
166
167        let suppressions = parse_suppressions(&source);
168
169        let state = FileState {
170            source,
171            parse,
172            knot_entries,
173            top_level,
174            hir,
175            manifest,
176            diagnostics,
177            suppressions,
178        };
179
180        // Update include graph
181        let include_ids: Vec<FileId> = state
182            .hir
183            .includes
184            .iter()
185            .filter_map(|inc| {
186                let resolved = resolve_include_path(path, &inc.file_path);
187                self.path_to_id.get(&resolved).copied()
188            })
189            .collect();
190        self.include_graph.update(file_id, include_ids);
191
192        self.files.insert(file_id, state);
193
194        file_id
195    }
196
197    /// Remove a file from the database.
198    pub fn remove_file(&mut self, path: &str) {
199        if let Some(id) = self.path_to_id.remove(path) {
200            self.id_to_path.remove(&id);
201            self.files.remove(&id);
202            self.include_graph.remove(id);
203        }
204    }
205
206    /// Look up a file's ID by path.
207    pub fn file_id(&self, path: &str) -> Option<FileId> {
208        self.path_to_id.get(path).copied()
209    }
210
211    /// Look up a file's path by ID.
212    pub fn file_path(&self, id: FileId) -> Option<&str> {
213        self.id_to_path.get(&id).map(String::as_str)
214    }
215
216    /// Iterate over all registered file IDs.
217    pub fn file_ids(&self) -> impl Iterator<Item = FileId> + '_ {
218        let mut ids: Vec<_> = self.files.keys().copied().collect();
219        ids.sort_by_key(|id| id.0);
220        ids.into_iter()
221    }
222
223    /// Return file IDs in topological include order (included files before
224    /// the files that include them), matching ink's `INCLUDE` paste semantics.
225    pub fn file_ids_topo(&self, entry: FileId) -> Vec<FileId> {
226        let all: Vec<_> = self.files.keys().copied().collect();
227        self.include_graph.topological_order(entry, &all)
228    }
229
230    /// Get the cached parse tree for a file.
231    pub fn parse(&self, id: FileId) -> Option<&Parse> {
232        self.files.get(&id).map(|s| &s.parse)
233    }
234
235    /// Get the cached HIR for a file.
236    pub fn hir(&self, id: FileId) -> Option<&HirFile> {
237        self.files.get(&id).map(|s| &s.hir)
238    }
239
240    /// Get the cached symbol manifest for a file.
241    pub fn manifest(&self, id: FileId) -> Option<&SymbolManifest> {
242        self.files.get(&id).map(|s| &s.manifest)
243    }
244
245    /// Get the source text for a file.
246    pub fn source(&self, id: FileId) -> Option<&str> {
247        self.files.get(&id).map(|s| s.source.as_str())
248    }
249
250    /// Get per-file diagnostics (parse + lowering).
251    pub fn file_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
252        self.files.get(&id).map(|s| s.diagnostics.as_slice())
253    }
254
255    /// Get parsed suppression directives for a file.
256    pub fn suppressions(&self, id: FileId) -> Option<&Suppressions> {
257        self.files.get(&id).map(|s| &s.suppressions)
258    }
259
260    /// Rebuild include graph edges for all files.
261    ///
262    /// Must be called after batch-loading files (e.g. workspace discovery)
263    /// because `set_file` can only create edges to files already in the db.
264    /// Files loaded before their include targets will have missing edges.
265    pub fn rebuild_include_graph(&mut self) {
266        let file_list: Vec<(FileId, String)> = self
267            .files
268            .keys()
269            .filter_map(|&id| self.id_to_path.get(&id).map(|p| (id, p.clone())))
270            .collect();
271
272        for (file_id, file_path) in &file_list {
273            if let Some(state) = self.files.get(file_id) {
274                let include_ids: Vec<FileId> = state
275                    .hir
276                    .includes
277                    .iter()
278                    .filter_map(|inc| {
279                        let resolved = resolve_include_path(file_path, &inc.file_path);
280                        self.path_to_id.get(&resolved).copied()
281                    })
282                    .collect();
283                self.include_graph.update(*file_id, include_ids);
284            }
285        }
286    }
287
288    /// Detect cycles in the include graph.
289    ///
290    /// Returns the first cycle found as an ordered path of file IDs.
291    pub fn find_cycle(&self) -> Option<Vec<FileId>> {
292        self.include_graph.find_cycle()
293    }
294
295    /// Compute independent projects from include relationships.
296    ///
297    /// Returns `(root, members)` pairs sorted by root `FileId`.
298    pub fn compute_projects(&self) -> Vec<(FileId, Vec<FileId>)> {
299        let all: Vec<_> = self.files.keys().copied().collect();
300        self.include_graph.compute_projects(&all)
301    }
302
303    /// Snapshot analysis inputs for a subset of files.
304    ///
305    /// Like `analysis_inputs()` but filtered to the given set.
306    pub fn analysis_inputs_for(
307        &self,
308        file_ids: &[FileId],
309    ) -> Vec<(FileId, HirFile, SymbolManifest)> {
310        let mut inputs: Vec<_> = file_ids
311            .iter()
312            .filter_map(|&id| {
313                let state = self.files.get(&id)?;
314                Some((id, state.hir.clone(), state.manifest.clone()))
315            })
316            .collect();
317        inputs.sort_by_key(|(id, _, _)| id.0);
318        inputs
319    }
320
321    /// Snapshot all analysis inputs for background analysis.
322    ///
323    /// Returns `(FileId, HirFile, SymbolManifest)` tuples cloned out of the db,
324    /// so the caller can run `brink_analyzer::analyze()` without holding the lock.
325    pub fn analysis_inputs(&self) -> Vec<(FileId, HirFile, SymbolManifest)> {
326        let mut inputs: Vec<_> = self
327            .files
328            .iter()
329            .map(|(&id, state)| (id, state.hir.clone(), state.manifest.clone()))
330            .collect();
331        inputs.sort_by_key(|(id, _, _)| id.0);
332        inputs
333    }
334
335    /// Snapshot file metadata for diagnostic publishing.
336    ///
337    /// Returns `(FileId, path, source)` tuples for all files in the db.
338    pub fn file_metadata(&self) -> Vec<(FileId, String, String)> {
339        let mut meta: Vec<_> = self
340            .files
341            .keys()
342            .filter_map(|&id| {
343                let path = self.id_to_path.get(&id)?.clone();
344                let source = self.files.get(&id)?.source.clone();
345                Some((id, path, source))
346            })
347            .collect();
348        meta.sort_by_key(|(id, _, _)| id.0);
349        meta
350    }
351
352    // ── Internal helpers ──────────────────────────────────────────────
353
354    fn get_or_create_id(&mut self, path: &str) -> FileId {
355        if let Some(&id) = self.path_to_id.get(path) {
356            return id;
357        }
358        let id = FileId(self.next_id);
359        self.next_id += 1;
360        self.path_to_id.insert(path.to_string(), id);
361        self.id_to_path.insert(id, path.to_string());
362        id
363    }
364
365    fn lower_top_level_entry(
366        file_id: FileId,
367        tree: &brink_syntax::ast::SourceFile,
368    ) -> TopLevelEntry {
369        let green_children = Self::collect_top_level_green(tree);
370        let (root_content, top_level_knots, manifest, diagnostics) = lower_top_level(file_id, tree);
371        TopLevelEntry {
372            green_children,
373            root_content,
374            top_level_knots,
375            manifest,
376            diagnostics,
377        }
378    }
379
380    /// Collect green nodes of non-knot direct children for diffing.
381    fn collect_top_level_green(tree: &brink_syntax::ast::SourceFile) -> Vec<GreenNode> {
382        use brink_syntax::SyntaxKind;
383
384        tree.syntax()
385            .children()
386            .filter(|child| child.kind() != SyntaxKind::KNOT_DEF)
387            .map(|child| child.green().into())
388            .collect()
389    }
390
391    /// Assemble a complete `HirFile` and `SymbolManifest` from cached pieces.
392    fn assemble(
393        file_id: FileId,
394        knot_entries: &[KnotEntry],
395        top_level: &TopLevelEntry,
396        tree: &brink_syntax::ast::SourceFile,
397    ) -> (HirFile, SymbolManifest, Vec<Diagnostic>) {
398        // We need declarations from the full lower to build HirFile.
399        // lower_top_level only returns (Block, SymbolManifest, diagnostics).
400        // For the declarations (variables, constants, lists, externals, includes),
401        // we need to call `lower()` or extract them from the AST.
402        //
403        // Approach: use `lower()` to get the full HirFile, then replace knots
404        // with our cached versions. This means top-level lowering happens twice
405        // on change, but it's simple and correct.
406        let (mut full_hir, _full_manifest, _full_diag) = lower(file_id, tree);
407
408        // Replace knots with our cached (possibly reused) versions,
409        // plus any top-level stitches promoted to knots.
410        full_hir.knots = knot_entries.iter().filter_map(|e| e.knot.clone()).collect();
411        full_hir.knots.extend(top_level.top_level_knots.clone());
412        full_hir.root_content = top_level.root_content.clone();
413
414        // Merge manifests: top-level + all knots
415        let mut manifest = top_level.manifest.clone();
416        for entry in knot_entries {
417            merge_manifest_into(&mut manifest, &entry.manifest);
418        }
419
420        // Merge diagnostics
421        let mut diagnostics = top_level.diagnostics.clone();
422        for entry in knot_entries {
423            diagnostics.extend(entry.diagnostics.iter().cloned());
424        }
425
426        (full_hir, manifest, diagnostics)
427    }
428}
429
430impl Default for ProjectDb {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436/// Merge `src` manifest fields into `dst`.
437fn merge_manifest_into(dst: &mut SymbolManifest, src: &SymbolManifest) {
438    dst.knots.extend(src.knots.iter().cloned());
439    dst.stitches.extend(src.stitches.iter().cloned());
440    dst.variables.extend(src.variables.iter().cloned());
441    dst.constants.extend(src.constants.iter().cloned());
442    dst.lists.extend(src.lists.iter().cloned());
443    dst.externals.extend(src.externals.iter().cloned());
444    dst.labels.extend(src.labels.iter().cloned());
445    dst.list_items.extend(src.list_items.iter().cloned());
446    dst.locals.extend(src.locals.iter().cloned());
447    dst.unresolved.extend(src.unresolved.iter().cloned());
448}
449
450/// Resolve an INCLUDE path relative to the including file's directory.
451///
452/// Uses string-based path manipulation (`rfind('/')`) rather than
453/// `std::path::Path` to avoid platform-specific separator issues and
454/// to work in WASM contexts.
455pub fn resolve_include_path(from_file: &str, include_path: &str) -> String {
456    match from_file.rfind('/') {
457        Some(i) => format!("{}/{include_path}", &from_file[..i]),
458        None => include_path.to_string(),
459    }
460}