feat: add rendering-primitives crate, share non_import_rels across renderers

This commit is contained in:
2026-06-17 13:26:02 +02:00
parent 7487cea0e2
commit 97c7268661
12 changed files with 100 additions and 39 deletions

View File

@@ -15,6 +15,7 @@ members = [
"crates/adapters/python-project",
"crates/adapters/d2",
"crates/adapters/html-viewer",
"crates/adapters/rendering-primitives",
]
[workspace.dependencies]
@@ -32,6 +33,7 @@ archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" }
archlens-python-project = { path = "crates/adapters/python-project" }
archlens-d2 = { path = "crates/adapters/d2" }
archlens-html = { path = "crates/adapters/html-viewer" }
archlens-rendering-primitives = { path = "crates/adapters/rendering-primitives" }
serde_json = "1"
# Error handling

View File

@@ -6,5 +6,6 @@ publish = false
[dependencies]
archlens-domain.workspace = true
archlens-rendering-primitives.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -2,6 +2,7 @@ use archlens_domain::{
CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile,
ports::DiagramRenderer,
};
use archlens_rendering_primitives::non_import_rels;
pub struct AsciiRenderer;
@@ -94,16 +95,12 @@ impl DiagramRenderer for AsciiRenderer {
lines.push("└───".to_string());
}
let non_import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.collect();
let filtered_rels: Vec<_> = non_import_rels(graph.relationships()).collect();
if !non_import_rels.is_empty() {
if !filtered_rels.is_empty() {
lines.push(String::new());
lines.push("── Relationships ──".to_string());
for rel in &non_import_rels {
for rel in &filtered_rels {
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "extends",
RelationshipKind::Composition => "has",

View File

@@ -6,6 +6,7 @@ publish = false
[dependencies]
archlens-domain.workspace = true
archlens-rendering-primitives.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -1,6 +1,7 @@
use archlens_domain::{
CodeGraph, DiagramLevel, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer,
};
use archlens_rendering_primitives::{non_import_rels, sanitize_identifier};
pub struct D2Renderer {
level: DiagramLevel,
@@ -36,20 +37,16 @@ impl DiagramRenderer for D2Renderer {
}
}
fn sanitize(name: &str) -> String {
name.replace("::", "_").replace(['-', ' '], "_")
}
fn render_type(graph: &CodeGraph) -> String {
let mut lines = Vec::new();
let (by_module, ungrouped) = graph.elements_by_module();
// Grouped by module
for (module, elements) in &by_module {
let mod_id = sanitize(module);
let mod_id = sanitize_identifier(module);
lines.push(format!("{mod_id}: {{"));
for el in elements {
let el_id = sanitize(el.name());
let el_id = sanitize_identifier(el.name());
lines.push(format!(" {el_id}: {{"));
lines.push(" shape: class".to_string());
for field in el.fields() {
@@ -69,21 +66,21 @@ fn render_type(graph: &CodeGraph) -> String {
// Ungrouped elements
for el in &ungrouped {
let el_id = sanitize(el.name());
let el_id = sanitize_identifier(el.name());
lines.push(format!("{el_id}: {{"));
lines.push(" shape: class".to_string());
lines.push("}".to_string());
}
// Relationships
for rel in graph.relationships() {
for rel in non_import_rels(graph.relationships()) {
use archlens_domain::RelationshipKind;
let src = sanitize(rel.source());
let tgt = sanitize(rel.target());
let src = sanitize_identifier(rel.source());
let tgt = sanitize_identifier(rel.target());
let arrow = match rel.kind() {
RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
RelationshipKind::Composition => format!("{src} -> {tgt}"),
RelationshipKind::Import => continue,
RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
};
lines.push(arrow);
}
@@ -95,47 +92,43 @@ fn render_module(graph: &CodeGraph) -> String {
let mut lines = Vec::new();
for module in graph.modules() {
let id = sanitize(module.as_str());
let id = sanitize_identifier(module.as_str());
lines.push(format!("{id}: {}", module.as_str()));
}
for (src, tgt) in graph.module_edges().keys() {
lines.push(format!("{} -> {}", sanitize(src), sanitize(tgt)));
lines.push(format!("{} -> {}", sanitize_identifier(src), sanitize_identifier(tgt)));
}
lines.join("\n")
}
fn render_project(graph: &CodeGraph) -> String {
use archlens_domain::RelationshipKind;
use std::collections::HashMap;
let mut lines = Vec::new();
let (by_module, ungrouped) = graph.elements_by_module();
for (module, elements) in &by_module {
let mod_id = sanitize(module);
let mod_id = sanitize_identifier(module);
lines.push(format!("{mod_id}: {{"));
for el in elements {
lines.push(format!(" {}: {}", sanitize(el.name()), el.name()));
lines.push(format!(" {}: {}", sanitize_identifier(el.name()), el.name()));
}
lines.push("}".to_string());
}
for el in &ungrouped {
lines.push(format!("{}: {}", sanitize(el.name()), el.name()));
lines.push(format!("{}: {}", sanitize_identifier(el.name()), el.name()));
}
let name_to_id: HashMap<&str, String> = graph
.elements()
.iter()
.map(|e| (e.name(), sanitize(e.name())))
.map(|e| (e.name(), sanitize_identifier(e.name())))
.collect();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
for rel in non_import_rels(graph.relationships()) {
if let (Some(src), Some(tgt)) = (name_to_id.get(rel.source()), name_to_id.get(rel.target()))
{
lines.push(format!("{src} -> {tgt}"));

View File

@@ -6,6 +6,7 @@ publish = false
[dependencies]
archlens-domain.workspace = true
archlens-rendering-primitives.workspace = true
serde.workspace = true
serde_json = "1"

View File

@@ -3,8 +3,9 @@ use std::collections::HashMap;
use serde::Serialize;
use archlens_domain::{
CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, ports::DiagramRenderer,
CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer,
};
use archlens_rendering_primitives::non_import_rels;
pub struct HtmlRenderer;
@@ -66,10 +67,7 @@ impl DiagramRenderer for HtmlRenderer {
});
}
let edges = graph
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
let edges = non_import_rels(graph.relationships())
.filter_map(|r| {
let src = id_map.get(r.source())?;
let tgt = id_map.get(r.target())?;

View File

@@ -6,5 +6,6 @@ publish = false
[dependencies]
archlens-domain.workspace = true
archlens-rendering-primitives.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -4,6 +4,7 @@ use archlens_domain::{
CodeElement, CodeGraph, DiagramLevel, DomainError, ModuleName, RelationshipKind, RenderOutput,
RenderedFile, Visibility, ports::DiagramRenderer,
};
use archlens_rendering_primitives::non_import_rels;
pub struct MermaidRenderer {
level: DiagramLevel,
@@ -94,14 +95,11 @@ impl MermaidRenderer {
lines.extend(deferred_members);
let mut rel_seen: HashSet<String> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
for rel in non_import_rels(graph.relationships()) {
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>",
RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
};
let src = Self::display_name(rel.source());
let tgt = Self::display_name(rel.target());

View File

@@ -0,0 +1,8 @@
[package]
name = "archlens-rendering-primitives"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true

View File

@@ -0,0 +1,11 @@
use archlens_domain::{Relationship, RelationshipKind};
/// Returns an iterator over all relationships except those with kind `Import`.
pub fn non_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship> {
rels.iter().filter(|r| r.kind() != RelationshipKind::Import)
}
/// Replaces `::`, `-`, `.`, and space with `_`.
pub fn sanitize_identifier(name: &str) -> String {
name.replace("::", "_").replace(['-', '.', ' '], "_")
}

View File

@@ -0,0 +1,50 @@
use archlens_domain::{
FilePath, Relationship, RelationshipKind,
};
use archlens_rendering_primitives::{non_import_rels, sanitize_identifier};
fn rel(src: &str, tgt: &str, kind: RelationshipKind) -> Relationship {
Relationship::new(src, tgt, kind).unwrap()
}
#[test]
fn non_import_rels_excludes_import_relationships() {
let rels = vec![
rel("A", "B", RelationshipKind::Composition),
rel("C", "D", RelationshipKind::Import),
rel("E", "F", RelationshipKind::Inheritance),
];
let filtered: Vec<_> = non_import_rels(&rels).collect();
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|r| r.kind() != RelationshipKind::Import));
}
#[test]
fn non_import_rels_passes_all_non_import_kinds() {
let rels = vec![
rel("A", "B", RelationshipKind::Composition),
rel("C", "D", RelationshipKind::Inheritance),
];
let filtered: Vec<_> = non_import_rels(&rels).collect();
assert_eq!(filtered.len(), 2);
}
#[test]
fn sanitize_identifier_replaces_double_colon_with_underscore() {
assert_eq!(sanitize_identifier("foo::bar"), "foo_bar");
}
#[test]
fn sanitize_identifier_replaces_hyphen_with_underscore() {
assert_eq!(sanitize_identifier("my-crate"), "my_crate");
}
#[test]
fn sanitize_identifier_replaces_dot_with_underscore() {
assert_eq!(sanitize_identifier("v1.2"), "v1_2");
}
#[test]
fn sanitize_identifier_replaces_space_with_underscore() {
assert_eq!(sanitize_identifier("my crate"), "my_crate");
}