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,10 @@
[package]
name = "archlens-ascii"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,153 @@
use std::collections::HashMap;
use archlens_domain::{
CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile,
ports::DiagramRenderer,
};
pub struct AsciiRenderer;
impl Default for AsciiRenderer {
fn default() -> Self {
Self::new()
}
}
impl AsciiRenderer {
pub fn new() -> Self {
Self
}
fn format_kind(element: &CodeElement) -> &'static str {
match element.kind() {
archlens_domain::CodeElementKind::Class => "cls",
archlens_domain::CodeElementKind::Struct => "str",
archlens_domain::CodeElementKind::Trait => "trt",
archlens_domain::CodeElementKind::Interface => "ifc",
archlens_domain::CodeElementKind::Enum => "enm",
archlens_domain::CodeElementKind::Project => "prj",
}
}
}
impl DiagramRenderer for AsciiRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let mut lines = Vec::new();
let total_elements = graph.elements().len();
let total_rels = graph.relationships().len();
let total_modules = graph.modules().len();
lines.push("╔══════════════════════════════════════╗".to_string());
lines.push("║ Architecture Overview ║".to_string());
lines.push("╠══════════════════════════════════════╣".to_string());
lines.push(format!(
"║ Elements: {:<5} Modules: {:<5}",
total_elements, total_modules
));
lines.push(format!("║ Relationships: {:<19}", total_rels));
lines.push("╚══════════════════════════════════════╝".to_string());
if graph.elements().is_empty() {
lines.push(" (no elements found)".to_string());
let content = lines.join("\n");
let file = RenderedFile::new("diagram.txt", &content)?;
return Ok(RenderOutput::single(file));
}
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
if !ungrouped.is_empty() {
lines.push(String::new());
lines.push("┌─ (ungrouped)".to_string());
for el in &ungrouped {
lines.push(format!("│ [{}] {}", Self::format_kind(el), el.name()));
}
lines.push("└───".to_string());
}
let mut module_names: Vec<&String> = grouped.keys().collect();
module_names.sort();
for module in module_names {
let elements = &grouped[module];
lines.push(String::new());
lines.push(format!("┌─ {} ({} types)", module, elements.len()));
lines.push("".to_string());
for (i, el) in elements.iter().enumerate() {
let prefix = if i == elements.len() - 1 {
"└──"
} else {
"├──"
};
let generics = if el.generics().is_empty() {
String::new()
} else {
format!("<{}>", el.generics().join(", "))
};
lines.push(format!(
"{} [{}] {}{}",
prefix,
Self::format_kind(el),
el.name(),
generics
));
}
lines.push("└───".to_string());
}
let non_import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.collect();
if !non_import_rels.is_empty() {
lines.push(String::new());
lines.push("── Relationships ──".to_string());
for rel in &non_import_rels {
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "extends",
RelationshipKind::Composition => "has",
RelationshipKind::Import => "imports",
};
lines.push(format!(
" {} ─[{}]─> {}",
rel.source(),
arrow,
rel.target()
));
}
}
let import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
if !import_rels.is_empty() {
lines.push(String::new());
lines.push(format!("── Imports ({}) ──", import_rels.len()));
for rel in &import_rels {
lines.push(format!(" {} ···> {}", rel.source(), rel.target()));
}
}
let content = lines.join("\n");
let file = RenderedFile::new("diagram.txt", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,3 @@
mod ascii_renderer;
pub use ascii_renderer::AsciiRenderer;

View File

@@ -0,0 +1,48 @@
use archlens_ascii::AsciiRenderer;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, FilePath, Relationship, RelationshipKind,
ports::DiagramRenderer,
};
#[test]
fn renders_elements_and_relationships_as_text() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
let renderer = AsciiRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("OrderService"));
assert!(content.contains("Order"));
assert!(content.contains("has"));
}
#[test]
fn empty_graph_produces_header_only() {
let renderer = AsciiRenderer::new();
let output = renderer.render(&CodeGraph::new()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("Architecture"));
}

View File

@@ -0,0 +1,15 @@
[package]
name = "archlens-cargo-workspace"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
toml.workspace = true
serde.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,123 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use serde::Deserialize;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, ModuleName, Relationship,
RelationshipKind, ports::ProjectAnalyzer,
};
pub struct CargoWorkspaceAnalyzer;
impl Default for CargoWorkspaceAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl CargoWorkspaceAnalyzer {
pub fn new() -> Self {
Self
}
}
#[derive(Deserialize)]
struct WorkspaceToml {
workspace: Option<WorkspaceSection>,
}
#[derive(Deserialize)]
struct WorkspaceSection {
#[serde(default)]
members: Vec<String>,
}
#[derive(Deserialize)]
struct MemberToml {
package: Option<PackageSection>,
#[serde(default)]
dependencies: HashMap<String, toml::Value>,
}
#[derive(Deserialize)]
struct PackageSection {
name: String,
}
impl ProjectAnalyzer for CargoWorkspaceAnalyzer {
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError> {
let workspace_toml_path = root.join("Cargo.toml");
let content = std::fs::read_to_string(&workspace_toml_path)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let workspace: WorkspaceToml =
toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?;
let members = workspace.workspace.map(|w| w.members).unwrap_or_default();
let mut graph = CodeGraph::new();
let mut name_set: HashSet<String> = HashSet::new();
let mut member_names: Vec<(String, String)> = Vec::new(); // (member_path, package_name)
for member_path in &members {
let member_cargo = root.join(member_path).join("Cargo.toml");
let member_content = std::fs::read_to_string(&member_cargo)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let member: MemberToml = toml::from_str(&member_content)
.map_err(|e| DomainError::ConfigError(e.to_string()))?;
let package_name = member
.package
.map(|p| p.name)
.unwrap_or_else(|| member_path.clone());
name_set.insert(package_name.clone());
member_names.push((member_path.clone(), package_name));
}
for (member_path, package_name) in &member_names {
let file_path = FilePath::new(&format!("{}/Cargo.toml", member_path))
.map_err(|e| DomainError::IoError(e.to_string()))?;
let mut element =
CodeElement::new(package_name, CodeElementKind::Project, file_path, 1)?;
if let Some(module) = infer_group(member_path) {
element = element.with_module(module);
}
graph.add_element(element);
}
for (member_path, package_name) in &member_names {
let member_cargo = root.join(member_path).join("Cargo.toml");
let member_content = std::fs::read_to_string(&member_cargo)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let member: MemberToml = toml::from_str(&member_content)
.map_err(|e| DomainError::ConfigError(e.to_string()))?;
for dep_name in member.dependencies.keys() {
let normalized = dep_name.replace('_', "-");
if name_set.contains(&normalized)
&& let Ok(rel) =
Relationship::new(package_name, &normalized, RelationshipKind::Composition)
{
graph.add_relationship(rel);
}
}
}
Ok(graph)
}
}
fn infer_group(member_path: &str) -> Option<ModuleName> {
let parts: Vec<&str> = member_path.split('/').collect();
if parts.len() < 3 {
return None;
}
let group = parts[parts.len() - 2];
let capitalized = format!("{}{}", group[..1].to_uppercase(), &group[1..]);
ModuleName::new(&capitalized).ok()
}

