#!/usr/bin/env perl # # uml_blueprint.pl — generate a PlantUML class diagram from OCaml sources. # # Each module (an *.ml / *.mli file) becomes a class: # - types -> fields # - vals -> methods (with a best-effort return type) # # Edges are DECLARED, not guessed. A signature has no bodies, so a call graph # cannot be recovered from it, and scraping .ml bodies is a heuristic that lies # on aliases, opens and functor params. Instead the author states each edge in a # comment tag, anywhere in the module's .ml/.mli: # # (* @uses [-- ] *) solid A --> B : (a real call) # (* @needs [...] *) dashed A ..> B (type-only) # # Example (lib/backend/managers/Lifecycle.mli): # (* @uses Object -- calls register/unregister *) # (* @needs Path *) # yields: # Lifecycle --> Object : calls register/unregister # Lifecycle ..> Path # # A tag naming an unknown module is reported on stderr (typo guard for CI). # # Usage: perl tools/uml_blueprint.pl --src lib --out docs/architecture.puml use strict; use warnings; use File::Find (); use Getopt::Long (); my $src = 'lib'; my $out = '-'; my $title = 'RNT Architecture'; my $strict = 0; # --strict: exit non-zero if a tag names an unknown module my $no_notes = 0; # --no-notes: drop @uses edge labels (pristine edges) Getopt::Long::GetOptions( 'src=s' => \$src, 'out=s' => \$out, 'title=s' => \$title, 'strict' => \$strict, 'no-notes' => \$no_notes, ) or die "usage: $0 [--src DIR] [--out FILE] [--title STR] [--strict] [--no-notes]\n"; # --------------------------------------------------------------------------- # 1. Discover modules. A module is a capitalised *.ml / *.mli basename. # --------------------------------------------------------------------------- my %mod; # base => { ml => path, mli => path } File::Find::find( sub { return unless -f $_; return unless /\A([A-Z][A-Za-z0-9_']*)\.(mli?)\z/; $mod{$1}{ $2 eq 'mli' ? 'mli' : 'ml' } = $File::Find::name; }, $src ); my %known = map { $_ => 1 } keys %mod; die "no OCaml modules found under $src\n" unless %known; # --------------------------------------------------------------------------- # Helpers. # --------------------------------------------------------------------------- sub slurp { my ($f) = @_; open my $fh, '<', $f or die "$f: $!\n"; local $/; return <$fh>; } # Remove OCaml comments (nested (* *)) and string literals, so a keyword inside # either cannot masquerade as a member. Tags are read from the RAW text; members # from this stripped text. sub strip { my ($s) = @_; my ($i, $n, $depth, $keep) = (0, length $s, 0, ''); while ($i < $n) { my $two = substr($s, $i, 2); if ($depth == 0 && $two eq '(*') { $depth = 1; $i += 2; next; } if ($depth > 0) { if ($two eq '(*') { $depth++; $i += 2; next; } elsif ($two eq '*)') { $depth--; $i += 2; next; } $i++; next; } my $c = substr($s, $i, 1); if ($c eq '"') { # skip a string literal, honouring \" escapes $i++; while ($i < $n) { my $d = substr($s, $i, 1); if ($d eq '\\') { $i += 2; next; } elsif ($d eq '"') { $i++; last; } $i++; } $keep .= ' '; next; } $keep .= $c; $i++; } return $keep; } sub clean_name { # strip the parens off an operator val, e.g. ( |=| ) my ($n) = @_; $n =~ s/\A\(\s*//; $n =~ s/\s*\)\z//; return $n; } sub ret_of { # best-effort return type: text after the last arrow my ($s) = @_; $s =~ s/\s+/ /g; $s = $1 if $s =~ /.*->\s*(.*)\z/s; $s =~ s/[(){}:]/ /g; # keep it a valid PlantUML label $s =~ s/\s+/ /g; $s =~ s/\A\s+|\s+\z//g; $s = substr($s, 0, 57) . '...' if length $s > 60; return $s; } sub uniq { my %seen; return grep { !$seen{$_}++ } @_; } # Members from a signature (.mli). sub parse_sig { my ($t) = @_; my (@types, @vals); while ($t =~ /(? ('composes', B, note) functor param / application # @uses B [-- note] -> ('uses', B, note) declared call # @needs B [C ...] -> ('needs', B, undef) type-only coupling my $warned = 0; sub parse_tags { my ($raw, $self) = @_; my @edges; for my $kw (qw(composes uses)) { while ($raw =~ /\@$kw\s+([A-Z][\w']*)\s*(?:--\s*(.+?))?\s*(?:\*\)|\R|\z)/g) { my ($b, $note) = ($1, $2); $note =~ s/\s+\z// if defined $note; push @edges, [ $kw, $b, $note ]; } } while ($raw =~ /\@needs\s+((?:[A-Z][\w']*[,\s]*)+)/g) { push @edges, [ 'needs', $_, undef ] for ($1 =~ /([A-Z][\w']*)/g); } for my $e (@edges) { my $b = $e->[1]; if (!$known{$b}) { warn "$self: \@$e->[0] names unknown module '$b'\n"; $warned++; } } return grep { $known{ $_->[1] } && $_->[1] ne $self } @edges; } # --------------------------------------------------------------------------- # 2. Build classes and edges. # --------------------------------------------------------------------------- my (%types, %vals, %edges); for my $m (keys %mod) { my $raw_sig = $mod{$m}{mli} ? slurp($mod{$m}{mli}) : ''; my $raw_impl = $mod{$m}{ml} ? slurp($mod{$m}{ml}) : ''; if ($raw_sig ne '') { ($types{$m}, $vals{$m}) = parse_sig(strip($raw_sig)); } else { ($types{$m}, $vals{$m}) = parse_impl(strip($raw_impl)); } # Tags may live in either file. $edges{$m} = [ parse_tags($raw_sig, $m), parse_tags($raw_impl, $m) ]; } # --------------------------------------------------------------------------- # 3. Emit PlantUML. # --------------------------------------------------------------------------- my $fh; if ($out eq '-') { $fh = \*STDOUT; } else { open $fh, '>', $out or die "$out: $!\n"; } print {$fh} "\@startuml\n"; print {$fh} "!theme toy\n"; print {$fh} "title $title\n"; print {$fh} "hide empty members\n"; print {$fh} "skinparam classAttributeIconSize 0\n"; # Orthogonal routing: straight segments with right-angle turns instead of # curved splines. Use "polyline" for straight-but-diagonal edges instead. print {$fh} "skinparam linetype ortho\n"; # Keep the graph compact so it does not shrink to unreadable when fit to width, # but leave enough gap that ortho channels do not merge. Larger font offsets the # tighter packing. print {$fh} "skinparam nodesep 30\n"; print {$fh} "skinparam ranksep 50\n"; print {$fh} "skinparam defaultFontSize 15\n"; print {$fh} "left to right direction\n\n"; for my $m (sort keys %mod) { print {$fh} "class $m <> {\n"; my @t = uniq(@{ $types{$m} }); if (@t) { print {$fh} " .. types ..\n"; print {$fh} " +$_\n" for @t; } my @v = uniq(map { $_->[0] } @{ $vals{$m} }); my %ret = map { $_->[0] => $_->[1] } @{ $vals{$m} }; if (@v) { print {$fh} " .. values ..\n"; for my $name (@v) { if ($name =~ /\A[a-z_][\w']*\z/) { print {$fh} $ret{$name} ? " +$name() : $ret{$name}\n" : " +$name()\n"; } else { print {$fh} " +\"$name\"\n"; } # operator, rendered flat } } print {$fh} "}\n\n"; } # One edge per pair: the strongest declared relation wins. # composes (3) *-- > uses (2) --> > needs (1) ..> my %rank = (composes => 3, uses => 2, needs => 1); my %best; # "from|to" => [kind, note] for my $from (sort keys %mod) { for my $e (@{ $edges{$from} }) { my ($kind, $to, $note) = @$e; my $key = "$from|$to"; next if $best{$key} && $rank{ $best{$key}[0] } >= $rank{$kind}; $best{$key} = [ $kind, $note ]; } } for my $key (sort keys %best) { my ($from, $to) = split /\|/, $key; my ($kind, $note) = @{ $best{$key} }; my $arrow = $kind eq 'composes' ? '*--' : $kind eq 'uses' ? '-->' : '..>'; my $label = (!$no_notes && defined $note && $note ne '') ? " : $note" : ''; print {$fh} "$from $arrow $to$label\n"; } print {$fh} "\nlegend right\n"; print {$fh} " diamond A *-- B : functor composition (\@composes)\n"; print {$fh} " solid A --> B : declared call (\@uses)\n"; print {$fh} " dashed A ..> B : type-only coupling (\@needs)\n"; print {$fh} "endlegend\n"; print {$fh} "\@enduml\n"; close $fh if $out ne '-'; exit 2 if $strict && $warned;