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 }