use std::collections::{BTreeSet, HashMap};
use brink_ir::suppressions::{Suppressions, parse_suppressions};
use brink_ir::{
Diagnostic, DiagnosticCode, FileId, HirFile, SymbolManifest, lower, lower_single_knot,
lower_top_level,
};
use brink_syntax::ast::AstNode as _;
use brink_syntax::{Parse, parse_with_cache};
use rowan::{GreenNode, NodeCache};
use tracing::debug;
use crate::file_state::{FileState, TopLevelEntry};
use crate::include_graph::IncludeGraph;
use crate::knot_cache::KnotEntry;
pub struct ProjectDb {
files: HashMap<FileId, FileState>,
path_to_id: HashMap<String, FileId>,
id_to_path: HashMap<FileId, String>,
next_id: u32,
include_graph: IncludeGraph,
node_cache: NodeCache,
}
impl ProjectDb {
pub fn new() -> Self {
Self {
files: HashMap::new(),
path_to_id: HashMap::new(),
id_to_path: HashMap::new(),
next_id: 0,
include_graph: IncludeGraph::new(),
node_cache: NodeCache::default(),
}
}
pub fn set_file(&mut self, path: &str, source: String) -> FileId {
let file_id = self.get_or_create_id(path);
let parse = parse_with_cache(&source, &mut self.node_cache);
let tree = parse.tree();
let knot_entries: Vec<KnotEntry> = tree
.knots()
.map(|knot_ast| {
let green = knot_ast.syntax().green().into();
let offset = knot_ast.syntax().text_range().start();
let (knot, manifest, diagnostics) = lower_single_knot(file_id, &knot_ast);
KnotEntry {
green,
offset,
knot,
manifest,
diagnostics,
}
})
.collect();
let top_level = Self::lower_top_level_entry(file_id, &tree);
let (hir, manifest, mut diagnostics) =
Self::assemble(file_id, &knot_entries, &top_level, &tree);
diagnostics.extend(Self::syntax_diagnostics(file_id, &parse));
let suppressions = parse_suppressions(&source);
let state = FileState {
source,
parse,
knot_entries,
top_level,
hir,
manifest,
diagnostics,
suppressions,
};
let include_ids: Vec<FileId> = state
.hir
.includes
.iter()
.filter_map(|inc| {
let resolved = resolve_include_path(path, &inc.file_path);
self.path_to_id.get(&resolved).copied()
})
.collect();
self.include_graph.update(file_id, include_ids);
self.files.insert(file_id, state);
debug!(path, id = file_id.0, "set_file complete");
file_id
}
pub fn update_file(&mut self, path: &str, source: String) -> FileId {
let file_id = self.get_or_create_id(path);
if !self.files.contains_key(&file_id) {
return self.set_file(path, source);
}
let parse = parse_with_cache(&source, &mut self.node_cache);
let tree = parse.tree();
let top_level = Self::lower_top_level_entry(file_id, &tree);
let new_knot_asts: Vec<_> = tree.knots().collect();
let old_state = self.files.get(&file_id);
let mut knot_entries = Vec::with_capacity(new_knot_asts.len());
let mut reused = 0u32;
for (i, knot_ast) in new_knot_asts.iter().enumerate() {
let new_green: GreenNode = knot_ast.syntax().green().into();
let new_offset = knot_ast.syntax().text_range().start();
let reuse_entry = old_state
.and_then(|s| s.knot_entries.get(i))
.filter(|old| old.green == new_green && old.offset == new_offset);
if let Some(old_entry) = reuse_entry {
knot_entries.push(KnotEntry {
green: new_green,
offset: new_offset,
knot: old_entry.knot.clone(),
manifest: old_entry.manifest.clone(),
diagnostics: old_entry.diagnostics.clone(),
});
reused += 1;
} else {
let (knot, manifest, diagnostics) = lower_single_knot(file_id, knot_ast);
knot_entries.push(KnotEntry {
green: new_green,
offset: new_offset,
knot,
manifest,
diagnostics,
});
}
}
debug!(
path,
total = new_knot_asts.len(),
reused,
"knot diff complete"
);
let (hir, manifest, mut diagnostics) =
Self::assemble(file_id, &knot_entries, &top_level, &tree);
diagnostics.extend(Self::syntax_diagnostics(file_id, &parse));
let suppressions = parse_suppressions(&source);
let state = FileState {
source,
parse,
knot_entries,
top_level,
hir,
manifest,
diagnostics,
suppressions,
};
let include_ids: Vec<FileId> = state
.hir
.includes
.iter()
.filter_map(|inc| {
let resolved = resolve_include_path(path, &inc.file_path);
self.path_to_id.get(&resolved).copied()
})
.collect();
self.include_graph.update(file_id, include_ids);
self.files.insert(file_id, state);
file_id
}
pub fn remove_file(&mut self, path: &str) {
if let Some(id) = self.path_to_id.remove(path) {
self.id_to_path.remove(&id);
self.files.remove(&id);
self.include_graph.remove(id);
}
}
pub fn file_id(&self, path: &str) -> Option<FileId> {
self.path_to_id.get(path).copied()
}
pub fn file_path(&self, id: FileId) -> Option<&str> {
self.id_to_path.get(&id).map(String::as_str)
}
pub fn file_ids(&self) -> impl Iterator<Item = FileId> + '_ {
let mut ids: Vec<_> = self.files.keys().copied().collect();
ids.sort_by_key(|id| id.0);
ids.into_iter()
}
pub fn file_ids_topo(&self, entry: FileId) -> Vec<FileId> {
let all: Vec<_> = self.files.keys().copied().collect();
self.include_graph.topological_order(entry, &all)
}
pub fn parse(&self, id: FileId) -> Option<&Parse> {
self.files.get(&id).map(|s| &s.parse)
}
pub fn hir(&self, id: FileId) -> Option<&HirFile> {
self.files.get(&id).map(|s| &s.hir)
}
pub fn manifest(&self, id: FileId) -> Option<&SymbolManifest> {
self.files.get(&id).map(|s| &s.manifest)
}
pub fn source(&self, id: FileId) -> Option<&str> {
self.files.get(&id).map(|s| s.source.as_str())
}
pub fn file_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
self.files.get(&id).map(|s| s.diagnostics.as_slice())
}
pub fn suppressions(&self, id: FileId) -> Option<&Suppressions> {
self.files.get(&id).map(|s| &s.suppressions)
}
pub fn rebuild_include_graph(&mut self) {
let file_list: Vec<(FileId, String)> = self
.files
.keys()
.filter_map(|&id| self.id_to_path.get(&id).map(|p| (id, p.clone())))
.collect();
for (file_id, file_path) in &file_list {
if let Some(state) = self.files.get(file_id) {
let include_ids: Vec<FileId> = state
.hir
.includes
.iter()
.filter_map(|inc| {
let resolved = resolve_include_path(file_path, &inc.file_path);
self.path_to_id.get(&resolved).copied()
})
.collect();
self.include_graph.update(*file_id, include_ids);
}
}
}
pub fn find_cycle(&self) -> Option<Vec<FileId>> {
self.include_graph.find_cycle()
}
pub fn compute_projects(&self) -> Vec<(FileId, Vec<FileId>)> {
let all: Vec<_> = self.files.keys().copied().collect();
self.include_graph.compute_projects(&all)
}
pub fn reachable_from(&self, entry: FileId) -> BTreeSet<FileId> {
self.include_graph.reachable_from(entry)
}
pub fn analysis_inputs_for(
&self,
file_ids: &[FileId],
) -> Vec<(FileId, HirFile, SymbolManifest)> {
let mut inputs: Vec<_> = file_ids
.iter()
.filter_map(|&id| {
let state = self.files.get(&id)?;
Some((id, state.hir.clone(), state.manifest.clone()))
})
.collect();
inputs.sort_by_key(|(id, _, _)| id.0);
inputs
}
pub fn analysis_inputs(&self) -> Vec<(FileId, HirFile, SymbolManifest)> {
let mut inputs: Vec<_> = self
.files
.iter()
.map(|(&id, state)| (id, state.hir.clone(), state.manifest.clone()))
.collect();
inputs.sort_by_key(|(id, _, _)| id.0);
inputs
}
pub fn file_metadata(&self) -> Vec<(FileId, String, String)> {
let mut meta: Vec<_> = self
.files
.keys()
.filter_map(|&id| {
let path = self.id_to_path.get(&id)?.clone();
let source = self.files.get(&id)?.source.clone();
Some((id, path, source))
})
.collect();
meta.sort_by_key(|(id, _, _)| id.0);
meta
}
fn get_or_create_id(&mut self, path: &str) -> FileId {
if let Some(&id) = self.path_to_id.get(path) {
return id;
}
let id = FileId(self.next_id);
self.next_id += 1;
self.path_to_id.insert(path.to_string(), id);
self.id_to_path.insert(id, path.to_string());
id
}
fn lower_top_level_entry(
file_id: FileId,
tree: &brink_syntax::ast::SourceFile,
) -> TopLevelEntry {
let green_children = Self::collect_top_level_green(tree);
let (root_content, top_level_knots, manifest, diagnostics) = lower_top_level(file_id, tree);
TopLevelEntry {
green_children,
root_content,
top_level_knots,
manifest,
diagnostics,
}
}
fn collect_top_level_green(tree: &brink_syntax::ast::SourceFile) -> Vec<GreenNode> {
use brink_syntax::SyntaxKind;
tree.syntax()
.children()
.filter(|child| child.kind() != SyntaxKind::KNOT_DEF)
.map(|child| child.green().into())
.collect()
}
fn assemble(
file_id: FileId,
knot_entries: &[KnotEntry],
top_level: &TopLevelEntry,
tree: &brink_syntax::ast::SourceFile,
) -> (HirFile, SymbolManifest, Vec<Diagnostic>) {
let (mut full_hir, _full_manifest, _full_diag) = lower(file_id, tree);
full_hir.knots = knot_entries.iter().filter_map(|e| e.knot.clone()).collect();
full_hir.knots.extend(top_level.top_level_knots.clone());
full_hir.root_content = top_level.root_content.clone();
let mut manifest = top_level.manifest.clone();
for entry in knot_entries {
merge_manifest_into(&mut manifest, &entry.manifest);
}
let mut diagnostics = top_level.diagnostics.clone();
for entry in knot_entries {
diagnostics.extend(entry.diagnostics.iter().cloned());
}
(full_hir, manifest, diagnostics)
}
fn syntax_diagnostics(file_id: FileId, parse: &Parse) -> Vec<Diagnostic> {
parse
.errors()
.iter()
.map(|e| Diagnostic {
file: file_id,
range: e.range,
message: e.message.clone(),
code: DiagnosticCode::E037,
})
.collect()
}
}
impl Default for ProjectDb {
fn default() -> Self {
Self::new()
}
}
fn merge_manifest_into(dst: &mut SymbolManifest, src: &SymbolManifest) {
dst.knots.extend(src.knots.iter().cloned());
dst.stitches.extend(src.stitches.iter().cloned());
dst.variables.extend(src.variables.iter().cloned());
dst.constants.extend(src.constants.iter().cloned());
dst.lists.extend(src.lists.iter().cloned());
dst.externals.extend(src.externals.iter().cloned());
dst.labels.extend(src.labels.iter().cloned());
dst.list_items.extend(src.list_items.iter().cloned());
dst.locals.extend(src.locals.iter().cloned());
dst.unresolved.extend(src.unresolved.iter().cloned());
dst.docs
.extend(src.docs.iter().map(|(k, v)| (k.clone(), v.clone())));
}
pub fn resolve_include_path(from_file: &str, include_path: &str) -> String {
let joined = match from_file.rfind('/') {
Some(i) => format!("{}/{include_path}", &from_file[..i]),
None => include_path.to_string(),
};
normalize_path(&joined)
}
fn normalize_path(path: &str) -> String {
let absolute = path.starts_with('/');
let mut out: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
"" | "." => {}
".." if matches!(out.last(), Some(&s) if s != "..") => {
out.pop();
}
s => out.push(s),
}
}
let joined = out.join("/");
if absolute {
format!("/{joined}")
} else {
joined
}
}
pub fn compute_relative_path(from_file: &str, to_file: &str) -> String {
let mut from_dirs: Vec<&str> = from_file.split('/').collect();
from_dirs.pop(); let to_all: Vec<&str> = to_file.split('/').collect();
let Some((to_name, to_dirs)) = to_all.split_last() else {
return to_file.to_owned();
};
let mut k = 0;
while k < from_dirs.len() && k < to_dirs.len() && from_dirs[k] == to_dirs[k] {
k += 1;
}
let mut parts: Vec<&str> = Vec::new();
parts.extend(std::iter::repeat_n("..", from_dirs.len() - k));
parts.extend_from_slice(&to_dirs[k..]);
parts.push(to_name);
parts.join("/")
}
#[cfg(test)]
mod path_tests {
use super::{compute_relative_path, resolve_include_path};
#[test]
fn resolve_forward_includes() {
assert_eq!(
resolve_include_path("src/main.ink", "utils.ink"),
"src/utils.ink"
);
assert_eq!(resolve_include_path("story.ink", "other.ink"), "other.ink");
assert_eq!(resolve_include_path("a/b/c.ink", "d/e.ink"), "a/b/d/e.ink");
}
#[test]
fn resolve_normalizes_dot_and_dotdot() {
assert_eq!(resolve_include_path("a/b/c.ink", "../d.ink"), "a/d.ink");
assert_eq!(resolve_include_path("a/b/c.ink", "./d.ink"), "a/b/d.ink");
assert_eq!(resolve_include_path("a/b/c.ink", "../../d.ink"), "d.ink");
assert_eq!(
resolve_include_path("a/b/c.ink", "../x/../d.ink"),
"a/d.ink"
);
}
#[test]
fn compute_relative_is_inverse_of_resolve() {
let cases = [
("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"), ];
for (from, to) in cases {
let rel = compute_relative_path(from, to);
assert_eq!(
resolve_include_path(from, &rel),
to,
"round-trip failed for from={from} to={to} rel={rel}",
);
}
}
#[test]
fn resolve_preserves_absolute_paths() {
assert_eq!(
resolve_include_path("/proj/tier3/main.ink", "included.ink"),
"/proj/tier3/included.ink",
);
assert_eq!(
resolve_include_path("/proj/a/b/c.ink", "../d.ink"),
"/proj/a/d.ink"
);
}
#[test]
fn compute_relative_rename_in_place_is_bare_name() {
assert_eq!(
compute_relative_path("a/b/c.ink", "a/b/renamed.ink"),
"renamed.ink"
);
assert_eq!(
compute_relative_path("main.ink", "renamed.ink"),
"renamed.ink"
);
}
#[test]
fn compute_relative_move_shallower_is_bare_name() {
assert_eq!(compute_relative_path("main.ink", "host.ink"), "host.ink");
assert_eq!(
compute_relative_path("chapters/main.ink", "host.ink"),
"../host.ink"
);
assert_eq!(
resolve_include_path("chapters/main.ink", "../host.ink"),
"host.ink"
);
}
}
#[cfg(test)]
mod reachable_tests {
use super::ProjectDb;
fn db_with(files: &[(&str, &str)]) -> ProjectDb {
let mut db = ProjectDb::new();
for (path, src) in files {
db.set_file(path, (*src).to_owned());
}
db.rebuild_include_graph();
db
}
#[test]
fn entry_is_always_reachable_from_itself() {
let db = db_with(&[("main.ink", "== hub ==\ntext\n")]);
let main = db.file_id("main.ink").expect("main");
let reachable = db.reachable_from(main);
assert_eq!(reachable.into_iter().collect::<Vec<_>>(), vec![main]);
}
#[test]
fn direct_includes_are_reachable() {
let db = db_with(&[
("main.ink", "INCLUDE a.ink\nINCLUDE b.ink\n"),
("a.ink", "== a ==\n"),
("b.ink", "== b ==\n"),
]);
let main = db.file_id("main.ink").expect("main");
let a = db.file_id("a.ink").expect("a");
let b = db.file_id("b.ink").expect("b");
let reachable: Vec<_> = db.reachable_from(main).into_iter().collect();
assert!(reachable.contains(&main));
assert!(reachable.contains(&a));
assert!(reachable.contains(&b));
assert_eq!(reachable.len(), 3);
}
#[test]
fn transitive_includes_are_reachable() {
let db = db_with(&[
("main.ink", "INCLUDE a.ink\n"),
("a.ink", "INCLUDE b.ink\n"),
("b.ink", "== b ==\n"),
("unrelated.ink", "== x ==\n"),
]);
let main = db.file_id("main.ink").expect("main");
let a = db.file_id("a.ink").expect("a");
let b = db.file_id("b.ink").expect("b");
let unrelated = db.file_id("unrelated.ink").expect("unrelated");
let reachable = db.reachable_from(main);
assert!(reachable.contains(&main));
assert!(reachable.contains(&a));
assert!(reachable.contains(&b));
assert!(
!reachable.contains(&unrelated),
"unrelated file is not reachable"
);
}
#[test]
fn reachable_terminates_on_cycles() {
let db = db_with(&[("a.ink", "INCLUDE b.ink\n"), ("b.ink", "INCLUDE a.ink\n")]);
let a = db.file_id("a.ink").expect("a");
let b = db.file_id("b.ink").expect("b");
let reachable: Vec<_> = db.reachable_from(a).into_iter().collect();
assert!(reachable.contains(&a));
assert!(reachable.contains(&b));
assert_eq!(reachable.len(), 2);
}
}