use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc};
use bumpalo::Bump;
use crate::{
error::{Diagnostic, ErrorCode, ParseError, SourceError},
file_id::FileId,
lexer, parser,
parser_types::{FileAst, Import, ImportDecl, ImportForm},
source_map::SourceMap,
source_provider::SourceProvider,
span::{Span, Spanned},
};
#[derive(Debug)]
pub struct ResolvedFile<'arena> {
source_map: SourceMap<'arena>,
file_ast: FileAst<'arena>,
}
impl<'arena> ResolvedFile<'arena> {
pub fn into_parts(self) -> (FileAst<'arena>, SourceMap<'arena>) {
(self.file_ast, self.source_map)
}
}
pub struct Resolver<'arena, P> {
arena: &'arena Bump,
provider: P,
source_map: SourceMap<'arena>,
cache: HashMap<FileId, Rc<RefCell<FileAst<'arena>>>>,
resolution_stack: Vec<FileId>,
}
impl<'arena, P: SourceProvider> Resolver<'arena, P> {
pub fn new(arena: &'arena Bump, provider: P) -> Self {
Self {
arena,
provider,
source_map: SourceMap::new(),
cache: HashMap::new(),
resolution_stack: Vec::new(),
}
}
pub fn resolve(mut self, root_path: &Path) -> Result<ResolvedFile<'arena>, ParseError<'arena>> {
let root_rc = match self.resolve_file(root_path, None) {
Ok(rc) => rc,
Err(diags) => return Err(ParseError::new(diags, self.source_map)),
};
self.cache.clear();
let file_ast = Rc::try_unwrap(root_rc)
.expect("cache cleared; root refcount should be 1")
.into_inner();
Ok(ResolvedFile {
source_map: self.source_map,
file_ast,
})
}
fn resolve_file(
&mut self,
path: &Path,
import_span: Option<Span>,
) -> Result<Rc<RefCell<FileAst<'arena>>>, Vec<Diagnostic>> {
let file_id = FileId::new(path);
if let Some(rc) = self.cache.get(&file_id) {
return Ok(Rc::clone(rc));
}
if self.resolution_stack.contains(&file_id) {
return Err(vec![self.cycle_error(file_id, import_span)]);
}
self.resolution_stack.push(file_id);
let source_string = self
.provider
.read_source(path)
.map_err(|e| vec![Self::file_not_found_diagnostic(&e, import_span)])?;
let source: &'arena str = self.arena.alloc_str(&source_string);
drop(source_string);
let base_offset = self
.source_map
.add_file(path.display().to_string(), source, import_span);
let tokens = lexer::tokenize(source, base_offset)?;
let mut file_ast = parser::build_file(&tokens).map_err(|diag| vec![diag])?;
for import_decl in &file_ast.import_decls {
let import = self.resolve_import_decl(path, import_decl)?;
file_ast.imports.push(import);
}
self.resolution_stack.pop();
let rc = Rc::new(RefCell::new(file_ast));
self.cache.insert(file_id, Rc::clone(&rc));
Ok(rc)
}
fn resolve_import_decl(
&mut self,
parent_path: &Path,
import_decl: &Spanned<ImportDecl>,
) -> Result<Import<'arena>, Vec<Diagnostic>> {
let import_path = &import_decl.path;
let decl_span = import_decl.span();
Self::validate_import_path(import_path, decl_span).map_err(|diag| vec![diag])?;
let resolved_path = self
.provider
.resolve_path(parent_path, import_path)
.map_err(|e| vec![Self::file_not_found_diagnostic(&e, Some(decl_span))])?;
let file_ast = self.resolve_file(&resolved_path, Some(decl_span))?;
let namespace = match &import_decl.inner().form {
ImportForm::Namespaced => {
let ns = self
.provider
.derive_namespace(&resolved_path)
.map_err(|e| vec![Self::invalid_namespace_diagnostic(&e, decl_span)])?;
Some(ns)
}
ImportForm::Aliased(alias) => Some(*alias.inner()),
ImportForm::Glob => None,
};
Ok(Import {
namespace,
file_ast,
})
}
fn validate_import_path(import_path: &str, span: Span) -> Result<(), Diagnostic> {
if import_path.is_empty() {
return Err(Diagnostic::error("import path is empty")
.with_code(ErrorCode::E402)
.with_label(span, "empty path"));
}
Ok(())
}
fn invalid_namespace_diagnostic(source_error: &SourceError, span: Span) -> Diagnostic {
Diagnostic::error(format!(
"cannot derive namespace: {}",
source_error.message()
))
.with_code(ErrorCode::E403)
.with_label(span, "imported here")
}
fn file_not_found_diagnostic(source_error: &SourceError, span: Option<Span>) -> Diagnostic {
let mut diag = Diagnostic::error(format!(
"cannot find file: {}",
source_error.path().display()
))
.with_code(ErrorCode::E400);
if let Some(span) = span {
diag = diag.with_label(span, "imported here");
}
diag
}
fn cycle_error(&self, file_id: FileId, import_span: Option<Span>) -> Diagnostic {
let cycle_start = self
.resolution_stack
.iter()
.position(|fid| *fid == file_id)
.expect("cycle target must be on the resolution stack");
let chain: Vec<&str> = self.resolution_stack[cycle_start..]
.iter()
.map(|fid| fid.as_str())
.chain(std::iter::once(file_id.as_str()))
.collect();
let chain_str = chain.join(" → ");
let mut diag = Diagnostic::error("circular dependency detected")
.with_code(ErrorCode::E401)
.with_help(format!("import chain: {chain_str}"));
if let Some(span) = import_span {
diag = diag.with_label(span, "cyclic import");
}
diag
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{parser_types::FileHeader, source_provider::InMemorySourceProvider};
fn resolve_with<'a>(
arena: &'a Bump,
files: &[(&str, &str)],
root: &str,
) -> Result<ResolvedFile<'a>, ParseError<'a>> {
let mut provider = InMemorySourceProvider::new();
for &(path, source) in files {
provider.add_file(path, source);
}
let resolver = Resolver::new(arena, provider);
resolver.resolve(Path::new(root))
}
#[test]
fn single_file_no_imports() {
let arena = Bump::new();
let resolved = resolve_with(
&arena,
&[("main.orr", "diagram component;\na: Rectangle;")],
"main.orr",
)
.expect("should resolve");
assert!(resolved.file_ast.imports.is_empty());
assert_eq!(resolved.source_map.file_count(), 1);
}
#[test]
fn library_file_resolves() {
let arena = Bump::new();
let resolved = resolve_with(&arena, &[("lib.orr", "library;")], "lib.orr")
.expect("should resolve library");
assert!(resolved.file_ast.imports.is_empty());
assert!(matches!(
resolved.file_ast.header,
FileHeader::Library { .. }
));
}
#[test]
fn single_import_loads_both() {
let arena = Bump::new();
let resolved = resolve_with(
&arena,
&[
(
"main.orr",
"diagram component;\nimport \"styles\";\na: Rectangle;",
),
("styles.orr", "library;"),
],
"main.orr",
)
.expect("should resolve");
assert_eq!(resolved.file_ast.imports.len(), 1);
assert_eq!(resolved.source_map.file_count(), 2);
let imported = &resolved.file_ast.imports[0];
let imported_ast = imported.file_ast.borrow();
assert!(matches!(imported_ast.header, FileHeader::Library { .. }));
}
#[test]
fn multiple_imports_from_same_file() {
let arena = Bump::new();
let resolved = resolve_with(
&arena,
&[
(
"a.orr",
"diagram component;\nimport \"b\";\nimport \"c\";\nimport \"d\";\na: Rectangle;",
),
("b.orr", "library;"),
("c.orr", "library;"),
("d.orr", "library;"),
],
"a.orr",
)
.expect("should resolve");
assert_eq!(resolved.file_ast.imports.len(), 3);
assert_eq!(resolved.source_map.file_count(), 4);
}
#[test]
fn nested_imports_resolve_transitively() {
let arena = Bump::new();
let resolved = resolve_with(
&arena,
&[
("a.orr", "diagram component;\nimport \"b\";\na: Rectangle;"),
("b.orr", "library;\nimport \"c\";"),
("c.orr", "library;"),
],
"a.orr",
)
.expect("should resolve nested imports");
assert_eq!(resolved.source_map.file_count(), 3);
let b_ast = resolved.file_ast.imports[0].file_ast.borrow();
let c_ast = b_ast.imports[0].file_ast.borrow();
assert!(matches!(c_ast.header, FileHeader::Library { .. }));
assert!(c_ast.imports.is_empty());
}
#[test]
fn duplicate_import_in_same_file_deduplicates() {
let arena = Bump::new();
let resolved = resolve_with(
&arena,
&[
(
"a.orr",
"diagram component;\nimport \"b\";\nimport \"b\";\na: Rectangle;",
),
("b.orr", "library;"),
],
"a.orr",
)
.expect("should resolve");
assert_eq!(resolved.file_ast.imports.len(), 2);
assert_eq!(resolved.source_map.file_count(), 2);
}
#[test]
fn diamond_dependency_deduplicates() {
let arena = Bump::new();
let resolved = resolve_with(
&arena,
&[
(
"a.orr",
"diagram component;\nimport \"b\";\nimport \"c\";\na: Rectangle;",
),
("b.orr", "library;\nimport \"d\";"),
("c.orr", "library;\nimport \"d\";"),
("d.orr", "library;"),
],
"a.orr",
)
.expect("should resolve diamond");
assert_eq!(resolved.source_map.file_count(), 4);
assert_eq!(resolved.file_ast.imports.len(), 2);
let b_ast = resolved.file_ast.imports[0].file_ast.borrow();
let c_ast = resolved.file_ast.imports[1].file_ast.borrow();
assert_eq!(b_ast.imports.len(), 1);
assert_eq!(c_ast.imports.len(), 1);
}
#[test]
fn missing_root_file_emits_e400() {
let arena = Bump::new();
let result = resolve_with(&arena, &[], "missing.orr");
let err = result.expect_err("should fail on missing root");
let diag = &err.diagnostics()[0];
assert_eq!(
diag.code().expect("should have error code"),
ErrorCode::E400
);
}
#[test]
fn missing_file_emits_e400() {
let arena = Bump::new();
let result = resolve_with(
&arena,
&[("main.orr", "diagram component;\nimport \"nonexistent\";")],
"main.orr",
);
let err = result.expect_err("should fail on missing file");
let diag = &err.diagnostics()[0];
assert_eq!(
diag.code().expect("should have error code"),
ErrorCode::E400
);
}
#[test]
fn self_import_cycle_emits_e401() {
let arena = Bump::new();
let result = resolve_with(
&arena,
&[("a.orr", "diagram component;\nimport \"a\";")],
"a.orr",
);
let err = result.expect_err("should detect self-cycle");
let diag = &err.diagnostics()[0];
assert_eq!(
diag.code().expect("should have error code"),
ErrorCode::E401
);
}
#[test]
fn circular_dependency_emits_e401() {
let arena = Bump::new();
let result = resolve_with(
&arena,
&[
("a.orr", "diagram component;\nimport \"b\";"),
("b.orr", "library;\nimport \"c\";"),
("c.orr", "library;\nimport \"a\";"),
],
"a.orr",
);
let err = result.expect_err("should detect 3-level cycle");
let diag = &err.diagnostics()[0];
assert_eq!(
diag.code().expect("should have error code"),
ErrorCode::E401
);
let help = diag.help().expect("should have help text");
assert!(help.contains("a.orr"), "chain should mention a.orr: {help}");
assert!(help.contains("b.orr"), "chain should mention b.orr: {help}");
assert!(help.contains("c.orr"), "chain should mention c.orr: {help}");
}
#[test]
fn cycle_error_shows_readable_paths() {
let arena = Bump::new();
let result = resolve_with(
&arena,
&[
("src/app.orr", "diagram component;\nimport \"lib\";"),
("src/lib.orr", "library;\nimport \"app\";"),
],
"src/app.orr",
);
let err = result.expect_err("should detect cycle");
let diag = &err.diagnostics()[0];
assert_eq!(
diag.code().expect("should have error code"),
ErrorCode::E401
);
let help = diag.help().expect("should have help text");
assert!(
help.contains("src/app.orr"),
"help should show readable path 'src/app.orr': {help}"
);
assert!(
help.contains("src/lib.orr"),
"help should show readable path 'src/lib.orr': {help}"
);
assert!(
help.contains(" → "),
"help should format the chain with arrow separators: {help}"
);
}
#[test]
fn namespace_derived_from_import_path() {
let arena = Bump::new();
let resolved = resolve_with(
&arena,
&[
(
"dir/main.orr",
"diagram component;\nimport \"../common/base\";\na: Rectangle;",
),
("dir/../common/base.orr", "library;"),
],
"dir/main.orr",
)
.expect("should resolve relative import");
let import = &resolved.file_ast.imports[0];
let ns = import.namespace.as_ref().expect("should have namespace");
assert!(ns == "base", "expected namespace 'base', got '{ns:?}'");
}
#[test]
fn alias_overrides_derived_namespace() {
let arena = Bump::new();
let resolved = resolve_with(
&arena,
&[
(
"main.orr",
"diagram component;\nimport \"styles\" as theme;\na: Rectangle;",
),
("styles.orr", "library;"),
],
"main.orr",
)
.expect("should resolve aliased import");
let import = &resolved.file_ast.imports[0];
let ns = import.namespace.as_ref().expect("should have namespace");
assert!(ns == "theme", "expected namespace 'theme', got '{ns:?}'");
}
}