View File

@@ -0,0 +1,3 @@
mod cargo_workspace_analyzer;
pub use cargo_workspace_analyzer::CargoWorkspaceAnalyzer;

View File

@@ -0,0 +1,132 @@
use std::fs;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_domain::{CodeElementKind, RelationshipKind, ports::ProjectAnalyzer};
fn create_workspace(dir: &std::path::Path) {
fs::write(
dir.join("Cargo.toml"),
r#"
[workspace]
members = ["crates/domain", "crates/application", "crates/adapters/sqlite"]
"#,
)
.unwrap();
fs::create_dir_all(dir.join("crates/domain/src")).unwrap();
fs::write(
dir.join("crates/domain/Cargo.toml"),
r#"
[package]
name = "myapp-domain"
version = "0.1.0"
edition = "2024"
[dependencies]
"#,
)
.unwrap();
fs::write(dir.join("crates/domain/src/lib.rs"), "").unwrap();
fs::create_dir_all(dir.join("crates/application/src")).unwrap();
fs::write(
dir.join("crates/application/Cargo.toml"),
r#"
[package]
name = "myapp-application"
version = "0.1.0"
edition = "2024"
[dependencies]
myapp-domain = { path = "../domain" }
"#,
)
.unwrap();
fs::write(dir.join("crates/application/src/lib.rs"), "").unwrap();
fs::create_dir_all(dir.join("crates/adapters/sqlite/src")).unwrap();
fs::write(
dir.join("crates/adapters/sqlite/Cargo.toml"),
r#"
[package]
name = "myapp-sqlite"
version = "0.1.0"
edition = "2024"
[dependencies]
myapp-domain = { path = "../../domain" }
"#,
)
.unwrap();
fs::write(dir.join("crates/adapters/sqlite/src/lib.rs"), "").unwrap();
}
#[test]
fn discovers_workspace_members_as_project_elements() {
let dir = tempfile::tempdir().unwrap();
create_workspace(dir.path());
let analyzer = CargoWorkspaceAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
assert_eq!(graph.elements().len(), 3);
assert!(
graph
.elements()
.iter()
.all(|e| e.kind() == CodeElementKind::Project)
);
let names: Vec<&str> = graph.elements().iter().map(|e| e.name()).collect();
assert!(names.contains(&"myapp-domain"));
assert!(names.contains(&"myapp-application"));
assert!(names.contains(&"myapp-sqlite"));
}
#[test]
fn extracts_dependencies_between_workspace_members() {
let dir = tempfile::tempdir().unwrap();
create_workspace(dir.path());
let analyzer = CargoWorkspaceAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
assert_eq!(graph.relationships().len(), 2);
assert!(
graph
.relationships()
.iter()
.all(|r| r.kind() == RelationshipKind::Composition)
);
let deps: Vec<(&str, &str)> = graph
.relationships()
.iter()
.map(|r| (r.source(), r.target()))
.collect();
assert!(deps.contains(&("myapp-application", "myapp-domain")));
assert!(deps.contains(&("myapp-sqlite", "myapp-domain")));
}
#[test]
fn assigns_module_from_directory_grouping() {
let dir = tempfile::tempdir().unwrap();
create_workspace(dir.path());
let analyzer = CargoWorkspaceAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
let sqlite = graph
.elements()
.iter()
.find(|e| e.name() == "myapp-sqlite")
.unwrap();
assert_eq!(sqlite.module().unwrap().as_str(), "Adapters");
let domain = graph
.elements()
.iter()
.find(|e| e.name() == "myapp-domain")
.unwrap();
assert!(domain.module().is_none() || domain.module().unwrap().as_str() != "Adapters");
}

View File

@@ -0,0 +1,13 @@
[package]
name = "archlens-file-writer"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,52 @@
use std::fs;
use std::path::PathBuf;
use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter};
pub struct FileOutputWriter {
output_path: OutputPath,
}
enum OutputPath {
Directory(PathBuf),
File(PathBuf),
}
impl FileOutputWriter {
pub fn new(output_dir: PathBuf) -> Self {
Self {
output_path: OutputPath::Directory(output_dir),
}
}
pub fn single_file(path: PathBuf) -> Self {
Self {
output_path: OutputPath::File(path),
}
}
}
impl OutputWriter for FileOutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError> {
match &self.output_path {
OutputPath::File(path) => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| DomainError::IoError(e.to_string()))?;
}
let content = output.files().first().map(|f| f.content()).unwrap_or("");
fs::write(path, content).map_err(|e| DomainError::IoError(e.to_string()))?;
}
OutputPath::Directory(dir) => {
fs::create_dir_all(dir).map_err(|e| DomainError::IoError(e.to_string()))?;
for file in output.files() {
let path = dir.join(file.name());
fs::write(&path, file.content())
.map_err(|e| DomainError::IoError(e.to_string()))?;
}
}
}
Ok(())
}
}

View File

@@ -0,0 +1,3 @@
mod file_output_writer;
pub use file_output_writer::FileOutputWriter;

