Skip to content

Implement diagram output - #18

Open
BartekBH wants to merge 6 commits into
mainfrom
feature/diagram
Open

Implement diagram output#18
BartekBH wants to merge 6 commits into
mainfrom
feature/diagram

Conversation

@BartekBH

@BartekBH BartekBH commented Feb 1, 2026

Copy link
Copy Markdown
Collaborator

#8
I started working on the diagram output. The work is still in progress, but in the meantime I’d be happy to get feedback on whether this is the right direction. (cc @Krever)
Since the code is still far from final, I’ve provided simplified documentation for the new features to make it easier to get familiar with the changes (see /docs/outputs/diagram and /docs/outputs/custom).

While designing the solution (or rather experimenting with it), my goal was to make it easy for users to implement their own diagrams with minimal effort. This influenced several design decisions, such as using traits and case classes (instead of enums) to model Relations.

There is still one important piece missing. At the moment, relations are always defined against the parent, which is why some of the examples use overcomplicated structures. It should be possible to define the target of a relation explicitly (i.e. another documented symbol).

Summary by CodeRabbit

  • New Features

    • ER diagram generation (Mermaid) for domain models with layout options
    • Simple file writer to emit outputs to specified paths
  • Documentation

    • Added guides for Diagram and Glossary outputs with examples
    • Enabled Mermaid rendering and added an "Outputs" docs section
  • Improvements

    • Example projects updated to produce glossary, HTML and diagram outputs
    • Navigation styling tweaked for improved readability

@coderabbitai

coderabbitai Bot commented Feb 1, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds Mermaid ER diagram generation and rendering, enriches documented symbols with member declarations and a name accessor, reorganizes glossary into a dedicated package, adjusts classpath handling to include JDK base modules, and adds file output utilities and website Mermaid support.

Changes

Cohort / File(s) Summary
Symbol Enrichment
domainDocs4s-core/src/main/scala/domaindocs4s/collector/Collector.scala
DocumentedSymbol now includes declarations: Vector[TermSymbol] and def name: String; constructor sites updated and getDeclarations helper added to extract non-synthetic member TermSymbols from ClassSymbols.
Classpath & JDK Integration
domainDocs4s-core/src/main/scala/domaindocs4s/collector/TastyContext.scala
Classpath entries normalized to absolute Paths; attempts to include java.base module via jrt filesystem (resilient to failures) and composes final classpath from app entries plus optional JDK base.
Diagram Generation Module
domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/...
Association.scala, Direction.scala, Entity.scala, Diagram.scala
New diagram model and renderer: Association and Direction enums, Entity case class with asMarkdown, and Diagram builder that converts Documentation -> Entities, infers field types and associations, and emits Mermaid ER markdown.
File Output Utility
domainDocs4s-core/src/main/scala/domaindocs4s/output/Writer.scala
New object Writer with apply(docs: String, path: String): Unit using java.nio.file.Files.write to persist outputs.
Glossary Namespace Reorg
domainDocs4s-core/src/main/scala/domaindocs4s/output/glossary/...
Entry.scala, Glossary.scala
Moved glossary files into domaindocs4s.output.glossary package; removed Glossary.write and adjusted imports.
Examples & Tests — Banking
domainDocs4s-examples/src/main/scala/domaindocs4s/banking/Main.scala, .../application/Application.scala, Income.scala, Liability.scala, src/test/.../BankingTest.scala
Main converted to object Main extends App; collector usage split and diagram generation added; Application model adjusted (income -> Option[Income], liabilities: List[Liability]), lifecycle methods removed; tests updated to generate glossary and diagram and to use new glossary package.
Examples & Tests — Order
domainDocs4s-examples/src/main/scala/domaindocs4s/order/Main.scala, src/test/.../OrderTest.scala
Replaced direct Glossary usage with Writer and updated imports to domaindocs4s.output.glossary.Glossary.
Example Resources
domainDocs4s-examples/src/test/resources/banking/diagram.md, glossary.md, glossary.html
Added Mermaid ER diagram fixture; removed four Application lifecycle glossary entries (approve, disburse, reject, submit).
Website — docs & config
website/docs/outputs/diagram.mdx, website/docs/outputs/glossary.mdx, website/docs/index.mdx, website/docs/outputs/_category_.json
New docs for diagram and glossary outputs; activated sidebar_label in index; added Outputs category config.
Website — Mermaid Support & Styling
website/docusaurus.config.ts, website/package.json, website/src/css/custom.css
Enabled Mermaid via markdown.mermaid = true, added @docusaurus/theme-mermaid theme dependency, and adjusted .menu__link line-height.

