mermaid_text/class.rs
1//! Data model for Mermaid `classDiagram` charts.
2//!
3//! Mermaid's `classDiagram` describes object-oriented classes with attributes,
4//! methods, visibility modifiers, and inter-class relationships (inheritance,
5//! composition, aggregation, association, dependency, and realization).
6//!
7//! # v1 supported features
8//!
9//! - Class declarations with optional `{ … }` body
10//! - Member visibility: `+` public, `-` private, `#` protected, `~` package
11//! - Attributes: `+name Type` or `+Type name` (typed-before and typed-after)
12//! - Methods: `+method(args) ReturnType`; `$` static suffix, `*` abstract suffix
13//! - Stereotypes: `<<interface>>`, `<<enumeration>>`, `<<abstract>>`
14//! - All seven relationship types (see [`RelKind`])
15//! - Edge labels and multiplicity (quoted strings)
16//! - `%%` line comments
17//!
18//! # v1 explicitly unsupported
19//!
20//! The following Mermaid features return a [`crate::Error::ParseError`] when
21//! encountered:
22//! - Generics (`Class~T~`)
23//! - Namespace blocks
24//! - `note for X` annotations
25//! - `link` / `click` directives
26//! - Colon-shorthand member form (`Animal : +name String`)
27//! - `direction` header inside the diagram body
28
29/// Visibility modifier on a class member.
30///
31/// Mermaid's four visibility symbols map directly to OOP conventions.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Visibility {
34 /// `+` — public
35 Public,
36 /// `-` — private
37 Private,
38 /// `#` — protected
39 Protected,
40 /// `~` — package (internal to the package/module)
41 Package,
42}
43
44impl Visibility {
45 /// The single-character source symbol for this visibility level.
46 pub fn as_char(self) -> char {
47 match self {
48 Self::Public => '+',
49 Self::Private => '-',
50 Self::Protected => '#',
51 Self::Package => '~',
52 }
53 }
54}
55
56/// An attribute (field) member inside a class body.
57///
58/// Mermaid allows the type before or after the name:
59/// - `+String name` — typed-before (Mermaid's primary form)
60/// - `+name String` — typed-after (also accepted)
61///
62/// Both forms are normalised to `(visibility, name, type)` here.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Attribute {
65 /// Visibility modifier (may be `None` when the `+/-/#/~` prefix is absent).
66 pub visibility: Option<Visibility>,
67 /// Attribute name.
68 pub name: String,
69 /// Declared type (may be empty if the source omits it).
70 pub type_name: String,
71 /// `true` when the `$` suffix marks this attribute as static.
72 pub is_static: bool,
73}
74
75/// A method member inside a class body.
76///
77/// `+method(args) ReturnType$*` decomposes as:
78/// - visibility = `Public`
79/// - name = `"method"`
80/// - params = `"args"`
81/// - return_type = `Some("ReturnType")`
82/// - is_static = `false`
83/// - is_abstract = `false`
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Method {
86 /// Visibility modifier.
87 pub visibility: Option<Visibility>,
88 /// Method name.
89 pub name: String,
90 /// Raw parameter list (content between parentheses), may be empty.
91 pub params: String,
92 /// Declared return type, if present.
93 pub return_type: Option<String>,
94 /// `true` when the `$` suffix marks this method as static.
95 pub is_static: bool,
96 /// `true` when the `*` suffix marks this method as abstract.
97 pub is_abstract: bool,
98}
99
100/// A member of a class body — either an attribute or a method.
101///
102/// Methods are distinguished from attributes by the presence of
103/// parentheses in the source (`method()`).
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Member {
106 /// A typed field / property.
107 Attribute(Attribute),
108 /// A callable method.
109 Method(Method),
110}
111
112impl Member {
113 /// The display name of this member (used for box width calculations).
114 pub fn name(&self) -> &str {
115 match self {
116 Self::Attribute(a) => &a.name,
117 Self::Method(m) => &m.name,
118 }
119 }
120}
121
122/// UML stereotype associated with a class.
123///
124/// Stereotypes appear as `<<name>>` inside or below the class declaration.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum Stereotype {
127 /// `<<interface>>` — class is a pure interface.
128 Interface,
129 /// `<<enumeration>>` — class is an enumeration.
130 Enumeration,
131 /// `<<abstract>>` — class is abstract.
132 Abstract,
133 /// Any other `<<name>>` (stored verbatim for future use).
134 Other(String),
135}
136
137impl Stereotype {
138 /// Returns the raw label string shown in `<<…>>`.
139 pub fn label(&self) -> &str {
140 match self {
141 Self::Interface => "interface",
142 Self::Enumeration => "enumeration",
143 Self::Abstract => "abstract",
144 Self::Other(s) => s,
145 }
146 }
147}
148
149/// One class in a [`ClassDiagram`].
150///
151/// Classes are identified by name. They may have zero or more members and an
152/// optional stereotype. Classes can be forward-declared (mentioned only in a
153/// relationship) with an empty `members` list.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct Class {
156 /// Unique class name (case-sensitive).
157 pub name: String,
158 /// Optional UML stereotype (`<<interface>>`, `<<enumeration>>`, etc.).
159 pub stereotype: Option<Stereotype>,
160 /// Members declared in the `{ … }` body, in source order.
161 pub members: Vec<Member>,
162}
163
164impl Class {
165 /// Construct a bare class with no members or stereotype.
166 ///
167 /// Used by the parser when a class is first mentioned in a relationship
168 /// before any `{ … }` declaration has been seen.
169 pub fn bare(name: impl Into<String>) -> Self {
170 Self {
171 name: name.into(),
172 stereotype: None,
173 members: Vec::new(),
174 }
175 }
176}
177
178/// The kind of relationship between two classes.
179///
180/// Mermaid's class diagram supports seven relationship arrows:
181///
182/// ```text
183/// <|-- Inheritance (solid line, hollow triangle at parent)
184/// --|> Inheritance (solid line, hollow triangle at child — reversed)
185/// *-- Composition (solid line, filled diamond at owner)
186/// o-- Aggregation (solid line, hollow diamond at owner)
187/// --> Association (solid directed arrow)
188/// -- Association (plain, no arrow)
189/// <|.. Realization (dashed line, hollow triangle at interface)
190/// ..|> Realization (dashed line, hollow triangle — reversed)
191/// <.. Dependency (dashed arrow, reversed)
192/// ..> Dependency (dashed arrow)
193/// ```
194///
195/// The `from`/`to` fields of [`Relation`] define which class the arrow
196/// originates from. The displayed endpoint glyph is at the `to` end unless
197/// `rel_kind` is `Inheritance` or `Realization` (where the triangle points
198/// at the parent/interface, i.e. the `to` end).
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum RelKind {
201 /// Inheritance — solid line with hollow triangle at parent (`--|>` / `<|--`).
202 Inheritance,
203 /// Composition — solid line with filled diamond at owner (`*--` / `--*`).
204 Composition,
205 /// Aggregation — solid line with hollow diamond at owner (`o--` / `--o`).
206 Aggregation,
207 /// Directed association — solid arrow (`-->`).
208 AssociationDirected,
209 /// Plain association — plain line with no arrow endpoints (`--`).
210 AssociationPlain,
211 /// Realization — dashed line with hollow triangle (`..|>` / `<|..`).
212 Realization,
213 /// Dependency — dashed arrow (`..>`).
214 Dependency,
215}
216
217impl RelKind {
218 /// Returns `true` if this relationship uses a dashed (non-identifying) line.
219 pub fn is_dashed(self) -> bool {
220 matches!(self, Self::Realization | Self::Dependency)
221 }
222}
223
224/// One directed relationship between two classes.
225///
226/// `from` and `to` are class names. The arrow's visual meaning depends on
227/// [`RelKind`]:
228/// - For [`RelKind::Inheritance`] and [`RelKind::Realization`] the hollow
229/// triangle points at `to` (the parent / interface).
230/// - For [`RelKind::Composition`] and [`RelKind::Aggregation`] the diamond is
231/// at the `from` end (the owner).
232/// - For [`RelKind::AssociationDirected`] and [`RelKind::Dependency`] the
233/// arrowhead points at `to`.
234/// - For [`RelKind::AssociationPlain`] there are no endpoint glyphs.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct Relation {
237 /// Source class name.
238 pub from: String,
239 /// Target class name.
240 pub to: String,
241 /// Kind of relationship (determines the endpoint glyph style).
242 pub kind: RelKind,
243 /// Optional multiplicity at the `from` end (e.g. `"1"`, `"0..*"`).
244 pub from_multiplicity: Option<String>,
245 /// Optional multiplicity at the `to` end.
246 pub to_multiplicity: Option<String>,
247 /// Optional label text placed along the line.
248 pub label: Option<String>,
249}
250
251/// A fully-parsed `classDiagram`.
252///
253/// Constructed by [`crate::parser::class::parse`] and consumed by
254/// [`crate::render::class::render`]. Classes are listed in declaration order
255/// (forward references from relationships are inserted when first encountered);
256/// relations in source order.
257#[derive(Debug, Clone, PartialEq, Eq, Default)]
258pub struct ClassDiagram {
259 /// All classes, in declaration/first-mention order.
260 pub classes: Vec<Class>,
261 /// All relationships, in source order.
262 pub relations: Vec<Relation>,
263}
264
265impl ClassDiagram {
266 /// Find a class by name. Returns its index in `classes`, or `None` if not
267 /// found.
268 pub fn class_index(&self, name: &str) -> Option<usize> {
269 self.classes.iter().position(|c| c.name == name)
270 }
271
272 /// Insert a bare class if its name isn't already present, returning its
273 /// index either way. Used by the parser when a relationship mentions a class
274 /// before its `{ … }` body is declared.
275 pub fn ensure_class(&mut self, name: &str) -> usize {
276 if let Some(idx) = self.class_index(name) {
277 return idx;
278 }
279 self.classes.push(Class::bare(name));
280 self.classes.len() - 1
281 }
282}
283
284// ---------------------------------------------------------------------------
285// Tests
286// ---------------------------------------------------------------------------
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn visibility_as_char_round_trips() {
294 assert_eq!(Visibility::Public.as_char(), '+');
295 assert_eq!(Visibility::Private.as_char(), '-');
296 assert_eq!(Visibility::Protected.as_char(), '#');
297 assert_eq!(Visibility::Package.as_char(), '~');
298 }
299
300 #[test]
301 fn class_bare_starts_empty() {
302 let c = Class::bare("Animal");
303 assert_eq!(c.name, "Animal");
304 assert!(c.members.is_empty());
305 assert!(c.stereotype.is_none());
306 }
307
308 #[test]
309 fn ensure_class_inserts_then_reuses() {
310 let mut diag = ClassDiagram::default();
311 let a0 = diag.ensure_class("A");
312 let a1 = diag.ensure_class("A");
313 let b0 = diag.ensure_class("B");
314 assert_eq!(a0, 0);
315 assert_eq!(a1, 0);
316 assert_eq!(b0, 1);
317 assert_eq!(diag.classes.len(), 2);
318 }
319
320 #[test]
321 fn class_index_returns_none_for_unknown() {
322 let diag = ClassDiagram::default();
323 assert_eq!(diag.class_index("X"), None);
324 }
325
326 #[test]
327 fn rel_kind_is_dashed_for_realization_and_dependency() {
328 assert!(RelKind::Realization.is_dashed());
329 assert!(RelKind::Dependency.is_dashed());
330 assert!(!RelKind::Inheritance.is_dashed());
331 assert!(!RelKind::Composition.is_dashed());
332 assert!(!RelKind::Aggregation.is_dashed());
333 assert!(!RelKind::AssociationDirected.is_dashed());
334 assert!(!RelKind::AssociationPlain.is_dashed());
335 }
336
337 #[test]
338 fn stereotype_label_returns_canonical_strings() {
339 assert_eq!(Stereotype::Interface.label(), "interface");
340 assert_eq!(Stereotype::Enumeration.label(), "enumeration");
341 assert_eq!(Stereotype::Abstract.label(), "abstract");
342 assert_eq!(Stereotype::Other("service".to_string()).label(), "service");
343 }
344
345 #[test]
346 fn member_name_delegates_correctly() {
347 let attr = Member::Attribute(Attribute {
348 visibility: None,
349 name: "count".to_string(),
350 type_name: "int".to_string(),
351 is_static: false,
352 });
353 let method = Member::Method(Method {
354 visibility: None,
355 name: "run".to_string(),
356 params: String::new(),
357 return_type: None,
358 is_static: false,
359 is_abstract: false,
360 });
361 assert_eq!(attr.name(), "count");
362 assert_eq!(method.name(), "run");
363 }
364}