View File

@@ -0,0 +1,35 @@
use std::fs;
use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter};
use archlens_file_writer::FileOutputWriter;
#[test]
fn writes_single_file_to_directory() {
let dir = tempfile::tempdir().unwrap();
let writer = FileOutputWriter::new(dir.path().to_path_buf());
let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap();
let output = RenderOutput::single(file);
writer.write(&output).unwrap();
let content = fs::read_to_string(dir.path().join("arch.mmd")).unwrap();
assert_eq!(content, "classDiagram");
}
#[test]
fn writes_multiple_files_to_directory() {
let dir = tempfile::tempdir().unwrap();
let writer = FileOutputWriter::new(dir.path().to_path_buf());
let files = vec![
RenderedFile::new("overview.mmd", "graph TD").unwrap(),
RenderedFile::new("orders.mmd", "classDiagram").unwrap(),
];
let output = RenderOutput::new(files);
writer.write(&output).unwrap();
assert!(dir.path().join("overview.mmd").exists());
assert!(dir.path().join("orders.mmd").exists());
}

View File

@@ -0,0 +1,10 @@
[package]
name = "archlens-mermaid"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,3 @@
mod mermaid_renderer;
pub use mermaid_renderer::MermaidRenderer;

View File

@@ -0,0 +1,266 @@
use std::collections::{HashMap, HashSet};
use archlens_domain::{
CodeElement, CodeGraph, DiagramLevel, DomainError, RelationshipKind, RenderOutput,
RenderedFile, Visibility, ports::DiagramRenderer,
};
pub struct MermaidRenderer {
level: DiagramLevel,
}
impl Default for MermaidRenderer {
fn default() -> Self {
Self::new()
}
}
impl MermaidRenderer {
pub fn new() -> Self {
Self {
level: DiagramLevel::Type,
}
}
pub fn with_level(level: DiagramLevel) -> Self {
Self { level }
}
fn format_element_name(element: &CodeElement) -> String {
let name = element.name();
if element.generics().is_empty() {
name.to_string()
} else {
format!("{}~{}~", name, element.generics().join(", "))
}
}
fn format_visibility(visibility: Visibility) -> &'static str {
match visibility {
Visibility::Public => "public",
Visibility::Private => "private",
Visibility::Internal => "internal",
}
}
fn render_class_diagram(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["classDiagram".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
let has_namespaces = !grouped.is_empty();
let mut seen: HashSet<String> = HashSet::new();
for element in &ungrouped {
if seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
if has_namespaces {
for (namespace, elements) in &grouped {
lines.push(format!(" namespace {namespace} {{"));
let mut ns_seen: HashSet<String> = HashSet::new();
for element in elements {
if ns_seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
lines.push(" }".to_string());
}
}
let mut rel_seen: HashSet<String> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>",
};
let key = format!("{} {} {}", rel.source(), arrow, rel.target());
if rel_seen.insert(key.clone()) {
lines.push(format!(" {key}"));
}
}
lines.join("\n")
}
fn push_class_lines(lines: &mut Vec<String>, element: &CodeElement, indent: &str) {
lines.push(format!(
"{indent}class {}",
Self::format_element_name(element)
));
if element.visibility() != Visibility::Public {
lines.push(format!(
"{indent}<<{}>> {}",
Self::format_visibility(element.visibility()),
element.name()
));
}
let name = element.name();
for field in element.fields() {
lines.push(format!("{indent}{name} : {field}"));
}
for method in element.methods() {
lines.push(format!("{indent}{name} : {method}"));
}
}
fn render_module_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut name_to_modules: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut file_to_module: HashMap<String, String> = HashMap::new();
let mut modules: HashSet<String> = HashSet::new();
for element in graph.elements() {
if let Some(module) = element.module() {
name_to_modules
.entry(element.name())
.or_default()
.insert(module.as_str());
modules.insert(module.as_str().to_string());
let file_stem = std::path::Path::new(element.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if !file_stem.is_empty() {
file_to_module.insert(file_stem.to_string(), module.as_str().to_string());
}
}
}
for module in &modules {
lines.push(format!(" {module}[{module}]"));
}
let mut module_edges: HashSet<(String, String)> = HashSet::new();
for rel in graph.relationships() {
match rel.kind() {
RelationshipKind::Import => {
let source_mod = file_to_module.get(rel.source());
let target_top = rel.target().split('.').next().unwrap_or("");
let target_mod = Self::capitalize(target_top);
if let Some(src) = source_mod
&& modules.contains(&target_mod)
&& *src != target_mod
{
module_edges.insert((src.clone(), target_mod));
}
}
_ => {
if modules.contains(rel.source())
&& modules.contains(rel.target())
&& rel.source() != rel.target()
{
module_edges.insert((rel.source().to_string(), rel.target().to_string()));
continue;
}
let src_mods = name_to_modules.get(rel.source());
let tgt_mods = name_to_modules.get(rel.target());
if let (Some(src_set), Some(tgt_set)) = (src_mods, tgt_mods) {
for src_mod in src_set {
if tgt_set.contains(src_mod) {
continue;
}
for tgt_mod in tgt_set {
if src_mod != tgt_mod {
module_edges.insert((src_mod.to_string(), tgt_mod.to_string()));
}
}
}
}
}
}
}
for (source, target) in &module_edges {
lines.push(format!(" {source} --> {target}"));
}
lines.join("\n")
}
fn capitalize(s: &str) -> String {
if s.is_empty() {
return String::new();
}
format!("{}{}", s[..1].to_uppercase(), &s[1..])
}
fn render_project_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
for element in &ungrouped {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
for (group, elements) in &grouped {
lines.push(format!(" subgraph {group}"));
for element in elements {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
lines.push(" end".to_string());
}
for rel in graph.relationships() {
let source_id = Self::sanitize_id(rel.source());
let target_id = Self::sanitize_id(rel.target());
lines.push(format!(" {source_id} --> {target_id}"));
}
lines.join("\n")
}
fn sanitize_id(name: &str) -> String {
name.replace(['-', '.'], "_")
}
}
impl DiagramRenderer for MermaidRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let content = match self.level {
DiagramLevel::Type => self.render_class_diagram(graph),
DiagramLevel::Module => self.render_module_flowchart(graph),
DiagramLevel::Project => self.render_project_flowchart(graph),
};
let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,328 @@
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship,
RelationshipKind, Visibility, ports::DiagramRenderer,
};
use archlens_mermaid::MermaidRenderer;
fn build_type_level_graph() -> CodeGraph {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
graph
}
#[test]
fn renders_class_diagram_with_elements_and_composition() {
let renderer = MermaidRenderer::new();
let output = renderer.render(&build_type_level_graph()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("classDiagram"));
assert!(content.contains("class OrderService"));
assert!(content.contains("class Order"));
assert!(content.contains("OrderService --> Order"));
}
#[test]
fn inheritance_uses_different_arrow() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"Animal",
CodeElementKind::Class,
FilePath::new("src/animal.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Dog",
CodeElementKind::Class,
FilePath::new("src/dog.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("Dog", "Animal", RelationshipKind::Inheritance).unwrap(),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("Dog <|-- Animal"));
}
#[test]
fn elements_show_kind_and_generics() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"Repository",
CodeElementKind::Trait,
FilePath::new("src/repo.rs").unwrap(),
1,
)
.unwrap()
.with_generics(vec!["T".to_string()]),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("class Repository~T~"));
}
#[test]
fn private_elements_show_visibility_annotation() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"InternalHelper",
CodeElementKind::Class,
FilePath::new("src/helper.rs").unwrap(),
1,
)
.unwrap()
.with_visibility(Visibility::Private),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("class InternalHelper"));
assert!(content.contains("<<private>>"));
}
#[test]
fn renders_module_level_flowchart() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"BillingService",
CodeElementKind::Class,
FilePath::new("src/billing/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
graph.add_relationship(
Relationship::new(
"OrderService",
"BillingService",
RelationshipKind::Composition,
)
.unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("graph TD"));
assert!(content.contains("Orders"));
assert!(content.contains("Billing"));
assert!(content.contains("Orders --> Billing"));
}
#[test]
fn empty_graph_produces_valid_diagram() {
let renderer = MermaidRenderer::new();
let output = renderer.render(&CodeGraph::new()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("classDiagram"));
}
#[test]
fn project_level_renders_subgraphs_for_grouped_projects() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"myapp-domain",
CodeElementKind::Project,
FilePath::new("crates/domain/Cargo.toml").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"myapp-sqlite",
CodeElementKind::Project,
FilePath::new("crates/adapters/sqlite/Cargo.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Adapters").unwrap()),
);
graph.add_element(
CodeElement::new(
"myapp-nats",
CodeElementKind::Project,
FilePath::new("crates/adapters/nats/Cargo.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Adapters").unwrap()),
);
graph.add_relationship(
Relationship::new(
"myapp-sqlite",
"myapp-domain",
RelationshipKind::Composition,
)
.unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Project);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("graph TD"));
assert!(content.contains("subgraph Adapters"));
assert!(content.contains("myapp-sqlite"));
assert!(content.contains("myapp-nats"));
assert!(content.contains("myapp-domain"));
assert!(content.contains("-->"));
}
#[test]
fn type_level_groups_elements_by_module() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/orders/order.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"Invoice",
CodeElementKind::Struct,
FilePath::new("src/billing/invoice.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("namespace Orders"));
assert!(content.contains("namespace Billing"));
assert!(content.contains("OrderService"));
assert!(content.contains("Invoice"));
}
#[test]
fn module_level_aggregates_cross_module_deps_into_single_arrow() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"OrderRepo",
CodeElementKind::Trait,
FilePath::new("src/orders/repo.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"DbPool",
CodeElementKind::Struct,
FilePath::new("src/infra/db.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Infra").unwrap()),
);
graph.add_element(
CodeElement::new(
"SqliteRepo",
CodeElementKind::Struct,
FilePath::new("src/infra/sqlite.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Infra").unwrap()),
);
// Two types in Orders depend on two types in Infra
graph.add_relationship(
Relationship::new("OrderService", "DbPool", RelationshipKind::Composition).unwrap(),
);
graph.add_relationship(
Relationship::new("OrderRepo", "SqliteRepo", RelationshipKind::Composition).unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
let arrow_count = content.matches("Orders --> Infra").count();
assert_eq!(
arrow_count, 1,
"should have exactly one aggregated arrow, got:\n{content}"
);
}