Sequence Diagram

sequenceDiagram
    actor User
    participant Collector
    participant Documentation
    participant Diagram
    participant EntityModel
    participant Writer
    participant Filesystem

    User->>Collector: collectSymbols(packageName)
    Collector->>Documentation: build DocumentedSymbols (incl. declarations)
    Documentation-->>Diagram: pass Documentation
    Diagram->>Documentation: filter class symbols
    Diagram->>EntityModel: buildDocEntity(symbol)
    EntityModel->>EntityModel: extract fields & term types
    EntityModel->>EntityModel: infer associations (Option/IterableOnce)
    EntityModel-->>Diagram: return Entity objects
    Diagram->>Diagram: asMarkdown(direction)
    Diagram-->>Writer: Markdown string
    Writer->>Filesystem: write(path, contents)
    Filesystem-->>User: file persisted
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Hopped through symbols, found members anew,

Chains turned to diagrams and glossaries grew,
Mermaid lines sketched the relations so neat,
Files now saved tidy — a documentation treat! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (16 files):

⚔️ domainDocs4s-core/src/main/scala/domaindocs4s/collector/Collector.scala (content)
⚔️ domainDocs4s-core/src/main/scala/domaindocs4s/collector/TastyContext.scala (content)
⚔️ domainDocs4s-examples/src/main/scala/domaindocs4s/banking/Main.scala (content)
⚔️ domainDocs4s-examples/src/main/scala/domaindocs4s/banking/application/Application.scala (content)
⚔️ domainDocs4s-examples/src/main/scala/domaindocs4s/banking/application/Income.scala (content)
⚔️ domainDocs4s-examples/src/main/scala/domaindocs4s/banking/application/Liability.scala (content)
⚔️ domainDocs4s-examples/src/main/scala/domaindocs4s/order/Main.scala (content)
⚔️ domainDocs4s-examples/src/test/resources/banking/glossary.html (content)
⚔️ domainDocs4s-examples/src/test/resources/banking/glossary.md (content)
⚔️ domainDocs4s-examples/src/test/scala/domaindocs4s/banking/BankingTest.scala (content)
⚔️ domainDocs4s-examples/src/test/scala/domaindocs4s/order/OrderTest.scala (content)
⚔️ website/docs/index.mdx (content)
⚔️ website/docusaurus.config.ts (content)
⚔️ website/package.json (content)
⚔️ website/src/css/custom.css (content)
⚔️ website/yarn.lock (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Implement diagram output' directly and clearly describes the main feature being added across the pull request—diagram generation and output capabilities.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/diagram
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch feature/diagram
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/Diagram.scala (3)

82-90: Repeated findTopLevelClass lookups on every field.

ctx.findTopLevelClass("scala.Option") and ctx.findTopLevelClass("scala.collection.IterableOnce") are resolved on every call to determineRelationshipType. Consider hoisting these lookups (e.g., resolve once in build or buildAssociations and pass them through).

♻️ Suggested approach
-  private def determineRelationshipType(field: TermSymbol)(using ctx: Context): Association = {
-    val fieldType       = typeOfTerm(field).dealias
-    val optionCls       = ctx.findTopLevelClass("scala.Option")
-    val iterableOnceCls = ctx.findTopLevelClass("scala.collection.IterableOnce")
+  private def determineRelationshipType(field: TermSymbol, optionCls: Symbol, iterableOnceCls: Symbol)(using ctx: Context): Association = {
+    val fieldType = typeOfTerm(field).dealias
 
     if (fieldType.baseType(optionCls).isDefined) Association.ZeroOrOne
     else if (fieldType.baseType(iterableOnceCls).isDefined) Association.ZeroOrMore
     else Association.ExactlyOne
   }

Then resolve once in build:

val optionCls       = ctx.findTopLevelClass("scala.Option")
val iterableOnceCls = ctx.findTopLevelClass("scala.collection.IterableOnce")

92-97: typeOfTerm throws on unexpected/missing trees — consider the blast radius.

This is called from multiple paths (buildFields, buildAssociations, isTypeArgument, determineRelationshipType). A single symbol with a missing tree will abort the entire diagram build. If partial output is acceptable, returning an Option[Type] or catching and logging the error per-symbol would be more resilient.


11-27: Direction.None as a sentinel value is fragile.

Using an enum case named None to mean "no direction" shadows scala.None and relies on equality checks to skip it. A more idiomatic approach would be to accept Option[Direction] and avoid the sentinel case entirely.

♻️ Sketch
-  def asMarkdown(direction: Direction = Direction.None): String = {
+  def asMarkdown(direction: Option[Direction] = None): String = {
     ...
-      val directionLine = if (direction == Direction.None) "" else s"  direction $direction\n"
+      val directionLine = direction.fold("")(d => s"  direction $d\n")

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Krever

Krever commented Feb 1, 2026

Copy link
Copy Markdown
Contributor

I only skimmed through the changes but from this the following questions/comments arose:

  1. I wouldn't tangle core model with mermaid - meaning the Relation type seems like currently quite specific to mermaid, with the rendering built-in. I'd rather keep it detached and interpret for mermaid in separation.
  2. Why are the relationships defined on the child and not the parent? And why those have to be defined at all? Im probably missing something, but in my head the relation of Adress has City is defined by the presence of def/val/var _: City field in the Address class. With arity (1-1, 1-n) further refined by being wrapped in a collection type or not.

@BartekBH

BartekBH commented Feb 1, 2026

Copy link
Copy Markdown
Collaborator Author
  1. Good catch. At this stage I didn’t pay much attention to that aspect, as this part was more about experimenting with the overall approach, as mentioned in the PR description. I agree that keeping the core model detached from Mermaid and interpreting it separately would be better.

  2. This mainly came from my uncertainty about the intended direction of diagram output. The approach you describe sounds very reasonable - I actually considered something similar, although I no longer remember why I decided against it at the time. That uncertainty is also why I decided to push these unfinished changes early: to get feedback on the conceptual direction. I’ll give your approach a try and see how it fits.

@BartekBH
BartekBH marked this pull request as ready for review February 12, 2026 21:37
@BartekBH

Copy link
Copy Markdown
Collaborator Author

I tried this approach and it makes much more sense now.
I've implemented a fairly minimal version, and there’s still a lot of room for improvement, but I think this could be a good starting point for now.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@domainDocs4s-core/src/main/scala/domaindocs4s/collector/TastyContext.scala`:
- Around line 23-29: The catch block in the val jdkBase (inside TastyContext)
currently catches Throwable and should be narrowed to avoid swallowing fatal
errors; change the catch to handle Exception or preferably
scala.util.control.NonFatal (e.g., case NonFatal(_) => Nil) around the
FileSystems.getFileSystem(URI.create("jrt:/")) / jrtFs.getPath("modules",
"java.base") call, and add the necessary import for scala.util.control.NonFatal
if you choose that approach.

In `@domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/Diagram.scala`:
- Around line 104-108: Diagram.write duplicates Writer.apply and introduces an
extra println side-effect; remove or consolidate it by deleting Diagram.write
and updating any callers to use Writer.apply (or have Diagram delegate to
Writer.apply) and ensure the println is removed so file writes go through the
single Writer.apply implementation (refer to Diagram.write and Writer.apply to
locate the code to change).
- Around line 51-60: buildAssociations currently collects (s.name,
determineRelationshipType(...)) pairs then calls .toMap which silently drops
duplicate keys when multiple fields reference the same documented entity; update
buildAssociations to preserve all associations by returning a
grouped/multi-value structure instead of a Map that loses duplicates—e.g.,
change the return to Map[String, List[Association]] (or List[(String,
Association)]/MultiMap) by grouping the flatMap results by s.name and merging
with a chosen strategy (e.g., collect all Association entries or reduce to the
most permissive cardinality); adjust the callers of buildAssociations
accordingly and use the existing helpers isFieldRelatedToSymbol and
determineRelationshipType to build the list before grouping.
- Around line 70-77: isTypeArgument only checks the top-level applied type args
and misses nested generics (e.g. Option[List[Foo]]), so modify isTypeArgument to
recursively inspect argument types: for each arg in the AppliedType (at.args) if
it's a TypeRef check tr.optSymbol.contains(symbol), and if it's an AppliedType
recurse into its args (or generally apply the same logic to nested
TypeRefs/AppliedTypes); ensure the function still accepts TermSymbol and Symbol
and uses typeOfTerm(field) as the entry point, preserving existing pattern
matches for AppliedType and TypeRef while adding the recursive descent to find
deeply nested occurrences.

In `@domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/Entity.scala`:
- Around line 28-29: The function escapeMermaidSpecialChars currently strips
dollar signs which can corrupt Scala compiler-generated names; update
escapeMermaidSpecialChars to stop removing "$" and instead replace it with a
readable character (e.g., "." or "_") while keeping the existing quote-escaping
logic (the method name to change is escapeMermaidSpecialChars); ensure the
replacement is applied via text.replace("$", ".") (or "_") so Scala
inner-class/companion names remain distinguishable in diagrams and document the
choice in a comment near the function.
- Around line 3-7: The Entity case class uses Map for fields and associations
which is non-deterministic for sizes >4; change Entity to use an ordered
collection (e.g., ListMap[String,String] and ListMap[String,Association] or
Vector[(String,String)] / Vector[(String,Association)]) so iteration order is
stable, update all call sites that construct Entity (notably Diagram.buildFields
and Diagram.buildAssociations) to produce ListMap.from(...) (or the chosen
ordered type) and ensure Entity.asMarkdown still iterates over those ordered
collections to produce deterministic Mermaid output and stable snapshots.

In `@domainDocs4s-core/src/main/scala/domaindocs4s/output/Writer.scala`:
- Around line 1-12: The project has duplicate file-write logic: update
Glossary.write to delegate to the shared Writer.apply instead of duplicating
Files.write (the same change should be considered for Diagram.write too);
enhance Writer.apply to explicitly use a charset (e.g., UTF-8) and ensure parent
directories exist before writing (create parent dir via Path.of(path).getParent
and Files.createDirectories if non-null) so consumers like Glossary.write and
Main.scala use a single, robust I/O primitive.

In `@domainDocs4s-examples/src/test/resources/banking/diagram.md`:
- Around line 1-40: The generated Markdown from Diagram.asMarkdown currently
preserves a leading newline due to using stripMargin without trimming; update
the asMarkdown implementation to trim the resulting string (e.g., call .trim or
equivalent on the output of stripMargin) before returning so the leading blank
line is removed while keeping the rest of the Mermaid content intact; reference
Diagram.asMarkdown and the place where stripMargin is used to locate the change.

In `@website/package.json`:
- Line 29: The devDependency "@docusaurus/theme-mermaid" is versioned at ^3.9.2
while other `@docusaurus/`* packages use ^3.7.0; update the package.json entry for
"@docusaurus/theme-mermaid" to ^3.7.0 (or alternatively update all other
`@docusaurus/`* entries to ^3.9.2) so all Docusaurus packages share the same
version; after changing the version string for "@docusaurus/theme-mermaid" run
npm/yarn install and verify the lockfile is updated and the site builds
successfully.
🧹 Nitpick comments (10)
domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/Association.scala (1)

3-7: Consider adding OneOrMore cardinality.

Standard ER notation includes four cardinalities: ExactlyOne (1..1), ZeroOrOne (0..1), ZeroOrMore (0..), and OneOrMore (1..). The last one is missing here. If this is intentional for the initial scope, a brief comment noting the omission would help future contributors.

Suggested addition
 enum Association {
   case ExactlyOne
   case ZeroOrOne
   case ZeroOrMore
+  case OneOrMore
 }
domainDocs4s-core/src/main/scala/domaindocs4s/output/Writer.scala (1)

8-9: Specify charset explicitly in getBytes.

docs.getBytes uses the platform-default charset, which can vary across environments and produce inconsistent output. Prefer docs.getBytes(StandardCharsets.UTF_8).

Proposed fix
-import java.nio.file.{Files, Path}
+import java.nio.file.{Files, Path}
+import java.nio.charset.StandardCharsets

 object Writer {

   def apply(docs: String, path: String): Unit = {
-    val _ = Files.write(Path.of(path), docs.getBytes)
+    val _ = Files.write(Path.of(path), docs.getBytes(StandardCharsets.UTF_8))
   }
domainDocs4s-core/src/main/scala/domaindocs4s/collector/Collector.scala (1)

11-19: Good addition of the name accessor — but Glossary.build still inlines the same logic.

DocumentedSymbol.name centralizes name resolution, but Glossary.build (Glossary.scala lines 73 and 82) still uses the manual s.nameOverride.getOrElse(s.symbol.name.toString) pattern. Consider updating Glossary.build to call .name instead to keep things DRY.

domainDocs4s-examples/src/main/scala/domaindocs4s/order/Main.scala (1)

24-25: Consider using Writer instead of Glossary.write.

Line 25 uses Glossary.write — if Glossary.write is deprecated in favor of the new Writer utility, this example should be updated to demonstrate the preferred API: Writer(glossary, "glossary.md").

domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/Direction.scala (1)

3-9: Direction.None shadows scala.None, prefer Option[Direction] instead.

The None case shadows scala.None, which can cause confusion in files that work with both Option and Direction. Since None acts as a sentinel for "no direction specified," using Option[Direction] as the parameter type in Diagram.asMarkdown is more idiomatic and eliminates the need for this case entirely.

♻️ Suggested change
 enum Direction {
   case TB
   case BT
   case LR
   case RL
-  case None
 }

Then in Diagram.asMarkdown:

def asMarkdown(direction: Option[Direction] = None): String = {
  // ...
  val directionLine = direction.map(d => s"  direction $d\n").getOrElse("")
  // ...
}
domainDocs4s-examples/src/test/scala/domaindocs4s/banking/BankingTest.scala (1)

34-37: Test uses default direction while the Main example uses Direction.TB.

The test calls asMarkdown() with the default Direction.None, but Main.scala demonstrates Direction.TB. Consider adding a test case with an explicit direction to cover that rendering path and catch regressions in the direction line output.

domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/Diagram.scala (2)

89-94: typeOfTerm throws hard exceptions on unexpected tree shapes — fragile for a documentation tool.

A single symbol with an unexpected or missing tree will crash the entire diagram build. For a documentation generator, it's better to be resilient and skip problematic symbols with a warning rather than abort.

♻️ Suggested approach
-  private def typeOfTerm(t: TermSymbol): Type = t.tree match {
-    case Some(v: ValDef) => v.tpt.toType
-    case Some(d: DefDef) => d.resultTpt.toType
-    case Some(other)     => throw new Exception(s"Unexpected tree type for $t: ${other.getClass.getSimpleName}")
-    case None            => throw new Exception(s"No tree found for term symbol $t")
-  }
+  private def typeOfTerm(t: TermSymbol): Option[Type] = t.tree match {
+    case Some(v: ValDef) => Some(v.tpt.toType)
+    case Some(d: DefDef) => Some(d.resultTpt.toType)
+    case _               => scala.None // skip symbols with unexpected/missing trees
+  }

Then update callers to handle Option[Type] with flatMap/collect.


79-87: ctx.findTopLevelClass is called for every field — consider hoisting the lookups.

optionCls and iterableOnceCls are resolved on every call to determineRelationshipType. While likely inexpensive for small domains, these are constants that could be resolved once and passed through or cached.

domainDocs4s-examples/src/main/scala/domaindocs4s/banking/Main.scala (1)

3-3: Replace deprecated App trait with Scala 3's @main entry point.

App is deprecated in Scala 3.8.0+ because it relied on DelayedInit, which Scala 3 dropped. For Scala 3–only code, use @main def instead—it's the idiomatic entry point and Scala 3 generates the required JVM main method automatically.

♻️ Suggested change
-object Main extends App {
+@main def main(): Unit = {
website/docs/outputs/diagram.mdx (1)

73-75: The import path resolves correctly, but consider moving generated output to the website directory structure.

The relative path '../../../domainDocs4s-examples/src/test/resources/banking/diagram.md' successfully resolves in Docusaurus and the import functions as intended. However, this pattern of importing from outside the website directory is unconventional. For better maintainability and to follow standard Docusaurus conventions, move the generated diagram.md output to the website's static/ or docs/outputs/ directory instead, so imports remain within the website project structure.

Comment thread domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/Diagram.scala Outdated
Comment thread domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/Diagram.scala Outdated
Comment thread domainDocs4s-core/src/main/scala/domaindocs4s/output/diagram/Entity.scala Outdated
Comment thread domainDocs4s-core/src/main/scala/domaindocs4s/output/Writer.scala
Comment thread domainDocs4s-examples/src/test/resources/banking/diagram.md
Comment thread website/package.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants