1 /** 2 DietDoc/DDOC support routines 3 4 Copyright: © 2012 RejectedSoftware e.K. 5 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. 6 Authors: Sönke Ludwig 7 */ 8 module ddox.ddoc; 9 10 import vibe.core.log; 11 import vibe.utils.string; 12 13 import std.algorithm : canFind, countUntil, map, min, remove; 14 import std.array; 15 import std.conv; 16 import std.string; 17 import std.uni : isAlpha; 18 19 20 static this() 21 { 22 s_standardMacros = [ 23 "P" : "<p>$0</p>", 24 "DL" : "<dl>$0</dl>", 25 "DT" : "<dt>$0</dt>", 26 "DD" : "<dd>$0</dd>", 27 "TABLE" : "<table>$0</table>", 28 "TR" : "<tr>$0</tr>", 29 "TH" : "<th>$0</th>", 30 "TD" : "<td>$0</td>", 31 "OL" : "<ol>$0</ol>", 32 "UL" : "<ul>$0</ul>", 33 "LI" : "<li>$0</li>", 34 "LINK" : "<a href=\"$0\">$0</a>", 35 "LINK2" : "<a href=\"$1\">$+</a>", 36 "LPAREN" : "(", 37 "RPAREN" : ")" 38 ]; 39 } 40 41 42 /** 43 Takes a DDOC string and outputs formatted HTML. 44 45 The hlevel parameter specifies the header level used for section names (<h2> by default). 46 By specifying a display_section callback it is also possible to output only certain sections. 47 */ 48 string formatDdocComment(string ddoc_, int hlevel = 2, bool delegate(string) display_section = null) 49 { 50 return formatDdocComment(ddoc_, new BareContext, hlevel, display_section); 51 } 52 /// ditto 53 string formatDdocComment(string text, DdocContext context, int hlevel = 2, bool delegate(string) display_section = null) 54 { 55 auto dst = appender!string(); 56 filterDdocComment(dst, text, context, hlevel, display_section); 57 return dst.data; 58 } 59 /// ditto 60 void filterDdocComment(R)(ref R dst, string text, DdocContext context, int hlevel = 2, bool delegate(string) display_section = null) 61 { 62 auto comment = new DdocComment(text); 63 comment.renderSectionsR(dst, context, display_section, hlevel); 64 } 65 66 67 /** 68 Sets a set of macros that will be available to all calls to formatDdocComment. 69 */ 70 void setDefaultDdocMacroFiles(string[] filenames) 71 { 72 import vibe.core.file; 73 import vibe.stream.operations; 74 s_defaultMacros = null; 75 foreach (filename; filenames) { 76 auto text = readAllUTF8(openFile(filename)); 77 parseMacros(s_defaultMacros, splitLines(text)); 78 } 79 } 80 81 82 /** 83 Sets a set of macros that will be available to all calls to formatDdocComment and override local macro definitions. 84 */ 85 void setOverrideDdocMacroFiles(string[] filenames) 86 { 87 import vibe.core.file; 88 import vibe.stream.operations; 89 s_overrideMacros = null; 90 foreach (filename; filenames) { 91 auto text = readAllUTF8(openFile(filename)); 92 parseMacros(s_overrideMacros, splitLines(text)); 93 } 94 } 95 96 97 /** 98 Holds a DDOC comment and formats it sectionwise as HTML. 99 */ 100 class DdocComment { 101 private { 102 Section[] m_sections; 103 string[string] m_macros; 104 bool m_isDitto = false; 105 bool m_isPrivate = false; 106 } 107 108 this(string text) 109 { 110 111 if (text.strip.icmp("ditto") == 0) { m_isDitto = true; return; } 112 if (text.strip.icmp("private") == 0) { m_isPrivate = true; return; } 113 114 115 // parseMacros(m_macros, context.defaultMacroDefinitions); 116 117 auto lines = splitLines(text); 118 if( !lines.length ) return; 119 120 int getLineType(int i) 121 { 122 auto ln = strip(lines[i]); 123 if( ln.length == 0 ) return BLANK; 124 else if( ln.length >= 3 && ln.allOf("-") ) return CODE; 125 else if( ln.indexOf(':') > 0 && isIdent(ln[0 .. ln.indexOf(':')]) ) return SECTION; 126 return TEXT; 127 } 128 129 int skipCodeBlock(int start) 130 { 131 do { 132 start++; 133 } while(start < lines.length && getLineType(start) != CODE); 134 return start+1; 135 } 136 137 int skipSection(int start) 138 { 139 while(start < lines.length ){ 140 if( getLineType(start) == SECTION ) break; 141 if( getLineType(start) == CODE ) 142 start = skipCodeBlock(start); 143 else start++; 144 } 145 return start; 146 } 147 148 int skipBlock(int start) 149 { 150 do { 151 start++; 152 } while(start < lines.length && getLineType(start) == TEXT); 153 return start; 154 } 155 156 157 int i = 0; 158 159 // special case short description on the first line 160 while( i < lines.length && getLineType(i) == BLANK ) i++; 161 if( i < lines.length && getLineType(i) == TEXT ){ 162 auto j = skipBlock(i); 163 m_sections ~= Section("$Short", lines[i .. j]); 164 i = j; 165 } 166 167 // first section is implicitly the long description 168 { 169 auto j = skipSection(i); 170 if( j > i ){ 171 m_sections ~= Section("$Long", lines[i .. j]); 172 i = j; 173 } 174 } 175 176 // parse all other sections 177 while( i < lines.length ){ 178 assert(getLineType(i) == SECTION); 179 auto j = skipSection(i+1); 180 auto pidx = lines[i].indexOf(':'); 181 auto sect = strip(lines[i][0 .. pidx]); 182 lines[i] = stripLeftDD(lines[i][pidx+1 .. $]); 183 if( lines[i].empty ) i++; 184 if( sect == "Macros" ) parseMacros(m_macros, lines[i .. j]); 185 else { 186 m_sections ~= Section(sect, lines[i .. j]); 187 } 188 i = j; 189 } 190 191 // parseMacros(m_macros, context.overrideMacroDefinitions); 192 } 193 194 @property bool isDitto() const { return m_isDitto; } 195 @property bool isPrivate() const { return m_isPrivate; } 196 197 bool hasSection(string name) const { return m_sections.canFind!(s => s.name == name); } 198 199 void renderSectionR(R)(ref R dst, DdocContext context, string name, int hlevel = 2) 200 { 201 foreach (s; m_sections) 202 if (s.name == name) 203 parseSection(dst, name, s.lines, context, hlevel, m_macros); 204 } 205 206 void renderSectionsR(R)(ref R dst, DdocContext context, bool delegate(string) display_section, int hlevel) 207 { 208 foreach (s; m_sections) { 209 if (display_section && !display_section(s.name)) continue; 210 parseSection(dst, s.name, s.lines, context, hlevel, m_macros); 211 } 212 } 213 214 string renderSection(DdocContext context, string name, int hlevel = 2) 215 { 216 auto dst = appender!string(); 217 renderSectionR(dst, context, name, hlevel); 218 return dst.data; 219 } 220 221 string renderSections(DdocContext context, bool delegate(string) display_section, int hlevel) 222 { 223 auto dst = appender!string(); 224 renderSectionsR(dst, context, display_section, hlevel); 225 return dst.data; 226 } 227 } 228 229 230 /** 231 Provides context information about the documented element. 232 */ 233 interface DdocContext { 234 /// A line array with macro definitions 235 @property string[] defaultMacroDefinitions(); 236 237 /// Line array with macro definitions that take precedence over local macros 238 @property string[] overrideMacroDefinitions(); 239 240 /// Looks up a symbol in the scope of the documented element and returns a link to it. 241 string lookupScopeSymbolLink(string name); 242 } 243 244 245 private class BareContext : DdocContext { 246 @property string[] defaultMacroDefinitions() { return null; } 247 @property string[] overrideMacroDefinitions() { return null; } 248 string lookupScopeSymbolLink(string name) { return null; } 249 } 250 251 private enum { 252 BLANK, 253 TEXT, 254 CODE, 255 SECTION 256 } 257 258 private struct Section { 259 string name; 260 string[] lines; 261 262 this(string name, string[] lines...) 263 { 264 this.name = name; 265 this.lines = lines; 266 } 267 } 268 269 private { 270 string[string] s_standardMacros; 271 string[string] s_defaultMacros; 272 string[string] s_overrideMacros; 273 } 274 275 /// private 276 private void parseSection(R)(ref R dst, string sect, string[] lines, DdocContext context, int hlevel, string[string] macros) 277 { 278 if( sect == "$Short" ) hlevel = -1; 279 280 void putHeader(string hdr){ 281 if( hlevel <= 0 ) return; 282 dst.put("<section>"); 283 if( sect.length > 0 && sect[0] != '$' ){ 284 dst.put("<h"~to!string(hlevel)~">"); 285 foreach( ch; hdr ) dst.put(ch == '_' ? ' ' : ch); 286 dst.put("</h"~to!string(hlevel)~">\n"); 287 } 288 } 289 290 void putFooter(){ 291 if( hlevel <= 0 ) return; 292 dst.put("</section>\n"); 293 } 294 295 int getLineType(int i) 296 { 297 auto ln = strip(lines[i]); 298 if( ln.length == 0 ) return BLANK; 299 else if( ln.length >= 3 &&ln.allOf("-") ) return CODE; 300 else if( ln.indexOf(':') > 0 && !ln[0 .. ln.indexOf(':')].anyOf(" \t") ) return SECTION; 301 return TEXT; 302 } 303 304 int skipBlock(int start) 305 { 306 do { 307 start++; 308 } while(start < lines.length && getLineType(start) == TEXT); 309 return start; 310 } 311 312 // run all macros first 313 { 314 //logTrace("MACROS for section %s: %s", sect, macros.keys); 315 auto tmpdst = appender!string(); 316 auto text = lines.join("\n"); 317 renderMacros(tmpdst, text, context, macros); 318 lines = splitLines(tmpdst.data); 319 } 320 321 int skipCodeBlock(int start) 322 { 323 do { 324 start++; 325 } while(start < lines.length && getLineType(start) != CODE); 326 return start; 327 } 328 329 switch( sect ){ 330 default: 331 putHeader(sect); 332 int i = 0; 333 while( i < lines.length ){ 334 int lntype = getLineType(i); 335 336 switch( lntype ){ 337 default: assert(false, "Unexpected line type "~to!string(lntype)~": "~lines[i]); 338 case BLANK: 339 dst.put('\n'); 340 i++; 341 continue; 342 case SECTION: 343 case TEXT: 344 if( hlevel >= 0 ) dst.put("<p>"); 345 auto j = skipBlock(i); 346 bool first = true; 347 renderTextLine(dst, lines[i .. j].join("\n"), context); 348 dst.put('\n'); 349 if( hlevel >= 0 ) dst.put("</p>\n"); 350 i = j; 351 break; 352 case CODE: 353 dst.put("<pre class=\"code prettyprint lang-d\">"); 354 auto j = skipCodeBlock(i); 355 auto base_indent = baseIndent(lines[i+1 .. j]); 356 foreach( ln; lines[i+1 .. j] ){ 357 renderCodeLine(dst, ln.unindent(base_indent), context); 358 dst.put('\n'); 359 } 360 dst.put("</pre>\n"); 361 i = j+1; 362 break; 363 } 364 } 365 putFooter(); 366 break; 367 case "Params": 368 putHeader("Parameters"); 369 dst.put("<table><col class=\"caption\"><tr><th>Name</th><th>Description</th></tr>\n"); 370 bool in_parameter = false; 371 string desc; 372 foreach( string ln; lines ){ 373 // check if the line starts a parameter documentation 374 string name; 375 auto eidx = ln.indexOf("="); 376 if( eidx > 0 ) name = ln[0 .. eidx].strip(); 377 if( !isIdent(name) ) name = null; 378 379 // if it does, start a new row 380 if( name.length ){ 381 if( in_parameter ){ 382 renderTextLine(dst, desc, context); 383 dst.put("</td></tr>\n"); 384 } 385 386 dst.put("<tr><td id=\""); 387 dst.put(name); 388 dst.put("\">"); 389 dst.put(name); 390 dst.put("</td><td>"); 391 392 desc = ln[eidx+1 .. $]; 393 in_parameter = true; 394 } else if( in_parameter ) desc ~= "\n" ~ ln; 395 } 396 397 if( in_parameter ){ 398 renderTextLine(dst, desc, context); 399 dst.put("</td></tr>\n"); 400 } 401 402 dst.put("</table>\n"); 403 putFooter(); 404 break; 405 } 406 407 } 408 409 /// private 410 private void renderTextLine(R)(ref R dst, string line, DdocContext context) 411 { 412 while( line.length > 0 ){ 413 switch( line[0] ){ 414 default: 415 dst.put(line[0]); 416 line = line[1 .. $]; 417 break; 418 case '<': 419 dst.put(skipHtmlTag(line)); 420 break; 421 case '>': 422 dst.put(">"); 423 line.popFront(); 424 break; 425 case '&': 426 if (line.length >= 2 && (line[1].isAlpha || line[1] == '#')) dst.put('&'); 427 else dst.put("&"); 428 line.popFront(); 429 break; 430 case '_': 431 line = line[1 .. $]; 432 auto ident = skipIdent(line); 433 if( ident.length ) dst.put(ident); 434 else dst.put('_'); 435 break; 436 case 'a': .. case 'z': 437 case 'A': .. case 'Z': 438 assert(line[0] >= 'a' && line[0] <= 'z' || line[0] >= 'A' && line[0] <= 'Z'); 439 440 auto url = skipUrl(line); 441 if( url.length ){ 442 /*dst.put("<a href=\""); 443 dst.put(url); 444 dst.put("\">");*/ 445 dst.put(url); 446 //dst.put("</a>"); 447 break; 448 } 449 450 auto ident = skipIdent(line); 451 auto link = context.lookupScopeSymbolLink(ident); 452 if( link.length ){ 453 if( link != "#" ){ 454 dst.put("<a href=\""); 455 dst.put(link); 456 dst.put("\">"); 457 } 458 dst.put("<code class=\"prettyprint lang-d\">"); 459 dst.put(ident); 460 dst.put("</code>"); 461 if( link != "#" ) dst.put("</a>"); 462 } else dst.put(ident.replace("._", ".")); 463 break; 464 } 465 } 466 } 467 468 /// private 469 private void renderCodeLine(R)(ref R dst, string line, DdocContext context) 470 { 471 while( line.length > 0 ){ 472 switch( line[0] ){ 473 default: 474 dst.put(line[0]); 475 line = line[1 .. $]; 476 break; 477 case 'a': .. case 'z': 478 case 'A': .. case 'Z': 479 assert(line[0] >= 'a' && line[0] <= 'z' || line[0] >= 'A' && line[0] <= 'Z'); 480 auto ident = skipIdent(line); 481 auto link = context.lookupScopeSymbolLink(ident); 482 if( link.length && link != "#" ){ 483 dst.put("<a href=\""); 484 dst.put(link); 485 dst.put("\">"); 486 dst.put(ident); 487 dst.put("</a>"); 488 } else dst.put(ident); 489 break; 490 } 491 } 492 } 493 494 /// private 495 private void renderMacros(R)(ref R dst, string line, DdocContext context, string[string] macros, string[] params = null) 496 { 497 while( !line.empty ){ 498 auto idx = line.indexOf('$'); 499 if( idx < 0 ){ 500 dst.put(line); 501 return; 502 } 503 dst.put(line[0 .. idx]); 504 line = line[idx .. $]; 505 renderMacro(dst, line, context, macros, params); 506 } 507 } 508 509 /// private 510 private void renderMacro(R)(ref R dst, ref string line, DdocContext context, string[string] macros, string[] params = null) 511 { 512 assert(line[0] == '$'); 513 line = line[1 .. $]; 514 if( line.length < 1) return; 515 516 if( line[0] >= '0' && line[0] <= '9' ){ 517 int pidx = line[0]-'0'; 518 if( pidx < params.length ) 519 dst.put(params[pidx]); 520 line = line[1 .. $]; 521 } else if( line[0] == '+' ){ 522 if( params.length ){ 523 auto idx = params[0].indexOf(','); 524 if( idx >= 0 ) dst.put(params[0][idx+1 .. $].stripLeftDD()); 525 } 526 line = line[1 .. $]; 527 } else if( line[0] == '(' ){ 528 line = line[1 .. $]; 529 int l = 1; 530 size_t cidx = 0; 531 for( cidx = 0; cidx < line.length && l > 0; cidx++ ){ 532 if( line[cidx] == '(' ) l++; 533 else if( line[cidx] == ')' ) l--; 534 } 535 if( l > 0 ){ 536 logDebug("Unmatched parenthesis in DDOC comment: %s", line[0 .. cidx]); 537 return; 538 } 539 if( cidx < 1 ){ 540 logDebug("Empty macro parens."); 541 return; 542 } 543 544 auto mnameidx = line[0 .. cidx-1].countUntilAny(" \t\r\n"); 545 if( mnameidx < 0 ) mnameidx = cidx-1; 546 if( mnameidx == 0 ){ 547 logDebug("Macro call in DDOC comment is missing macro name."); 548 return; 549 } 550 auto mname = line[0 .. mnameidx]; 551 552 string[] args; 553 if( mnameidx+1 < cidx ){ 554 auto rawargs = splitParams(line[mnameidx+1 .. cidx-1]); 555 foreach( arg; rawargs ){ 556 auto argtext = appender!string(); 557 renderMacros(argtext, arg, context, macros, params); 558 args ~= argtext.data(); 559 } 560 } 561 args = join(args, ",").stripLeftDD() ~ args.map!(s => s.stripLeftDD()).array; 562 563 logTrace("PARAMS for %s: %s", mname, args); 564 line = line[cidx .. $]; 565 566 auto pm = mname in s_overrideMacros; 567 if( !pm ) pm = mname in macros; 568 if( !pm ) pm = mname in s_defaultMacros; 569 if( !pm ) pm = mname in s_standardMacros; 570 571 if( pm ){ 572 logTrace("MACRO %s: %s", mname, *pm); 573 renderMacros(dst, *pm, context, macros, args); 574 } else { 575 logTrace("Macro '%s' not found.", mname); 576 if( args.length ) dst.put(args[0]); 577 } 578 } 579 } 580 581 private string[] splitParams(string ln) 582 { 583 string[] ret; 584 size_t i = 0, start = 0; 585 while(i < ln.length){ 586 if( ln[i] == ',' ){ 587 ret ~= ln[start .. i]; 588 start = ++i; 589 } else if( ln[i] == '(' ){ 590 i++; 591 int l = 1; 592 for( ; i < ln.length && l > 0; i++ ){ 593 if( ln[i] == '(' ) l++; 594 else if( ln[i] == ')' ) l--; 595 } 596 } else i++; 597 } 598 if( i > start ) ret ~= ln[start .. i]; 599 return ret; 600 } 601 602 private string skipHtmlTag(ref string ln) 603 { 604 assert(ln[0] == '<'); 605 606 // skip HTML comment 607 if (ln.startsWith("<!--")) { 608 auto idx = ln[4 .. $].indexOf("-->"); 609 if (idx < 0) { 610 ln.popFront(); 611 return "<"; 612 } 613 auto ret = ln[0 .. idx+7]; 614 ln = ln[ret.length .. $]; 615 return ret; 616 } 617 618 // too short for a tag 619 if (ln.length < 2 || (!ln[1].isAlpha && ln[1] != '#' && ln[1] != '/')) { 620 // found no match, return escaped '<' 621 logTrace("Found stray '<' in DDOC string."); 622 ln.popFront(); 623 return "<"; 624 } 625 626 // skip over regular start/end tag 627 auto idx = ln.indexOf(">"); 628 if (idx < 0) { 629 ln.popFront(); 630 return "<"; 631 } 632 auto ret = ln[0 .. idx+1]; 633 ln = ln[ret.length .. $]; 634 return ret; 635 } 636 637 private string skipUrl(ref string ln) 638 { 639 if( !ln.startsWith("http://") && !ln.startsWith("http://") ) 640 return null; 641 642 bool saw_dot = false; 643 size_t i = 7; 644 645 for_loop: 646 while( i < ln.length ){ 647 switch( ln[i] ){ 648 default: 649 break for_loop; 650 case 'a': .. case 'z': 651 case 'A': .. case 'Z': 652 case '0': .. case '9': 653 case '_', '-', '?', '=', '%', '&', '/', '+', '#', '~': 654 break; 655 case '.': 656 saw_dot = true; 657 break; 658 } 659 i++; 660 } 661 662 if( saw_dot ){ 663 auto ret = ln[0 .. i]; 664 ln = ln[i .. $]; 665 return ret; 666 } else return null; 667 } 668 669 private string skipIdent(ref string str) 670 { 671 string strcopy = str; 672 673 bool last_was_ident = false; 674 while( !str.empty ){ 675 auto ch = str.front; 676 677 if( last_was_ident ){ 678 // dots are allowed if surrounded by identifiers 679 if( ch == '.' ) last_was_ident = false; 680 else if( ch != '_' && (ch < '0' || ch > '9') && !std.uni.isAlpha(ch) ) break; 681 } else { 682 if( ch != '_' && !std.uni.isAlpha(ch) ) break; 683 last_was_ident = true; 684 } 685 str.popFront(); 686 } 687 688 // if the identifier ended in a '.', remove it again 689 if( str.length != strcopy.length && !last_was_ident ) 690 str = strcopy[strcopy.length-str.length-1 .. $]; 691 692 return strcopy[0 .. strcopy.length-str.length]; 693 } 694 695 private bool isIdent(string str) 696 { 697 skipIdent(str); 698 return str.length == 0; 699 } 700 701 private void parseMacros(ref string[string] macros, in string[] lines) 702 { 703 string name; 704 foreach (string ln; lines) { 705 // macro definitions are of the form IDENT = ... 706 auto pidx = ln.indexOf('='); 707 if( pidx > 0 ){ 708 auto tmpnam = ln[0 .. pidx].strip(); 709 if( isIdent(tmpnam) ){ 710 // got new macro definition 711 name = tmpnam; 712 macros[name] = stripLeftDD(ln[pidx+1 .. $]); 713 continue; 714 } 715 } 716 717 // append to previous macro definition, if any 718 if (name.length) macros[name] ~= "\n" ~ ln; 719 } 720 } 721 722 private int baseIndent(string[] lines) 723 { 724 if( lines.length == 0 ) return 0; 725 int ret = int.max; 726 foreach( ln; lines ){ 727 int i = 0; 728 while( i < ln.length && (ln[i] == ' ' || ln[i] == '\t') ) 729 i++; 730 if( i < ln.length ) ret = min(ret, i); 731 } 732 return ret; 733 } 734 735 private string unindent(string ln, int amount) 736 { 737 while( amount > 0 && ln.length > 0 && (ln[0] == ' ' || ln[0] == '\t') ) 738 ln = ln[1 .. $], amount--; 739 return ln; 740 } 741 742 private string stripLeftDD(string s) 743 { 744 while (!s.empty && (s.front == ' ' || s.front == '\t')) 745 s.popFront(); 746 return s; 747 } 748 749 750 import std.stdio; 751 unittest { 752 auto src = "$(M a b)\n$(M a\nb)\nMacros:\n M = -$0-\n"; 753 auto dst = "-a b-\n-a\nb-\n"; 754 assert(formatDdocComment(src) == dst); 755 } 756 757 unittest { 758 auto src = "\n $(M a b)\n$(M a \nb)\nMacros:\n M = -$0- \n\nN=$0"; 759 auto dst = " -a b- \n\n-a \nb- \n"; 760 assert(formatDdocComment(src) == dst); 761 } 762 763 unittest { 764 auto src = "$(M a, b)\n$(M a,\n b)\nMacros:\n M = -$1-\n\n +$2+\n\n N=$0"; 765 auto dst = "-a-\n\n +b+\n\n-a-\n\n +\n b+\n"; 766 assert(formatDdocComment(src) == dst); 767 } 768 769 unittest { 770 auto src = "$(GLOSSARY a\nb)\nMacros:\n GLOSSARY = $(LINK2 glossary.html#$0, $0)"; 771 auto dst = "<a href=\"glossary.html#a\nb\">a\nb</a>\n"; 772 assert(formatDdocComment(src) == dst); 773 } 774 775 unittest { 776 auto src = "a > b < < c > <a <# </ <br> <abc> <.abc> <-abc> <+abc> <0abc> <abc-> <> <!-- c --> <!--> <! > <!-- > >a."; 777 auto dst = "a > b < < c > <a <# </ <br> <abc> <.abc> <-abc> <+abc> <0abc> <abc-> <> <!-- c --> <!--> <! > <!-- > >a.\n"; 778 assert(formatDdocComment(src) == dst); 779 } 780 781 unittest { 782 auto src = "& &a < &#lt; &- &03; &;"; 783 auto dst = "& &a < &#lt; &- &03; &;\n"; 784 assert(formatDdocComment(src) == dst); 785 } 786 787 unittest { 788 auto src = "<a href=\"abc\">test $(LT)peter@parker.com$(GT)</a>\nMacros:\nLT = <\nGT = >"; 789 auto dst = "<a href=\"abc\">test <peter@parker.com></a>\n"; 790 //writeln(formatDdocComment(src).splitLines().map!(s => "|"~s~"|").join("\n")); 791 assert(formatDdocComment(src) == dst); 792 }