View File

@@ -0,0 +1,10 @@
[package]
name = "archlens-stdout-writer"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,3 @@
mod stdout_output_writer;
pub use stdout_output_writer::StdoutOutputWriter;

View File

@@ -0,0 +1,24 @@
use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter};
pub struct StdoutOutputWriter;
impl Default for StdoutOutputWriter {
fn default() -> Self {
Self::new()
}
}
impl StdoutOutputWriter {
pub fn new() -> Self {
Self
}
}
impl OutputWriter for StdoutOutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError> {
for file in output.files() {
println!("{}", file.content());
}
Ok(())
}
}

View File

@@ -0,0 +1,12 @@
use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter};
use archlens_stdout_writer::StdoutOutputWriter;
#[test]
fn writes_without_error() {
let writer = StdoutOutputWriter::new();
let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap();
let output = RenderOutput::single(file);
let result = writer.write(&output);
assert!(result.is_ok());
}

View File

@@ -0,0 +1,15 @@
[package]
name = "archlens-toml-config"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
toml.workspace = true
serde.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,3 @@
mod toml_config_loader;
pub use toml_config_loader::TomlConfigLoader;

View File

@@ -0,0 +1,81 @@
use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use archlens_domain::{
AnalysisConfig, DiagramLevel, DomainError, OutputConfig, ports::ConfigLoader,
};
#[derive(Debug, Deserialize, Default)]
struct RawConfig {
#[serde(default)]
analysis: RawAnalysis,
#[serde(default)]
output: RawOutput,
#[serde(default)]
modules: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawAnalysis {
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
level: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawOutput {
#[serde(default)]
#[allow(dead_code)]
format: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
split_by_module: bool,
}
#[derive(Default)]
pub struct TomlConfigLoader {
raw: RawConfig,
}
impl TomlConfigLoader {
pub fn from_path(path: &Path) -> Result<Self, DomainError> {
let content =
std::fs::read_to_string(path).map_err(|e| DomainError::IoError(e.to_string()))?;
let raw: RawConfig =
toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?;
Ok(Self { raw })
}
fn parse_level(level: &Option<String>) -> DiagramLevel {
match level.as_deref() {
Some("type") => DiagramLevel::Type,
Some("project") => DiagramLevel::Project,
_ => DiagramLevel::Module,
}
}
}
impl ConfigLoader for TomlConfigLoader {
fn load_analysis_config(&self) -> Result<AnalysisConfig, DomainError> {
let config = AnalysisConfig::default()
.with_excludes(self.raw.analysis.exclude.clone())
.with_level(Self::parse_level(&self.raw.analysis.level))
.with_module_mappings(self.raw.modules.clone());
Ok(config)
}
fn load_output_config(&self) -> Result<OutputConfig, DomainError> {
let mut config =
OutputConfig::default().with_split_by_module(self.raw.output.split_by_module);
if let Some(path) = &self.raw.output.path {
config = config.with_output_path(path.clone());
}
Ok(config)
}
}

View File

@@ -0,0 +1,68 @@
use std::fs;
use archlens_domain::{DiagramLevel, ports::ConfigLoader};
use archlens_toml_config::TomlConfigLoader;
#[test]
fn loads_analysis_config_from_toml_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("archlens.toml");
fs::write(
&config_path,
r#"
[analysis]
exclude = ["tests/", "vendor/"]
level = "type"
[modules]
"src/orders" = "Orders"
"src/billing" = "Billing"
"#,
)
.unwrap();
let loader = TomlConfigLoader::from_path(&config_path).unwrap();
let config = loader.load_analysis_config().unwrap();
assert_eq!(config.excludes(), &["tests/", "vendor/"]);
assert_eq!(config.level(), DiagramLevel::Type);
assert_eq!(
config.module_mappings().get("src/orders").unwrap(),
"Orders"
);
}
#[test]
fn loads_output_config_from_toml_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("archlens.toml");
fs::write(
&config_path,
r#"
[output]
format = "mermaid"
path = "docs/arch.mmd"
split_by_module = true
"#,
)
.unwrap();
let loader = TomlConfigLoader::from_path(&config_path).unwrap();
let config = loader.load_output_config().unwrap();
assert!(config.split_by_module());
assert_eq!(config.output_path(), Some("docs/arch.mmd"));
}
#[test]
fn missing_file_returns_defaults() {
let loader = TomlConfigLoader::default();
let analysis = loader.load_analysis_config().unwrap();
assert!(analysis.excludes().is_empty());
assert_eq!(analysis.level(), DiagramLevel::Module);
let output = loader.load_output_config().unwrap();
assert!(!output.split_by_module());
assert!(output.output_path().is_none());
}

