1 module prettyprint; 2 3 pure: 4 @safe: 5 6 import std.algorithm; 7 import std.range; 8 import std.typecons; 9 version(unittest) import unit_threaded.should; 10 11 /** 12 * This function takes the input text and returns a pretty-printed, multiline, indented version. 13 * It assumes that the input text is the output of a well-structured toString and forms a valid 14 * comma separated paren tree. 15 * 16 * A comma separated paren tree is a string that contains a balanced number of quotation marks, parentheses 17 * and brackets. 18 * 19 * For example, the string may take the form `Class(field1=1, field2="text", field3=Struct(a=4, b=5))`. 20 */ 21 public string prettyprint(const string text, size_t columnWidth = 80) 22 { 23 const trees = text.parse; 24 25 if (trees.empty) 26 { 27 return text; 28 } 29 30 auto buffer = OutputBuffer(); 31 32 trees.each!(tree => prettyprint(buffer, tree, columnWidth)); 33 return buffer.data; 34 } 35 36 /// 37 @("pretty print a string") 38 unittest 39 { 40 import std.string : outdent, strip; 41 42 prettyprint("Foo").shouldEqual("Foo"); 43 prettyprint("Foo(").shouldEqual("Foo("); 44 prettyprint("Foo()").shouldEqual("Foo()"); 45 prettyprint("Foo[]").shouldEqual("Foo[]"); 46 prettyprint("Foo{}").shouldEqual("Foo{}"); 47 prettyprint("Foo(A, B)").shouldEqual("Foo(A, B)"); 48 prettyprint("Foo(Bar(Baz()), Baq())", 16).shouldEqual(" 49 Foo( 50 Bar(Baz()), 51 Baq() 52 )".outdent.strip); 53 prettyprint("Foo(Bar(Baz()), Baq())", 12).shouldEqual(" 54 Foo( 55 Bar( 56 Baz( 57 ) 58 ), 59 Baq() 60 )".outdent.strip); 61 } 62 63 @("list of strings") 64 unittest 65 { 66 prettyprint(`["a", "b"]`).shouldEqual(`["a", "b"]`); 67 } 68 69 // bug 70 @("linebreak with comma separated elements without children") 71 unittest 72 { 73 import std.string : outdent, strip; 74 75 const filler = "-".repeat(80).join; 76 77 prettyprint(filler ~ `("a", "b")`).shouldEqual(filler ~ ` 78 ( 79 "a", 80 "b" 81 )`.outdent.strip); 82 } 83 84 private enum indent = " ".repeat(4).join; 85 86 private void prettyprint(ref OutputBuffer buffer, const Tree tree, size_t width) 87 { 88 import std.string : stripLeft; 89 90 // skip prefix so caller can decide whether or not to strip 91 void renderSingleLine(const Tree tree) @safe 92 { 93 if (tree.parenType.isNull) 94 { 95 buffer ~= tree.suffix; 96 return; 97 } 98 buffer ~= tree.parenType.get.opening; 99 tree.children.enumerate.each!((index, child) { 100 buffer ~= child.prefix; 101 renderSingleLine(child); 102 }); 103 buffer ~= tree.parenType.get.closing; 104 buffer ~= tree.suffix; 105 } 106 107 void renderIndented(const Tree tree, size_t level = 0) @safe 108 { 109 const remainingWidth = width - buffer.currentLineLength; 110 111 buffer ~= (level == 0) ? tree.prefix : tree.prefix.stripLeft; 112 if (!tree.lengthExceeds(remainingWidth)) 113 { 114 renderSingleLine(tree); 115 return; 116 } 117 if (tree.parenType.isNull) 118 { 119 buffer ~= tree.suffix; 120 return; 121 } 122 buffer ~= tree.parenType.get.opening; 123 tree.children.enumerate.each!((index, child) { 124 buffer.newline; 125 (level + 1).iota.each!((_) { buffer ~= indent; }); 126 renderIndented(child, level + 1); 127 }); 128 buffer.newline; 129 level.iota.each!((_) { buffer ~= indent; }); 130 buffer ~= tree.parenType.get.closing; 131 buffer ~= tree.suffix; 132 } 133 renderIndented(tree); 134 } 135 136 private struct OutputBuffer 137 { 138 Appender!string appender; 139 140 size_t lastLinebreak = 0; 141 142 void opOpAssign(string op, T)(T text) 143 if (op == "~") 144 { 145 this.appender ~= text; 146 } 147 148 string data() nothrow pure @safe 149 { 150 return this.appender.data; 151 } 152 153 size_t currentLineLength() nothrow pure @safe 154 { 155 return this.appender.data.length - this.lastLinebreak; 156 } 157 158 void newline() nothrow pure 159 { 160 this.appender ~= "\n"; 161 this.lastLinebreak = this.appender.data.length; 162 } 163 } 164 165 private Tree[] parse(string text) 166 { 167 auto textRange = text.quoted; 168 Tree[] trees; 169 170 do 171 { 172 auto tree = textRange.parse; 173 174 if (tree.isNull) 175 { 176 return null; 177 } 178 179 trees ~= tree.get; 180 } 181 while (!textRange.empty); 182 183 return trees; 184 } 185 186 @("parse a paren expression to a tree") 187 unittest 188 { 189 parse("Foo").shouldEqual([Tree("Foo")]); 190 parse(`"Foo"`).shouldEqual([Tree(`"Foo"`)]); 191 parse("Foo,").shouldEqual([Tree("Foo", Nullable!ParenType(), null, ",")]); 192 parse("Foo, Bar").shouldEqual([ 193 Tree("Foo", Nullable!ParenType(), null, ","), 194 Tree(" Bar")]); 195 parse("Foo()").shouldEqual([Tree("Foo", ParenType.paren.nullable)]); 196 parse("Foo[a, b]").shouldEqual([Tree( 197 "Foo", 198 ParenType.squareBracket.nullable, 199 [Tree("a", Nullable!ParenType(), null, ","), Tree(" b")])]); 200 parse(`Foo{"\""}`).shouldEqual([Tree( 201 "Foo", 202 ParenType.curlyBracket.nullable, 203 [Tree(`"\""`)])]); 204 parse("Foo{`\"`}").shouldEqual([Tree( 205 "Foo", 206 ParenType.curlyBracket.nullable, 207 [Tree("`\"`")])]); 208 parse("Foo{'\"'}").shouldEqual([Tree( 209 "Foo", 210 ParenType.curlyBracket.nullable, 211 [Tree("'\"'")])]); 212 parse("Foo() Bar()").shouldEqual([ 213 Tree("Foo", ParenType.paren.nullable), 214 Tree(" Bar", ParenType.paren.nullable)]); 215 } 216 217 // bug 218 @("tree with trailing text") 219 unittest 220 { 221 parse(`(() )`).shouldEqual([ 222 Tree( 223 "", 224 ParenType.paren.nullable, 225 [ 226 Tree("", ParenType.paren.nullable), 227 Tree(" "), 228 ])]); 229 } 230 231 // bug 232 @("quote followed by wrong closing paren") 233 unittest 234 { 235 const text = `("",""]`; 236 237 parse(text).shouldEqual(Tree[].init); 238 } 239 240 // bug 241 @("list of strings") 242 unittest 243 { 244 parse(`["a", "b"]`).shouldEqual([Tree("", ParenType.squareBracket.nullable, [ 245 Tree(`"a"`, Nullable!ParenType(), null, ","), 246 Tree(` "b"`), 247 ])]); 248 } 249 250 private Nullable!Tree parse(ref QuotedText textRange, string expectedClosers = ",") 251 { 252 auto parenStart = textRange.findAmong("({["); 253 auto closer = textRange.findAmong(expectedClosers); 254 255 if (textRange.textUntil(closer).length < textRange.textUntil(parenStart).length) 256 { 257 const prefix = textRange.textUntil(closer); 258 259 textRange = closer.consumeQuote; 260 261 return prefix.empty ? Nullable!Tree() : textRange.parseSuffix(Tree(prefix)).nullable; 262 } 263 264 const prefix = textRange.textUntil(parenStart); 265 266 if (parenStart.empty) 267 { 268 textRange = parenStart.consumeQuote; 269 270 return prefix.empty ? Nullable!Tree() : textRange.parseSuffix(Tree(prefix)).nullable; 271 } 272 273 const parenType = () { 274 switch (parenStart.front) 275 { 276 case '(': 277 return ParenType.paren; 278 case '[': 279 return ParenType.squareBracket; 280 case '{': 281 return ParenType.curlyBracket; 282 default: 283 assert(false); 284 } 285 }(); 286 287 textRange = parenStart; 288 textRange.popFront; 289 290 Tree[] children = null; 291 292 while (true) 293 { 294 if (textRange.empty) 295 { 296 return Nullable!Tree(); 297 } 298 if (textRange.front == parenType.closing) 299 { 300 // single child, quote only 301 const quoteChild = textRange.textUntil(textRange); 302 303 if (!quoteChild.empty) 304 { 305 children ~= Tree(quoteChild); 306 } 307 308 textRange.popFront; 309 310 return textRange.parseSuffix(Tree(prefix, Nullable!ParenType(parenType), children)).nullable; 311 } 312 313 auto child = textRange.parse(parenType.closingWithComma); 314 315 if (child.isNull) 316 { 317 return Nullable!Tree(); 318 } 319 320 children ~= child; 321 } 322 } 323 324 private Tree parseSuffix(ref QuotedText range, Tree tree) 325 in (tree.suffix.empty) 326 { 327 if (!range.empty && range.front == ',') 328 { 329 range.popFront; 330 tree.suffix = ","; 331 } 332 return tree; 333 } 334 335 // prefix 336 // prefix { (child(, child)*)? } 337 // prefix ( (child(, child)*)? ) 338 // prefix [ (child(, child)*)? ] 339 private struct Tree 340 { 341 string prefix; 342 343 Nullable!ParenType parenType = Nullable!ParenType(); 344 345 Tree[] children = null; 346 347 string suffix = null; 348 349 bool lengthExceeds(size_t limit) const nothrow pure @safe 350 { 351 return lengthRemainsOf(limit) < 0; 352 } 353 354 string toString() const pure 355 { 356 import std.format : format; 357 358 if (this.parenType.isNull) 359 { 360 return format!"%s%s"(this.prefix, this.suffix); 361 } 362 return format!"%s%s%(%s, %)%s"( 363 this.prefix, this.parenType.get.opening, this.children, this.parenType.get.closing); 364 } 365 366 // returns how much remains of length after printing this. if negative, may be inaccurate. 367 private ptrdiff_t lengthRemainsOf(ptrdiff_t length) const nothrow pure 368 { 369 length -= this.prefix.length; 370 length -= this.suffix.length; 371 length -= this.parenType.isNull ? 0 : 2; 372 if (length >= 0) 373 { 374 foreach (child; this.children) 375 { 376 length = child.lengthRemainsOf(length); 377 if (length < 0) 378 { 379 break; 380 } 381 } 382 } 383 return length; 384 } 385 } 386 387 @("estimate the print length of a tree") 388 unittest 389 { 390 parse("Foo(Bar(Baz()), Baq())").front.lengthRemainsOf(10).shouldBeSmallerThan(0); 391 } 392 393 private enum ParenType 394 { 395 paren, // () 396 squareBracket, // [] 397 curlyBracket, // {} 398 } 399 400 private alias opening = mapEnum!([ 401 ParenType.paren: '(', 402 ParenType.squareBracket: '[', 403 ParenType.curlyBracket: '{']); 404 405 private alias closing = mapEnum!([ 406 ParenType.paren: ')', 407 ParenType.squareBracket: ']', 408 ParenType.curlyBracket: '}']); 409 410 private alias closingWithComma = mapEnum!([ 411 ParenType.paren: ",)", 412 ParenType.squareBracket: ",]", 413 ParenType.curlyBracket: ",}"]); 414 415 private auto mapEnum(alias enumTable)(const typeof(enumTable.keys.front) key) 416 { 417 final switch (key) 418 { 419 static foreach (mapKey, mapValue; enumTable) 420 { 421 case mapKey: 422 return mapValue; 423 } 424 } 425 } 426 427 private QuotedText quoted(string text) 428 { 429 return QuotedText(text); 430 } 431 432 // range over text that skips quoted strings 433 private struct QuotedText 434 { 435 string text; // current read head after skipping quotes 436 437 string textBeforeSkip; // current read head before skipping quotes 438 439 debug invariant(this.text.refSuffixOf(this.textBeforeSkip)); 440 441 this(string text) nothrow pure 442 { 443 this(text, text); 444 } 445 446 private this(string text, string textBeforeSkip) nothrow pure 447 { 448 this.text = text; 449 this.textBeforeSkip = textBeforeSkip; 450 skipQuote; 451 } 452 453 QuotedText consumeQuote() pure 454 { 455 // set this.textBeforeSkip to this.text, indicating that we've already accounted for quotes 456 return QuotedText(this.text); 457 } 458 459 // return text from start until other, which must be a different range over the same text 460 string textUntil(QuotedText other) pure 461 in (other.text.refSuffixOf(this.textBeforeSkip)) 462 { 463 // from our skip-front to other's skip-back 464 // ie. foo"test"bar 465 // from ^ to ^ is the "same" range, but returns '"test"' 466 return this.textBeforeSkip[0 .. this.textBeforeSkip.length - other.text.length]; 467 } 468 469 bool empty() const pure 470 { 471 return this.text.empty; 472 } 473 474 dchar front() const pure 475 { 476 return this.text.front; 477 } 478 479 void popFront() pure 480 { 481 this.text.popFront; 482 this.textBeforeSkip = this.text; 483 skipQuote; 484 } 485 486 private void skipQuote() nothrow pure 487 { 488 bool skippedQuote(dchar marker, bool escapeChars) nothrow pure 489 { 490 import std.exception : assumeWontThrow; 491 492 if (this.text.empty || assumeWontThrow(this.text.front) != marker) 493 { 494 return false; 495 } 496 this.text.popFront; // skip opening marker character 497 498 while (!this.text.empty && assumeWontThrow(this.text.front) != marker) 499 { 500 if (escapeChars && assumeWontThrow(this.text.front) == '\\') 501 { 502 this.text.popFront; // if escaping, skip an additional character 503 } 504 if (!this.text.empty) 505 { 506 this.text.popFront; 507 } 508 } 509 if (!this.text.empty) 510 { 511 this.text.popFront; // skip closing marker 512 } 513 return true; 514 } 515 516 while (skippedQuote('"', true) || skippedQuote('\'', true) || skippedQuote('`', false)) 517 { 518 } 519 } 520 } 521 522 // given an unsigned offset, left can be written as right[offset .. $]. 523 private bool refSuffixOf(string left, string right) 524 { 525 return cast(size_t) left.ptr + left.length == cast(size_t) right.ptr + right.length && left.ptr >= right.ptr; 526 } 527 528 @("\"\" quote at the beginning and end of a range") 529 unittest 530 { 531 auto range = QuotedText(`"Foo"`); 532 533 range.textUntil(range).shouldEqual(`"Foo"`); 534 } 535 536 @("`` quote at the beginning and end of a range") 537 unittest 538 { 539 auto range = QuotedText("`Foo\\`"); 540 541 range.textUntil(range).shouldEqual("`Foo\\`"); 542 } 543 544 @("'' quote at the beginning and end of a range") 545 unittest 546 { 547 auto range = QuotedText("'Foo'"); 548 549 range.textUntil(range).shouldEqual("'Foo'"); 550 }