init: archlens — architecture diagram generator
Some checks failed
CI / Check / Test (push) Failing after 1m24s

Hex arch + DDD, tree-sitter parsing, Mermaid/ASCII output.
Supports Rust + Python. 92 tests. CI, diff, --check for staleness detection.
This commit is contained in:
2026-06-16 16:13:04 +02:00
commit 35f27d00b0
106 changed files with 6744 additions and 0 deletions

View File

@@ -0,0 +1 @@
pub mod queries;

View File

@@ -0,0 +1,244 @@
use std::collections::HashSet;
use std::path::Path;
use rayon::prelude::*;
use archlens_domain::{
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship,
RelationshipKind,
ports::{FileDiscovery, SourceAnalyzer},
};
pub struct AnalyzeCodebase<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
file_discovery: F,
source_analyzer: S,
}
impl<F, S> AnalyzeCodebase<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
pub fn new(file_discovery: F, source_analyzer: S) -> Self {
Self {
file_discovery,
source_analyzer,
}
}
pub fn execute(
&self,
root: &Path,
config: &AnalysisConfig,
) -> Result<AnalyzeCodebaseResult, DomainError> {
let files = self.file_discovery.discover(root, config)?;
let file_results: Vec<(Vec<CodeElement>, Vec<Relationship>, Vec<AnalysisWarning>)> = files
.par_iter()
.map(|file| match self.source_analyzer.analyze_file(file) {
Ok(result) => {
let module = infer_module(file.path().as_str(), root, config);
let elements: Vec<CodeElement> = result
.elements()
.iter()
.map(|el| {
let mut el = el.clone();
if el.module().is_none()
&& let Some(ref m) = module
{
el = el.with_module(m.clone());
}
el
})
.collect();
(
elements,
result.relationships().to_vec(),
result.warnings().to_vec(),
)
}
Err(err) => {
let mut warnings = Vec::new();
if let Ok(warning) =
AnalysisWarning::new(file.path().clone(), 0, &err.to_string())
{
warnings.push(warning);
}
(Vec::new(), Vec::new(), warnings)
}
})
.collect();
let mut graph = CodeGraph::new();
let mut warnings = Vec::new();
for (elements, relationships, warns) in file_results {
for el in elements {
graph.add_element(el);
}
for rel in relationships {
graph.add_relationship(rel);
}
warnings.extend(warns);
}
let graph = resolve_cross_file_relationships(graph);
let graph = filter_external_imports(graph, root);
Ok(AnalyzeCodebaseResult { graph, warnings })
}
}
fn resolve_cross_file_relationships(graph: CodeGraph) -> CodeGraph {
use std::collections::HashMap;
let mut file_types: HashMap<String, HashSet<String>> = HashMap::new();
let mut name_modules: HashMap<&str, HashSet<Option<&str>>> = HashMap::new();
let all_type_names: HashSet<&str> = graph.elements().iter().map(|e| e.name()).collect();
for element in graph.elements() {
file_types
.entry(element.file_path().as_str().to_string())
.or_default()
.insert(element.name().to_string());
name_modules
.entry(element.name())
.or_default()
.insert(element.module().map(|m| m.as_str()));
}
let mut resolved = CodeGraph::new();
for element in graph.elements() {
resolved.add_element(element.clone());
}
for rel in graph.relationships() {
match rel.kind() {
RelationshipKind::Import => {
resolved.add_relationship(rel.clone());
}
_ => {
if !all_type_names.contains(rel.source()) || !all_type_names.contains(rel.target())
{
continue;
}
if let Some(src_file) = rel.source_file() {
let file_key = src_file.as_str().to_string();
if let Some(types_in_file) = file_types.get(&file_key)
&& types_in_file.contains(rel.target())
{
resolved.add_relationship(rel.clone());
continue;
}
}
let tgt_modules = &name_modules[rel.target()];
if tgt_modules.len() == 1 {
resolved.add_relationship(rel.clone());
}
}
}
}
resolved
}
fn filter_external_imports(graph: CodeGraph, root: &Path) -> CodeGraph {
let known_dirs: HashSet<String> = std::fs::read_dir(root)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.map(|s| s.to_lowercase())
.collect();
let module_names: HashSet<String> = graph
.modules()
.iter()
.map(|m| m.as_str().to_lowercase())
.collect();
let all_known: HashSet<&str> = known_dirs
.iter()
.map(|s| s.as_str())
.chain(module_names.iter().map(|s| s.as_str()))
.collect();
let mut filtered = CodeGraph::new();
for element in graph.elements() {
filtered.add_element(element.clone());
}
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase();
if !all_known.contains(target_top.as_str()) {
continue;
}
}
filtered.add_relationship(rel.clone());
}
filtered
}
fn infer_module(file_path: &str, root: &Path, config: &AnalysisConfig) -> Option<ModuleName> {
let relative = if let Some(stripped) = file_path.strip_prefix(root.to_str().unwrap_or("")) {
stripped.trim_start_matches('/')
} else {
file_path
};
for (pattern, module_name) in config.module_mappings() {
if relative.starts_with(pattern) {
return ModuleName::new(module_name).ok();
}
}
let parts: Vec<&str> = relative.split('/').collect();
if parts.len() <= 1 {
return None;
}
let module_dir = if parts[0] == "crates" && parts.len() > 2 {
// workspace: crates/<crate-name>/src/...
parts[1]
} else if parts[0] == "src" && parts.len() > 2 {
// single project: src/<module>/...
parts[1]
} else if parts[0] != "src" && parts.len() > 1 {
parts[0]
} else {
return None;
};
let capitalized = module_dir
.split('-')
.map(|seg| {
if seg.is_empty() {
String::new()
} else {
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
}
})
.collect::<Vec<_>>()
.join("-");
ModuleName::new(&capitalized).ok()
}
pub struct AnalyzeCodebaseResult {
graph: CodeGraph,
warnings: Vec<AnalysisWarning>,
}
impl AnalyzeCodebaseResult {
pub fn graph(&self) -> &CodeGraph {
&self.graph
}
pub fn warnings(&self) -> &[AnalysisWarning] {
&self.warnings
}
}

View File

@@ -0,0 +1,5 @@
mod analyze_codebase;
mod render_diagrams;
pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult};
pub use render_diagrams::RenderDiagrams;

View File

@@ -0,0 +1,45 @@
use archlens_domain::{
CodeGraph, DomainError, OutputConfig,
ports::{DiagramRenderer, OutputWriter},
};
pub struct RenderDiagrams<R, W>
where
R: DiagramRenderer,
W: OutputWriter,
{
renderer: R,
writer: W,
}
impl<R, W> RenderDiagrams<R, W>
where
R: DiagramRenderer,
W: OutputWriter,
{
pub fn new(renderer: R, writer: W) -> Self {
Self { renderer, writer }
}
pub fn execute(&self, graph: &CodeGraph, config: &OutputConfig) -> Result<(), DomainError> {
if config.split_by_module() {
let overview = self.renderer.render(graph)?;
self.writer.write(&overview)?;
for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module);
let output = self.renderer.render(&subgraph)?;
self.writer.write(&output)?;
}
} else {
let output = self.renderer.render(graph)?;
self.writer.write(&output)?;
}
Ok(())
}
pub fn writer(&self) -> &W {
&self.writer
}
}