View File

@@ -0,0 +1,17 @@
[package]
name = "archlens-tree-sitter"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
tree-sitter.workspace = true
tree-sitter-rust.workspace = true
tree-sitter-python.workspace = true
tree-sitter-c-sharp.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,5 @@
mod python;
mod rust;
mod tree_sitter_analyzer;
pub use tree_sitter_analyzer::TreeSitterAnalyzer;

View File

@@ -0,0 +1,337 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind,
};
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let root = tree.root_node();
collect_classes(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut relationships,
&mut warnings,
);
collect_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
Ok(AnalysisResult::new(elements, relationships, warnings))
}
fn collect_classes(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "class_definition" {
continue;
}
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1;
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
Ok(element) => {
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
continue;
}
}
if let Some(superclasses) = child.child_by_field_name("superclasses") {
collect_inheritance(&superclasses, source, name, type_names, relationships);
}
if let Some(body) = child.child_by_field_name("body") {
collect_typed_fields(&body, source, name, type_names, relationships);
collect_constructor_params(&body, source, name, type_names, relationships);
}
}
}
fn collect_inheritance(
superclasses: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = superclasses.walk();
for child in superclasses.children(&mut cursor) {
if child.kind() == "identifier" {
let base_name = &source[child.byte_range()];
if !is_python_builtin(base_name)
&& let Ok(rel) =
Relationship::new(class_name, base_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
}
}
}
}
const PYTHON_BUILTINS: &[&str] = &[
"str",
"int",
"float",
"bool",
"bytes",
"list",
"dict",
"set",
"tuple",
"None",
"type",
"object",
"Exception",
"BaseException",
"Optional",
"Any",
"Union",
"List",
"Dict",
"Set",
"Tuple",
"Callable",
"Sequence",
"Mapping",
"Iterable",
"Iterator",
"Generator",
"Coroutine",
"AsyncGenerator",
"ClassVar",
"Final",
"Literal",
"TypeVar",
"Generic",
"Protocol",
"runtime_checkable",
"Self",
];
fn is_python_builtin(name: &str) -> bool {
PYTHON_BUILTINS.contains(&name)
}
const STDLIB_MODULES: &[&str] = &[
"os",
"sys",
"typing",
"logging",
"json",
"re",
"io",
"abc",
"collections",
"datetime",
"enum",
"functools",
"hashlib",
"http",
"importlib",
"inspect",
"itertools",
"math",
"pathlib",
"pickle",
"random",
"shutil",
"signal",
"socket",
"string",
"subprocess",
"tempfile",
"threading",
"time",
"traceback",
"unittest",
"urllib",
"uuid",
"warnings",
"contextlib",
"dataclasses",
"copy",
"struct",
"base64",
"csv",
"glob",
"operator",
"textwrap",
"asyncio",
"concurrent",
"multiprocessing",
];
fn is_external_import(module: &str) -> bool {
let top = module.split('.').next().unwrap_or(module);
if STDLIB_MODULES.contains(&top) {
return true;
}
if top.starts_with('_') {
return true;
}
false
}
fn collect_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"import_statement" => {
let mut name_cursor = child.walk();
for name_child in child.children(&mut name_cursor) {
if name_child.kind() == "dotted_name" {
let module = &source[name_child.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
}
"import_from_statement" => {
if let Some(module_node) = child.child_by_field_name("module_name") {
let module = &source[module_node.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
_ => {}
}
}
}
fn collect_constructor_params(
body: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() != "function_definition" {
continue;
}
let Some(fn_name) = child.child_by_field_name("name") else {
continue;
};
if &source[fn_name.byte_range()] != "__init__" {
continue;
}
let Some(params) = child.child_by_field_name("parameters") else {
continue;
};
let mut param_cursor = params.walk();
for param in params.children(&mut param_cursor) {
if param.kind() == "typed_parameter"
&& let Some(type_node) = param.child_by_field_name("type")
{
let type_text = &source[type_node.byte_range()];
let base_type = type_text.split('[').next().unwrap_or(type_text).trim();
if base_type != class_name
&& !base_type.is_empty()
&& !is_python_builtin(base_type)
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
}
}
}
fn collect_typed_fields(
body: &Node,
source: &str,
class_name: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
collect_typed_fields_recursive(body, source, class_name, type_names, relationships);
}
fn collect_typed_fields_recursive(
node: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if (child.kind() == "assignment" || child.kind() == "typed_assignment")
&& let Some(type_node) = child.child_by_field_name("type")
{
let type_text = &source[type_node.byte_range()];
let base_type = type_text.split('[').next().unwrap_or(type_text).trim();
if base_type != class_name
&& !base_type.is_empty()
&& !is_python_builtin(base_type)
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships);
}
}

View File

@@ -0,0 +1,349 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
const RUST_PRIMITIVES: &[&str] = &[
"bool",
"char",
"str",
"String",
"u8",
"u16",
"u32",
"u64",
"u128",
"usize",
"i8",
"i16",
"i32",
"i64",
"i128",
"isize",
"f32",
"f64",
"Vec",
"Option",
"Result",
"Box",
"Rc",
"Arc",
"HashMap",
"HashSet",
"BTreeMap",
"BTreeSet",
"PhantomData",
"Pin",
"Cow",
"Self",
];
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind, Visibility,
};
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let root = tree.root_node();
collect_types(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut warnings,
);
collect_relationships(
&root,
source,
&type_names,
&mut relationships,
&mut warnings,
);
collect_mod_declarations(&root, source, file_path, &mut relationships);
collect_use_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
Ok(AnalysisResult::new(elements, relationships, warnings))
}
fn collect_types(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let (kind, name_field) = match child.kind() {
"struct_item" => (CodeElementKind::Struct, "name"),
"enum_item" => (CodeElementKind::Enum, "name"),
"trait_item" => (CodeElementKind::Trait, "name"),
_ => continue,
};
if let Some(name_node) = child.child_by_field_name(name_field) {
let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1;
let visibility = detect_visibility(&child, source);
match CodeElement::new(name, kind, file_path.clone(), line) {
Ok(element) => {
let fields = extract_fields(&child, source);
let methods = extract_methods(node, source, name);
let element = element
.with_visibility(visibility)
.with_fields(fields)
.with_methods(methods);
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
}
}
}
}
}
fn collect_relationships(
node: &Node,
source: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"struct_item" => {
if let Some(name_node) = child.child_by_field_name("name") {
let struct_name = source[name_node.byte_range()].to_string();
collect_field_compositions(
&child,
source,
&struct_name,
type_names,
relationships,
);
}
}
"impl_item" => {
collect_trait_impl(&child, source, type_names, relationships, warnings);
}
_ => {}
}
}
}
fn collect_field_compositions(
struct_node: &Node,
source: &str,
struct_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
if let Some(body) = struct_node.child_by_field_name("body") {
let mut cursor = body.walk();
for field in body.children(&mut cursor) {
if field.kind() == "field_declaration"
&& let Some(type_node) = field.child_by_field_name("type")
{
let type_text = extract_base_type(&type_node, source);
if type_text != struct_name
&& !type_text.is_empty()
&& !RUST_PRIMITIVES.contains(&type_text.as_str())
&& let Ok(rel) =
Relationship::new(struct_name, &type_text, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
}
}
}
fn collect_trait_impl(
impl_node: &Node,
source: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
_warnings: &mut Vec<AnalysisWarning>,
) {
let trait_node = impl_node.child_by_field_name("trait");
let type_node = impl_node.child_by_field_name("type");
if let (Some(trait_n), Some(type_n)) = (trait_node, type_node) {
let trait_name = extract_base_type(&trait_n, source);
let type_name = extract_base_type(&type_n, source);
if !trait_name.is_empty()
&& !type_name.is_empty()
&& !RUST_PRIMITIVES.contains(&trait_name.as_str())
&& !RUST_PRIMITIVES.contains(&type_name.as_str())
&& let Ok(rel) =
Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
}
}
}
fn extract_fields(node: &Node, source: &str) -> Vec<String> {
let mut fields = Vec::new();
if let Some(body) = node.child_by_field_name("body") {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() == "field_declaration" {
let name = child
.child_by_field_name("name")
.map(|n| &source[n.byte_range()]);
let ty = child
.child_by_field_name("type")
.map(|n| extract_base_type(&n, source));
if let (Some(name), Some(ty)) = (name, ty) {
fields.push(format!("{name}: {ty}"));
}
}
}
}
fields
}
fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
let mut methods = Vec::new();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() != "impl_item" {
continue;
}
if child.child_by_field_name("trait").is_some() {
continue;
}
let type_node = child.child_by_field_name("type");
if let Some(tn) = type_node {
let impl_name = extract_base_type(&tn, source);
if impl_name != type_name {
continue;
}
}
if let Some(body) = child.child_by_field_name("body") {
let mut body_cursor = body.walk();
for item in body.children(&mut body_cursor) {
if item.kind() == "function_item"
&& let Some(name_node) = item.child_by_field_name("name")
{
let fn_name = &source[name_node.byte_range()];
let vis = if detect_visibility(&item, source) == Visibility::Public {
"+"
} else {
"-"
};
methods.push(format!("{vis}{fn_name}()"));
}
}
}
}
methods
}
fn collect_mod_declarations(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "mod_item" {
continue;
}
if child.child_by_field_name("body").is_some() {
continue;
}
if let Some(name_node) = child.child_by_field_name("name") {
let mod_name = &source[name_node.byte_range()];
let target = format!("crate::{mod_name}");
if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) {
relationships.push(rel);
}
}
}
}
fn collect_use_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "use_declaration" {
continue;
}
if let Some(arg) = child.child_by_field_name("argument") {
let text = &source[arg.byte_range()];
let path = text
.split('{')
.next()
.unwrap_or(text)
.trim_end_matches("::");
if (path.starts_with("crate::") || path.starts_with("super::"))
&& let Ok(rel) = Relationship::new(file_name, path, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
}
fn extract_base_type(node: &Node, source: &str) -> String {
let text = &source[node.byte_range()];
text.split('<').next().unwrap_or(text).trim().to_string()
}
fn detect_visibility(node: &Node, source: &str) -> Visibility {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "visibility_modifier" {
let text = &source[child.byte_range()];
if text.contains("pub") {
return Visibility::Public;
}
}
}
Visibility::Private
}

