refactor: five architectural deepening improvements
Candidate 1 (NormalizedGraph): qualify→resolve→filter is now a single
named operation returning a distinct type; raw CodeGraph cannot call
module_edges/subgraph_by_module — pipeline order enforced at compile time.
Candidate 2 (Use cases): GenerateDiagram, CheckFreshness, DiffDiagram
extracted to application/src/use_cases/; presentation is now a thin CLI
dispatcher (~100 lines less, three fewer local functions).
Candidate 3 (ExtractionContext): shared accumulator for both Rust and
Python extractors replaces parallel Vec<> + 4-arg passing chains.
Candidate 4 (ModuleAssignment): ModuleName::assign() returns
ModuleAssignment { Explicit | Inferred | Unresolved } instead of Option,
callers can distinguish resolution strategies.
Candidate 5 (SplitRenderer): append_cross_module_deps removed from
DiagramRenderer port; replaced by render_for_module() default impl —
port interface now reflects what all renderers actually share.
This commit is contained in:
@@ -226,26 +226,22 @@ impl DiagramRenderer for MermaidRenderer {
|
||||
Ok(RenderOutput::single(file))
|
||||
}
|
||||
|
||||
fn append_cross_module_deps(
|
||||
fn render_for_module(
|
||||
&self,
|
||||
content: &str,
|
||||
subgraph: &CodeGraph,
|
||||
module: &ModuleName,
|
||||
deps: &[(ModuleName, usize)],
|
||||
) -> String {
|
||||
if deps.is_empty() {
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
let src_id = format!(
|
||||
"{}_module",
|
||||
module.as_str().to_lowercase().replace('-', "_")
|
||||
);
|
||||
cross_deps: &[(ModuleName, usize)],
|
||||
) -> Result<RenderOutput, DomainError> {
|
||||
let base = self.render_class_diagram(subgraph);
|
||||
let content = if cross_deps.is_empty() {
|
||||
base
|
||||
} else {
|
||||
let src_id = format!("{}_module", module.as_str().to_lowercase().replace('-', "_"));
|
||||
let mut extra = format!(
|
||||
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
|
||||
module.as_str()
|
||||
);
|
||||
|
||||
for (dep_mod, count) in deps {
|
||||
for (dep_mod, count) in cross_deps {
|
||||
let dep_id = format!(
|
||||
"{}_module",
|
||||
dep_mod.as_str().to_lowercase().replace('-', "_")
|
||||
@@ -254,14 +250,12 @@ impl DiagramRenderer for MermaidRenderer {
|
||||
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
|
||||
dep_mod.as_str()
|
||||
));
|
||||
let label = if *count == 1 {
|
||||
"1 dep".to_string()
|
||||
} else {
|
||||
format!("{count} deps")
|
||||
};
|
||||
let label = if *count == 1 { "1 dep".to_string() } else { format!("{count} deps") };
|
||||
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
|
||||
}
|
||||
|
||||
format!("{content}\n{extra}")
|
||||
format!("{base}\n{extra}")
|
||||
};
|
||||
let file = RenderedFile::new("diagram.mmd", &content)?;
|
||||
Ok(RenderOutput::single(file))
|
||||
}
|
||||
}
|
||||
|
||||
56
crates/adapters/tree-sitter/src/extraction_context.rs
Normal file
56
crates/adapters/tree-sitter/src/extraction_context.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use archlens_domain::{
|
||||
AnalysisResult, AnalysisWarning, CodeElement, DomainError, FilePath, Relationship,
|
||||
};
|
||||
|
||||
/// Accumulator passed through tree-walking helpers.
|
||||
///
|
||||
/// Bundles the four parallel mutable collections that every language extractor
|
||||
/// maintains, giving helpers a single receiver to push into and eliminating
|
||||
/// 4-parameter passing chains.
|
||||
pub struct ExtractionContext {
|
||||
elements: Vec<CodeElement>,
|
||||
relationships: Vec<Relationship>,
|
||||
warnings: Vec<AnalysisWarning>,
|
||||
/// Names of types defined in the current file — used to filter primitive
|
||||
/// and external types from relationship targets.
|
||||
pub local_types: HashSet<String>,
|
||||
file_path: FilePath,
|
||||
}
|
||||
|
||||
impl ExtractionContext {
|
||||
pub fn new(file_path: FilePath) -> Self {
|
||||
Self {
|
||||
elements: Vec::new(),
|
||||
relationships: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
local_types: HashSet::new(),
|
||||
file_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_element(&mut self, element: CodeElement) {
|
||||
self.local_types.insert(element.name().to_string());
|
||||
self.elements.push(element);
|
||||
}
|
||||
|
||||
pub fn add_relationship(&mut self, rel: Relationship) {
|
||||
self.relationships.push(rel.with_source_file(self.file_path.clone()));
|
||||
}
|
||||
|
||||
pub fn add_warning(&mut self, file_path: FilePath, line: usize, message: &str) {
|
||||
if let Ok(w) = AnalysisWarning::new(file_path, line, message) {
|
||||
self.warnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_path(&self) -> &FilePath {
|
||||
&self.file_path
|
||||
}
|
||||
|
||||
/// Consumes the context and returns the completed `AnalysisResult`.
|
||||
pub fn into_result(self) -> Result<AnalysisResult, DomainError> {
|
||||
Ok(AnalysisResult::new(self.elements, self.relationships, self.warnings))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod extraction_context;
|
||||
mod language_extractor;
|
||||
mod python;
|
||||
mod rust;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tree_sitter::{Node, Parser};
|
||||
|
||||
use archlens_domain::{
|
||||
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
|
||||
Relationship, RelationshipKind,
|
||||
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
|
||||
RelationshipKind,
|
||||
};
|
||||
|
||||
use crate::extraction_context::ExtractionContext;
|
||||
use crate::language_extractor::LanguageExtractor;
|
||||
|
||||
pub struct PythonExtractor;
|
||||
@@ -27,40 +26,16 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, Dom
|
||||
.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 mut ctx = ExtractionContext::new(file_path.clone());
|
||||
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();
|
||||
collect_classes(&root, source, &mut ctx);
|
||||
collect_imports(&root, source, &mut ctx);
|
||||
|
||||
Ok(AnalysisResult::new(elements, relationships, warnings))
|
||||
ctx.into_result()
|
||||
}
|
||||
|
||||
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>,
|
||||
) {
|
||||
fn collect_classes(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if child.kind() != "class_definition" {
|
||||
@@ -79,26 +54,21 @@ fn collect_classes(
|
||||
.map(|body| collect_methods(&body, source))
|
||||
.unwrap_or_default();
|
||||
|
||||
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
|
||||
Ok(element) => {
|
||||
type_names.insert(name.to_string());
|
||||
elements.push(element.with_methods(methods));
|
||||
}
|
||||
match CodeElement::new(name, CodeElementKind::Class, ctx.file_path().clone(), line) {
|
||||
Ok(element) => ctx.add_element(element.with_methods(methods)),
|
||||
Err(e) => {
|
||||
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
|
||||
warnings.push(w);
|
||||
}
|
||||
ctx.add_warning(ctx.file_path().clone(), line, &e.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(superclasses) = child.child_by_field_name("superclasses") {
|
||||
collect_inheritance(&superclasses, source, name, type_names, relationships);
|
||||
collect_inheritance(&superclasses, source, name, ctx);
|
||||
}
|
||||
|
||||
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);
|
||||
collect_typed_fields(&body, source, name, ctx);
|
||||
collect_constructor_params(&body, source, name, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,8 +77,7 @@ fn collect_inheritance(
|
||||
superclasses: &Node,
|
||||
source: &str,
|
||||
class_name: &str,
|
||||
_type_names: &HashSet<String>,
|
||||
relationships: &mut Vec<Relationship>,
|
||||
ctx: &mut ExtractionContext,
|
||||
) {
|
||||
let mut cursor = superclasses.walk();
|
||||
for child in superclasses.children(&mut cursor) {
|
||||
@@ -118,7 +87,7 @@ fn collect_inheritance(
|
||||
&& let Ok(rel) =
|
||||
Relationship::new(class_name, base_name, RelationshipKind::Inheritance)
|
||||
{
|
||||
relationships.push(rel);
|
||||
ctx.add_relationship(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,16 +197,12 @@ fn is_external_import(module: &str) -> bool {
|
||||
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())
|
||||
fn collect_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||
let file_name = std::path::Path::new(ctx.file_path().as_str())
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
@@ -249,9 +214,9 @@ fn collect_imports(
|
||||
let module = &source[name_child.byte_range()];
|
||||
if !is_external_import(module)
|
||||
&& let Ok(rel) =
|
||||
Relationship::new(file_name, module, RelationshipKind::Import)
|
||||
Relationship::new(&file_name, module, RelationshipKind::Import)
|
||||
{
|
||||
relationships.push(rel);
|
||||
ctx.add_relationship(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,9 +226,9 @@ fn collect_imports(
|
||||
let module = &source[module_node.byte_range()];
|
||||
if !is_external_import(module)
|
||||
&& let Ok(rel) =
|
||||
Relationship::new(file_name, module, RelationshipKind::Import)
|
||||
Relationship::new(&file_name, module, RelationshipKind::Import)
|
||||
{
|
||||
relationships.push(rel);
|
||||
ctx.add_relationship(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +279,6 @@ fn extract_python_params(params_node: &Node, source: &str) -> String {
|
||||
match param.kind() {
|
||||
"typed_parameter" => {
|
||||
if let Some(type_node) = param.child_by_field_name("type") {
|
||||
// name is the first identifier child (not a named field)
|
||||
let mut inner = param.walk();
|
||||
let name = param
|
||||
.children(&mut inner)
|
||||
@@ -339,13 +303,7 @@ fn extract_python_params(params_node: &Node, source: &str) -> String {
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
fn collect_constructor_params(
|
||||
body: &Node,
|
||||
source: &str,
|
||||
class_name: &str,
|
||||
_type_names: &HashSet<String>,
|
||||
relationships: &mut Vec<Relationship>,
|
||||
) {
|
||||
fn collect_constructor_params(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
|
||||
let mut cursor = body.walk();
|
||||
for child in body.children(&mut cursor) {
|
||||
if child.kind() != "function_definition" {
|
||||
@@ -373,30 +331,18 @@ fn collect_constructor_params(
|
||||
&& let Ok(rel) =
|
||||
Relationship::new(class_name, base_type, RelationshipKind::Composition)
|
||||
{
|
||||
relationships.push(rel);
|
||||
ctx.add_relationship(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(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
|
||||
collect_typed_fields_recursive(body, source, class_name, ctx);
|
||||
}
|
||||
|
||||
fn collect_typed_fields_recursive(
|
||||
node: &Node,
|
||||
source: &str,
|
||||
class_name: &str,
|
||||
_type_names: &HashSet<String>,
|
||||
relationships: &mut Vec<Relationship>,
|
||||
) {
|
||||
fn collect_typed_fields_recursive(node: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if (child.kind() == "assignment" || child.kind() == "typed_assignment")
|
||||
@@ -410,10 +356,10 @@ fn collect_typed_fields_recursive(
|
||||
&& let Ok(rel) =
|
||||
Relationship::new(class_name, base_type, RelationshipKind::Composition)
|
||||
{
|
||||
relationships.push(rel);
|
||||
ctx.add_relationship(rel);
|
||||
}
|
||||
}
|
||||
|
||||
collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships);
|
||||
collect_typed_fields_recursive(&child, source, class_name, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
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",
|
||||
"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,
|
||||
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
|
||||
RelationshipKind, Visibility,
|
||||
};
|
||||
|
||||
use crate::extraction_context::ExtractionContext;
|
||||
use crate::language_extractor::LanguageExtractor;
|
||||
|
||||
pub struct RustExtractor;
|
||||
@@ -62,46 +32,18 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, Dom
|
||||
.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 mut ctx = ExtractionContext::new(file_path.clone());
|
||||
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();
|
||||
collect_types(&root, source, &mut ctx);
|
||||
collect_relationships(&root, source, &mut ctx);
|
||||
collect_mod_declarations(&root, source, &mut ctx);
|
||||
collect_use_imports(&root, source, &mut ctx);
|
||||
|
||||
Ok(AnalysisResult::new(elements, relationships, warnings))
|
||||
ctx.into_result()
|
||||
}
|
||||
|
||||
fn collect_types(
|
||||
node: &Node,
|
||||
source: &str,
|
||||
file_path: &FilePath,
|
||||
elements: &mut Vec<CodeElement>,
|
||||
type_names: &mut HashSet<String>,
|
||||
warnings: &mut Vec<AnalysisWarning>,
|
||||
) {
|
||||
fn collect_types(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
let (kind, name_field) = match child.kind() {
|
||||
@@ -116,52 +58,34 @@ fn collect_types(
|
||||
let line = child.start_position().row + 1;
|
||||
let visibility = detect_visibility(&child, source);
|
||||
|
||||
match CodeElement::new(name, kind, file_path.clone(), line) {
|
||||
match CodeElement::new(name, kind, ctx.file_path().clone(), line) {
|
||||
Ok(element) => {
|
||||
let fields = extract_fields(&child, source);
|
||||
let methods = extract_methods(node, source, name);
|
||||
let element = element
|
||||
ctx.add_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);
|
||||
}
|
||||
.with_methods(methods),
|
||||
);
|
||||
}
|
||||
Err(e) => ctx.add_warning(ctx.file_path().clone(), line, &e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_relationships(
|
||||
node: &Node,
|
||||
source: &str,
|
||||
type_names: &HashSet<String>,
|
||||
relationships: &mut Vec<Relationship>,
|
||||
warnings: &mut Vec<AnalysisWarning>,
|
||||
) {
|
||||
fn collect_relationships(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||
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,
|
||||
);
|
||||
collect_field_compositions(&child, source, &struct_name, ctx);
|
||||
}
|
||||
}
|
||||
"impl_item" => {
|
||||
collect_trait_impl(&child, source, type_names, relationships, warnings);
|
||||
}
|
||||
"impl_item" => collect_trait_impl(&child, source, ctx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -171,8 +95,7 @@ fn collect_field_compositions(
|
||||
struct_node: &Node,
|
||||
source: &str,
|
||||
struct_name: &str,
|
||||
_type_names: &HashSet<String>,
|
||||
relationships: &mut Vec<Relationship>,
|
||||
ctx: &mut ExtractionContext,
|
||||
) {
|
||||
if let Some(body) = struct_node.child_by_field_name("body") {
|
||||
let mut cursor = body.walk();
|
||||
@@ -187,20 +110,14 @@ fn collect_field_compositions(
|
||||
&& let Ok(rel) =
|
||||
Relationship::new(struct_name, &type_text, RelationshipKind::Composition)
|
||||
{
|
||||
relationships.push(rel);
|
||||
ctx.add_relationship(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_trait_impl(
|
||||
impl_node: &Node,
|
||||
source: &str,
|
||||
_type_names: &HashSet<String>,
|
||||
relationships: &mut Vec<Relationship>,
|
||||
_warnings: &mut Vec<AnalysisWarning>,
|
||||
) {
|
||||
fn collect_trait_impl(impl_node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||
let trait_node = impl_node.child_by_field_name("trait");
|
||||
let type_node = impl_node.child_by_field_name("type");
|
||||
|
||||
@@ -215,7 +132,7 @@ fn collect_trait_impl(
|
||||
&& let Ok(rel) =
|
||||
Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance)
|
||||
{
|
||||
relationships.push(rel);
|
||||
ctx.add_relationship(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,12 +143,8 @@ fn extract_fields(node: &Node, source: &str) -> Vec<String> {
|
||||
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));
|
||||
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}"));
|
||||
}
|
||||
@@ -245,19 +158,15 @@ 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" {
|
||||
if child.kind() != "impl_item" || child.child_by_field_name("trait").is_some() {
|
||||
continue;
|
||||
}
|
||||
if child.child_by_field_name("trait").is_some() {
|
||||
if child
|
||||
.child_by_field_name("type")
|
||||
.is_some_and(|tn| extract_base_type(&tn, source) != type_name)
|
||||
{
|
||||
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) {
|
||||
@@ -265,11 +174,7 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
|
||||
&& 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 {
|
||||
"-"
|
||||
};
|
||||
let vis = if detect_visibility(&item, source) == Visibility::Public { "+" } else { "-" };
|
||||
let params = extract_fn_params(&item, source);
|
||||
let ret = extract_fn_return(&item, source);
|
||||
let sig = if ret.is_empty() {
|
||||
@@ -317,45 +222,34 @@ fn extract_fn_return(fn_item: &Node, source: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
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())
|
||||
fn collect_mod_declarations(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||
let file_name = std::path::Path::new(ctx.file_path().as_str())
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
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() {
|
||||
if child.kind() != "mod_item" || 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);
|
||||
if let Ok(rel) = Relationship::new(&file_name, &target, RelationshipKind::Import) {
|
||||
ctx.add_relationship(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())
|
||||
fn collect_use_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||
let file_name = std::path::Path::new(ctx.file_path().as_str())
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
@@ -364,16 +258,12 @@ fn collect_use_imports(
|
||||
}
|
||||
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("::");
|
||||
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)
|
||||
&& let Ok(rel) = Relationship::new(&file_name, path, RelationshipKind::Import)
|
||||
{
|
||||
relationships.push(rel);
|
||||
ctx.add_relationship(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod queries;
|
||||
pub mod use_cases;
|
||||
|
||||
@@ -4,7 +4,8 @@ use std::path::Path;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use archlens_domain::{
|
||||
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship,
|
||||
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName,
|
||||
NormalizedGraph, Relationship,
|
||||
ports::{FileDiscovery, SourceAnalyzer},
|
||||
};
|
||||
|
||||
@@ -40,26 +41,22 @@ where
|
||||
.par_iter()
|
||||
.map(|file| match self.source_analyzer.analyze_file(file) {
|
||||
Ok(result) => {
|
||||
let module =
|
||||
ModuleName::from_path(file.path().as_str(), root, config.module_mappings());
|
||||
let assignment =
|
||||
ModuleName::assign(file.path().as_str(), root, config.module_mappings());
|
||||
let elements: Vec<CodeElement> = result
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|el| {
|
||||
let mut el = el.clone();
|
||||
if el.module().is_none()
|
||||
&& let Some(ref m) = module
|
||||
&& let Some(m) = assignment.module_name()
|
||||
{
|
||||
el = el.with_module(m.clone());
|
||||
}
|
||||
el
|
||||
})
|
||||
.collect();
|
||||
(
|
||||
elements,
|
||||
result.relationships().to_vec(),
|
||||
result.warnings().to_vec(),
|
||||
)
|
||||
(elements, result.relationships().to_vec(), result.warnings().to_vec())
|
||||
}
|
||||
Err(err) => {
|
||||
let mut warnings = Vec::new();
|
||||
@@ -73,14 +70,14 @@ where
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut graph = CodeGraph::new();
|
||||
let mut raw = CodeGraph::new();
|
||||
let mut warnings = Vec::new();
|
||||
for (elements, relationships, warns) in file_results {
|
||||
for el in elements {
|
||||
graph.add_element(el);
|
||||
raw.add_element(el);
|
||||
}
|
||||
for rel in relationships {
|
||||
graph.add_relationship(rel);
|
||||
raw.add_relationship(rel);
|
||||
}
|
||||
warnings.extend(warns);
|
||||
}
|
||||
@@ -94,22 +91,19 @@ where
|
||||
.map(|s| s.to_lowercase())
|
||||
.collect();
|
||||
|
||||
let graph = graph
|
||||
.qualify()
|
||||
.resolve_relationships()
|
||||
.filter_external_imports(&known_dirs);
|
||||
let graph = NormalizedGraph::from_analyzed(raw, &known_dirs)?;
|
||||
|
||||
Ok(AnalyzeCodebaseResult { graph, warnings })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AnalyzeCodebaseResult {
|
||||
graph: CodeGraph,
|
||||
graph: NormalizedGraph,
|
||||
warnings: Vec<AnalysisWarning>,
|
||||
}
|
||||
|
||||
impl AnalyzeCodebaseResult {
|
||||
pub fn graph(&self) -> &CodeGraph {
|
||||
pub fn graph(&self) -> &NormalizedGraph {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
|
||||
18
crates/application/src/use_cases/check_freshness.rs
Normal file
18
crates/application/src/use_cases/check_freshness.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
|
||||
|
||||
/// Compares the current rendered output against an on-disk file.
|
||||
/// Returns `Ok(true)` if up to date, `Ok(false)` if stale.
|
||||
pub struct CheckFreshness<'a> {
|
||||
pub graph: &'a NormalizedGraph,
|
||||
pub renderer: &'a dyn DiagramRenderer,
|
||||
pub existing_path: &'a std::path::Path,
|
||||
}
|
||||
|
||||
impl<'a> CheckFreshness<'a> {
|
||||
pub fn execute(&self) -> Result<bool, DomainError> {
|
||||
let rendered = self.renderer.render(self.graph.as_graph())?;
|
||||
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
||||
let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default();
|
||||
Ok(current == existing)
|
||||
}
|
||||
}
|
||||
46
crates/application/src/use_cases/diff_diagram.rs
Normal file
46
crates/application/src/use_cases/diff_diagram.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
|
||||
|
||||
/// The semantic result of comparing two diagram snapshots.
|
||||
pub struct DiffResult {
|
||||
pub added: Vec<String>,
|
||||
pub removed: Vec<String>,
|
||||
}
|
||||
|
||||
impl DiffResult {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.added.is_empty() && self.removed.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares the rendered current graph against an existing diagram file and
|
||||
/// returns which lines were added or removed.
|
||||
pub struct DiffDiagram<'a> {
|
||||
pub graph: &'a NormalizedGraph,
|
||||
pub renderer: &'a dyn DiagramRenderer,
|
||||
pub existing_path: &'a std::path::Path,
|
||||
}
|
||||
|
||||
impl<'a> DiffDiagram<'a> {
|
||||
pub fn execute(&self) -> Result<DiffResult, DomainError> {
|
||||
let rendered = self.renderer.render(self.graph.as_graph())?;
|
||||
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
||||
let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default();
|
||||
|
||||
let current_lines: std::collections::HashSet<&str> = current.lines().collect();
|
||||
let existing_lines: std::collections::HashSet<&str> = existing.lines().collect();
|
||||
|
||||
let added: Vec<String> = current_lines
|
||||
.difference(&existing_lines)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| format!("+ {l}"))
|
||||
.collect();
|
||||
|
||||
let removed: Vec<String> = existing_lines
|
||||
.difference(¤t_lines)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| format!("- {l}"))
|
||||
.collect();
|
||||
|
||||
Ok(DiffResult { added, removed })
|
||||
}
|
||||
}
|
||||
125
crates/application/src/use_cases/generate_diagram.rs
Normal file
125
crates/application/src/use_cases/generate_diagram.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use archlens_domain::{
|
||||
BoundaryRule, DomainError, NormalizedGraph, RenderedFile, RenderOutput,
|
||||
check_boundary_rules,
|
||||
ports::DiagramRenderer,
|
||||
};
|
||||
|
||||
/// Result of running the generate use case — exposed violations and any output
|
||||
/// that should be written to disk.
|
||||
pub struct GenerateDiagramResult {
|
||||
pub violations: Vec<String>,
|
||||
pub output: RenderOutput,
|
||||
}
|
||||
|
||||
/// Orchestrates diagram generation: renders the graph (split or single),
|
||||
/// checks boundary rules, and returns the output for the caller to write.
|
||||
pub struct GenerateDiagram {
|
||||
pub graph: NormalizedGraph,
|
||||
pub renderer: Box<dyn DiagramRenderer>,
|
||||
pub allow_rules: Vec<BoundaryRule>,
|
||||
pub deny_rules: Vec<BoundaryRule>,
|
||||
pub split_by_module: bool,
|
||||
pub format_ext: String,
|
||||
pub output_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl GenerateDiagram {
|
||||
pub fn execute(self) -> Result<(), DomainError> {
|
||||
// Boundary rule checking
|
||||
let violations = if !self.allow_rules.is_empty() || !self.deny_rules.is_empty() {
|
||||
check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Render and write
|
||||
if self.split_by_module {
|
||||
write_split(
|
||||
&self.graph,
|
||||
&*self.renderer,
|
||||
&self.output_dir,
|
||||
&self.format_ext,
|
||||
)?;
|
||||
} else {
|
||||
let rendered = self.renderer.render(self.graph.as_graph())?;
|
||||
write_to_output(rendered, &self.output_dir)?;
|
||||
}
|
||||
|
||||
// Report violations (after writing so the diagram is still produced)
|
||||
for v in &violations {
|
||||
eprintln!("RULE VIOLATION: {}", v.message());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_violations_only(&self) -> Vec<String> {
|
||||
if self.allow_rules.is_empty() && self.deny_rules.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
|
||||
.into_iter()
|
||||
.map(|v| v.message())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_split(
|
||||
graph: &NormalizedGraph,
|
||||
renderer: &dyn DiagramRenderer,
|
||||
output_dir: &Option<PathBuf>,
|
||||
ext: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let dir = output_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
let overview = renderer.render(graph.as_graph())?;
|
||||
let overview_file = RenderedFile::new(
|
||||
&format!("overview.{ext}"),
|
||||
overview.files().first().map(|f| f.content()).unwrap_or(""),
|
||||
)?;
|
||||
write_file_to_dir(&dir, overview_file)?;
|
||||
|
||||
for module in graph.modules() {
|
||||
let subgraph = graph.subgraph_by_module(&module);
|
||||
let cross_deps = graph.cross_module_deps_for(&module);
|
||||
let module_output = renderer.render_for_module(&subgraph, &module, &cross_deps)?;
|
||||
let module_file = RenderedFile::new(
|
||||
&format!("{}.{ext}", module.as_str().to_lowercase()),
|
||||
module_output.files().first().map(|f| f.content()).unwrap_or(""),
|
||||
)?;
|
||||
write_file_to_dir(&dir, module_file)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file_to_dir(dir: &PathBuf, file: RenderedFile) -> Result<(), DomainError> {
|
||||
let path = dir.join(file.name());
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
std::fs::write(&path, file.content())
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_to_output(rendered: RenderOutput, output: &Option<PathBuf>) -> Result<(), DomainError> {
|
||||
let content = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
||||
match output {
|
||||
Some(path) => {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
}
|
||||
std::fs::write(path, content)
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))
|
||||
}
|
||||
None => {
|
||||
print!("{content}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
3
crates/application/src/use_cases/mod.rs
Normal file
3
crates/application/src/use_cases/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod check_freshness;
|
||||
pub mod diff_diagram;
|
||||
pub mod generate_diagram;
|
||||
@@ -1,3 +1,5 @@
|
||||
mod code_graph;
|
||||
mod normalized_graph;
|
||||
|
||||
pub use code_graph::CodeGraph;
|
||||
pub use normalized_graph::NormalizedGraph;
|
||||
|
||||
77
crates/domain/src/aggregates/normalized_graph.rs
Normal file
77
crates/domain/src/aggregates/normalized_graph.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::{CodeElement, CodeGraph, DomainError, ModuleName, Relationship};
|
||||
|
||||
/// A `CodeGraph` that has been fully normalized: qualified, resolved, and
|
||||
/// filtered. Only this type exposes module-level and split-by-module queries —
|
||||
/// callers cannot call those on a raw `CodeGraph`, making incorrect pipeline
|
||||
/// order a compile-time error rather than a silent bug.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NormalizedGraph(CodeGraph);
|
||||
|
||||
impl NormalizedGraph {
|
||||
/// Normalize a raw `CodeGraph` — qualifies type names, resolves
|
||||
/// relationships, and filters external imports in one named operation.
|
||||
pub fn from_analyzed(
|
||||
graph: CodeGraph,
|
||||
known_dirs: &HashSet<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let normalized = graph
|
||||
.qualify()
|
||||
.resolve_relationships()
|
||||
.filter_external_imports(known_dirs);
|
||||
Ok(Self(normalized))
|
||||
}
|
||||
|
||||
/// Wrap a project-level graph (from `CargoWorkspaceAnalyzer` or
|
||||
/// `PythonProjectAnalyzer`) that is already ready to render — no
|
||||
/// analysis pipeline needed.
|
||||
pub fn from_project(graph: CodeGraph) -> Self {
|
||||
Self(graph)
|
||||
}
|
||||
|
||||
// ── Element access ───────────────────────────────────────────────────────
|
||||
|
||||
pub fn elements(&self) -> &[CodeElement] {
|
||||
self.0.elements()
|
||||
}
|
||||
|
||||
pub fn relationships(&self) -> &[Relationship] {
|
||||
self.0.relationships()
|
||||
}
|
||||
|
||||
pub fn modules(&self) -> Vec<ModuleName> {
|
||||
self.0.modules()
|
||||
}
|
||||
|
||||
pub fn elements_by_module(
|
||||
&self,
|
||||
) -> (HashMap<String, Vec<&CodeElement>>, Vec<&CodeElement>) {
|
||||
self.0.elements_by_module()
|
||||
}
|
||||
|
||||
// ── Module-level queries (only available on NormalizedGraph) ─────────────
|
||||
|
||||
pub fn module_edges(&self) -> HashMap<(String, String), usize> {
|
||||
self.0.module_edges()
|
||||
}
|
||||
|
||||
pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph {
|
||||
self.0.subgraph_by_module(module)
|
||||
}
|
||||
|
||||
pub fn cross_module_deps_for(&self, module: &ModuleName) -> Vec<(ModuleName, usize)> {
|
||||
self.0.cross_module_deps_for(module)
|
||||
}
|
||||
|
||||
// ── Mutation (merge project edges after normalization) ───────────────────
|
||||
|
||||
pub fn merge_project_edges(&mut self, project_graph: &CodeGraph) {
|
||||
self.0.merge_project_edges(project_graph);
|
||||
}
|
||||
|
||||
/// Expose the inner graph for renderers that accept `&CodeGraph`.
|
||||
pub fn as_graph(&self) -> &CodeGraph {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ pub mod entities;
|
||||
pub mod ports;
|
||||
pub mod value_objects;
|
||||
|
||||
pub use aggregates::CodeGraph;
|
||||
pub use aggregates::{CodeGraph, NormalizedGraph};
|
||||
pub use entities::{CodeElement, Relationship};
|
||||
pub use error::DomainError;
|
||||
pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning};
|
||||
@@ -13,5 +13,6 @@ pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility};
|
||||
pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile};
|
||||
pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules};
|
||||
pub use value_objects::source::{
|
||||
FilePath, Language, ModuleName, SourceFile, normalize_cargo_package, normalize_python_package,
|
||||
FilePath, Language, ModuleAssignment, ModuleName, SourceFile,
|
||||
normalize_cargo_package, normalize_python_package,
|
||||
};
|
||||
|
||||
@@ -3,13 +3,19 @@ use crate::{CodeGraph, DomainError, ModuleName, RenderOutput};
|
||||
pub trait DiagramRenderer {
|
||||
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
|
||||
|
||||
fn append_cross_module_deps(
|
||||
/// Render a single module's subgraph for split-by-module output.
|
||||
///
|
||||
/// `cross_deps` is the list of (external module, relationship count) pairs
|
||||
/// for dependencies this module has on other modules. The default
|
||||
/// implementation ignores cross_deps and falls back to `render(subgraph)`.
|
||||
/// Adapters that support per-module annotations (e.g. Mermaid) override
|
||||
/// this to include cross-module dependency information in the output.
|
||||
fn render_for_module(
|
||||
&self,
|
||||
content: &str,
|
||||
module: &ModuleName,
|
||||
deps: &[(ModuleName, usize)],
|
||||
) -> String {
|
||||
let _ = (module, deps);
|
||||
content.to_string()
|
||||
subgraph: &CodeGraph,
|
||||
_module: &ModuleName,
|
||||
_cross_deps: &[(ModuleName, usize)],
|
||||
) -> Result<RenderOutput, DomainError> {
|
||||
self.render(subgraph)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ mod source_file;
|
||||
|
||||
pub use file_path::FilePath;
|
||||
pub use language::Language;
|
||||
pub use module_name::ModuleName;
|
||||
pub use module_name::{ModuleAssignment, ModuleName};
|
||||
pub use source_file::SourceFile;
|
||||
|
||||
pub fn normalize_cargo_package(name: &str) -> String {
|
||||
|
||||
@@ -6,6 +6,40 @@ use crate::DomainError;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ModuleName(String);
|
||||
|
||||
/// How a module name was assigned to a source file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModuleAssignment {
|
||||
/// Matched an explicit user-configured mapping in `archlens.toml`.
|
||||
Explicit(ModuleName),
|
||||
/// Derived from file path structure by heuristic (e.g. `crates/<name>/...`).
|
||||
Inferred(ModuleName),
|
||||
/// No mapping matched and the heuristic could not determine a module.
|
||||
Unresolved(&'static str),
|
||||
}
|
||||
|
||||
impl ModuleAssignment {
|
||||
pub fn module_name(&self) -> Option<&ModuleName> {
|
||||
match self {
|
||||
Self::Explicit(m) | Self::Inferred(m) => Some(m),
|
||||
Self::Unresolved(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_module_name(self) -> Option<ModuleName> {
|
||||
match self {
|
||||
Self::Explicit(m) | Self::Inferred(m) => Some(m),
|
||||
Self::Unresolved(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reason(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Unresolved(r) => Some(r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModuleName {
|
||||
pub fn new(value: &str) -> Result<Self, DomainError> {
|
||||
let trimmed = value.trim();
|
||||
@@ -15,11 +49,13 @@ impl ModuleName {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
|
||||
pub fn from_path(
|
||||
/// Assign a module to a file path, returning a typed assignment that
|
||||
/// distinguishes explicit mappings, heuristic inference, and failure.
|
||||
pub fn assign(
|
||||
file_path: &str,
|
||||
root: &Path,
|
||||
module_mappings: &HashMap<String, String>,
|
||||
) -> Option<Self> {
|
||||
) -> ModuleAssignment {
|
||||
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
||||
let root_str = canonical_root.to_str().unwrap_or("");
|
||||
let relative = file_path
|
||||
@@ -27,15 +63,19 @@ impl ModuleName {
|
||||
.unwrap_or(file_path)
|
||||
.trim_start_matches('/');
|
||||
|
||||
// 1. Explicit mapping
|
||||
for (pattern, module_name) in module_mappings {
|
||||
if relative.starts_with(pattern.as_str()) {
|
||||
return Self::new(module_name).ok();
|
||||
if relative.starts_with(pattern.as_str())
|
||||
&& let Ok(m) = Self::new(module_name)
|
||||
{
|
||||
return ModuleAssignment::Explicit(m);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Heuristic inference from path structure
|
||||
let parts: Vec<&str> = relative.split('/').collect();
|
||||
if parts.len() <= 1 {
|
||||
return None;
|
||||
return ModuleAssignment::Unresolved("path has no directory component");
|
||||
}
|
||||
|
||||
let module_dir = if (parts[0] == "crates" || parts[0] == "src") && parts.len() > 2 {
|
||||
@@ -43,10 +83,23 @@ impl ModuleName {
|
||||
} else if parts[0] != "src" && parts.len() > 1 {
|
||||
parts[0]
|
||||
} else {
|
||||
return None;
|
||||
return ModuleAssignment::Unresolved("path under src/ with no further structure");
|
||||
};
|
||||
|
||||
Self::new(&Self::capitalize(module_dir)).ok()
|
||||
match Self::new(&Self::capitalize(module_dir)) {
|
||||
Ok(m) => ModuleAssignment::Inferred(m),
|
||||
Err(_) => ModuleAssignment::Unresolved("inferred directory name was empty"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience wrapper — returns None for Unresolved.
|
||||
/// Prefer `assign()` when you want to distinguish strategies.
|
||||
pub fn from_path(
|
||||
file_path: &str,
|
||||
root: &Path,
|
||||
module_mappings: &HashMap<String, String>,
|
||||
) -> Option<Self> {
|
||||
Self::assign(file_path, root, module_mappings).into_module_name()
|
||||
}
|
||||
|
||||
pub fn from_directory_group(member_path: &str) -> Option<Self> {
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
mod cli;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use archlens_application::queries::AnalyzeCodebase;
|
||||
use archlens_application::use_cases::{
|
||||
check_freshness::CheckFreshness,
|
||||
diff_diagram::DiffDiagram,
|
||||
generate_diagram::{GenerateDiagram, write_split},
|
||||
};
|
||||
use archlens_ascii::AsciiRenderer;
|
||||
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
|
||||
use archlens_d2::D2Renderer;
|
||||
use archlens_domain::{
|
||||
BoundaryRule, CodeGraph, DiagramLevel, check_boundary_rules,
|
||||
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
|
||||
BoundaryRule, DiagramLevel, NormalizedGraph,
|
||||
ports::{ConfigLoader, ProjectAnalyzer},
|
||||
};
|
||||
use archlens_file_writer::FileOutputWriter;
|
||||
use archlens_html::HtmlRenderer;
|
||||
use archlens_mermaid::MermaidRenderer;
|
||||
use archlens_python_project::PythonProjectAnalyzer;
|
||||
use archlens_stdout_writer::StdoutOutputWriter;
|
||||
use archlens_toml_config::TomlConfigLoader;
|
||||
use archlens_tree_sitter::TreeSitterAnalyzer;
|
||||
use archlens_walkdir::WalkdirDiscovery;
|
||||
|
||||
pub use cli::{Cli, Command};
|
||||
|
||||
pub type CliArgs = Cli;
|
||||
|
||||
pub fn run(args: Cli) -> Result<()> {
|
||||
@@ -41,40 +41,44 @@ pub fn run(args: Cli) -> Result<()> {
|
||||
let config_loader = load_config(&args)?;
|
||||
let graph = build_graph(&args, level)?;
|
||||
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
||||
let ext = format_extension(&args.format);
|
||||
|
||||
if args.check {
|
||||
return check_freshness(&args.output, &graph, &*renderer);
|
||||
}
|
||||
|
||||
// Boundary rule checking
|
||||
let (raw_allow, raw_deny) = config_loader.load_rules();
|
||||
let allow: Vec<BoundaryRule> = raw_allow
|
||||
.iter()
|
||||
.filter_map(|s| BoundaryRule::parse(s))
|
||||
.collect();
|
||||
let deny: Vec<BoundaryRule> = raw_deny
|
||||
.iter()
|
||||
.filter_map(|s| BoundaryRule::parse(s))
|
||||
.collect();
|
||||
if !allow.is_empty() || !deny.is_empty() {
|
||||
let violations = check_boundary_rules(&graph, &allow, &deny);
|
||||
for v in &violations {
|
||||
eprintln!("RULE VIOLATION: {}", v.message());
|
||||
}
|
||||
if args.strict && !violations.is_empty() {
|
||||
bail!(
|
||||
"{} boundary rule violation(s) in strict mode",
|
||||
violations.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if args.split_by_module {
|
||||
write_split(&graph, &*renderer, &args.output, ext)?;
|
||||
let existing_path = args.output.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("--check requires --output to specify the file to check against"))?;
|
||||
let up_to_date = CheckFreshness {
|
||||
graph: &graph,
|
||||
renderer: &*renderer,
|
||||
existing_path: std::path::Path::new(existing_path),
|
||||
}.execute()?;
|
||||
if up_to_date {
|
||||
println!("Architecture diagram is up to date.");
|
||||
} else {
|
||||
write_single(&graph, &*renderer, &args.output)?;
|
||||
eprintln!("Architecture diagram is outdated: {existing_path}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (raw_allow, raw_deny) = config_loader.load_rules();
|
||||
let allow: Vec<BoundaryRule> = raw_allow.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
|
||||
let deny: Vec<BoundaryRule> = raw_deny.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
|
||||
let output_dir = args.output.as_ref().map(std::path::PathBuf::from);
|
||||
|
||||
let use_case = GenerateDiagram {
|
||||
graph,
|
||||
renderer,
|
||||
allow_rules: allow,
|
||||
deny_rules: deny,
|
||||
split_by_module: args.split_by_module,
|
||||
format_ext: format_extension(&args.format).to_string(),
|
||||
output_dir,
|
||||
};
|
||||
|
||||
let violations = use_case.check_violations_only();
|
||||
if args.strict && !violations.is_empty() {
|
||||
bail!("{} boundary rule violation(s) in strict mode", violations.len());
|
||||
}
|
||||
use_case.execute()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -93,7 +97,7 @@ fn load_config(args: &Cli) -> Result<TomlConfigLoader> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
|
||||
fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
|
||||
let config_loader = load_config(args)?;
|
||||
let mut analysis_config = config_loader.load_analysis_config()?;
|
||||
analysis_config = analysis_config.with_level(level);
|
||||
@@ -116,10 +120,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
|
||||
|
||||
if level == DiagramLevel::Project {
|
||||
let cargo_toml = args.path.join("Cargo.toml");
|
||||
if cargo_toml.exists() {
|
||||
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
|
||||
}
|
||||
return Ok(PythonProjectAnalyzer::new().analyze(&args.path)?);
|
||||
let project_graph = if cargo_toml.exists() {
|
||||
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
|
||||
} else {
|
||||
PythonProjectAnalyzer::new().analyze(&args.path)?
|
||||
};
|
||||
return Ok(NormalizedGraph::from_project(project_graph));
|
||||
}
|
||||
|
||||
let discovery = WalkdirDiscovery::new();
|
||||
@@ -129,18 +135,10 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
|
||||
|
||||
if !result.warnings().is_empty() {
|
||||
for warning in result.warnings() {
|
||||
eprintln!(
|
||||
"WARNING: {}:{} {}",
|
||||
warning.file_path().as_str(),
|
||||
warning.line(),
|
||||
warning.message()
|
||||
);
|
||||
eprintln!("WARNING: {}:{} {}", warning.file_path().as_str(), warning.line(), warning.message());
|
||||
}
|
||||
if args.strict {
|
||||
bail!(
|
||||
"analysis produced {} warning(s) in strict mode",
|
||||
result.warnings().len()
|
||||
);
|
||||
bail!("analysis produced {} warning(s) in strict mode", result.warnings().len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,9 +165,7 @@ fn create_renderer(
|
||||
show_weights: bool,
|
||||
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
|
||||
match format {
|
||||
"mermaid" => Ok(Box::new(
|
||||
MermaidRenderer::with_level(level).with_weights(show_weights),
|
||||
)),
|
||||
"mermaid" => Ok(Box::new(MermaidRenderer::with_level(level).with_weights(show_weights))),
|
||||
"ascii" => Ok(Box::new(AsciiRenderer::new())),
|
||||
"d2" => Ok(Box::new(D2Renderer::with_level(level))),
|
||||
"html" => Ok(Box::new(HtmlRenderer::new())),
|
||||
@@ -186,85 +182,6 @@ fn format_extension(format: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_freshness(
|
||||
output: &Option<String>,
|
||||
graph: &CodeGraph,
|
||||
renderer: &dyn archlens_domain::ports::DiagramRenderer,
|
||||
) -> Result<()> {
|
||||
let Some(path) = output else {
|
||||
bail!("--check requires --output to specify the file to check against");
|
||||
};
|
||||
let rendered = renderer.render(graph)?;
|
||||
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
||||
let existing = std::fs::read_to_string(path).unwrap_or_default();
|
||||
if current != existing {
|
||||
eprintln!("Architecture diagram is outdated: {path}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Architecture diagram is up to date.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_split(
|
||||
graph: &CodeGraph,
|
||||
renderer: &dyn archlens_domain::ports::DiagramRenderer,
|
||||
output: &Option<String>,
|
||||
ext: &str,
|
||||
) -> Result<()> {
|
||||
let output_dir = output
|
||||
.as_ref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let writer = FileOutputWriter::new(output_dir);
|
||||
|
||||
let overview = renderer.render(graph)?;
|
||||
let overview_file = archlens_domain::RenderedFile::new(
|
||||
&format!("overview.{ext}"),
|
||||
overview.files().first().map(|f| f.content()).unwrap_or(""),
|
||||
)?;
|
||||
writer.write(&archlens_domain::RenderOutput::single(overview_file))?;
|
||||
|
||||
for module in graph.modules() {
|
||||
let subgraph = graph.subgraph_by_module(&module);
|
||||
let cross_deps = graph.cross_module_deps_for(&module);
|
||||
let module_output = renderer.render(&subgraph)?;
|
||||
let raw = module_output
|
||||
.files()
|
||||
.first()
|
||||
.map(|f| f.content())
|
||||
.unwrap_or("");
|
||||
let content = renderer.append_cross_module_deps(raw, &module, &cross_deps);
|
||||
let module_file = archlens_domain::RenderedFile::new(
|
||||
&format!("{}.{ext}", module.as_str().to_lowercase()),
|
||||
&content,
|
||||
)?;
|
||||
writer.write(&archlens_domain::RenderOutput::single(module_file))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_single(
|
||||
graph: &CodeGraph,
|
||||
renderer: &dyn archlens_domain::ports::DiagramRenderer,
|
||||
output: &Option<String>,
|
||||
) -> Result<()> {
|
||||
let rendered = renderer.render(graph)?;
|
||||
|
||||
match output {
|
||||
Some(path) => {
|
||||
let writer = FileOutputWriter::single_file(PathBuf::from(path));
|
||||
writer.write(&rendered)?;
|
||||
}
|
||||
None => {
|
||||
let writer = StdoutOutputWriter::new();
|
||||
writer.write(&rendered)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
|
||||
init_tracing(args.verbose);
|
||||
|
||||
@@ -272,47 +189,24 @@ fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
|
||||
let graph = build_graph(args, level)?;
|
||||
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
||||
|
||||
let output = renderer.render(&graph)?;
|
||||
let current = output.files().first().map(|f| f.content()).unwrap_or("");
|
||||
let diff = DiffDiagram {
|
||||
graph: &graph,
|
||||
renderer: &*renderer,
|
||||
existing_path,
|
||||
}.execute()?;
|
||||
|
||||
let existing = std::fs::read_to_string(existing_path).unwrap_or_default();
|
||||
|
||||
if current == existing {
|
||||
if diff.is_empty() {
|
||||
println!("No changes detected.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_lines: Vec<&str> = current.lines().collect();
|
||||
let existing_lines: Vec<&str> = existing.lines().collect();
|
||||
|
||||
let mut added = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
|
||||
for line in ¤t_lines {
|
||||
if !existing_lines.contains(line) {
|
||||
added.push(*line);
|
||||
for line in &diff.removed {
|
||||
println!("{line}");
|
||||
}
|
||||
for line in &diff.added {
|
||||
println!("{line}");
|
||||
}
|
||||
for line in &existing_lines {
|
||||
if !current_lines.contains(line) {
|
||||
removed.push(*line);
|
||||
}
|
||||
}
|
||||
|
||||
if !removed.is_empty() {
|
||||
println!("Removed:");
|
||||
for line in &removed {
|
||||
println!(" - {line}");
|
||||
}
|
||||
}
|
||||
if !added.is_empty() {
|
||||
println!("Added:");
|
||||
for line in &added {
|
||||
println!(" + {line}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{} added, {} removed", added.len(), removed.len());
|
||||
println!("\n{} added, {} removed", diff.added.len(), diff.removed.len());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -372,7 +266,6 @@ fn init_tracing(verbosity: u8) {
|
||||
2 => "debug",
|
||||
_ => "trace",
|
||||
};
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
@@ -393,10 +286,7 @@ fn get_changed_files(
|
||||
.map_err(|e| anyhow::anyhow!("git not found: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git diff failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
bail!("git diff failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
let files = String::from_utf8_lossy(&output.stdout)
|
||||
@@ -420,33 +310,40 @@ fn run_watch(args: Cli) -> Result<()> {
|
||||
let config_loader = load_config(args)?;
|
||||
let graph = build_graph(args, level)?;
|
||||
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
||||
let output_dir = args.output.as_ref().map(std::path::PathBuf::from);
|
||||
|
||||
if args.split_by_module {
|
||||
write_split(&graph, &*renderer, &args.output, ext)?;
|
||||
write_split(&graph, &*renderer, &output_dir, ext)?;
|
||||
} else {
|
||||
write_single(&graph, &*renderer, &args.output)?;
|
||||
let rendered = renderer.render(graph.as_graph())?;
|
||||
let content = rendered.files().first().map(|f| f.content()).unwrap_or("");
|
||||
match &output_dir {
|
||||
Some(path) => std::fs::write(path, content)?,
|
||||
None => print!("{content}"),
|
||||
}
|
||||
}
|
||||
|
||||
let (raw_allow, raw_deny) = config_loader.load_rules();
|
||||
let allow: Vec<BoundaryRule> = raw_allow
|
||||
.iter()
|
||||
.filter_map(|s| BoundaryRule::parse(s))
|
||||
.collect();
|
||||
let deny: Vec<BoundaryRule> = raw_deny
|
||||
.iter()
|
||||
.filter_map(|s| BoundaryRule::parse(s))
|
||||
.collect();
|
||||
let allow: Vec<BoundaryRule> = raw_allow.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
|
||||
let deny: Vec<BoundaryRule> = raw_deny.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
|
||||
if !allow.is_empty() || !deny.is_empty() {
|
||||
let violations = check_boundary_rules(&graph, &allow, &deny);
|
||||
for v in &violations {
|
||||
eprintln!("RULE VIOLATION: {}", v.message());
|
||||
let use_case = GenerateDiagram {
|
||||
graph,
|
||||
renderer,
|
||||
allow_rules: allow,
|
||||
deny_rules: deny,
|
||||
split_by_module: false,
|
||||
format_ext: ext.to_string(),
|
||||
output_dir: None,
|
||||
};
|
||||
for v in use_case.check_violations_only() {
|
||||
eprintln!("RULE VIOLATION: {v}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"Watching {} for changes (Ctrl+C to stop)...",
|
||||
args.path.display()
|
||||
);
|
||||
eprintln!("Watching {} for changes (Ctrl+C to stop)...", args.path.display());
|
||||
if let Err(e) = run_once(&args) {
|
||||
eprintln!("Error: {e}");
|
||||
} else {
|
||||
@@ -454,18 +351,14 @@ fn run_watch(args: Cli) -> Result<()> {
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut watcher = recommended_watcher(move |res| {
|
||||
let _ = tx.send(res);
|
||||
})?;
|
||||
let mut watcher = recommended_watcher(move |res| { let _ = tx.send(res); })?;
|
||||
watcher.watch(&args.path, RecursiveMode::Recursive)?;
|
||||
|
||||
let mut last_run = Instant::now();
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(_) => {
|
||||
if last_run.elapsed() < debounce {
|
||||
continue;
|
||||
}
|
||||
if last_run.elapsed() < debounce { continue; }
|
||||
last_run = Instant::now();
|
||||
eprintln!("Change detected, regenerating...");
|
||||
if let Err(e) = run_once(&args) {
|
||||
@@ -474,10 +367,7 @@ fn run_watch(args: Cli) -> Result<()> {
|
||||
eprintln!("Diagram updated.");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Watch error: {e}");
|
||||
break;
|
||||
}
|
||||
Err(e) => { eprintln!("Watch error: {e}"); break; }
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user