From 97c7268661fa37acb40e94aaa268b02ab7ee5ec3 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 17 Jun 2026 13:26:02 +0200 Subject: [PATCH] feat: add rendering-primitives crate, share non_import_rels across renderers --- Cargo.toml | 2 + crates/adapters/ascii/Cargo.toml | 1 + crates/adapters/ascii/src/ascii_renderer.rs | 11 ++-- crates/adapters/d2/Cargo.toml | 1 + crates/adapters/d2/src/d2_renderer.rs | 37 ++++++-------- crates/adapters/html-viewer/Cargo.toml | 1 + .../adapters/html-viewer/src/html_renderer.rs | 8 ++- crates/adapters/mermaid/Cargo.toml | 1 + .../adapters/mermaid/src/mermaid_renderer.rs | 8 ++- .../adapters/rendering-primitives/Cargo.toml | 8 +++ .../adapters/rendering-primitives/src/lib.rs | 11 ++++ .../tests/rendering_primitives_tests.rs | 50 +++++++++++++++++++ 12 files changed, 100 insertions(+), 39 deletions(-) create mode 100644 crates/adapters/rendering-primitives/Cargo.toml create mode 100644 crates/adapters/rendering-primitives/src/lib.rs create mode 100644 crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 09c20aa..8779f63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/adapters/ascii/Cargo.toml b/crates/adapters/ascii/Cargo.toml index 8308808..e615a57 100644 --- a/crates/adapters/ascii/Cargo.toml +++ b/crates/adapters/ascii/Cargo.toml @@ -6,5 +6,6 @@ publish = false [dependencies] archlens-domain.workspace = true +archlens-rendering-primitives.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/crates/adapters/ascii/src/ascii_renderer.rs b/crates/adapters/ascii/src/ascii_renderer.rs index 53901f9..c29cdd5 100644 --- a/crates/adapters/ascii/src/ascii_renderer.rs +++ b/crates/adapters/ascii/src/ascii_renderer.rs @@ -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", diff --git a/crates/adapters/d2/Cargo.toml b/crates/adapters/d2/Cargo.toml index c49555f..58155f2 100644 --- a/crates/adapters/d2/Cargo.toml +++ b/crates/adapters/d2/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] archlens-domain.workspace = true +archlens-rendering-primitives.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/adapters/d2/src/d2_renderer.rs b/crates/adapters/d2/src/d2_renderer.rs index 4450851..9875c95 100644 --- a/crates/adapters/d2/src/d2_renderer.rs +++ b/crates/adapters/d2/src/d2_renderer.rs @@ -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}")); diff --git a/crates/adapters/html-viewer/Cargo.toml b/crates/adapters/html-viewer/Cargo.toml index b57bfef..e42a941 100644 --- a/crates/adapters/html-viewer/Cargo.toml +++ b/crates/adapters/html-viewer/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] archlens-domain.workspace = true +archlens-rendering-primitives.workspace = true serde.workspace = true serde_json = "1" diff --git a/crates/adapters/html-viewer/src/html_renderer.rs b/crates/adapters/html-viewer/src/html_renderer.rs index 793ede9..f2ffede 100644 --- a/crates/adapters/html-viewer/src/html_renderer.rs +++ b/crates/adapters/html-viewer/src/html_renderer.rs @@ -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())?; diff --git a/crates/adapters/mermaid/Cargo.toml b/crates/adapters/mermaid/Cargo.toml index 3a19309..0579437 100644 --- a/crates/adapters/mermaid/Cargo.toml +++ b/crates/adapters/mermaid/Cargo.toml @@ -6,5 +6,6 @@ publish = false [dependencies] archlens-domain.workspace = true +archlens-rendering-primitives.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/crates/adapters/mermaid/src/mermaid_renderer.rs b/crates/adapters/mermaid/src/mermaid_renderer.rs index 32368bf..9d7b7d9 100644 --- a/crates/adapters/mermaid/src/mermaid_renderer.rs +++ b/crates/adapters/mermaid/src/mermaid_renderer.rs @@ -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 = 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()); diff --git a/crates/adapters/rendering-primitives/Cargo.toml b/crates/adapters/rendering-primitives/Cargo.toml new file mode 100644 index 0000000..446771d --- /dev/null +++ b/crates/adapters/rendering-primitives/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "archlens-rendering-primitives" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true diff --git a/crates/adapters/rendering-primitives/src/lib.rs b/crates/adapters/rendering-primitives/src/lib.rs new file mode 100644 index 0000000..7ac088e --- /dev/null +++ b/crates/adapters/rendering-primitives/src/lib.rs @@ -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 { + rels.iter().filter(|r| r.kind() != RelationshipKind::Import) +} + +/// Replaces `::`, `-`, `.`, and space with `_`. +pub fn sanitize_identifier(name: &str) -> String { + name.replace("::", "_").replace(['-', '.', ' '], "_") +} diff --git a/crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs b/crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs new file mode 100644 index 0000000..861bfa1 --- /dev/null +++ b/crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs @@ -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"); +}