View File

@@ -0,0 +1,30 @@
use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer};
use crate::{python, rust};
pub struct TreeSitterAnalyzer;
impl Default for TreeSitterAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl TreeSitterAnalyzer {
pub fn new() -> Self {
Self
}
}
impl SourceAnalyzer for TreeSitterAnalyzer {
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
let source = std::fs::read_to_string(file.path().as_str())
.map_err(|e| DomainError::IoError(e.to_string()))?;
match file.language() {
Language::Rust => rust::analyze(&source, file.path()),
Language::Python => python::analyze(&source, file.path()),
Language::CSharp => Ok(AnalysisResult::empty()),
}
}
}

View File

@@ -0,0 +1,138 @@
use archlens_domain::{
CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer,
};
use archlens_tree_sitter::TreeSitterAnalyzer;
fn analyze_python(source: &str, filename: &str) -> archlens_domain::AnalysisResult {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join(filename);
std::fs::write(&file_path, source).unwrap();
let analyzer = TreeSitterAnalyzer::new();
let source_file = SourceFile::new(
FilePath::new(file_path.to_str().unwrap()).unwrap(),
Language::Python,
);
analyzer.analyze_file(&source_file).unwrap()
}
#[test]
fn extracts_python_class() {
let result = analyze_python("class Order:\n pass\n", "order.py");
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Order");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Class);
}
#[test]
fn extracts_python_inheritance() {
let source = "class Animal:\n pass\n\nclass Dog(Animal):\n pass\n";
let result = analyze_python(source, "animals.py");
assert_eq!(result.elements().len(), 2);
let inheritance: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Inheritance)
.collect();
assert_eq!(inheritance.len(), 1);
assert_eq!(inheritance[0].source(), "Dog");
assert_eq!(inheritance[0].target(), "Animal");
}
#[test]
fn extracts_composition_from_type_annotated_fields() {
let source = "class Address:\n pass\n\nclass User:\n def __init__(self):\n self.address: Address = Address()\n";
let result = analyze_python(source, "user.py");
let composition: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Composition)
.collect();
assert_eq!(composition.len(), 1);
assert_eq!(composition[0].source(), "User");
assert_eq!(composition[0].target(), "Address");
}
#[test]
fn extracts_import_from_import_statement() {
let source = "import os\nfrom commons.src.schema import BaseModel\n";
let result = analyze_python(source, "service.py");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "commons.src.schema"));
assert!(
!imports.iter().any(|r| r.target() == "os"),
"stdlib should be filtered"
);
}
#[test]
fn extracts_relative_imports_from_init() {
let source = "from .schema import BaseModel\nfrom .client import ApiClient\n";
let result = analyze_python(source, "__init__.py");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert_eq!(imports.len(), 2);
}
#[test]
fn extracts_import_from_plain_import() {
let source = "import commons.utils\n";
let result = analyze_python(source, "service.py");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "commons.utils"));
}
#[test]
fn extracts_composition_from_constructor_params() {
let source = "class Config:\n pass\n\nclass Service:\n def __init__(self, config: Config):\n pass\n";
let result = analyze_python(source, "service.py");
let composition: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Composition)
.collect();
assert_eq!(composition.len(), 1);
assert_eq!(composition[0].source(), "Service");
assert_eq!(composition[0].target(), "Config");
}
#[test]
fn extracts_composition_from_class_level_annotations() {
let source = "class Gad:\n pass\n\nclass Definition:\n gad: Gad\n name: str\n";
let result = analyze_python(source, "models.py");
let composition: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Composition)
.collect();
assert_eq!(composition.len(), 1);
assert_eq!(composition[0].source(), "Definition");
assert_eq!(composition[0].target(), "Gad");
}

