1 /++
2  + Asciitable to nicely format tables of strings.
3  +
4  + Authors: Christian Koestlin
5  + Copyright: Copyright © 2018, Christian Köstlin
6  + License: MIT
7  +/
8 
9 module asciitable;
10 
11 public import asciitable.packageversion;
12 
13 import std.string;
14 import std.algorithm;
15 import std.range;
16 import std.conv;
17 import colored;
18 
19 import std.stdio; // TODO
20 class Cell
21 {
22     AsciiTable table;
23     Row row;
24     string[] lines;
25     ulong width;
26     this(AsciiTable table, Row row, string formatted)
27     {
28         import std.array;
29 
30         this.table = table;
31         this.row = row;
32         this.lines = formatted.split("\n");
33         this.width = lines.map!(line => line.unformattedLength).maxElement;
34     }
35 
36     ulong height()
37     {
38         return lines.length;
39     }
40 
41     string render(ulong row)
42     {
43         return colored.leftJustifyFormattedString(row < lines.length ? lines[row] : "", width);
44     }
45 
46     override string toString()
47     {
48         return super.toString ~ " { lines: %s }".format(lines);
49     }
50 }
51 
52 class Row
53 {
54     AsciiTable table;
55     ulong nrOfColumns;
56     bool header;
57     Cell[] cells;
58     public ulong height = 0;
59 
60     this(AsciiTable table, ulong nrOfColumns, bool header)
61     {
62         this.table = table;
63         this.nrOfColumns = nrOfColumns;
64         this.header = header;
65     }
66 
67     auto add(V)(V v)
68     {
69         if (cells.length == nrOfColumns)
70         {
71             throw new Exception("too many elements in row nrOfColumns=%s cells=%s".format(nrOfColumns,
72                     cells.length));
73         }
74 
75         auto cell = new Cell(table, this, v.to!string);
76         cells ~= cell;
77         this.height = max(height, cell.height);
78         return this;
79     }
80 
81     auto row()
82     {
83         return table.row();
84     }
85 
86     auto format()
87     {
88         return table.format();
89     }
90 
91     auto render(ulong row, string leftBorder, string columnSeparator, string rightBorder)
92     {
93         auto res = leftBorder ~ cells.map!(cell => cell.render(row))
94             .join(columnSeparator) ~ rightBorder;
95         return res;
96     }
97 
98     auto width(string columnSeparator)
99     {
100         return cells.fold!((memo,
101                 cell) => memo + cell.width)(0UL) + (cells.length + 1) * columnSeparator.length;
102     }
103 
104     override string toString()
105     {
106         return super.toString ~ " { nrOfColumns: %s, cells: %s }".format(nrOfColumns, cells);
107     }
108 }
109 /++
110  + Build your table and later format it
111  +/
112 class AsciiTable
113 {
114     size_t nrOfColumns;
115     Row[] rows;
116 
117     /++ create a new asciitable
118      +/
119     this(size_t nrOfColumns)
120     {
121         this.nrOfColumns = nrOfColumns;
122     }
123 
124     /// Open a row
125     auto row()
126     {
127         return add(new Row(this, nrOfColumns, false));
128     }
129 
130     auto header()
131     {
132         return add(new Row(this, nrOfColumns, true));
133     }
134 
135     private auto add(Row row)
136     {
137         rows ~= row;
138         return row;
139     }
140     /// create formatter to fine tune tabular presentation
141     Formatter format()
142     {
143         return Formatter(this);
144     }
145 
146 }
147 
148 class Parts
149 {
150     string horizontal;
151     string vertical;
152     string crossing;
153 
154     string topLeft;
155     string topRight;
156     string bottomLeft;
157     string bottomRight;
158     string topCrossing;
159     string leftCrossing;
160     string bottomCrossing;
161     string rightCrossing;
162     string headerHorizontal;
163     string headerCrossing;
164     string headerLeftCrossing;
165     string headerRightCrossing;
166     this(string horizontal, string vertical, string crossing, string topLeft, string topRight, string bottomLeft,
167             string bottomRight, string topCrossing, string bottomCrossing,
168             string leftCrossing, string rightCrossing, string headerHorizontal,
169             string headerCrossing, string headerLeftCrossing, string headerRightCrossing)
170     {
171         this.horizontal = horizontal;
172         this.headerHorizontal = headerHorizontal;
173         this.vertical = vertical;
174         this.crossing = crossing;
175         this.headerCrossing = headerCrossing;
176         this.topLeft = topLeft;
177         this.topRight = topRight;
178         this.bottomLeft = bottomLeft;
179         this.bottomRight = bottomRight;
180         this.topCrossing = topCrossing;
181         this.bottomCrossing = bottomCrossing;
182         this.leftCrossing = leftCrossing;
183         this.headerLeftCrossing = headerLeftCrossing;
184         this.rightCrossing = rightCrossing;
185         this.headerRightCrossing = headerRightCrossing;
186     }
187 }
188 
189 class AsciiParts : Parts
190 {
191     this()
192     {
193         super("-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+", "=", "+", "+", "+");
194     }
195 }
196 
197 class UnicodeParts : Parts
198 {
199     this()
200     {
201         super("─", "│", "┼", "┌", "┐", "└", "┘", "┬", "┴",
202                 "├", "┤", "═", "╪", "╞", "╡");
203     }
204 }
205 /// the formatter collects format parameters and prints the table
206 struct Formatter
207 {
208     private AsciiTable table;
209     private string mPrefix = null;
210     private bool mRowSeparator = false;
211     private bool mHeaderSeparator = false;
212     private bool mColumnSeparator = false;
213     private ulong[] mColumnWidths = null;
214     private Parts mParts = null;
215     private bool mTopBorder = false;
216     private bool mLeftBorder = false;
217     private bool mBottomBorder = false;
218     private bool mRightBorder = false;
219     this(AsciiTable newTable, Parts parts = new AsciiParts)
220     {
221         table = newTable;
222         mParts = parts;
223     }
224 
225     auto parts(Parts parts)
226     {
227         mParts = parts;
228         return this;
229     }
230 
231     auto borders(bool borders)
232     {
233         topBorder(borders);
234         leftBorder(borders);
235         bottomBorder(borders);
236         rightBorder(borders);
237         return this;
238     }
239 
240     /// Switch top and bottom border
241     auto horizontalBorders(bool borders)
242     {
243         topBorder(borders);
244         bottomBorder(borders);
245         return this;
246     }
247     /// Switch left and right border
248     auto verticalBorders(bool borders)
249     {
250         leftBorder(borders);
251         rightBorder(borders);
252         return this;
253     }
254 
255     auto topBorder(bool topBorder)
256     {
257         mTopBorder = topBorder;
258         return this;
259     }
260 
261     auto leftBorder(bool leftBorder)
262     {
263         mLeftBorder = leftBorder;
264         return this;
265     }
266 
267     auto bottomBorder(bool bottomBorder)
268     {
269         mBottomBorder = bottomBorder;
270         return this;
271     }
272 
273     auto rightBorder(bool rightBorder)
274     {
275         mRightBorder = rightBorder;
276         return this;
277     }
278     /// change the prefix that is printed in front of each row
279     auto prefix(string newPrefix)
280     {
281         mPrefix = newPrefix;
282         return this;
283     }
284 
285     auto separators(bool active)
286     {
287         columnSeparator(active);
288         rowSeparator(active);
289         headerSeparator(active);
290         return this;
291     }
292     /// change the separator between columns, use null for no separator
293     auto columnSeparator(bool columnSeparator)
294     {
295         mColumnSeparator = columnSeparator;
296         return this;
297     }
298     /// change the separator between rows, use null for no separator
299     auto rowSeparator(bool rowSeparator)
300     {
301         mRowSeparator = rowSeparator;
302         return this;
303     }
304 
305     auto headerSeparator(bool headerSeparator)
306     {
307         mHeaderSeparator = headerSeparator;
308         return this;
309     }
310 
311     auto columnWidths(ulong[] widths)
312     {
313         if (widths.length != table.nrOfColumns)
314         {
315             throw new Exception("Wrong number of widths");
316         }
317         mColumnWidths = widths;
318         return this;
319     }
320 
321     private string calcSeparatorRow(size_t length, string s)
322     {
323         string res = "";
324         for (size_t i = 0; i < length; ++i)
325         {
326             res ~= s;
327         }
328         return res;
329     }
330 
331     private auto renderRow(Row row)
332     {
333         string[] lines = [];
334         for (int i = 0; i < row.height; ++i)
335         {
336             lines ~= row.render(i, mLeftBorder ? mParts.vertical : "", mColumnSeparator
337                     ? mParts.vertical : "", mRightBorder ? mParts.vertical : "");
338         }
339         return lines;
340     }
341 
342     private void updateCellWidths()
343     {
344         if (table.rows.length == 0)
345         {
346             return;
347         }
348         for (int i = 0; i < table.rows[0].cells.length; ++i)
349         {
350             ulong width = mColumnWidths ? mColumnWidths[i] : 0;
351             for (int j = 0; j < table.rows.length; ++j)
352             {
353                 width = max(width, table.rows[j].cells[i].width);
354             }
355             for (int j = 0; j < table.rows.length; ++j)
356             {
357                 table.rows[j].cells[i].width = width;
358             }
359         }
360     }
361 
362     string calcHorizontalSeparator(string normal, string left, string middle, string right)
363     {
364         return (mLeftBorder ? left : "") ~ table.rows[0].cells.map!(
365                 cell => normal.replicate(cell.width)).join(mColumnSeparator
366                 ? middle : normal) ~ (mRightBorder ? right : "");
367     }
368     /// Convert to tabular presentation
369     string toString()
370     {
371         updateCellWidths();
372         auto rSeparator = mRowSeparator ? calcHorizontalSeparator(mParts.horizontal,
373                 mParts.leftCrossing, mParts.crossing, mParts.rightCrossing) : null;
374         auto hSeparator = mHeaderSeparator ? calcHorizontalSeparator(mParts.headerHorizontal,
375                 mParts.headerLeftCrossing, mParts.headerCrossing, mParts.headerRightCrossing) : null;
376         auto prefix = mPrefix ? mPrefix : "";
377         string[] lines = [];
378         if (mTopBorder)
379         {
380             lines ~= calcHorizontalSeparator(mParts.horizontal, mParts.topLeft,
381                     mParts.topCrossing, mParts.topRight);
382         }
383         foreach (idx, row; table.rows)
384         {
385             auto newLines = toString(table, row, idx == table.rows.length - 1,
386                     hSeparator, rSeparator).map!(line => prefix ~ line);
387             foreach (newLine; newLines)
388             {
389                 lines ~= newLine;
390             }
391         }
392         if (mBottomBorder)
393         {
394             lines ~= calcHorizontalSeparator(mParts.horizontal,
395                     mParts.bottomLeft, mParts.bottomCrossing, mParts.bottomRight);
396         }
397         return lines.join("\n");
398     }
399 
400     private auto toString(AsciiTable table, Row row, bool last,
401             string headerSeparator, string rowSeparator)
402     {
403         if (row.cells.length != table.nrOfColumns)
404         {
405             throw new Exception("row %s not fully filled".format(row));
406         }
407         auto res = renderRow(row);
408         if (last)
409         {
410             return res;
411         }
412         else
413         {
414             if (mHeaderSeparator)
415             {
416                 if (row.header)
417                 {
418                     return res ~ headerSeparator;
419                 }
420             }
421             if (mRowSeparator)
422             {
423                 return res ~ rowSeparator;
424             }
425         }
426         return res;
427     }
428 }
429 
430 @("emtpy table") unittest
431 {
432     new AsciiTable(2).format.to!string;
433 }
434 
435 ///
436 @("example") unittest
437 {
438     import unit_threaded;
439     import std.conv;
440 
441     // dfmt off
442     auto table = new AsciiTable(2)
443        .header.add("HA").add("HB")
444        .row.add("C").add("D")
445        .row.add("E").add("F")
446        .table;
447   
448     auto f1 = table
449        .format
450        .parts(new UnicodeParts)
451        .borders(true)
452        .separators(true)
453        .to!string;
454     // dfmt on
455     std.stdio.writeln(f1);
456     f1.shouldEqual(`┌──┬──┐
457 │HA│HB│
458 ╞══╪══╡
459 │C │D │
460 ├──┼──┤
461 │E │F │
462 └──┴──┘`);
463 
464     // dfmt off
465    auto f2 = table
466        .format
467        .parts(new UnicodeParts)
468        .prefix("  ")
469        .rowSeparator(true)
470        .to!string;
471    // dfmt on
472     f2.shouldEqual(`  HAHB
473   ─────
474   C D 
475   ─────
476   E F `);
477     // dfmt off
478     auto f3 = table
479        .format
480        .parts(new UnicodeParts)
481        .columnSeparator(true)
482        .to!string;
483     // dfmt on
484     f3.shouldEqual(`HA│HB
485 C │D 
486 E │F `);
487 }
488 
489 ///
490 @("multiline cells") unittest
491 {
492     import unit_threaded;
493 
494     auto table = new AsciiTable(2).row.add("1\n2").add("3").row.add("4").add("5\n6").table;
495     auto f = table.format.prefix("test:").to!string;
496     f.shouldEqual(`test:13
497 test:2 
498 test:45
499 test: 6`);
500 }
501 
502 ///
503 @("headers") unittest
504 {
505     import unit_threaded;
506 
507     auto table = new AsciiTable(1).header.add("1").row.add(2).table;
508     auto f1 = table.format.headerSeparator(true).rowSeparator(true)
509         .topBorder(true).bottomBorder(true).to!string;
510     f1.shouldEqual(`-
511 1
512 =
513 2
514 -`);
515 }
516 
517 @("wrong usage of ascii table") unittest
518 {
519     import unit_threaded;
520 
521     new AsciiTable(2).row.add("1").add("2").add("3").shouldThrow!Exception;
522 }
523 
524 @("auto expand columns") unittest
525 {
526     import unit_threaded;
527 
528     new AsciiTable(1).row.add("test").format.columnWidths([10])
529         .to!string.shouldEqual("test      ");
530 }
531 
532 @("row not fully filled") unittest
533 {
534     import unit_threaded;
535 
536     new AsciiTable(2).row.add("test").format.to!string.shouldThrow!Exception;
537 }