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 }