View File

@@ -0,0 +1,130 @@
use archlens_domain::{
CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer,
};
use archlens_tree_sitter::TreeSitterAnalyzer;
fn analyze_rust(source: &str, filename: &str) -> archlens_domain::AnalysisResult {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join(filename);
std::fs::write(&file_path, source).unwrap();
let analyzer = TreeSitterAnalyzer::new();
let source_file = SourceFile::new(
FilePath::new(file_path.to_str().unwrap()).unwrap(),
Language::Rust,
);
analyzer.analyze_file(&source_file).unwrap()
}
#[test]
fn extracts_rust_struct() {
let result = analyze_rust("pub struct Order {\n id: u64,\n}", "order.rs");
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Order");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Struct);
}
#[test]
fn extracts_rust_enum() {
let result = analyze_rust(
"pub enum Status {\n Active,\n Inactive,\n}",
"status.rs",
);
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Status");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Enum);
}
#[test]
fn extracts_rust_trait() {
let result = analyze_rust("pub trait Repository {\n fn find(&self);\n}", "repo.rs");
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Repository");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Trait);
}
#[test]
fn extracts_composition_from_struct_fields() {
let source =
"pub struct Order {\n id: u64,\n}\npub struct OrderService {\n order: Order,\n}";
let result = analyze_rust(source, "service.rs");
assert_eq!(result.elements().len(), 2);
assert_eq!(result.relationships().len(), 1);
assert_eq!(result.relationships()[0].source(), "OrderService");
assert_eq!(result.relationships()[0].target(), "Order");
assert_eq!(
result.relationships()[0].kind(),
RelationshipKind::Composition
);
}
#[test]
fn extracts_inheritance_from_trait_impl() {
let source = "pub trait Printable {}\npub struct Order {}\nimpl Printable for Order {}";
let result = analyze_rust(source, "order.rs");
let inheritance: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Inheritance)
.collect();
assert_eq!(inheritance.len(), 1);
assert_eq!(inheritance[0].source(), "Order");
assert_eq!(inheritance[0].target(), "Printable");
}
#[test]
fn extracts_use_imports() {
let source =
"use crate::domain::Order;\nuse crate::ports::Repository;\n\npub struct Service {}";
let result = analyze_rust(source, "service.rs");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "crate::domain::Order"));
assert!(
imports
.iter()
.any(|r| r.target() == "crate::ports::Repository")
);
}
#[test]
fn filters_std_and_external_crate_imports() {
let source =
"use std::collections::HashMap;\nuse serde::Serialize;\nuse crate::models::Order;\n";
let result = analyze_rust(source, "lib.rs");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].target(), "crate::models::Order");
}
#[test]
fn extracts_mod_declarations() {
let source = "mod models;\nmod services;\npub struct App {}";
let result = analyze_rust(source, "lib.rs");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "crate::models"));
assert!(imports.iter().any(|r| r.target() == "crate::services"));
}

