refactor: deepen modules, consolidate inference, delete dead code
- Extract build_graph/load_config/create_renderer in presentation (393→~250 lines) - Move module inference into ModuleName::from_path(), delete 3 scattered copies - Move resolve_relationships/filter_external_imports into CodeGraph - Add LanguageExtractor trait in tree-sitter adapter - Add CodeGraph::elements_by_module(), replace 6 identical grouping loops - Delete dead RenderDiagrams query
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile,
|
CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile,
|
||||||
ports::DiagramRenderer,
|
ports::DiagramRenderer,
|
||||||
@@ -55,19 +53,7 @@ impl DiagramRenderer for AsciiRenderer {
|
|||||||
return Ok(RenderOutput::single(file));
|
return Ok(RenderOutput::single(file));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
|
let (grouped, ungrouped) = graph.elements_by_module();
|
||||||
let mut ungrouped: Vec<&CodeElement> = Vec::new();
|
|
||||||
|
|
||||||
for element in graph.elements() {
|
|
||||||
if let Some(module) = element.module() {
|
|
||||||
grouped
|
|
||||||
.entry(module.as_str().to_string())
|
|
||||||
.or_default()
|
|
||||||
.push(element);
|
|
||||||
} else {
|
|
||||||
ungrouped.push(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ungrouped.is_empty() {
|
if !ungrouped.is_empty() {
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ impl ProjectAnalyzer for CargoWorkspaceAnalyzer {
|
|||||||
let mut element =
|
let mut element =
|
||||||
CodeElement::new(package_name, CodeElementKind::Project, file_path, 1)?;
|
CodeElement::new(package_name, CodeElementKind::Project, file_path, 1)?;
|
||||||
|
|
||||||
if let Some(module) = infer_group(member_path) {
|
if let Some(module) = ModuleName::from_directory_group(member_path) {
|
||||||
element = element.with_module(module);
|
element = element.with_module(module);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,14 +110,3 @@ impl ProjectAnalyzer for CargoWorkspaceAnalyzer {
|
|||||||
Ok(graph)
|
Ok(graph)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn infer_group(member_path: &str) -> Option<ModuleName> {
|
|
||||||
let parts: Vec<&str> = member_path.split('/').collect();
|
|
||||||
if parts.len() < 3 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let group = parts[parts.len() - 2];
|
|
||||||
let capitalized = format!("{}{}", group[..1].to_uppercase(), &group[1..]);
|
|
||||||
ModuleName::new(&capitalized).ok()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
CodeElement, CodeGraph, DiagramLevel, DomainError, RelationshipKind, RenderOutput,
|
CodeElement, CodeGraph, DiagramLevel, DomainError, ModuleName, RelationshipKind, RenderOutput,
|
||||||
RenderedFile, Visibility, ports::DiagramRenderer,
|
RenderedFile, Visibility, ports::DiagramRenderer,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,20 +46,7 @@ impl MermaidRenderer {
|
|||||||
fn render_class_diagram(&self, graph: &CodeGraph) -> String {
|
fn render_class_diagram(&self, graph: &CodeGraph) -> String {
|
||||||
let mut lines = vec!["classDiagram".to_string()];
|
let mut lines = vec!["classDiagram".to_string()];
|
||||||
|
|
||||||
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
|
let (grouped, ungrouped) = graph.elements_by_module();
|
||||||
let mut ungrouped: Vec<&CodeElement> = Vec::new();
|
|
||||||
|
|
||||||
for element in graph.elements() {
|
|
||||||
if let Some(module) = element.module() {
|
|
||||||
grouped
|
|
||||||
.entry(module.as_str().to_string())
|
|
||||||
.or_default()
|
|
||||||
.push(element);
|
|
||||||
} else {
|
|
||||||
ungrouped.push(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_namespaces = !grouped.is_empty();
|
let has_namespaces = !grouped.is_empty();
|
||||||
|
|
||||||
let mut seen: HashSet<String> = HashSet::new();
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
@@ -157,7 +144,7 @@ impl MermaidRenderer {
|
|||||||
RelationshipKind::Import => {
|
RelationshipKind::Import => {
|
||||||
let source_mod = file_to_module.get(rel.source());
|
let source_mod = file_to_module.get(rel.source());
|
||||||
let target_top = rel.target().split('.').next().unwrap_or("");
|
let target_top = rel.target().split('.').next().unwrap_or("");
|
||||||
let target_mod = Self::capitalize(target_top);
|
let target_mod = ModuleName::capitalize(target_top);
|
||||||
|
|
||||||
if let Some(src) = source_mod
|
if let Some(src) = source_mod
|
||||||
&& modules.contains(&target_mod)
|
&& modules.contains(&target_mod)
|
||||||
@@ -201,29 +188,10 @@ impl MermaidRenderer {
|
|||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capitalize(s: &str) -> String {
|
|
||||||
if s.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
format!("{}{}", s[..1].to_uppercase(), &s[1..])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_project_flowchart(&self, graph: &CodeGraph) -> String {
|
fn render_project_flowchart(&self, graph: &CodeGraph) -> String {
|
||||||
let mut lines = vec!["graph TD".to_string()];
|
let mut lines = vec!["graph TD".to_string()];
|
||||||
|
|
||||||
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
|
let (grouped, ungrouped) = graph.elements_by_module();
|
||||||
let mut ungrouped: Vec<&CodeElement> = Vec::new();
|
|
||||||
|
|
||||||
for element in graph.elements() {
|
|
||||||
if let Some(module) = element.module() {
|
|
||||||
grouped
|
|
||||||
.entry(module.as_str().to_string())
|
|
||||||
.or_default()
|
|
||||||
.push(element);
|
|
||||||
} else {
|
|
||||||
ungrouped.push(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for element in &ungrouped {
|
for element in &ungrouped {
|
||||||
let id = Self::sanitize_id(element.name());
|
let id = Self::sanitize_id(element.name());
|
||||||
|
|||||||
5
crates/adapters/tree-sitter/src/language_extractor.rs
Normal file
5
crates/adapters/tree-sitter/src/language_extractor.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
use archlens_domain::{AnalysisResult, DomainError, FilePath};
|
||||||
|
|
||||||
|
pub trait LanguageExtractor {
|
||||||
|
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError>;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod language_extractor;
|
||||||
mod python;
|
mod python;
|
||||||
mod rust;
|
mod rust;
|
||||||
mod tree_sitter_analyzer;
|
mod tree_sitter_analyzer;
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ use archlens_domain::{
|
|||||||
Relationship, RelationshipKind,
|
Relationship, RelationshipKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::language_extractor::LanguageExtractor;
|
||||||
|
|
||||||
|
pub struct PythonExtractor;
|
||||||
|
|
||||||
|
impl LanguageExtractor for PythonExtractor {
|
||||||
|
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
||||||
|
analyze(source, file_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
||||||
let mut parser = Parser::new();
|
let mut parser = Parser::new();
|
||||||
parser
|
parser
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ use archlens_domain::{
|
|||||||
Relationship, RelationshipKind, Visibility,
|
Relationship, RelationshipKind, Visibility,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::language_extractor::LanguageExtractor;
|
||||||
|
|
||||||
|
pub struct RustExtractor;
|
||||||
|
|
||||||
|
impl LanguageExtractor for RustExtractor {
|
||||||
|
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
||||||
|
analyze(source, file_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
|
||||||
let mut parser = Parser::new();
|
let mut parser = Parser::new();
|
||||||
parser
|
parser
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer};
|
use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer};
|
||||||
|
|
||||||
use crate::{python, rust};
|
use crate::language_extractor::LanguageExtractor;
|
||||||
|
use crate::python::PythonExtractor;
|
||||||
|
use crate::rust::RustExtractor;
|
||||||
|
|
||||||
pub struct TreeSitterAnalyzer;
|
pub struct TreeSitterAnalyzer {
|
||||||
|
rust: RustExtractor,
|
||||||
|
python: PythonExtractor,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for TreeSitterAnalyzer {
|
impl Default for TreeSitterAnalyzer {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -12,7 +17,18 @@ impl Default for TreeSitterAnalyzer {
|
|||||||
|
|
||||||
impl TreeSitterAnalyzer {
|
impl TreeSitterAnalyzer {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self {
|
||||||
|
rust: RustExtractor,
|
||||||
|
python: PythonExtractor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extractor_for(&self, language: Language) -> Option<&dyn LanguageExtractor> {
|
||||||
|
match language {
|
||||||
|
Language::Rust => Some(&self.rust),
|
||||||
|
Language::Python => Some(&self.python),
|
||||||
|
Language::CSharp => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,10 +37,9 @@ impl SourceAnalyzer for TreeSitterAnalyzer {
|
|||||||
let source = std::fs::read_to_string(file.path().as_str())
|
let source = std::fs::read_to_string(file.path().as_str())
|
||||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||||
|
|
||||||
match file.language() {
|
match self.extractor_for(file.language()) {
|
||||||
Language::Rust => rust::analyze(&source, file.path()),
|
Some(extractor) => extractor.analyze(&source, file.path()),
|
||||||
Language::Python => python::analyze(&source, file.path()),
|
None => Ok(AnalysisResult::empty()),
|
||||||
Language::CSharp => Ok(AnalysisResult::empty()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use rayon::prelude::*;
|
|||||||
|
|
||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship,
|
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship,
|
||||||
RelationshipKind,
|
|
||||||
ports::{FileDiscovery, SourceAnalyzer},
|
ports::{FileDiscovery, SourceAnalyzer},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,7 +40,8 @@ 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 = infer_module(file.path().as_str(), root, config);
|
let module =
|
||||||
|
ModuleName::from_path(file.path().as_str(), root, config.module_mappings());
|
||||||
let elements: Vec<CodeElement> = result
|
let elements: Vec<CodeElement> = result
|
||||||
.elements()
|
.elements()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -85,67 +85,6 @@ where
|
|||||||
warnings.extend(warns);
|
warnings.extend(warns);
|
||||||
}
|
}
|
||||||
|
|
||||||
let graph = resolve_cross_file_relationships(graph);
|
|
||||||
let graph = filter_external_imports(graph, root);
|
|
||||||
|
|
||||||
Ok(AnalyzeCodebaseResult { graph, warnings })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_cross_file_relationships(graph: CodeGraph) -> CodeGraph {
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
let mut file_types: HashMap<String, HashSet<String>> = HashMap::new();
|
|
||||||
let mut name_modules: HashMap<&str, HashSet<Option<&str>>> = HashMap::new();
|
|
||||||
let all_type_names: HashSet<&str> = graph.elements().iter().map(|e| e.name()).collect();
|
|
||||||
|
|
||||||
for element in graph.elements() {
|
|
||||||
file_types
|
|
||||||
.entry(element.file_path().as_str().to_string())
|
|
||||||
.or_default()
|
|
||||||
.insert(element.name().to_string());
|
|
||||||
name_modules
|
|
||||||
.entry(element.name())
|
|
||||||
.or_default()
|
|
||||||
.insert(element.module().map(|m| m.as_str()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut resolved = CodeGraph::new();
|
|
||||||
for element in graph.elements() {
|
|
||||||
resolved.add_element(element.clone());
|
|
||||||
}
|
|
||||||
for rel in graph.relationships() {
|
|
||||||
match rel.kind() {
|
|
||||||
RelationshipKind::Import => {
|
|
||||||
resolved.add_relationship(rel.clone());
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if !all_type_names.contains(rel.source()) || !all_type_names.contains(rel.target())
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(src_file) = rel.source_file() {
|
|
||||||
let file_key = src_file.as_str().to_string();
|
|
||||||
if let Some(types_in_file) = file_types.get(&file_key)
|
|
||||||
&& types_in_file.contains(rel.target())
|
|
||||||
{
|
|
||||||
resolved.add_relationship(rel.clone());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tgt_modules = &name_modules[rel.target()];
|
|
||||||
if tgt_modules.len() == 1 {
|
|
||||||
resolved.add_relationship(rel.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
fn filter_external_imports(graph: CodeGraph, root: &Path) -> CodeGraph {
|
|
||||||
let known_dirs: HashSet<String> = std::fs::read_dir(root)
|
let known_dirs: HashSet<String> = std::fs::read_dir(root)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -155,77 +94,12 @@ fn filter_external_imports(graph: CodeGraph, root: &Path) -> CodeGraph {
|
|||||||
.map(|s| s.to_lowercase())
|
.map(|s| s.to_lowercase())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let module_names: HashSet<String> = graph
|
let graph = graph
|
||||||
.modules()
|
.resolve_relationships()
|
||||||
.iter()
|
.filter_external_imports(&known_dirs);
|
||||||
.map(|m| m.as_str().to_lowercase())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let all_known: HashSet<&str> = known_dirs
|
Ok(AnalyzeCodebaseResult { graph, warnings })
|
||||||
.iter()
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.chain(module_names.iter().map(|s| s.as_str()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut filtered = CodeGraph::new();
|
|
||||||
for element in graph.elements() {
|
|
||||||
filtered.add_element(element.clone());
|
|
||||||
}
|
}
|
||||||
for rel in graph.relationships() {
|
|
||||||
if rel.kind() == RelationshipKind::Import {
|
|
||||||
let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase();
|
|
||||||
if !all_known.contains(target_top.as_str()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filtered.add_relationship(rel.clone());
|
|
||||||
}
|
|
||||||
filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
fn infer_module(file_path: &str, root: &Path, config: &AnalysisConfig) -> Option<ModuleName> {
|
|
||||||
let relative = if let Some(stripped) = file_path.strip_prefix(root.to_str().unwrap_or("")) {
|
|
||||||
stripped.trim_start_matches('/')
|
|
||||||
} else {
|
|
||||||
file_path
|
|
||||||
};
|
|
||||||
|
|
||||||
for (pattern, module_name) in config.module_mappings() {
|
|
||||||
if relative.starts_with(pattern) {
|
|
||||||
return ModuleName::new(module_name).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let parts: Vec<&str> = relative.split('/').collect();
|
|
||||||
if parts.len() <= 1 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let module_dir = if parts[0] == "crates" && parts.len() > 2 {
|
|
||||||
// workspace: crates/<crate-name>/src/...
|
|
||||||
parts[1]
|
|
||||||
} else if parts[0] == "src" && parts.len() > 2 {
|
|
||||||
// single project: src/<module>/...
|
|
||||||
parts[1]
|
|
||||||
} else if parts[0] != "src" && parts.len() > 1 {
|
|
||||||
parts[0]
|
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let capitalized = module_dir
|
|
||||||
.split('-')
|
|
||||||
.map(|seg| {
|
|
||||||
if seg.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("-");
|
|
||||||
|
|
||||||
ModuleName::new(&capitalized).ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AnalyzeCodebaseResult {
|
pub struct AnalyzeCodebaseResult {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
mod analyze_codebase;
|
mod analyze_codebase;
|
||||||
mod render_diagrams;
|
|
||||||
|
|
||||||
pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult};
|
pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult};
|
||||||
pub use render_diagrams::RenderDiagrams;
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
use archlens_domain::{
|
|
||||||
CodeGraph, DomainError, OutputConfig,
|
|
||||||
ports::{DiagramRenderer, OutputWriter},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct RenderDiagrams<R, W>
|
|
||||||
where
|
|
||||||
R: DiagramRenderer,
|
|
||||||
W: OutputWriter,
|
|
||||||
{
|
|
||||||
renderer: R,
|
|
||||||
writer: W,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<R, W> RenderDiagrams<R, W>
|
|
||||||
where
|
|
||||||
R: DiagramRenderer,
|
|
||||||
W: OutputWriter,
|
|
||||||
{
|
|
||||||
pub fn new(renderer: R, writer: W) -> Self {
|
|
||||||
Self { renderer, writer }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn execute(&self, graph: &CodeGraph, config: &OutputConfig) -> Result<(), DomainError> {
|
|
||||||
if config.split_by_module() {
|
|
||||||
let overview = self.renderer.render(graph)?;
|
|
||||||
self.writer.write(&overview)?;
|
|
||||||
|
|
||||||
for module in graph.modules() {
|
|
||||||
let subgraph = graph.subgraph_by_module(&module);
|
|
||||||
let output = self.renderer.render(&subgraph)?;
|
|
||||||
self.writer.write(&output)?;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let output = self.renderer.render(graph)?;
|
|
||||||
self.writer.write(&output)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn writer(&self) -> &W {
|
|
||||||
&self.writer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
mod fakes;
|
|
||||||
|
|
||||||
use archlens_application::queries::RenderDiagrams;
|
|
||||||
use archlens_domain::{
|
|
||||||
CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, OutputConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
use fakes::{FakeDiagramRenderer, FakeOutputWriter};
|
|
||||||
|
|
||||||
fn build_graph() -> CodeGraph {
|
|
||||||
let mut graph = CodeGraph::new();
|
|
||||||
graph.add_element(
|
|
||||||
CodeElement::new(
|
|
||||||
"OrderService",
|
|
||||||
CodeElementKind::Class,
|
|
||||||
FilePath::new("src/service.rs").unwrap(),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.with_module(ModuleName::new("Orders").unwrap()),
|
|
||||||
);
|
|
||||||
graph.add_element(
|
|
||||||
CodeElement::new(
|
|
||||||
"BillingService",
|
|
||||||
CodeElementKind::Class,
|
|
||||||
FilePath::new("src/billing.rs").unwrap(),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.with_module(ModuleName::new("Billing").unwrap()),
|
|
||||||
);
|
|
||||||
graph
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_single_diagram_and_writes_output() {
|
|
||||||
let renderer = FakeDiagramRenderer::new();
|
|
||||||
let writer = FakeOutputWriter::new();
|
|
||||||
let config = OutputConfig::default();
|
|
||||||
|
|
||||||
let use_case = RenderDiagrams::new(renderer, writer);
|
|
||||||
use_case.execute(&build_graph(), &config).unwrap();
|
|
||||||
|
|
||||||
let outputs = use_case.writer().written_outputs();
|
|
||||||
assert_eq!(outputs.len(), 1);
|
|
||||||
assert_eq!(outputs[0].files().len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_mode_renders_overview_plus_per_module_diagrams() {
|
|
||||||
let renderer = FakeDiagramRenderer::new();
|
|
||||||
let writer = FakeOutputWriter::new();
|
|
||||||
let config = OutputConfig::default().with_split_by_module(true);
|
|
||||||
|
|
||||||
let graph = build_graph(); // has 2 modules: Orders, Billing
|
|
||||||
|
|
||||||
let use_case = RenderDiagrams::new(renderer, writer);
|
|
||||||
use_case.execute(&graph, &config).unwrap();
|
|
||||||
|
|
||||||
let outputs = use_case.writer().written_outputs();
|
|
||||||
// 1 overview + 2 module diagrams = 3 writes
|
|
||||||
assert_eq!(outputs.len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_graph_still_produces_output() {
|
|
||||||
let renderer = FakeDiagramRenderer::new();
|
|
||||||
let writer = FakeOutputWriter::new();
|
|
||||||
let config = OutputConfig::default();
|
|
||||||
|
|
||||||
let graph = CodeGraph::new();
|
|
||||||
|
|
||||||
let use_case = RenderDiagrams::new(renderer, writer);
|
|
||||||
use_case.execute(&graph, &config).unwrap();
|
|
||||||
|
|
||||||
let outputs = use_case.writer().written_outputs();
|
|
||||||
assert_eq!(outputs.len(), 1);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::{CodeElement, ModuleName, Relationship};
|
use crate::{CodeElement, ModuleName, Relationship, RelationshipKind};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CodeGraph {
|
pub struct CodeGraph {
|
||||||
@@ -53,6 +53,105 @@ impl CodeGraph {
|
|||||||
modules
|
modules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn elements_by_module(&self) -> (HashMap<String, Vec<&CodeElement>>, Vec<&CodeElement>) {
|
||||||
|
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
|
||||||
|
let mut ungrouped: Vec<&CodeElement> = Vec::new();
|
||||||
|
|
||||||
|
for element in &self.elements {
|
||||||
|
if let Some(module) = element.module() {
|
||||||
|
grouped
|
||||||
|
.entry(module.as_str().to_string())
|
||||||
|
.or_default()
|
||||||
|
.push(element);
|
||||||
|
} else {
|
||||||
|
ungrouped.push(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(grouped, ungrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_relationships(self) -> CodeGraph {
|
||||||
|
let mut file_types: HashMap<String, HashSet<String>> = HashMap::new();
|
||||||
|
let mut name_modules: HashMap<&str, HashSet<Option<&str>>> = HashMap::new();
|
||||||
|
let all_type_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect();
|
||||||
|
|
||||||
|
for element in &self.elements {
|
||||||
|
file_types
|
||||||
|
.entry(element.file_path().as_str().to_string())
|
||||||
|
.or_default()
|
||||||
|
.insert(element.name().to_string());
|
||||||
|
name_modules
|
||||||
|
.entry(element.name())
|
||||||
|
.or_default()
|
||||||
|
.insert(element.module().map(|m| m.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut resolved = CodeGraph::new();
|
||||||
|
for element in &self.elements {
|
||||||
|
resolved.add_element(element.clone());
|
||||||
|
}
|
||||||
|
for rel in &self.relationships {
|
||||||
|
match rel.kind() {
|
||||||
|
RelationshipKind::Import => {
|
||||||
|
resolved.add_relationship(rel.clone());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if !all_type_names.contains(rel.source())
|
||||||
|
|| !all_type_names.contains(rel.target())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(src_file) = rel.source_file() {
|
||||||
|
let file_key = src_file.as_str().to_string();
|
||||||
|
if let Some(types_in_file) = file_types.get(&file_key)
|
||||||
|
&& types_in_file.contains(rel.target())
|
||||||
|
{
|
||||||
|
resolved.add_relationship(rel.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tgt_modules = &name_modules[rel.target()];
|
||||||
|
if tgt_modules.len() == 1 {
|
||||||
|
resolved.add_relationship(rel.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filter_external_imports(self, known_modules: &HashSet<String>) -> CodeGraph {
|
||||||
|
let module_names: HashSet<String> = self
|
||||||
|
.modules()
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.as_str().to_lowercase())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let all_known: HashSet<&str> = known_modules
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.chain(module_names.iter().map(|s| s.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut filtered = CodeGraph::new();
|
||||||
|
for element in &self.elements {
|
||||||
|
filtered.add_element(element.clone());
|
||||||
|
}
|
||||||
|
for rel in &self.relationships {
|
||||||
|
if rel.kind() == RelationshipKind::Import {
|
||||||
|
let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase();
|
||||||
|
if !all_known.contains(target_top.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered.add_relationship(rel.clone());
|
||||||
|
}
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
|
||||||
pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph {
|
pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph {
|
||||||
let filtered_elements: Vec<CodeElement> = self
|
let filtered_elements: Vec<CodeElement> = self
|
||||||
.elements
|
.elements
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::DomainError;
|
use crate::DomainError;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
@@ -12,6 +15,60 @@ impl ModuleName {
|
|||||||
Ok(Self(trimmed.to_string()))
|
Ok(Self(trimmed.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_path(
|
||||||
|
file_path: &str,
|
||||||
|
root: &Path,
|
||||||
|
module_mappings: &HashMap<String, String>,
|
||||||
|
) -> Option<Self> {
|
||||||
|
let relative = file_path
|
||||||
|
.strip_prefix(root.to_str().unwrap_or(""))
|
||||||
|
.unwrap_or(file_path)
|
||||||
|
.trim_start_matches('/');
|
||||||
|
|
||||||
|
for (pattern, module_name) in module_mappings {
|
||||||
|
if relative.starts_with(pattern.as_str()) {
|
||||||
|
return Self::new(module_name).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = relative.split('/').collect();
|
||||||
|
if parts.len() <= 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let module_dir = if (parts[0] == "crates" || parts[0] == "src") && parts.len() > 2 {
|
||||||
|
parts[1]
|
||||||
|
} else if parts[0] != "src" && parts.len() > 1 {
|
||||||
|
parts[0]
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::new(&Self::capitalize(module_dir)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_directory_group(member_path: &str) -> Option<Self> {
|
||||||
|
let parts: Vec<&str> = member_path.split('/').collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let group = parts[parts.len() - 2];
|
||||||
|
Self::new(&Self::capitalize(group)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capitalize(s: &str) -> String {
|
||||||
|
s.split('-')
|
||||||
|
.map(|seg| {
|
||||||
|
if seg.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use archlens_application::queries::AnalyzeCodebase;
|
|||||||
use archlens_ascii::AsciiRenderer;
|
use archlens_ascii::AsciiRenderer;
|
||||||
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
|
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
|
||||||
use archlens_domain::{
|
use archlens_domain::{
|
||||||
CodeGraph, DiagramLevel,
|
CodeGraph, DiagramLevel, ModuleName,
|
||||||
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
|
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
|
||||||
};
|
};
|
||||||
use archlens_file_writer::FileOutputWriter;
|
use archlens_file_writer::FileOutputWriter;
|
||||||
@@ -30,21 +30,43 @@ pub fn run(args: Cli) -> Result<()> {
|
|||||||
}
|
}
|
||||||
init_tracing(args.verbose);
|
init_tracing(args.verbose);
|
||||||
|
|
||||||
let config_loader = match &args.config {
|
let level = parse_level(&args.level);
|
||||||
Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?,
|
let graph = build_graph(&args, level)?;
|
||||||
|
let renderer = create_renderer(&args.format, level)?;
|
||||||
|
let ext = format_extension(&args.format);
|
||||||
|
|
||||||
|
if args.check {
|
||||||
|
return check_freshness(&args.output, &graph, &*renderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.split_by_module {
|
||||||
|
write_split(&graph, &*renderer, &args.output, ext)?;
|
||||||
|
} else {
|
||||||
|
write_single(&graph, &*renderer, &args.output)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config(args: &Cli) -> Result<TomlConfigLoader> {
|
||||||
|
match &args.config {
|
||||||
|
Some(path) => Ok(TomlConfigLoader::from_path(std::path::Path::new(path))?),
|
||||||
None => {
|
None => {
|
||||||
let default_path = args.path.join("archlens.toml");
|
let default_path = args.path.join("archlens.toml");
|
||||||
if default_path.exists() {
|
if default_path.exists() {
|
||||||
TomlConfigLoader::from_path(&default_path)?
|
Ok(TomlConfigLoader::from_path(&default_path)?)
|
||||||
} else {
|
} else {
|
||||||
TomlConfigLoader::default()
|
Ok(TomlConfigLoader::default())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
|
||||||
|
let config_loader = load_config(args)?;
|
||||||
let mut analysis_config = config_loader.load_analysis_config()?;
|
let mut analysis_config = config_loader.load_analysis_config()?;
|
||||||
let level = parse_level(&args.level);
|
|
||||||
analysis_config = analysis_config.with_level(level);
|
analysis_config = analysis_config.with_level(level);
|
||||||
|
|
||||||
if let Some(ref scope) = args.scope {
|
if let Some(ref scope) = args.scope {
|
||||||
analysis_config = analysis_config.with_scope(scope.clone());
|
analysis_config = analysis_config.with_scope(scope.clone());
|
||||||
}
|
}
|
||||||
@@ -54,10 +76,10 @@ pub fn run(args: Cli) -> Result<()> {
|
|||||||
analysis_config = analysis_config.with_excludes(excludes);
|
analysis_config = analysis_config.with_excludes(excludes);
|
||||||
}
|
}
|
||||||
|
|
||||||
let graph = if level == DiagramLevel::Project {
|
if level == DiagramLevel::Project {
|
||||||
let project_analyzer = CargoWorkspaceAnalyzer::new();
|
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
|
||||||
project_analyzer.analyze(&args.path)?
|
}
|
||||||
} else {
|
|
||||||
let discovery = WalkdirDiscovery::new();
|
let discovery = WalkdirDiscovery::new();
|
||||||
let analyzer = TreeSitterAnalyzer::new();
|
let analyzer = TreeSitterAnalyzer::new();
|
||||||
let analyze = AnalyzeCodebase::new(discovery, analyzer);
|
let analyze = AnalyzeCodebase::new(discovery, analyzer);
|
||||||
@@ -91,42 +113,43 @@ pub fn run(args: Cli) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
graph
|
Ok(graph)
|
||||||
};
|
}
|
||||||
|
|
||||||
let renderer: Box<dyn archlens_domain::ports::DiagramRenderer> = match &args.format[..] {
|
fn create_renderer(
|
||||||
"mermaid" => Box::new(MermaidRenderer::with_level(level)),
|
format: &str,
|
||||||
"ascii" => Box::new(AsciiRenderer::new()),
|
level: DiagramLevel,
|
||||||
|
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
|
||||||
|
match format {
|
||||||
|
"mermaid" => Ok(Box::new(MermaidRenderer::with_level(level))),
|
||||||
|
"ascii" => Ok(Box::new(AsciiRenderer::new())),
|
||||||
fmt => bail!("unknown format: {fmt}"),
|
fmt => bail!("unknown format: {fmt}"),
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let ext = match &args.format[..] {
|
fn format_extension(format: &str) -> &str {
|
||||||
|
match format {
|
||||||
"mermaid" => "mmd",
|
"mermaid" => "mmd",
|
||||||
_ => "txt",
|
_ => "txt",
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if args.check {
|
fn check_freshness(
|
||||||
if let Some(ref path) = args.output {
|
output: &Option<String>,
|
||||||
let output = renderer.render(&graph)?;
|
graph: &CodeGraph,
|
||||||
let current = output.files().first().map(|f| f.content()).unwrap_or("");
|
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();
|
let existing = std::fs::read_to_string(path).unwrap_or_default();
|
||||||
if current != existing {
|
if current != existing {
|
||||||
eprintln!("Architecture diagram is outdated: {path}");
|
eprintln!("Architecture diagram is outdated: {path}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
println!("Architecture diagram is up to date.");
|
println!("Architecture diagram is up to date.");
|
||||||
return Ok(());
|
|
||||||
} else {
|
|
||||||
bail!("--check requires --output to specify the file to check against");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if args.split_by_module {
|
|
||||||
write_split(&graph, &*renderer, &args.output, ext)?;
|
|
||||||
} else {
|
|
||||||
write_single(&graph, &*renderer, &args.output)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,8 +236,8 @@ fn merge_project_deps_as_module_edges(
|
|||||||
let tgt_module = crate_to_module.get(rel.target());
|
let tgt_module = crate_to_module.get(rel.target());
|
||||||
|
|
||||||
if let (Some(src), Some(tgt)) = (src_module, tgt_module) {
|
if let (Some(src), Some(tgt)) = (src_module, tgt_module) {
|
||||||
let src_cap = capitalize(src);
|
let src_cap = ModuleName::capitalize(src);
|
||||||
let tgt_cap = capitalize(tgt);
|
let tgt_cap = ModuleName::capitalize(tgt);
|
||||||
|
|
||||||
if src_cap != tgt_cap
|
if src_cap != tgt_cap
|
||||||
&& graph_modules.contains(&src_cap)
|
&& graph_modules.contains(&src_cap)
|
||||||
@@ -231,62 +254,12 @@ fn merge_project_deps_as_module_edges(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capitalize(s: &str) -> String {
|
|
||||||
s.split('-')
|
|
||||||
.map(|seg| {
|
|
||||||
if seg.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("-")
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
let config_loader = match &args.config {
|
|
||||||
Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?,
|
|
||||||
None => {
|
|
||||||
let default_path = args.path.join("archlens.toml");
|
|
||||||
if default_path.exists() {
|
|
||||||
TomlConfigLoader::from_path(&default_path)?
|
|
||||||
} else {
|
|
||||||
TomlConfigLoader::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut analysis_config = config_loader.load_analysis_config()?;
|
|
||||||
let level = parse_level(&args.level);
|
let level = parse_level(&args.level);
|
||||||
analysis_config = analysis_config.with_level(level);
|
let graph = build_graph(args, level)?;
|
||||||
|
let renderer = create_renderer(&args.format, level)?;
|
||||||
let graph = if level == DiagramLevel::Project {
|
|
||||||
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
|
|
||||||
} else {
|
|
||||||
let discovery = WalkdirDiscovery::new();
|
|
||||||
let analyzer = TreeSitterAnalyzer::new();
|
|
||||||
let analyze = AnalyzeCodebase::new(discovery, analyzer);
|
|
||||||
let result = analyze.execute(&args.path, &analysis_config)?;
|
|
||||||
let mut graph = result.graph().clone();
|
|
||||||
if level == DiagramLevel::Module {
|
|
||||||
let workspace_toml = args.path.join("Cargo.toml");
|
|
||||||
if workspace_toml.exists()
|
|
||||||
&& let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path)
|
|
||||||
{
|
|
||||||
merge_project_deps_as_module_edges(&mut graph, &project_graph);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
graph
|
|
||||||
};
|
|
||||||
|
|
||||||
let renderer: Box<dyn archlens_domain::ports::DiagramRenderer> = match &args.format[..] {
|
|
||||||
"mermaid" => Box::new(MermaidRenderer::with_level(level)),
|
|
||||||
"ascii" => Box::new(AsciiRenderer::new()),
|
|
||||||
fmt => bail!("unknown format: {fmt}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let output = renderer.render(&graph)?;
|
let output = renderer.render(&graph)?;
|
||||||
let current = output.files().first().map(|f| f.content()).unwrap_or("");
|
let current = output.files().first().map(|f| f.content()).unwrap_or("");
|
||||||
|
|||||||
Reference in New Issue
Block a user