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
17pub 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 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 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 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 let top_level = Self::lower_top_level_entry(file_id, &tree);
70
71 let (hir, manifest, mut diagnostics) =
73 Self::assemble(file_id, &knot_entries, &top_level, &tree);
74 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 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 pub fn update_file(&mut self, path: &str, source: String) -> FileId {
111 let file_id = self.get_or_create_id(path);
112
113 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 let top_level = Self::lower_top_level_entry(file_id, &tree);
123
124 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 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 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 pub fn file_id(&self, path: &str) -> Option<FileId> {
212 self.path_to_id.get(path).copied()
213 }
214
215 pub fn file_path(&self, id: FileId) -> Option<&str> {
217 self.id_to_path.get(&id).map(String::as_str)
218 }
219
220 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 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 pub fn parse(&self, id: FileId) -> Option<&Parse> {
236 self.files.get(&id).map(|s| &s.parse)
237 }
238
239 pub fn hir(&self, id: FileId) -> Option<&HirFile> {
241 self.files.get(&id).map(|s| &s.hir)
242 }
243
244 pub fn manifest(&self, id: FileId) -> Option<&SymbolManifest> {
246 self.files.get(&id).map(|s| &s.manifest)
247 }
248
249 pub fn source(&self, id: FileId) -> Option<&str> {
251 self.files.get(&id).map(|s| s.source.as_str())
252 }
253
254 pub fn file_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
256 self.files.get(&id).map(|s| s.diagnostics.as_slice())
257 }
258
259 pub fn suppressions(&self, id: FileId) -> Option<&Suppressions> {
261 self.files.get(&id).map(|s| &s.suppressions)
262 }
263
264 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 pub fn find_cycle(&self) -> Option<Vec<FileId>> {
296 self.include_graph.find_cycle()
297 }
298
299 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 pub fn reachable_from(&self, entry: FileId) -> BTreeSet<FileId> {
314 self.include_graph.reachable_from(entry)
315 }
316
317 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 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 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 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 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 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 let (mut full_hir, _full_manifest, _full_diag) = lower(file_id, tree);
421
422 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 let mut manifest = top_level.manifest.clone();
430 for entry in knot_entries {
431 merge_manifest_into(&mut manifest, &entry.manifest);
432 }
433
434 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 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
465fn 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
481pub 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
497fn 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
522pub 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(); 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 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 let cases = [
578 ("main.ink", "scenes/intro.ink"), ("a/b/c.ink", "a/d.ink"), ("a/b/c.ink", "a/b/renamed.ink"), ("scenes/intro.ink", "lib.ink"), ("a/b/c.ink", "x/y/z.ink"), ("main.ink", "other.ink"), ];
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 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 assert_eq!(compute_relative_path("main.ink", "host.ink"), "host.ink");
628 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 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 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}