View File

@@ -0,0 +1,15 @@
[package]
name = "archlens-walkdir"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
walkdir.workspace = true
ignore.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,3 @@
mod walkdir_discovery;
pub use walkdir_discovery::WalkdirDiscovery;

View File

@@ -0,0 +1,100 @@
use std::path::Path;
use ignore::WalkBuilder;
use archlens_domain::{
AnalysisConfig, DomainError, FilePath, Language, SourceFile, ports::FileDiscovery,
};
const DEFAULT_EXCLUDES: &[&str] = &[
".venv",
"venv",
"node_modules",
"__pycache__",
".git",
"target",
"bin",
"obj",
"dist",
".tox",
".eggs",
];
pub struct WalkdirDiscovery;
impl Default for WalkdirDiscovery {
fn default() -> Self {
Self::new()
}
}
impl WalkdirDiscovery {
pub fn new() -> Self {
Self
}
fn detect_language(path: &Path) -> Option<Language> {
match path.extension()?.to_str()? {
"rs" => Some(Language::Rust),
"py" => Some(Language::Python),
"cs" => Some(Language::CSharp),
_ => None,
}
}
fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool {
let relative = path.strip_prefix(root).unwrap_or(path);
let relative_str = relative.to_string_lossy();
for component in relative.components() {
let name = component.as_os_str().to_string_lossy();
if DEFAULT_EXCLUDES.iter().any(|e| name == *e) {
return true;
}
}
excludes
.iter()
.any(|exclude| relative_str.contains(exclude.trim_end_matches('/')))
}
}
impl FileDiscovery for WalkdirDiscovery {
fn discover(
&self,
root: &Path,
config: &AnalysisConfig,
) -> Result<Vec<SourceFile>, DomainError> {
let mut files = Vec::new();
let walker = WalkBuilder::new(root).hidden(true).git_ignore(true).build();
for entry in walker.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
if Self::is_excluded(path, root, config.excludes()) {
continue;
}
if let Some(scope) = config.scope() {
let relative = path.strip_prefix(root).unwrap_or(path);
if !relative.starts_with(scope) {
continue;
}
}
if let Some(language) = Self::detect_language(path) {
let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let file_path = FilePath::new(&absolute.to_string_lossy())
.map_err(|e| DomainError::IoError(e.to_string()))?;
files.push(SourceFile::new(file_path, language));
}
}
Ok(files)
}
}

View File

@@ -0,0 +1,71 @@
use std::fs;
use archlens_domain::{AnalysisConfig, Language, ports::FileDiscovery};
use archlens_walkdir::WalkdirDiscovery;
fn create_test_tree(dir: &std::path::Path) {
fs::create_dir_all(dir.join("src/orders")).unwrap();
fs::create_dir_all(dir.join("src/billing")).unwrap();
fs::write(dir.join("src/orders/service.rs"), "struct OrderService;").unwrap();
fs::write(dir.join("src/orders/model.py"), "class Order: pass").unwrap();
fs::write(dir.join("src/billing/invoice.cs"), "class Invoice {}").unwrap();
fs::write(dir.join("src/readme.txt"), "not source code").unwrap();
}
#[test]
fn discovers_rust_python_and_csharp_files() {
let dir = tempfile::tempdir().unwrap();
create_test_tree(dir.path());
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert_eq!(files.len(), 3);
let languages: Vec<Language> = files.iter().map(|f| f.language()).collect();
assert!(languages.contains(&Language::Rust));
assert!(languages.contains(&Language::Python));
assert!(languages.contains(&Language::CSharp));
}
#[test]
fn ignores_non_source_files() {
let dir = tempfile::tempdir().unwrap();
create_test_tree(dir.path());
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path().as_str()).collect();
assert!(!paths.iter().any(|p| p.ends_with(".txt")));
}
#[test]
fn respects_exclude_patterns() {
let dir = tempfile::tempdir().unwrap();
create_test_tree(dir.path());
let config = AnalysisConfig::default().with_excludes(vec!["billing".to_string()]);
let discovery = WalkdirDiscovery::new();
let files = discovery.discover(dir.path(), &config).unwrap();
assert_eq!(files.len(), 2);
assert!(!files.iter().any(|f| f.path().as_str().contains("billing")));
}
#[test]
fn empty_directory_returns_no_files() {
let dir = tempfile::tempdir().unwrap();
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert!(files.is_empty());
}