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 }