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 class Cell
20 {
21   AsciiTable table;
22   Row row;
23   string[] lines;
24   ulong width;
25   this(AsciiTable table, Row row, string formatted)
26   {
27     import std.array;
28     this.table = table;
29     this.row = row;
30     this.lines = formatted.split("\n");
31     this.width = lines
32       .map!(line => line.unformattedLength)
33       .maxElement;
34   }
35   ulong height()
36   {
37     return lines.length;
38   }
39   string render(ulong row) {
40     return (row < lines.length ? lines[row] : "").leftJustifyFormattedString(width);
41   }
42   override string toString() {
43     return super.toString ~ " { lines: %s }".format(lines);
44   }
45 }
46 
47 
48 class Row
49 {
50   AsciiTable table;
51   ulong nrOfColumns;
52   bool header;
53   Cell[] cells;
54   public ulong height = 0;
55 
56   this(AsciiTable table, ulong nrOfColumns, bool header)
57   {
58     this.table = table;
59     this.nrOfColumns = nrOfColumns;
60     this.header = header;
61   }
62 
63   auto add(V)(V v)
64   {
65     if (cells.length == nrOfColumns)
66     {
67       throw new Exception("too many elements in row nrOfColumns=%s cells=%s".format(nrOfColumns, cells.length));
68     }
69     
70     auto cell = new Cell(table, this, v.to!string);
71     cells ~= cell;
72     this.height = max(height, cell.height);
73     return this;
74   }
75 
76   auto row()
77   {
78     return table.row();
79   }
80 
81   auto format()
82   {
83     return table.format();
84   }
85   
86   auto render(ulong row, string columnSeparator) {
87     if (!columnSeparator) {
88       columnSeparator = "";
89     }
90     return columnSeparator
91       ~ cells.map!(cell => cell.render(row)).join(columnSeparator)
92       ~ columnSeparator;
93   }
94   auto width(string columnSeparator) {
95     return cells.fold!((memo, cell) => memo + cell.width)(0UL)
96       + (cells.length+1) * columnSeparator.length;
97   }
98   override string toString() {
99     return super.toString ~ " { nrOfColumns: %s, cells: %s }".format(nrOfColumns, cells);
100   }
101 }
102 /++
103  + Build your table and later format it
104  +/
105 class AsciiTable
106 {
107   size_t nrOfColumns;
108   Row[] rows;
109 
110   /++ create a new asciitable
111    + Params:
112    +     minimumWidths = minimum widths of the columns. columns are adjusted so that all entries of a column fit.
113    +/
114   this(size_t nrOfColumns)
115   {
116     this.nrOfColumns = nrOfColumns;
117   }
118 
119   /// Open a row
120   auto row()
121   {
122     return add(new Row(this, nrOfColumns, false));
123   }
124   auto header()
125   {
126     return add(new Row(this, nrOfColumns, true));
127   }
128   private auto add(Row row) {
129     rows ~= row;
130     return row;
131   }
132   /// create formatter to fine tune tabular presentation
133   Formatter format()
134   {
135     return Formatter(this);
136   }
137 
138 }
139 
140 /// the formatter collects format parameters and prints the table
141 struct Formatter
142 {
143   private AsciiTable table;
144   private string mPrefix = null;
145   private string mRowSeparator = null;
146   private string mHeaderSeparator = null;
147   private string mColumnSeparator = null;
148   private ulong[] mColumnWidths = null;
149   this(AsciiTable newTable)
150   {
151     table = newTable;
152   }
153   /// change the prefix that is printed in front of each row
154   auto prefix(string newPrefix)
155   {
156     mPrefix = newPrefix;
157     return this;
158   }
159     
160   /// change the separator between columns, use null for no separator
161   auto columnSeparator(string s)
162   {
163     mColumnSeparator = s;
164     return this;
165   }
166   /// change the separator between rows, use null for no separator
167   auto rowSeparator(string s)
168   {
169     mRowSeparator = s;
170     return this;
171   }
172   auto headerSeparator(string s)
173   {
174     mHeaderSeparator = s;
175     return this;
176   }
177   auto columnWidths(ulong[] widths)
178   {
179     if (widths.length != table.nrOfColumns) {
180       throw new Exception("Wrong number of widths");
181     }
182     mColumnWidths = widths;
183     return this;
184   }
185   
186   private string calcSeparatorRow(size_t length, string s) {
187     string res = "";
188     for (size_t i=0; i<length; ++i) {
189       res ~= s;
190     }
191     return res;
192   }
193 
194   private auto renderRow(Row row, string[] lines) {
195     for (int i=0; i<row.height; ++i) {
196       lines ~= row.render(i, mColumnSeparator);
197     }
198     return lines;
199   }
200 
201   private void updateCellWidths() {
202     if (table.rows.length == 0) {
203       return;
204     }
205     for (int i=0; i<table.rows[0].cells.length; ++i) {
206       ulong width = mColumnWidths ? mColumnWidths[i] : 0;
207       for (int j=0; j<table.rows.length; ++j) {
208         width = max(width, table.rows[j].cells[i].width);
209       }
210       for (int j=0; j<table.rows.length; ++j) {
211         table.rows[j].cells[i].width = width;
212       }
213     }
214   }
215   /// Convert to tabular presentation
216   string toString()
217   {
218     updateCellWidths();
219     ulong width= table.rows[0].width(mColumnSeparator);
220       
221     auto rSeparator = mRowSeparator ? mRowSeparator.replicate(width) : null;
222     auto hSeparator = mHeaderSeparator ? mHeaderSeparator.replicate(width) : null;
223     auto prefix = mPrefix ? mPrefix : "";
224     auto lines = rSeparator ? [rSeparator] : [];
225     return table
226       .rows
227       .fold!((memo, row) => toString(memo, table, row, hSeparator, rSeparator))(lines)
228       .map!(line => prefix ~ line)
229       .join("\n");
230   }
231     
232   private auto toString(string[] lines, AsciiTable table, Row row, string hSeparator, string rSeparator) {
233     if (row.cells.length != table.nrOfColumns) {
234       throw new Exception("row %s not fully filled".format(row));
235     }
236     lines = renderRow(row, lines);
237     if (row.header && hSeparator) {
238       lines ~= hSeparator;
239     } else if (rSeparator) {
240       lines ~= rSeparator;
241     }
242     return lines;
243   }
244 }
245 
246 ///
247 @("example") unittest
248 {
249   import unit_threaded;
250   import std.conv;
251 
252   auto table = new AsciiTable(2)
253     .row.add("1").add("2")
254     .row.add("3").add("4")
255     .table;
256   auto f1 = table.format.to!string;
257   f1.shouldEqual("12\n34");
258 
259   auto f2 = table.format.prefix("  ").rowSeparator("-").to!string;
260   f2.shouldEqual("  --\n  12\n  --\n  34\n  --");
261 
262   auto f3 = table.format.prefix("  ").columnSeparator("|").to!string;
263   f3.shouldEqual("  |1|2|\n  |3|4|");
264 
265   auto f4 = table.format.prefix("  ").columnSeparator("|").rowSeparator("-").to!string;
266   f4.shouldEqual("  -----\n  |1|2|\n  -----\n  |3|4|\n  -----");
267 }
268 
269 ///
270 @("multiline cells") unittest
271 {
272   import unit_threaded;
273   auto table = new AsciiTable(2)
274     .row.add("1\n2").add("3")
275     .row.add("4").add("5\n6")
276     .table;
277   auto f = table.format.prefix("test:").to!string;
278   f.shouldEqual("test:13\ntest:2 \ntest:45\ntest: 6");
279 }
280 
281 ///
282 @("headers") unittest
283 {
284   import unit_threaded;
285   auto table = new AsciiTable(1)
286     .header.add("1")
287     .row.add(2)
288     .table;
289   auto f1 = table.format.headerSeparator("=").rowSeparator("-").to!string;
290   f1.shouldEqual("-\n1\n=\n2\n-");
291 }
292 
293 @("wrong usage of ascii table") unittest
294 {
295   import unit_threaded;
296   
297   new AsciiTable(2)
298     .row.add("1").add("2").add("3")
299     .shouldThrow!Exception;
300 }
301 
302 @("auto expand columns") unittest
303 {
304   import unit_threaded;
305 
306   new AsciiTable(1)
307     .row.add("test")
308     .format.columnWidths([10])
309     .to!string
310     .shouldEqual("test      ");
311 }
312 
313 @("row not fully filled") unittest
314 {
315   import unit_threaded;
316 
317   new AsciiTable(2)
318     .row.add("test")
319     .format
320     .to!string
321     .shouldThrow!Exception;
322 }