Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ Thumbs.db
/web/public/wasm
*.wasm
/node_modules

# Claude Code
.claude/
7 changes: 7 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,11 @@ pub struct Args {
/// Hide private members (fields and methods with names starting with _) from the diagram.
#[arg(long, verbatim_doc_comment, default_value = "false")]
pub hide_private_members: bool,

/// Group classes by their source module when processing a directory.
///
/// Each file's classes are wrapped in a Mermaid namespace block named after
/// the file's dotted module path (e.g. models/user.py -> namespace models.user).
#[arg(long, verbatim_doc_comment, default_value = "false")]
pub namespace: bool,
}
7 changes: 7 additions & 0 deletions src/class_diagram/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub struct ClassDiagram {
diagram: Diagram,
options: crate::render::mermaid_renderer::RenderOptions,
pub path: String,
current_namespace: Option<String>,
}

impl Default for ClassDiagram {
Expand All @@ -57,13 +58,18 @@ impl ClassDiagram {
diagram: Diagram::new(),
options,
path: String::new(),
current_namespace: None,
}
}

pub const fn set_hide_private_members(&mut self, hide: bool) {
self.options.hide_private_members = hide;
}

pub fn set_namespace(&mut self, namespace: Option<String>) {
self.current_namespace = namespace;
}

#[must_use]
pub const fn is_empty(&self) -> bool {
self.diagram.is_empty()
Expand Down Expand Up @@ -158,6 +164,7 @@ impl ClassDiagram {
class_type,
attributes,
methods,
namespace: self.current_namespace.clone(),
};

self.diagram.add_class(class_node);
Expand Down
78 changes: 78 additions & 0 deletions src/mermaider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,41 @@ impl Mermaider {
hide_private_members: self.args.hide_private_members,
};
let mut class_diagram = ClassDiagram::new(options);
let root = self.file_settings.project_root.as_path();

for file in parsed_files {
let Ok(source) = std::fs::read_to_string(file) else {
continue;
};
if self.args.namespace {
let ns = Self::namespace_for_file(file, root);
class_diagram.set_namespace(ns);
}
class_diagram.add_file(&source, file);
}

// Clear namespace context after all files are processed
class_diagram.set_namespace(None);
class_diagram
}

/// Compute a dotted module namespace from a file path relative to the project root.
/// e.g. `models/user.py` -> `Some("models.user")`, `main.py` -> `None`
fn namespace_for_file(file: &Path, root: &Path) -> Option<String> {
let relative = file.strip_prefix(root).unwrap_or(file);
let without_ext = relative.with_extension("");
let parts: Vec<_> = without_ext
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
if parts.len() <= 1 {
// Top-level file: no namespace (avoids wrapping everything in a single-file case)
None
} else {
Some(parts.join("."))
}
}

fn parse_folder(&self, path: &Path) -> Vec<PathBuf> {
let mut parsed_files = vec![];

Expand Down Expand Up @@ -198,6 +222,7 @@ mod tests {
direction: DiagramDirection::default(),
no_title: false,
hide_private_members: false,
namespace: false,
}
}

Expand Down Expand Up @@ -358,6 +383,59 @@ mod tests {
Ok(())
}

#[test]
fn test_namespace_groups_by_module() -> Result<()> {
init_logger();
let temp = TempDir::new()?;

let models_dir = temp.path().join("models");
std::fs::create_dir_all(&models_dir)?;
std::fs::File::create(models_dir.join("user.py"))?.write_all(b"class User: ...")?;
std::fs::File::create(models_dir.join("item.py"))?.write_all(b"class Item: ...")?;

let mut args = default_args();
args.namespace = true;
let mermaider = Mermaider::new(args, default_settings(temp.path()));
let diagrams = mermaider.generate_diagrams();

assert_eq!(diagrams.len(), 1);
let rendered = diagrams[0].render().unwrap();
assert!(
rendered.contains("namespace models.item"),
"should have namespace for models/item.py; got: {rendered}"
);
assert!(
rendered.contains("namespace models.user"),
"should have namespace for models/user.py; got: {rendered}"
);
assert!(
rendered.contains("class User"),
"User should appear inside namespace; got: {rendered}"
);
Ok(())
}

#[test]
fn test_namespace_top_level_file_has_no_namespace() -> Result<()> {
init_logger();
let temp = TempDir::new()?;
std::fs::File::create(temp.path().join("main.py"))?.write_all(b"class Main: ...")?;

let mut args = default_args();
args.namespace = true;
let mermaider = Mermaider::new(args, default_settings(temp.path()));
let diagrams = mermaider.generate_diagrams();

assert_eq!(diagrams.len(), 1);
let rendered = diagrams[0].render().unwrap();
assert!(
!rendered.contains("namespace"),
"top-level file should have no namespace; got: {rendered}"
);
assert!(rendered.contains("class Main"));
Ok(())
}

#[test]
fn test_no_title_omits_path() -> Result<()> {
init_logger();
Expand Down
31 changes: 28 additions & 3 deletions src/render/mermaid_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,13 @@ pub fn render_header(title: Option<&str>, direction: DiagramDirection) -> String

#[must_use]
pub fn render_class(class: &ClassNode, opts: &RenderOptions) -> String {
render_class_at(class, opts, 1)
}

fn render_class_at(class: &ClassNode, opts: &RenderOptions, base_indent: usize) -> String {
let mut output = String::new();
let outer_indent = indent(1);
let inner_indent = indent(2);
let outer_indent = indent(base_indent);
let inner_indent = indent(base_indent + 1);

// Class declaration
output.push_str(&outer_indent);
Expand Down Expand Up @@ -223,10 +227,30 @@ pub fn render_diagram(
let mut output = String::with_capacity(1024);
output.push_str(&render_header(title, opts.direction));

for class in diagram.classes_topologically_sorted_unique() {
// Group by namespace: flat classes first, then namespace blocks (alphabetical)
let classes = diagram.classes_topologically_sorted_unique();
let mut flat: Vec<&ClassNode> = Vec::new();
let mut namespaced: std::collections::BTreeMap<String, Vec<&ClassNode>> =
std::collections::BTreeMap::new();
for class in classes {
match &class.namespace {
None => flat.push(class),
Some(ns) => namespaced.entry(ns.clone()).or_default().push(class),
}
}

for class in &flat {
output.push_str(&render_class(class, opts));
}

for (ns, ns_classes) in &namespaced {
let _ = writeln!(output, "{}namespace {} {{", indent(1), ns);
for class in ns_classes {
output.push_str(&render_class_at(class, opts, 2));
}
let _ = write!(output, "{}}}\n\n", indent(1));
}

// Relationships (deduped; stable order)
let unique_relationships: IndexSet<_> = diagram.relationships.iter().collect();
if !unique_relationships.is_empty() {
Expand Down Expand Up @@ -284,6 +308,7 @@ mod tests {
is_async: false,
decorators: vec![],
}],
namespace: None,
};

let output = render_class(&class, &RenderOptions::default());
Expand Down
1 change: 1 addition & 0 deletions src/render/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub struct ClassNode {
pub class_type: ClassType,
pub attributes: Vec<Attribute>,
pub methods: Vec<MethodSignature>,
pub namespace: Option<String>,
}

/// Type of relationship between classes
Expand Down
Loading