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