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))
|
Ok(RenderOutput::single(file))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_cross_module_deps(
|
fn render_for_module(
|
||||||
&self,
|
&self,
|
||||||
content: &str,
|
subgraph: &CodeGraph,
|
||||||
module: &ModuleName,
|
module: &ModuleName,
|
||||||
deps: &[(ModuleName, usize)],
|
cross_deps: &[(ModuleName, usize)],
|
||||||
) -> String {
|
) -> Result<RenderOutput, DomainError> {
|
||||||
if deps.is_empty() {
|
let base = self.render_class_diagram(subgraph);
|
||||||
return content.to_string();
|
let content = if cross_deps.is_empty() {
|
||||||
}
|
base
|
||||||
|
} else {
|
||||||
let src_id = format!(
|
let src_id = format!("{}_module", module.as_str().to_lowercase().replace('-', "_"));
|
||||||
"{}_module",
|
|
||||||
module.as_str().to_lowercase().replace('-', "_")
|
|
||||||
);
|
|
||||||
let mut extra = format!(
|
let mut extra = format!(
|
||||||
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
|
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
|
||||||
module.as_str()
|
module.as_str()
|
||||||
);
|
);
|
||||||
|
for (dep_mod, count) in cross_deps {
|
||||||
for (dep_mod, count) in deps {
|
|
||||||
let dep_id = format!(
|
let dep_id = format!(
|
||||||
"{}_module",
|
"{}_module",
|
||||||
dep_mod.as_str().to_lowercase().replace('-', "_")
|
dep_mod.as_str().to_lowercase().replace('-', "_")
|
||||||
@@ -254,14 +250,12 @@ impl DiagramRenderer for MermaidRenderer {
|
|||||||
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
|
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
|
||||||
dep_mod.as_str()
|
dep_mod.as_str()
|
||||||
));
|
));
|
||||||
let label = if *count == 1 {
|
let label = if *count == 1 { "1 dep".to_string() } else { format!("{count} deps") };
|
||||||
"1 dep".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{count} deps")
|
|
||||||
};
|
|
||||||
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
|
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
|
||||||
}
|
}
|
||||||
|
format!("{base}\n{extra}")
|
||||||
format!("{content}\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 language_extractor;
|
||||||
mod python;
|
mod python;
|
||||||
mod rust;
|
mod rust;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use tree_sitter::{Node, Parser};
|
use tree_sitter::{Node, Parser};
|
||||||
|
|
||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
|
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
|
||||||
Relationship, RelationshipKind,
|
RelationshipKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::extraction_context::ExtractionContext;
|
||||||
use crate::language_extractor::LanguageExtractor;
|
use crate::language_extractor::LanguageExtractor;
|
||||||
|
|
||||||
pub struct PythonExtractor;
|
pub struct PythonExtractor;
|
||||||
@@ -27,40 +26,16 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, Dom
|
|||||||
.parse(source, None)
|
.parse(source, None)
|
||||||
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
|
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
|
||||||
|
|
||||||
let mut elements = Vec::new();
|
let mut ctx = ExtractionContext::new(file_path.clone());
|
||||||
let mut relationships = Vec::new();
|
|
||||||
let mut warnings = Vec::new();
|
|
||||||
let mut type_names: HashSet<String> = HashSet::new();
|
|
||||||
|
|
||||||
let root = tree.root_node();
|
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
|
collect_classes(&root, source, &mut ctx);
|
||||||
.into_iter()
|
collect_imports(&root, source, &mut ctx);
|
||||||
.map(|r| r.with_source_file(file_path.clone()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(AnalysisResult::new(elements, relationships, warnings))
|
ctx.into_result()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_classes(
|
fn collect_classes(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
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();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
if child.kind() != "class_definition" {
|
if child.kind() != "class_definition" {
|
||||||
@@ -79,26 +54,21 @@ fn collect_classes(
|
|||||||
.map(|body| collect_methods(&body, source))
|
.map(|body| collect_methods(&body, source))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
|
match CodeElement::new(name, CodeElementKind::Class, ctx.file_path().clone(), line) {
|
||||||
Ok(element) => {
|
Ok(element) => ctx.add_element(element.with_methods(methods)),
|
||||||
type_names.insert(name.to_string());
|
|
||||||
elements.push(element.with_methods(methods));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
|
ctx.add_warning(ctx.file_path().clone(), line, &e.to_string());
|
||||||
warnings.push(w);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(superclasses) = child.child_by_field_name("superclasses") {
|
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") {
|
if let Some(body) = child.child_by_field_name("body") {
|
||||||
collect_typed_fields(&body, source, name, type_names, relationships);
|
collect_typed_fields(&body, source, name, ctx);
|
||||||
collect_constructor_params(&body, source, name, type_names, relationships);
|
collect_constructor_params(&body, source, name, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,8 +77,7 @@ fn collect_inheritance(
|
|||||||
superclasses: &Node,
|
superclasses: &Node,
|
||||||
source: &str,
|
source: &str,
|
||||||
class_name: &str,
|
class_name: &str,
|
||||||
_type_names: &HashSet<String>,
|
ctx: &mut ExtractionContext,
|
||||||
relationships: &mut Vec<Relationship>,
|
|
||||||
) {
|
) {
|
||||||
let mut cursor = superclasses.walk();
|
let mut cursor = superclasses.walk();
|
||||||
for child in superclasses.children(&mut cursor) {
|
for child in superclasses.children(&mut cursor) {
|
||||||
@@ -118,7 +87,7 @@ fn collect_inheritance(
|
|||||||
&& let Ok(rel) =
|
&& let Ok(rel) =
|
||||||
Relationship::new(class_name, base_name, RelationshipKind::Inheritance)
|
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
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_imports(
|
fn collect_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
node: &Node,
|
let file_name = std::path::Path::new(ctx.file_path().as_str())
|
||||||
source: &str,
|
|
||||||
file_path: &FilePath,
|
|
||||||
relationships: &mut Vec<Relationship>,
|
|
||||||
) {
|
|
||||||
let file_name = std::path::Path::new(file_path.as_str())
|
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
@@ -249,9 +214,9 @@ fn collect_imports(
|
|||||||
let module = &source[name_child.byte_range()];
|
let module = &source[name_child.byte_range()];
|
||||||
if !is_external_import(module)
|
if !is_external_import(module)
|
||||||
&& let Ok(rel) =
|
&& 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()];
|
let module = &source[module_node.byte_range()];
|
||||||
if !is_external_import(module)
|
if !is_external_import(module)
|
||||||
&& let Ok(rel) =
|
&& 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() {
|
match param.kind() {
|
||||||
"typed_parameter" => {
|
"typed_parameter" => {
|
||||||
if let Some(type_node) = param.child_by_field_name("type") {
|
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 mut inner = param.walk();
|
||||||
let name = param
|
let name = param
|
||||||
.children(&mut inner)
|
.children(&mut inner)
|
||||||
@@ -339,13 +303,7 @@ fn extract_python_params(params_node: &Node, source: &str) -> String {
|
|||||||
parts.join(", ")
|
parts.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_constructor_params(
|
fn collect_constructor_params(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
|
||||||
body: &Node,
|
|
||||||
source: &str,
|
|
||||||
class_name: &str,
|
|
||||||
_type_names: &HashSet<String>,
|
|
||||||
relationships: &mut Vec<Relationship>,
|
|
||||||
) {
|
|
||||||
let mut cursor = body.walk();
|
let mut cursor = body.walk();
|
||||||
for child in body.children(&mut cursor) {
|
for child in body.children(&mut cursor) {
|
||||||
if child.kind() != "function_definition" {
|
if child.kind() != "function_definition" {
|
||||||
@@ -373,30 +331,18 @@ fn collect_constructor_params(
|
|||||||
&& let Ok(rel) =
|
&& let Ok(rel) =
|
||||||
Relationship::new(class_name, base_type, RelationshipKind::Composition)
|
Relationship::new(class_name, base_type, RelationshipKind::Composition)
|
||||||
{
|
{
|
||||||
relationships.push(rel);
|
ctx.add_relationship(rel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_typed_fields(
|
fn collect_typed_fields(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
|
||||||
body: &Node,
|
collect_typed_fields_recursive(body, source, class_name, ctx);
|
||||||
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(
|
fn collect_typed_fields_recursive(node: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
|
||||||
node: &Node,
|
|
||||||
source: &str,
|
|
||||||
class_name: &str,
|
|
||||||
_type_names: &HashSet<String>,
|
|
||||||
relationships: &mut Vec<Relationship>,
|
|
||||||
) {
|
|
||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
if (child.kind() == "assignment" || child.kind() == "typed_assignment")
|
if (child.kind() == "assignment" || child.kind() == "typed_assignment")
|
||||||
@@ -410,10 +356,10 @@ fn collect_typed_fields_recursive(
|
|||||||
&& let Ok(rel) =
|
&& let Ok(rel) =
|
||||||
Relationship::new(class_name, base_type, RelationshipKind::Composition)
|
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};
|
use tree_sitter::{Node, Parser};
|
||||||
|
|
||||||
const RUST_PRIMITIVES: &[&str] = &[
|
const RUST_PRIMITIVES: &[&str] = &[
|
||||||
"bool",
|
"bool", "char", "str", "String", "u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16",
|
||||||
"char",
|
"i32", "i64", "i128", "isize", "f32", "f64", "Vec", "Option", "Result", "Box", "Rc", "Arc",
|
||||||
"str",
|
"HashMap", "HashSet", "BTreeMap", "BTreeSet", "PhantomData", "Pin", "Cow", "Self",
|
||||||
"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::{
|
use archlens_domain::{
|
||||||
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
|
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
|
||||||
Relationship, RelationshipKind, Visibility,
|
RelationshipKind, Visibility,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::extraction_context::ExtractionContext;
|
||||||
use crate::language_extractor::LanguageExtractor;
|
use crate::language_extractor::LanguageExtractor;
|
||||||
|
|
||||||
pub struct RustExtractor;
|
pub struct RustExtractor;
|
||||||
@@ -62,46 +32,18 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, Dom
|
|||||||
.parse(source, None)
|
.parse(source, None)
|
||||||
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
|
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
|
||||||
|
|
||||||
let mut elements = Vec::new();
|
let mut ctx = ExtractionContext::new(file_path.clone());
|
||||||
let mut relationships = Vec::new();
|
|
||||||
let mut warnings = Vec::new();
|
|
||||||
let mut type_names: HashSet<String> = HashSet::new();
|
|
||||||
|
|
||||||
let root = tree.root_node();
|
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
|
collect_types(&root, source, &mut ctx);
|
||||||
.into_iter()
|
collect_relationships(&root, source, &mut ctx);
|
||||||
.map(|r| r.with_source_file(file_path.clone()))
|
collect_mod_declarations(&root, source, &mut ctx);
|
||||||
.collect();
|
collect_use_imports(&root, source, &mut ctx);
|
||||||
|
|
||||||
Ok(AnalysisResult::new(elements, relationships, warnings))
|
ctx.into_result()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_types(
|
fn collect_types(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
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();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
let (kind, name_field) = match child.kind() {
|
let (kind, name_field) = match child.kind() {
|
||||||
@@ -116,52 +58,34 @@ fn collect_types(
|
|||||||
let line = child.start_position().row + 1;
|
let line = child.start_position().row + 1;
|
||||||
let visibility = detect_visibility(&child, source);
|
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) => {
|
Ok(element) => {
|
||||||
let fields = extract_fields(&child, source);
|
let fields = extract_fields(&child, source);
|
||||||
let methods = extract_methods(node, source, name);
|
let methods = extract_methods(node, source, name);
|
||||||
let element = element
|
ctx.add_element(
|
||||||
|
element
|
||||||
.with_visibility(visibility)
|
.with_visibility(visibility)
|
||||||
.with_fields(fields)
|
.with_fields(fields)
|
||||||
.with_methods(methods);
|
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Err(e) => ctx.add_warning(ctx.file_path().clone(), line, &e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_relationships(
|
fn collect_relationships(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
node: &Node,
|
|
||||||
source: &str,
|
|
||||||
type_names: &HashSet<String>,
|
|
||||||
relationships: &mut Vec<Relationship>,
|
|
||||||
warnings: &mut Vec<AnalysisWarning>,
|
|
||||||
) {
|
|
||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
match child.kind() {
|
match child.kind() {
|
||||||
"struct_item" => {
|
"struct_item" => {
|
||||||
if let Some(name_node) = child.child_by_field_name("name") {
|
if let Some(name_node) = child.child_by_field_name("name") {
|
||||||
let struct_name = source[name_node.byte_range()].to_string();
|
let struct_name = source[name_node.byte_range()].to_string();
|
||||||
collect_field_compositions(
|
collect_field_compositions(&child, source, &struct_name, ctx);
|
||||||
&child,
|
|
||||||
source,
|
|
||||||
&struct_name,
|
|
||||||
type_names,
|
|
||||||
relationships,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"impl_item" => {
|
"impl_item" => collect_trait_impl(&child, source, ctx),
|
||||||
collect_trait_impl(&child, source, type_names, relationships, warnings);
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,8 +95,7 @@ fn collect_field_compositions(
|
|||||||
struct_node: &Node,
|
struct_node: &Node,
|
||||||
source: &str,
|
source: &str,
|
||||||
struct_name: &str,
|
struct_name: &str,
|
||||||
_type_names: &HashSet<String>,
|
ctx: &mut ExtractionContext,
|
||||||
relationships: &mut Vec<Relationship>,
|
|
||||||
) {
|
) {
|
||||||
if let Some(body) = struct_node.child_by_field_name("body") {
|
if let Some(body) = struct_node.child_by_field_name("body") {
|
||||||
let mut cursor = body.walk();
|
let mut cursor = body.walk();
|
||||||
@@ -187,20 +110,14 @@ fn collect_field_compositions(
|
|||||||
&& let Ok(rel) =
|
&& let Ok(rel) =
|
||||||
Relationship::new(struct_name, &type_text, RelationshipKind::Composition)
|
Relationship::new(struct_name, &type_text, RelationshipKind::Composition)
|
||||||
{
|
{
|
||||||
relationships.push(rel);
|
ctx.add_relationship(rel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_trait_impl(
|
fn collect_trait_impl(impl_node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
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 trait_node = impl_node.child_by_field_name("trait");
|
||||||
let type_node = impl_node.child_by_field_name("type");
|
let type_node = impl_node.child_by_field_name("type");
|
||||||
|
|
||||||
@@ -215,7 +132,7 @@ fn collect_trait_impl(
|
|||||||
&& let Ok(rel) =
|
&& let Ok(rel) =
|
||||||
Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance)
|
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();
|
let mut cursor = body.walk();
|
||||||
for child in body.children(&mut cursor) {
|
for child in body.children(&mut cursor) {
|
||||||
if child.kind() == "field_declaration" {
|
if child.kind() == "field_declaration" {
|
||||||
let name = child
|
let name = child.child_by_field_name("name").map(|n| &source[n.byte_range()]);
|
||||||
.child_by_field_name("name")
|
let ty = child.child_by_field_name("type").map(|n| extract_base_type(&n, source));
|
||||||
.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) {
|
if let (Some(name), Some(ty)) = (name, ty) {
|
||||||
fields.push(format!("{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 methods = Vec::new();
|
||||||
let mut cursor = root.walk();
|
let mut cursor = root.walk();
|
||||||
for child in root.children(&mut cursor) {
|
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;
|
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;
|
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") {
|
if let Some(body) = child.child_by_field_name("body") {
|
||||||
let mut body_cursor = body.walk();
|
let mut body_cursor = body.walk();
|
||||||
for item in body.children(&mut body_cursor) {
|
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 Some(name_node) = item.child_by_field_name("name")
|
||||||
{
|
{
|
||||||
let fn_name = &source[name_node.byte_range()];
|
let fn_name = &source[name_node.byte_range()];
|
||||||
let vis = if detect_visibility(&item, source) == Visibility::Public {
|
let vis = if detect_visibility(&item, source) == Visibility::Public { "+" } else { "-" };
|
||||||
"+"
|
|
||||||
} else {
|
|
||||||
"-"
|
|
||||||
};
|
|
||||||
let params = extract_fn_params(&item, source);
|
let params = extract_fn_params(&item, source);
|
||||||
let ret = extract_fn_return(&item, source);
|
let ret = extract_fn_return(&item, source);
|
||||||
let sig = if ret.is_empty() {
|
let sig = if ret.is_empty() {
|
||||||
@@ -317,45 +222,34 @@ fn extract_fn_return(fn_item: &Node, source: &str) -> String {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_mod_declarations(
|
fn collect_mod_declarations(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
node: &Node,
|
let file_name = std::path::Path::new(ctx.file_path().as_str())
|
||||||
source: &str,
|
|
||||||
file_path: &FilePath,
|
|
||||||
relationships: &mut Vec<Relationship>,
|
|
||||||
) {
|
|
||||||
let file_name = std::path::Path::new(file_path.as_str())
|
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
if child.kind() != "mod_item" {
|
if child.kind() != "mod_item" || child.child_by_field_name("body").is_some() {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if child.child_by_field_name("body").is_some() {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(name_node) = child.child_by_field_name("name") {
|
if let Some(name_node) = child.child_by_field_name("name") {
|
||||||
let mod_name = &source[name_node.byte_range()];
|
let mod_name = &source[name_node.byte_range()];
|
||||||
let target = format!("crate::{mod_name}");
|
let target = format!("crate::{mod_name}");
|
||||||
if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) {
|
if let Ok(rel) = Relationship::new(&file_name, &target, RelationshipKind::Import) {
|
||||||
relationships.push(rel);
|
ctx.add_relationship(rel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_use_imports(
|
fn collect_use_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) {
|
||||||
node: &Node,
|
let file_name = std::path::Path::new(ctx.file_path().as_str())
|
||||||
source: &str,
|
|
||||||
file_path: &FilePath,
|
|
||||||
relationships: &mut Vec<Relationship>,
|
|
||||||
) {
|
|
||||||
let file_name = std::path::Path::new(file_path.as_str())
|
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
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") {
|
if let Some(arg) = child.child_by_field_name("argument") {
|
||||||
let text = &source[arg.byte_range()];
|
let text = &source[arg.byte_range()];
|
||||||
let path = text
|
let path = text.split('{').next().unwrap_or(text).trim_end_matches("::");
|
||||||
.split('{')
|
|
||||||
.next()
|
|
||||||
.unwrap_or(text)
|
|
||||||
.trim_end_matches("::");
|
|
||||||
|
|
||||||
if (path.starts_with("crate::") || path.starts_with("super::"))
|
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 queries;
|
||||||
|
pub mod use_cases;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use std::path::Path;
|
|||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
|
||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship,
|
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName,
|
||||||
|
NormalizedGraph, Relationship,
|
||||||
ports::{FileDiscovery, SourceAnalyzer},
|
ports::{FileDiscovery, SourceAnalyzer},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,26 +41,22 @@ where
|
|||||||
.par_iter()
|
.par_iter()
|
||||||
.map(|file| match self.source_analyzer.analyze_file(file) {
|
.map(|file| match self.source_analyzer.analyze_file(file) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let module =
|
let assignment =
|
||||||
ModuleName::from_path(file.path().as_str(), root, config.module_mappings());
|
ModuleName::assign(file.path().as_str(), root, config.module_mappings());
|
||||||
let elements: Vec<CodeElement> = result
|
let elements: Vec<CodeElement> = result
|
||||||
.elements()
|
.elements()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|el| {
|
.map(|el| {
|
||||||
let mut el = el.clone();
|
let mut el = el.clone();
|
||||||
if el.module().is_none()
|
if el.module().is_none()
|
||||||
&& let Some(ref m) = module
|
&& let Some(m) = assignment.module_name()
|
||||||
{
|
{
|
||||||
el = el.with_module(m.clone());
|
el = el.with_module(m.clone());
|
||||||
}
|
}
|
||||||
el
|
el
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
(
|
(elements, result.relationships().to_vec(), result.warnings().to_vec())
|
||||||
elements,
|
|
||||||
result.relationships().to_vec(),
|
|
||||||
result.warnings().to_vec(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
@@ -73,14 +70,14 @@ where
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut graph = CodeGraph::new();
|
let mut raw = CodeGraph::new();
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
for (elements, relationships, warns) in file_results {
|
for (elements, relationships, warns) in file_results {
|
||||||
for el in elements {
|
for el in elements {
|
||||||
graph.add_element(el);
|
raw.add_element(el);
|
||||||
}
|
}
|
||||||
for rel in relationships {
|
for rel in relationships {
|
||||||
graph.add_relationship(rel);
|
raw.add_relationship(rel);
|
||||||
}
|
}
|
||||||
warnings.extend(warns);
|
warnings.extend(warns);
|
||||||
}
|
}
|
||||||
@@ -94,22 +91,19 @@ where
|
|||||||
.map(|s| s.to_lowercase())
|
.map(|s| s.to_lowercase())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let graph = graph
|
let graph = NormalizedGraph::from_analyzed(raw, &known_dirs)?;
|
||||||
.qualify()
|
|
||||||
.resolve_relationships()
|
|
||||||
.filter_external_imports(&known_dirs);
|
|
||||||
|
|
||||||
Ok(AnalyzeCodebaseResult { graph, warnings })
|
Ok(AnalyzeCodebaseResult { graph, warnings })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AnalyzeCodebaseResult {
|
pub struct AnalyzeCodebaseResult {
|
||||||
graph: CodeGraph,
|
graph: NormalizedGraph,
|
||||||
warnings: Vec<AnalysisWarning>,
|
warnings: Vec<AnalysisWarning>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnalyzeCodebaseResult {
|
impl AnalyzeCodebaseResult {
|
||||||
pub fn graph(&self) -> &CodeGraph {
|
pub fn graph(&self) -> &NormalizedGraph {
|
||||||
&self.graph
|
&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 code_graph;
|
||||||
|
mod normalized_graph;
|
||||||
|
|
||||||
pub use code_graph::CodeGraph;
|
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 ports;
|
||||||
pub mod value_objects;
|
pub mod value_objects;
|
||||||
|
|
||||||
pub use aggregates::CodeGraph;
|
pub use aggregates::{CodeGraph, NormalizedGraph};
|
||||||
pub use entities::{CodeElement, Relationship};
|
pub use entities::{CodeElement, Relationship};
|
||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning};
|
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::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile};
|
||||||
pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules};
|
pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules};
|
||||||
pub use value_objects::source::{
|
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 {
|
pub trait DiagramRenderer {
|
||||||
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
|
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,
|
&self,
|
||||||
content: &str,
|
subgraph: &CodeGraph,
|
||||||
module: &ModuleName,
|
_module: &ModuleName,
|
||||||
deps: &[(ModuleName, usize)],
|
_cross_deps: &[(ModuleName, usize)],
|
||||||
) -> String {
|
) -> Result<RenderOutput, DomainError> {
|
||||||
let _ = (module, deps);
|
self.render(subgraph)
|
||||||
content.to_string()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ mod source_file;
|
|||||||
|
|
||||||
pub use file_path::FilePath;
|
pub use file_path::FilePath;
|
||||||
pub use language::Language;
|
pub use language::Language;
|
||||||
pub use module_name::ModuleName;
|
pub use module_name::{ModuleAssignment, ModuleName};
|
||||||
pub use source_file::SourceFile;
|
pub use source_file::SourceFile;
|
||||||
|
|
||||||
pub fn normalize_cargo_package(name: &str) -> String {
|
pub fn normalize_cargo_package(name: &str) -> String {
|
||||||
|
|||||||
@@ -6,6 +6,40 @@ use crate::DomainError;
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct ModuleName(String);
|
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 {
|
impl ModuleName {
|
||||||
pub fn new(value: &str) -> Result<Self, DomainError> {
|
pub fn new(value: &str) -> Result<Self, DomainError> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
@@ -15,11 +49,13 @@ impl ModuleName {
|
|||||||
Ok(Self(trimmed.to_string()))
|
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,
|
file_path: &str,
|
||||||
root: &Path,
|
root: &Path,
|
||||||
module_mappings: &HashMap<String, String>,
|
module_mappings: &HashMap<String, String>,
|
||||||
) -> Option<Self> {
|
) -> ModuleAssignment {
|
||||||
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
||||||
let root_str = canonical_root.to_str().unwrap_or("");
|
let root_str = canonical_root.to_str().unwrap_or("");
|
||||||
let relative = file_path
|
let relative = file_path
|
||||||
@@ -27,15 +63,19 @@ impl ModuleName {
|
|||||||
.unwrap_or(file_path)
|
.unwrap_or(file_path)
|
||||||
.trim_start_matches('/');
|
.trim_start_matches('/');
|
||||||
|
|
||||||
|
// 1. Explicit mapping
|
||||||
for (pattern, module_name) in module_mappings {
|
for (pattern, module_name) in module_mappings {
|
||||||
if relative.starts_with(pattern.as_str()) {
|
if relative.starts_with(pattern.as_str())
|
||||||
return Self::new(module_name).ok();
|
&& let Ok(m) = Self::new(module_name)
|
||||||
|
{
|
||||||
|
return ModuleAssignment::Explicit(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Heuristic inference from path structure
|
||||||
let parts: Vec<&str> = relative.split('/').collect();
|
let parts: Vec<&str> = relative.split('/').collect();
|
||||||
if parts.len() <= 1 {
|
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 {
|
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 {
|
} else if parts[0] != "src" && parts.len() > 1 {
|
||||||
parts[0]
|
parts[0]
|
||||||
} else {
|
} 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> {
|
pub fn from_directory_group(member_path: &str) -> Option<Self> {
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
use archlens_application::queries::AnalyzeCodebase;
|
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_ascii::AsciiRenderer;
|
||||||
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
|
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
|
||||||
use archlens_d2::D2Renderer;
|
use archlens_d2::D2Renderer;
|
||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
BoundaryRule, CodeGraph, DiagramLevel, check_boundary_rules,
|
BoundaryRule, DiagramLevel, NormalizedGraph,
|
||||||
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
|
ports::{ConfigLoader, ProjectAnalyzer},
|
||||||
};
|
};
|
||||||
use archlens_file_writer::FileOutputWriter;
|
|
||||||
use archlens_html::HtmlRenderer;
|
use archlens_html::HtmlRenderer;
|
||||||
use archlens_mermaid::MermaidRenderer;
|
use archlens_mermaid::MermaidRenderer;
|
||||||
use archlens_python_project::PythonProjectAnalyzer;
|
use archlens_python_project::PythonProjectAnalyzer;
|
||||||
use archlens_stdout_writer::StdoutOutputWriter;
|
|
||||||
use archlens_toml_config::TomlConfigLoader;
|
use archlens_toml_config::TomlConfigLoader;
|
||||||
use archlens_tree_sitter::TreeSitterAnalyzer;
|
use archlens_tree_sitter::TreeSitterAnalyzer;
|
||||||
use archlens_walkdir::WalkdirDiscovery;
|
use archlens_walkdir::WalkdirDiscovery;
|
||||||
|
|
||||||
pub use cli::{Cli, Command};
|
pub use cli::{Cli, Command};
|
||||||
|
|
||||||
pub type CliArgs = Cli;
|
pub type CliArgs = Cli;
|
||||||
|
|
||||||
pub fn run(args: Cli) -> Result<()> {
|
pub fn run(args: Cli) -> Result<()> {
|
||||||
@@ -41,40 +41,44 @@ pub fn run(args: Cli) -> Result<()> {
|
|||||||
let config_loader = load_config(&args)?;
|
let config_loader = load_config(&args)?;
|
||||||
let graph = build_graph(&args, level)?;
|
let graph = build_graph(&args, level)?;
|
||||||
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
||||||
let ext = format_extension(&args.format);
|
|
||||||
|
|
||||||
if args.check {
|
if args.check {
|
||||||
return check_freshness(&args.output, &graph, &*renderer);
|
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 {
|
||||||
// Boundary rule checking
|
graph: &graph,
|
||||||
let (raw_allow, raw_deny) = config_loader.load_rules();
|
renderer: &*renderer,
|
||||||
let allow: Vec<BoundaryRule> = raw_allow
|
existing_path: std::path::Path::new(existing_path),
|
||||||
.iter()
|
}.execute()?;
|
||||||
.filter_map(|s| BoundaryRule::parse(s))
|
if up_to_date {
|
||||||
.collect();
|
println!("Architecture diagram is up to date.");
|
||||||
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)?;
|
|
||||||
} else {
|
} 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(())
|
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 config_loader = load_config(args)?;
|
||||||
let mut analysis_config = config_loader.load_analysis_config()?;
|
let mut analysis_config = config_loader.load_analysis_config()?;
|
||||||
analysis_config = analysis_config.with_level(level);
|
analysis_config = analysis_config.with_level(level);
|
||||||
@@ -116,10 +120,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
|
|||||||
|
|
||||||
if level == DiagramLevel::Project {
|
if level == DiagramLevel::Project {
|
||||||
let cargo_toml = args.path.join("Cargo.toml");
|
let cargo_toml = args.path.join("Cargo.toml");
|
||||||
if cargo_toml.exists() {
|
let project_graph = if cargo_toml.exists() {
|
||||||
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
|
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
|
||||||
}
|
} else {
|
||||||
return Ok(PythonProjectAnalyzer::new().analyze(&args.path)?);
|
PythonProjectAnalyzer::new().analyze(&args.path)?
|
||||||
|
};
|
||||||
|
return Ok(NormalizedGraph::from_project(project_graph));
|
||||||
}
|
}
|
||||||
|
|
||||||
let discovery = WalkdirDiscovery::new();
|
let discovery = WalkdirDiscovery::new();
|
||||||
@@ -129,18 +135,10 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
|
|||||||
|
|
||||||
if !result.warnings().is_empty() {
|
if !result.warnings().is_empty() {
|
||||||
for warning in result.warnings() {
|
for warning in result.warnings() {
|
||||||
eprintln!(
|
eprintln!("WARNING: {}:{} {}", warning.file_path().as_str(), warning.line(), warning.message());
|
||||||
"WARNING: {}:{} {}",
|
|
||||||
warning.file_path().as_str(),
|
|
||||||
warning.line(),
|
|
||||||
warning.message()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if args.strict {
|
if args.strict {
|
||||||
bail!(
|
bail!("analysis produced {} warning(s) in strict mode", result.warnings().len());
|
||||||
"analysis produced {} warning(s) in strict mode",
|
|
||||||
result.warnings().len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,9 +165,7 @@ fn create_renderer(
|
|||||||
show_weights: bool,
|
show_weights: bool,
|
||||||
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
|
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
|
||||||
match format {
|
match format {
|
||||||
"mermaid" => Ok(Box::new(
|
"mermaid" => Ok(Box::new(MermaidRenderer::with_level(level).with_weights(show_weights))),
|
||||||
MermaidRenderer::with_level(level).with_weights(show_weights),
|
|
||||||
)),
|
|
||||||
"ascii" => Ok(Box::new(AsciiRenderer::new())),
|
"ascii" => Ok(Box::new(AsciiRenderer::new())),
|
||||||
"d2" => Ok(Box::new(D2Renderer::with_level(level))),
|
"d2" => Ok(Box::new(D2Renderer::with_level(level))),
|
||||||
"html" => Ok(Box::new(HtmlRenderer::new())),
|
"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<()> {
|
fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
|
||||||
init_tracing(args.verbose);
|
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 graph = build_graph(args, level)?;
|
||||||
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
||||||
|
|
||||||
let output = renderer.render(&graph)?;
|
let diff = DiffDiagram {
|
||||||
let current = output.files().first().map(|f| f.content()).unwrap_or("");
|
graph: &graph,
|
||||||
|
renderer: &*renderer,
|
||||||
|
existing_path,
|
||||||
|
}.execute()?;
|
||||||
|
|
||||||
let existing = std::fs::read_to_string(existing_path).unwrap_or_default();
|
if diff.is_empty() {
|
||||||
|
|
||||||
if current == existing {
|
|
||||||
println!("No changes detected.");
|
println!("No changes detected.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let current_lines: Vec<&str> = current.lines().collect();
|
for line in &diff.removed {
|
||||||
let existing_lines: Vec<&str> = existing.lines().collect();
|
println!("{line}");
|
||||||
|
|
||||||
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.added {
|
||||||
|
println!("{line}");
|
||||||
}
|
}
|
||||||
for line in &existing_lines {
|
println!("\n{} added, {} removed", diff.added.len(), diff.removed.len());
|
||||||
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());
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +266,6 @@ fn init_tracing(verbosity: u8) {
|
|||||||
2 => "debug",
|
2 => "debug",
|
||||||
_ => "trace",
|
_ => "trace",
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
@@ -393,10 +286,7 @@ fn get_changed_files(
|
|||||||
.map_err(|e| anyhow::anyhow!("git not found: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("git not found: {e}"))?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
bail!(
|
bail!("git diff failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||||
"git diff failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let files = String::from_utf8_lossy(&output.stdout)
|
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 config_loader = load_config(args)?;
|
||||||
let graph = build_graph(args, level)?;
|
let graph = build_graph(args, level)?;
|
||||||
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
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 {
|
if args.split_by_module {
|
||||||
write_split(&graph, &*renderer, &args.output, ext)?;
|
write_split(&graph, &*renderer, &output_dir, ext)?;
|
||||||
} else {
|
} 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 (raw_allow, raw_deny) = config_loader.load_rules();
|
||||||
let allow: Vec<BoundaryRule> = raw_allow
|
let allow: Vec<BoundaryRule> = raw_allow.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
|
||||||
.iter()
|
let deny: Vec<BoundaryRule> = raw_deny.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
|
||||||
.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() {
|
if !allow.is_empty() || !deny.is_empty() {
|
||||||
let violations = check_boundary_rules(&graph, &allow, &deny);
|
let use_case = GenerateDiagram {
|
||||||
for v in &violations {
|
graph,
|
||||||
eprintln!("RULE VIOLATION: {}", v.message());
|
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(())
|
Ok(())
|
||||||
};
|
};
|
||||||
|
|
||||||
eprintln!(
|
eprintln!("Watching {} for changes (Ctrl+C to stop)...", args.path.display());
|
||||||
"Watching {} for changes (Ctrl+C to stop)...",
|
|
||||||
args.path.display()
|
|
||||||
);
|
|
||||||
if let Err(e) = run_once(&args) {
|
if let Err(e) = run_once(&args) {
|
||||||
eprintln!("Error: {e}");
|
eprintln!("Error: {e}");
|
||||||
} else {
|
} else {
|
||||||
@@ -454,18 +351,14 @@ fn run_watch(args: Cli) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let mut watcher = recommended_watcher(move |res| {
|
let mut watcher = recommended_watcher(move |res| { let _ = tx.send(res); })?;
|
||||||
let _ = tx.send(res);
|
|
||||||
})?;
|
|
||||||
watcher.watch(&args.path, RecursiveMode::Recursive)?;
|
watcher.watch(&args.path, RecursiveMode::Recursive)?;
|
||||||
|
|
||||||
let mut last_run = Instant::now();
|
let mut last_run = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
match rx.recv() {
|
match rx.recv() {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if last_run.elapsed() < debounce {
|
if last_run.elapsed() < debounce { continue; }
|
||||||
continue;
|
|
||||||
}
|
|
||||||
last_run = Instant::now();
|
last_run = Instant::now();
|
||||||
eprintln!("Change detected, regenerating...");
|
eprintln!("Change detected, regenerating...");
|
||||||
if let Err(e) = run_once(&args) {
|
if let Err(e) = run_once(&args) {
|
||||||
@@ -474,10 +367,7 @@ fn run_watch(args: Cli) -> Result<()> {
|
|||||||
eprintln!("Diagram updated.");
|
eprintln!("Diagram updated.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => { eprintln!("Watch error: {e}"); break; }
|
||||||
eprintln!("Watch error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user