graph.pm 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. use POE;
  2. use HTML::HTML5::Parser;
  3. use HTML::Entities;
  4. use HTTP::Request::Common;
  5. use JSON;
  6. use URI::Escape;
  7. use XML::LibXML::XPathContext;
  8. my $baseurl = $BotIrc::config->{graph_baseurl} // "https://gitirc.eu/g";
  9. my $dograph = sub {
  10. my ($type, $text) = @_;
  11. BotIrc::check_ctx() or return;
  12. my $req = POST("$baseurl/gen.php", [type => $type, def => $text]);
  13. $ctx = BotIrc::ctx_frozen;
  14. BotHttp::request($req, sub {
  15. my $dom = eval { HTML::HTML5::Parser->new->parse_string(shift); };
  16. if ($@) {
  17. BotIrc::send_noise($ctx, "Graph error: parsing HTML: $@");
  18. return;
  19. }
  20. my $xpc = XML::LibXML::XPathContext->new($dom);
  21. $xpc->registerNs('x', 'http://www.w3.org/1999/xhtml');
  22. my @nodes = $xpc->findnodes('//x:pre[1]');
  23. if (@nodes) {
  24. my $err = $nodes[0]->textContent;
  25. chomp $err;
  26. $err =~ s/[\r\n]+/ | /g;
  27. BotIrc::send_noise($ctx, "Error processing graph definition: $err");
  28. return;
  29. }
  30. my $src = $xpc->findvalue('//x:img/@src');
  31. if (!defined $src) {
  32. BotIrc::send_noise($ctx, "Graph error: couldn't find generated image, sorry");
  33. return;
  34. }
  35. BotIrc::send_wisdom($ctx, "Generated graph: $baseurl/$src");
  36. }, sub {
  37. BotIrc::send_noise($ctx, "Graph error: graph generation failed: ".shift);
  38. return;
  39. });
  40. };
  41. # example gitgraph definition (newlines added for clarity):
  42. # commit A[label "root" tag v0.1] B C[branch master? branch topic remote origin/master];
  43. # commit D E F[branch master mergeto C tag v0.2];
  44. # HEAD master;
  45. # HEAD? master?;
  46. # symref FOO master;
  47. # edge B A label "stuff happened here";;
  48. # A' B' C' D' E'[branch rebased];;
  49. # merge J G H I
  50. #
  51. # "commit" is optional if the commit name starts with uppercase or digit
  52. # keywords can be abbreviated:
  53. # align -> a
  54. # branch -> b
  55. # commit -> c
  56. # dim -> d
  57. # dashed -> -
  58. # dotted -> .
  59. # edge -> e
  60. # label -> l
  61. # mergeto -> m
  62. # merge -> m
  63. # remote -> r
  64. # symref -> s
  65. # tag -> t
  66. # up -> u
  67. #
  68. # empty definition part (e.g. ;;) splits into a new cluster which adds extra
  69. # padding
  70. my $gitgraph = sub {
  71. my $text = shift;
  72. BotIrc::check_ctx() or return;
  73. # Parse
  74. my @defs = split(/;/, $text);
  75. my $cluster = 0;
  76. my %nodes;
  77. my %edges;
  78. my @clusters = ([]);
  79. my @align;
  80. my @force_up;
  81. my $getident = sub {
  82. return $1 if $_[0] =~ s{^\s*([\w/.-]+'?\??)\s*}{};
  83. undef;
  84. };
  85. my $getstr = sub {
  86. return $1 if $_[0] =~ s{^\s*([\w/.-]+)\s*}{};
  87. return $1 if $_[0] =~ s{^\s*"([^"]*)"\s*}{};
  88. undef;
  89. };
  90. my $iscommit = sub {
  91. return $_[0] =~ /^[A-Z0-9]/;
  92. };
  93. my $isempty = sub {
  94. return $_[0] =~ /^\s*$/;
  95. };
  96. my $mark = sub {
  97. my ($full, $part) = @_;
  98. $full =~ s/\Q$part\E$//;
  99. "$full(HERE:)$part";
  100. };
  101. my $fail = sub {
  102. BotIrc::send_noise(".gitgraph parse error: ". shift);
  103. undef;
  104. };
  105. my $get_edge = sub {
  106. my ($id1, $id2) = @_;
  107. my $lookup = "$id1|$id2";
  108. return $edges{$lookup} if $edges{$lookup};
  109. $edges{$lookup} = {};
  110. };
  111. my $commit_node = sub {
  112. my ($id) = @_;
  113. my $node = $nodes{$id};
  114. my ($label) = ($id =~ s/\?$//r);
  115. if (!$node) {
  116. $node = {
  117. id => $id,
  118. label => $label,
  119. dim => $label ne $id,
  120. parents => [],
  121. orig => undef,
  122. type => 'commit',
  123. cluster => $cluster,
  124. };
  125. if ($id =~ /^(.+)'\??$/ && $nodes{$1}) {
  126. $node->{orig} = $1;
  127. } elsif ($id =~ /^(.+)'\??$/ && $nodes{"$1?"}) {
  128. $node->{orig} = "$1?";
  129. }
  130. push @{$clusters[$cluster]}, $node;
  131. }
  132. $nodes{$id} = $node;
  133. };
  134. my $branch_node = sub {
  135. my ($id, $commit) = @_;
  136. my $node = $nodes{$id};
  137. my ($label) = ($id =~ s/\?$//r);
  138. if (!$node) {
  139. $node = {
  140. id => $id,
  141. label => $label,
  142. dim => $label ne $id,
  143. parents => [$commit],
  144. type => 'branch',
  145. cluster => $cluster,
  146. };
  147. push @{$clusters[$cluster]}, $node;
  148. } else {
  149. $node->{parents} = [$commit];
  150. }
  151. $nodes{$id} = $node;
  152. };
  153. my $remote_node = sub {
  154. my ($id, $commit) = @_;
  155. my $node = $nodes{$id};
  156. my ($label) = ($id =~ s/\?$//r);
  157. if (!$node) {
  158. my $node = {
  159. id => $id,
  160. label => $label,
  161. dim => $label ne $id,
  162. parents => [$commit],
  163. type => 'remote',
  164. cluster => $cluster,
  165. };
  166. push @{$clusters[$cluster]}, $node;
  167. } else {
  168. $node->{parents} = [$commit];
  169. }
  170. $nodes{$id} = $node;
  171. };
  172. my $tag_node = sub {
  173. my ($id, $commit) = @_;
  174. my $node = $nodes{$id};
  175. my ($label) = ($id =~ s/\?$//r);
  176. if (!$node) {
  177. $node = {
  178. id => $id,
  179. label => $label,
  180. dim => $label ne $id,
  181. parents => [$commit],
  182. type => 'tag',
  183. cluster => $cluster,
  184. };
  185. push @{$clusters[$cluster]}, $node;
  186. } else {
  187. $node->{parents} = [$commit];
  188. }
  189. $nodes{$id} = $node;
  190. };
  191. my $symref_node = sub {
  192. my ($id, $ref) = @_;
  193. my $node = $nodes{$id};
  194. my ($label) = ($id =~ s/\?$//r);
  195. if (!$node) {
  196. $node = {
  197. id => $id,
  198. label => $label,
  199. dim => $label ne $id,
  200. parents => [$ref],
  201. type => 'symref',
  202. cluster => $cluster,
  203. };
  204. push @{$clusters[$cluster]}, $node;
  205. } else {
  206. $node->{parents} = [$ref];
  207. }
  208. $nodes{$id} = $node;
  209. };
  210. my $parse_commit = sub {
  211. my ($d, $bkup) = @_;
  212. my $id = $getident->($_[0]) or return;
  213. my $commit = $commit_node->($id);
  214. my $pre_attrs = $_[0];
  215. return $commit unless $_[0] =~ s/^\s*\[\s*//;
  216. while (1) {
  217. last if $_[0] =~ s/^\s*\]\s*//;
  218. return $fail->("unterminated attrs list: ". $mark->($bkup, $pre_attrs)) if $isempty->($_[0]);
  219. my $tmp_d = $_[0];
  220. my $cmd = $getident->($_[0]) or return $fail->("missing commit definition subcommand: ". $mark->($bkup, $_[0]));
  221. if ($cmd =~ /^b(ranch)?$/) {
  222. my $arg = $getident->($_[0]) or return $fail->("missing branch identifier: ". $mark->($bkup, $_[0]));
  223. $branch_node->($arg, $id);
  224. } elsif ($cmd =~ /^l(abel)?$/) {
  225. my $arg = $getstr->($_[0]) or return $fail->("missing label string: ". $mark->($bkup, $_[0]));
  226. $commit->{label} = $arg;
  227. } elsif ($cmd =~ /^m(ergeto)?$/) {
  228. my $arg = $getident->($_[0]) or return $fail->("missing commit identifier: ". $mark->($bkup, $_[0]));
  229. $commit_node->($arg);
  230. unshift @{$commit->{parents}}, $arg;
  231. } elsif ($cmd =~ /^r(emote)?$/) {
  232. my $arg = $getident->($_[0]) or return $fail->("missing remote identifier: ". $mark->($bkup, $_[0]));
  233. $remote_node->($arg, $id);
  234. } elsif ($cmd =~ /^t(ag)?$/) {
  235. my $arg = $getident->($_[0]) or return $fail->("missing tag identifier: ". $mark->($bkup, $_[0]));
  236. $tag_node->($arg, $id);
  237. } else {
  238. return $fail->("invalid commit definition subcommand: ". $mark->($bkup, $tmp_d));
  239. }
  240. }
  241. $commit;
  242. };
  243. for my $d (@defs) {
  244. if ($isempty->($d)) {
  245. $cluster++;
  246. push @clusters, [];
  247. next;
  248. }
  249. $d =~ s/^\s+//;
  250. my $bkup = $d;
  251. # hack to allow shorthand HEAD definition
  252. $d =~ s/^\s*(HEAD\??)\s*/symref $1 /;
  253. # shorthand for commit definition
  254. $d = "commit $d" if $iscommit->($d);
  255. my $cmd = $getident->($d);
  256. if ($cmd =~ /^a(lign)?$/) {
  257. my @args;
  258. my $tmp_d = $d;
  259. while (my $arg = $getident->($d)) {
  260. return $fail->("unknown identifier for 'align': ". $mark->($bkup, $tmp_d)) unless $nodes{$arg};
  261. push @args, $arg;
  262. $tmp_d = $d;
  263. }
  264. return $fail->("'align' needs at least two identifiers: ". $mark->($bkup, $d)) if @args < 2;
  265. push @align, \@args;
  266. } elsif ($cmd =~ /^c(ommit)?$/) {
  267. my $prev;
  268. while (my $commit = $parse_commit->($d, $bkup)) {
  269. push @{$commit->{parents}}, $prev->{id} if $prev;
  270. $prev = $commit;
  271. }
  272. return $fail->("commit list ends in junk: ". $mark->($bkup, $d)) unless $isempty->($d);
  273. } elsif ($cmd =~ /^e(dge)?$/) {
  274. my $arg1 = $getident->($d) or return $fail->("missing first identifier for edge definition: ". $mark->($bkup, $d));
  275. my $arg2 = $getident->($d) or return $fail->("missing second identifier for edge definition: ". $mark->($bkup, $d));
  276. my $edge = $get_edge->($arg1, $arg2);
  277. while (1) {
  278. my $subcmd = $getident->($d);
  279. last unless $subcmd;
  280. if ($subcmd =~ /^(dashed|-)$/) {
  281. $edge->{style} = 'dashed';
  282. } elsif ($subcmd =~ /^d(im)?$/) {
  283. $edge->{dim} = 1;
  284. } elsif ($subcmd =~ /^(dotted|\.)$/) {
  285. $edge->{style} = 'dotted';
  286. } elsif ($subcmd =~ /^l(abel)?$/) {
  287. my $label = $getstr->($d) or return $fail->("missing edge label: ". $mark->($bkup, $d));
  288. $edge->{label} = $label;
  289. } else {
  290. return $fail->("invalid subcommand for edge definition: ". $mark->($bkup, $d));
  291. }
  292. }
  293. } elsif ($cmd =~ /^m(erge)?$/) {
  294. my $main = $parse_commit->($d, $bkup) or return $fail->("merge does not attach to a valid commit: ". $mark->($bkup, $d));
  295. my @commits;
  296. while (my $commit = $parse_commit->($d, $bkup)) {
  297. push @commits, $commit;
  298. }
  299. return $fail->("invalid merge definition (missing enough valid parents): ". $mark->($bkup, $d)) unless @commits >= 2;
  300. return $fail->("merge definition ends in junk: ". $mark->($bkup, $d)) unless $isempty->($d);
  301. $main->{parents} = [map { $_->{id} } @commits];
  302. } elsif ($cmd =~ /^s(ymref)?$/) {
  303. my $arg = $getident->($d) or return $fail->("no valid symref provided: ". $mark->($bkup, $d));
  304. my $tmp_d = $d;
  305. my $ref = $getident->($d) or return $fail->("no valid target for symref provided: ". $mark->($bkup, $d));
  306. if (!$nodes{$ref}) {
  307. # Try parsing it as a new commit definition
  308. $d = $tmp_d;
  309. my $commit = $parse_commit->($d, $bkup);
  310. $ref = $commit->{id} if $commit;
  311. }
  312. unless ($nodes{$ref}) {
  313. return $fail->("target for symref is not a known node: ". $mark->($bkup, $tmp_d));
  314. }
  315. $symref_node->($arg, $ref);
  316. } elsif ($cmd =~ /^u(p)?$/) {
  317. my $tmp_d = $d;
  318. my $arg1 = $getident->($d) or return $fail->("missing base node for 'up': ". $mark->($bkup, $d));
  319. return $fail->("unknown base node for 'up': ". $mark->($bkup, $tmp_d)) unless $nodes{$arg1};
  320. $tmp_d = $d;
  321. my @args;
  322. while (my $arg = $getident->($d)) {
  323. return $fail->("unknown identifier for 'up': ". $mark->($bkup, $tmp_d)) unless $nodes{$arg};
  324. push @args, $arg;
  325. $tmp_d = $d;
  326. }
  327. return $fail->("'up' needs at least one node to move up: ". $mark->($bkup, $d)) if @args < 1;
  328. push @force_up, [$arg1, @args];
  329. } else {
  330. return $fail->("unrecognizable definition line: $bkup");
  331. }
  332. }
  333. my $render_attrs = sub {
  334. my @attrs = grep { $_ } @_;
  335. return '' unless @attrs;
  336. '['. join(', ', @attrs) .']';
  337. };
  338. my $render_node = sub {
  339. my $node = shift;
  340. my $out = qq{"$node->{id}"};
  341. my @common_attrs = (
  342. qq{label="$node->{label}"},
  343. qq{group="c$node->{cluster}"},
  344. $node->{dim} && qq{color="#00000080"},
  345. $node->{dim} && qq{fontcolor="#00000080"},
  346. );
  347. if ($node->{type} eq 'branch') {
  348. $out .= $render_attrs->(
  349. qq{shape="ellipse"},
  350. $node->{dim} ? qq{fillcolor="#ccffff"} : qq{fillcolor="#00ffff"},
  351. @common_attrs,
  352. );
  353. } elsif ($node->{type} eq 'commit') {
  354. my $ellip = $node->{label} eq '...';
  355. $out .= $render_attrs->(
  356. (@{$node->{parents}} > 1) && qq{fillcolor="#f0fcff"},
  357. $ellip && qq{penwidth=0},
  358. $ellip && qq{fillcolor="#e0e0e0"},
  359. @common_attrs,
  360. );
  361. } elsif ($node->{type} eq 'remote') {
  362. $out .= $render_attrs->(
  363. qq{shape="ellipse"},
  364. $node->{dim} ? qq{fillcolor="#ffeebb"} : qq{fillcolor="#ffbb00"},
  365. @common_attrs,
  366. );
  367. } elsif ($node->{type} eq 'symref') {
  368. $out .= $render_attrs->(
  369. qq{shape="diamond"},
  370. $node->{dim} ? qq{fillcolor="#ffffaa"} : qq{fillcolor="#ffff00"},
  371. @common_attrs,
  372. );
  373. } elsif ($node->{type} eq 'tag') {
  374. $out .= $render_attrs->(
  375. qq{shape="ellipse"},
  376. $node->{dim} ? qq{fillcolor="#ffffaa"} : qq{fillcolor="#ffff00"},
  377. );
  378. }
  379. $out .= ";";
  380. for my $p (@{$node->{parents}}) {
  381. my $edge_data = $edges{"$node->{id}|$p"};
  382. my $attrs = $render_attrs->(
  383. $node->{dim} && qq{color="#00000080"},
  384. $edge_data->{dim} && qq{color="#00000080"},
  385. $edge_data->{style} && qq{style="$edge_data->{style}"},
  386. $edge_data->{label} && qq{label="$edge_data->{label}"},
  387. );
  388. $out .= qq{"$node->{id}" -> "$p"$attrs;};
  389. }
  390. if ($node->{orig}) {
  391. $out .= qq{"$node->{id}" -> "$node->{orig}" [color="#00000080", dir=none, constraint=false, style=dotted];};
  392. }
  393. $out;
  394. };
  395. # generate dot data
  396. my $out = <<EOT;
  397. graph [nodesep=0.2, ranksep=0.3, penwidth=0.0, packmode=clust, forcelabels=true];
  398. node [shape=box, style="filled", fillcolor="#ffffff"];
  399. edge [arrowsize=0.5, fontsize=10.0];
  400. EOT
  401. my $cur_cluster = 0;
  402. for $cluster (@clusters) {
  403. $out .= qq[subgraph cluster$cur_cluster { margin = "15,0"; ];
  404. for my $node (@$cluster) {
  405. $out .= $render_node->($node);
  406. }
  407. $out .= "}";
  408. $cur_cluster++;
  409. }
  410. # Force alignment of nodes to same rank
  411. for my $align (@align) {
  412. $out .= '{ rank=same; '. join('; ', map { qq["$_"] } @$align) . '; }';
  413. }
  414. # Force ranking above a base node
  415. for my $up (@force_up) {
  416. my $arg1 = shift @$up;
  417. $out .= join('', map { qq{"$_" -> "$arg1" [style=invis]; } } @$up);
  418. }
  419. $dograph->('digraph', $out);
  420. };
  421. {
  422. irc_commands => {
  423. graph => sub { $dograph->('graph', $_[2]); },
  424. digraph => sub { $dograph->('digraph', $_[2]); },
  425. gitgraph => sub { $gitgraph->($_[2]); },
  426. },
  427. };