use std::collections::{HashMap, HashSet};
use assura_parser::ast::{ImportDecl, SourceFile};
use crate::errors::ResolutionError;
#[derive(Debug, Clone, PartialEq)]
pub enum ImportStatus {
Resolved,
Unresolved,
Circular,
}
#[derive(Debug, Clone)]
pub struct ResolvedImport {
pub path: Vec<String>,
pub alias: Option<String>,
pub items: Vec<String>,
pub status: ImportStatus,
pub span: std::ops::Range<usize>,
}
pub type ModuleMap = HashMap<String, SourceFile>;
pub(crate) fn is_valid_path_segment(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub(crate) fn resolve_imports(
imports: &[ImportDecl],
module_map: &ModuleMap,
visited: &HashSet<String>,
errors: &mut Vec<ResolutionError>,
) -> Vec<ResolvedImport> {
let mut seen_paths: HashSet<String> = HashSet::new();
for imp in imports {
let path_str = imp.path.join(".");
if !seen_paths.insert(path_str.clone()) {
errors.push(ResolutionError {
code: "A02006".into(),
message: format!("duplicate import of module `{path_str}`"),
span: imp.span.clone(),
secondary: None,
suggestion: None,
});
}
}
for imp in imports {
if imp.path.is_empty() {
errors.push(ResolutionError {
code: "A02008".into(),
message: "import path is empty".to_string(),
span: imp.span.clone(),
secondary: None,
suggestion: None,
});
continue;
}
let module_segments = if imp.path.len() > 1 {
&imp.path[..imp.path.len() - 1]
} else {
&imp.path[..]
};
for segment in module_segments {
if !is_valid_path_segment(segment) {
errors.push(ResolutionError {
code: "A02008".into(),
message: format!(
"invalid module path segment `{segment}` in import `{}`; \
segments must start with a lowercase letter or underscore",
imp.path.join(".")
),
span: imp.span.clone(),
secondary: None,
suggestion: None,
});
}
}
}
for imp in imports {
let path_str = imp.path.join(".");
if visited.contains(&path_str) && !imp.path.is_empty() {
}
}
imports
.iter()
.map(|imp| {
let path_str = imp.path.join(".");
let status = if visited.contains(&path_str) {
errors.push(ResolutionError {
code: "A02005".into(),
message: format!("circular import of module `{path_str}`"),
span: imp.span.clone(),
secondary: None,
suggestion: None,
});
ImportStatus::Circular
} else if module_map.contains_key(&path_str)
|| find_module_prefix(&imp.path, module_map).is_some()
{
ImportStatus::Resolved
} else {
if !module_map.is_empty() {
errors.push(ResolutionError {
code: "A02010".into(),
message: format!(
"cannot resolve import `{path_str}`: module not found in project"
),
span: imp.span.clone(),
secondary: None,
suggestion: Some(
"check the module path, or add the .assura file under the project root"
.into(),
),
});
}
ImportStatus::Unresolved
};
ResolvedImport {
path: imp.path.clone(),
alias: imp.alias.clone(),
items: imp.items.clone(),
status,
span: imp.span.clone(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_import(path: &[&str]) -> ImportDecl {
ImportDecl {
path: path.iter().map(|s| s.to_string()).collect(),
alias: None,
items: vec![],
span: 0..1,
}
}
fn make_import_with_span(path: &[&str], span: std::ops::Range<usize>) -> ImportDecl {
ImportDecl {
path: path.iter().map(|s| s.to_string()).collect(),
alias: None,
items: vec![],
span,
}
}
#[test]
fn valid_segment_lowercase() {
assert!(is_valid_path_segment("math"));
assert!(is_valid_path_segment("std"));
assert!(is_valid_path_segment("my_module"));
}
#[test]
fn valid_segment_underscore_start() {
assert!(is_valid_path_segment("_private"));
assert!(is_valid_path_segment("_"));
}
#[test]
fn valid_segment_with_digits() {
assert!(is_valid_path_segment("v2"));
assert!(is_valid_path_segment("sha256"));
}
#[test]
fn invalid_segment_uppercase_start() {
assert!(!is_valid_path_segment("Math"));
assert!(!is_valid_path_segment("A"));
}
#[test]
fn invalid_segment_digit_start() {
assert!(!is_valid_path_segment("2fast"));
}
#[test]
fn invalid_segment_empty() {
assert!(!is_valid_path_segment(""));
}
#[test]
fn invalid_segment_special_chars() {
assert!(!is_valid_path_segment("my-module"));
assert!(!is_valid_path_segment("my.module"));
}
#[test]
fn resolve_empty_imports() {
let mut errors = vec![];
let result = resolve_imports(&[], &ModuleMap::new(), &HashSet::new(), &mut errors);
assert!(result.is_empty());
assert!(errors.is_empty());
}
#[test]
fn resolve_unresolved_import_empty_map_is_not_hard_error() {
let imports = [make_import(&["std", "math"])];
let mut errors = vec![];
let result = resolve_imports(&imports, &ModuleMap::new(), &HashSet::new(), &mut errors);
assert_eq!(result.len(), 1);
assert_eq!(result[0].status, ImportStatus::Unresolved);
assert!(errors.is_empty());
}
#[test]
fn resolve_unresolved_import_nonempty_map_is_a02006() {
let imports = [make_import(&["missing_mod"])];
let mut module_map = ModuleMap::new();
module_map.insert(
"lib".into(),
SourceFile {
project: None,
module: None,
imports: vec![],
decls: vec![],
},
);
let mut errors = vec![];
let result = resolve_imports(&imports, &module_map, &HashSet::new(), &mut errors);
assert_eq!(result[0].status, ImportStatus::Unresolved);
assert!(
errors.iter().any(|e| e.code == "A02010"),
"expected A02010, got {errors:?}"
);
}
#[test]
fn resolve_resolved_import() {
let imports = [make_import(&["std", "math"])];
let mut module_map = ModuleMap::new();
module_map.insert(
"std.math".into(),
SourceFile {
project: None,
module: None,
imports: vec![],
decls: vec![],
},
);
let mut errors = vec![];
let result = resolve_imports(&imports, &module_map, &HashSet::new(), &mut errors);
assert_eq!(result.len(), 1);
assert_eq!(result[0].status, ImportStatus::Resolved);
}
#[test]
fn resolve_circular_import() {
let imports = [make_import(&["self_module"])];
let mut visited = HashSet::new();
visited.insert("self_module".into());
let mut errors = vec![];
let result = resolve_imports(&imports, &ModuleMap::new(), &visited, &mut errors);
assert_eq!(result.len(), 1);
assert_eq!(result[0].status, ImportStatus::Circular);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].code, "A02005");
}
#[test]
fn resolve_duplicate_import() {
let imports = [
make_import_with_span(&["std", "math"], 0..10),
make_import_with_span(&["std", "math"], 20..30),
];
let mut errors = vec![];
resolve_imports(&imports, &ModuleMap::new(), &HashSet::new(), &mut errors);
assert!(errors.iter().any(|e| e.code == "A02006"));
}
#[test]
fn resolve_empty_path_error() {
let imports = [ImportDecl {
path: vec![],
alias: None,
items: vec![],
span: 0..1,
}];
let mut errors = vec![];
resolve_imports(&imports, &ModuleMap::new(), &HashSet::new(), &mut errors);
assert!(errors.iter().any(|e| e.code == "A02008"));
}
#[test]
fn resolve_invalid_segment_error() {
let imports = [make_import(&["123invalid", "sub"])];
let mut errors = vec![];
resolve_imports(&imports, &ModuleMap::new(), &HashSet::new(), &mut errors);
assert!(
errors
.iter()
.any(|e| e.code == "A02008" && e.message.contains("123invalid"))
);
}
#[test]
fn resolve_preserves_alias() {
let imports = [ImportDecl {
path: vec!["std".into(), "math".into()],
alias: Some("m".into()),
items: vec![],
span: 0..1,
}];
let mut errors = vec![];
let result = resolve_imports(&imports, &ModuleMap::new(), &HashSet::new(), &mut errors);
assert_eq!(result[0].alias, Some("m".into()));
}
#[test]
fn resolve_preserves_items() {
let imports = [ImportDecl {
path: vec!["std".into(), "math".into()],
alias: None,
items: vec!["sin".into(), "cos".into()],
span: 0..1,
}];
let mut errors = vec![];
let result = resolve_imports(&imports, &ModuleMap::new(), &HashSet::new(), &mut errors);
assert_eq!(result[0].items, vec!["sin", "cos"]);
}
#[test]
fn find_prefix_exact_match() {
let mut map = ModuleMap::new();
map.insert(
"std.math".into(),
SourceFile {
project: None,
module: None,
imports: vec![],
decls: vec![],
},
);
let path: Vec<String> = vec!["std".into(), "math".into()];
assert_eq!(find_module_prefix(&path, &map), Some("std.math".into()));
}
#[test]
fn find_prefix_partial_match() {
let mut map = ModuleMap::new();
map.insert(
"std".into(),
SourceFile {
project: None,
module: None,
imports: vec![],
decls: vec![],
},
);
let path: Vec<String> = vec!["std".into(), "List".into()];
assert_eq!(find_module_prefix(&path, &map), Some("std".into()));
}
#[test]
fn find_prefix_no_match() {
let map = ModuleMap::new();
let path: Vec<String> = vec!["unknown".into()];
assert_eq!(find_module_prefix(&path, &map), None);
}
}
pub(crate) fn find_module_prefix(path: &[String], module_map: &ModuleMap) -> Option<String> {
for end in (1..=path.len()).rev() {
let candidate = path[..end].join(".");
if module_map.contains_key(&candidate) {
return Some(candidate);
}
}
None
}