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