1 module tabletool;
2 
3 import std.algorithm : canFind, max, min, map;
4 import std.array : array;
5 import std.conv : to;
6 import std.format : format;
7 import std.range : join, repeat, zip;
8 import std.traits : getUDAs, hasUDA;
9 import std.utf;
10 
11 import eastasianwidth : displayWidth;
12 
13 /// Option to specify the table style.
14 enum Style
15 {
16     simple,
17     markdown,
18     grid,
19 }
20 
21 /// Option to specify the position of element in the cell.
22 enum Align
23 {
24     center,
25     left,
26     right,
27 }
28 
29 /// Configurations for tabulate.
30 struct Config
31 {
32     Style style = Style.simple;
33     Align align_ = Align.center;
34     bool showHeader = true;
35 }
36 
37 /// UDA to set display name of the struct member.
38 struct DisplayName
39 {
40     string name;
41 }
42 
43 /// Detailed configurations to set table-wide appearance.
44 struct TableConfig
45 {
46     Style style = Style.simple;
47     string leftPadding = " ";
48     string rightPadding = " ";
49     bool showHeader = true;
50 }
51 
52 /// Detailed configurations to set each column appearance.
53 struct ColumnConfig
54 {
55     size_t width;
56     string header = "";
57     Align align_ = Align.center;
58 }
59 
60 /**
61  * Tabulate array of array data.
62  * Params:
63  *      data = An array of array of string compatible data
64  *      headers = Headers for each columns
65  *      config = A configuration to set appearance
66  * Returns: The table string
67  */
68 string tabulate(T)(in T[][] data, in string[] headers, in Config config = Config())
69 {
70     assert(data.length > 0);
71     assert(headers.length == 0 || data[0].length == headers.length);
72 
73     auto actualHeaders = headers.length > 0 ? headers : "".repeat(data[0].length).array();
74     auto widthes = calcWidthes(data, actualHeaders);
75 
76     auto tableConfig = TableConfig();
77     tableConfig.style = config.style;
78     tableConfig.showHeader = config.showHeader && headers.length > 0;
79 
80     auto columnConfigs = zip(widthes, actualHeaders).map!(tup => ColumnConfig(tup[0], tup[1], config
81             .align_)).array();
82 
83     return tabulate(data, tableConfig, columnConfigs);
84 }
85 
86 ///
87 unittest
88 {
89     const testdata = [
90         ["D-man", "Programming Language"],
91         ["D言語くん", "プログラミング言語"],
92     ];
93     const headers = ["マスコットキャラクタ", "about"];
94     const reference =
95         " マスコットキャラクタ          about         \n" ~
96         "---------------------- ----------------------\n" ~
97         "        D-man           Programming Language \n" ~
98         "      D言語くん          プログラミング言語  ";
99     assert(tabulate(testdata, headers, Config(Style.simple, Align.center, true)) == reference);
100 }
101 
102 /**
103  * Tabulate array of array data (headerless version).
104  *
105  * In this version, config.showHeader will be ignored and header section of the
106  * table will be invisible.
107  * 
108  * Params:
109  *      data =  An array of array of string compatible data
110  *      config = A configuration to set appearance
111  * Returns: The table string
112  */
113 string tabulate(T)(in T[][] data, in Config config = Config())
114 {
115     return tabulate(data, [], config);
116 }
117 
118 /// 
119 unittest
120 {
121     const testdata = [
122         ["D-man", "Programming Language"],
123         ["D言語くん", "プログラミング言語"],
124     ];
125     const reference =
126         "   D-man     Programming Language \n" ~
127         " D言語くん    プログラミング言語  ";
128     assert(tabulate(testdata, Config(Style.simple, Align.center, true)) == reference);
129 }
130 
131 /**
132  * Tabulate array of strut data.
133  *
134  * This version consume an array of struct. The headers will be extrated from
135  * members' name and each member should be able to convert to string. If some
136  * of members need to be re-named, an UDA DisplayName can be used.
137  * 
138  * Params:
139  *      data = An array of struct data
140  *      config = A configuration to set appearance
141  * Returns: The table string
142  */
143 string tabulate(T)(in T[] data, in Config config = Config()) if (is(T == struct))
144 {
145     string[][] stringData;
146     string[] headers;
147 
148     foreach (member; __traits(allMembers, T))
149     {
150         static if (hasUDA!(__traits(getMember, T, member), DisplayName))
151         {
152             enum displayName = getUDAs!(__traits(getMember, T, member), DisplayName)[0];
153             headers ~= displayName.name;
154         }
155         else
156         {
157             headers ~= member;
158         }
159     }
160     foreach (d; data)
161     {
162         string[] line;
163         foreach (member; __traits(allMembers, T))
164         {
165             line ~= __traits(getMember, d, member).to!string;
166         }
167         stringData ~= line;
168     }
169     return tabulate(stringData, headers, config);
170 }
171 
172 ///
173 unittest
174 {
175     struct TestData
176     {
177         @DisplayName("マスコットキャラクタ")
178         string name;
179         string about;
180     }
181 
182     const testdata = [
183         TestData("D-man", "Programming Language"),
184         TestData("D言語くん", "プログラミング言語"),
185     ];
186     const reference =
187         " マスコットキャラクタ          about         \n" ~
188         "---------------------- ----------------------\n" ~
189         "        D-man           Programming Language \n" ~
190         "      D言語くん          プログラミング言語  ";
191 
192     assert(tabulate(testdata, Config(Style.simple, Align.center, true)) == reference);
193 }
194 
195 /**
196  * Tabulate an array of associative array.
197  *
198  * This version tabulates an array of associative array. The keys will be used
199  * as headers and there is no need to align each keys of each array elements.
200  * If some missing key exists in the one line, that cell will be empty.
201  *
202  * Params:
203  *      data = An array of associative array data
204  *      config = A configuration to set appearance
205  * Returns: The table string
206  */
207 string tabulate(Key, Value)(in Value[Key][] data, in Config config = Config())
208 {
209     string[][] stringData;
210     Key[] headers;
211     foreach (line; data)
212     {
213         foreach (key; line.byKey())
214         {
215             if (!headers.canFind(key))
216             {
217                 headers ~= key;
218             }
219         }
220     }
221     foreach (line; data)
222     {
223         string toStr(Key h)
224         {
225             if (h in line)
226             {
227                 return line[h].to!string;
228             }
229             else
230             {
231                 return "";
232             }
233         }
234 
235         stringData ~= headers.map!(h => toStr(h)).array();
236     }
237     string[] stringHeaders = headers.map!(h => h.to!string).array();
238     return tabulate(stringData, stringHeaders, config);
239 }
240 
241 ///
242 unittest
243 {
244     const testdata = [
245         [
246             "マスコットキャラクタ": "D-man",
247             "about": "Programming Language"
248         ],
249         [
250             "マスコットキャラクタ": "D言語くん",
251             "about": "プログラミング言語"
252         ],
253     ];
254     const reference =
255         " マスコットキャラクタ          about         \n" ~
256         "---------------------- ----------------------\n" ~
257         "        D-man           Programming Language \n" ~
258         "      D言語くん          プログラミング言語  ";
259     assert(tabulate(testdata, Config(Style.simple, Align.center, true)) == reference);
260 }
261 
262 /**
263  * Tabulate an array of array data with detailed configurations.
264  * 
265  * This version uses TableConfig and an array of ColumnConfig instead of Config.
266  * TableConfig affects the whole table appearance and ColumnConfigs affect each
267  * columns' appearance. This can be used if you want to configure (e.g.)
268  * columns one-by-one.
269  * 
270  * Params:
271  *      data = An array of array data
272  *      tableConfig = A table-wide configuration
273  *      columnConfigs = Configurations for each columns (The length should match with data)
274  * Returns: The table string
275  */
276 string tabulate(T)(in T[][] data, in TableConfig tableConfig, in ColumnConfig[] columnConfigs)
277 {
278     assert(data.length > 0);
279     assert(data[0].length == columnConfigs.length);
280 
281     const ruler = Ruler(tableConfig.style);
282     const widthes = columnConfigs.map!(c => c.width).array();
283     const aligns = columnConfigs.map!(c => c.align_).array();
284     const widthForRuler = widthes.map!(w => w + displayWidth(
285             tableConfig.leftPadding) + displayWidth(tableConfig.rightPadding)).array();
286 
287     string[] lines;
288 
289     if (auto top = ruler.top(widthForRuler))
290     {
291         lines ~= top;
292     }
293 
294     if (tableConfig.showHeader)
295     {
296         const headers = columnConfigs.map!(c => c.header).array();
297         lines ~= makeItemLine(
298             headers,
299             widthes,
300             aligns,
301             ruler,
302             tableConfig.leftPadding,
303             tableConfig.rightPadding
304         );
305         if (auto sep = ruler.headerItemSeperator(widthForRuler))
306         {
307             lines ~= sep;
308         }
309     }
310     foreach (i, line; data)
311     {
312         lines ~= makeItemLine(
313             line,
314             widthes,
315             aligns,
316             ruler,
317             tableConfig.leftPadding,
318             tableConfig.rightPadding,
319         );
320         if ((i + 1) != data.length)
321         {
322             if (auto sep = ruler.horizontalItemSeperator(widthForRuler))
323             {
324                 lines ~= sep;
325             }
326         }
327     }
328     if (auto bottom = ruler.bottom(widthForRuler))
329     {
330         lines ~= bottom;
331     }
332 
333     return lines.join("\n");
334 }
335 
336 ///
337 unittest
338 {
339     const testdata = [
340         ["D-man", "Programming Language"],
341         ["D言語くん", "プログラミング言語"],
342     ];
343     const tableConfig = TableConfig(Style.simple, " ", " ", true);
344     const columnConfigs = [
345         ColumnConfig(20, "マスコットキャラクタ", Align.center),
346         ColumnConfig(10, "about", Align.center)
347     ];
348     const reference =
349         " マスコットキャラクタ     about    \n" ~
350         "---------------------- ------------\n" ~
351         "        D-man           ..ming L.. \n" ~
352         "      D言語くん         ..ラミン.. ";
353     assert(tabulate(testdata, tableConfig, columnConfigs) == reference);
354 }
355 
356 private string alignment(string text, Align align_, size_t width)
357 {
358     static immutable dotTable = ["", ".", ".."];
359     if (width == 0)
360     {
361         return "";
362     }
363     const textWidth = displayWidth(text);
364     if (textWidth > width)
365     {
366         with (Align) final switch (align_)
367         {
368         case left:
369             return cutRight(text, width.to!int - 2) ~ dotTable[min(width, 2)];
370         case right:
371             return dotTable[min(width, 2)] ~ cutLeft(text, width.to!int - 2);
372         case center:
373             const c = cutBoth(text, width.to!int - 4);
374             const l = min(width / 2, 2);
375             const r = min(width / 2 + width % 2, 2);
376             return dotTable[l] ~ c ~ dotTable[r];
377         }
378     }
379     else
380     {
381         with (Align) final switch (align_)
382         {
383         case left:
384             return format!"%-s%-s"(text, ' '.repeat(width - textWidth));
385         case right:
386             return format!"%-s%-s"(' '.repeat(width - textWidth), text);
387         case center:
388             const l = (width - textWidth) / 2;
389             const r = (width - textWidth) / 2 + (width - textWidth) % 2;
390             return format!"%-s%-s%-s"(' '.repeat(l), text, ' '.repeat(r));
391         }
392     }
393 }
394 
395 private string cutRight(string text, int width)
396 {
397     if (width <= 0)
398     {
399         return "";
400     }
401     for (int c = count(text).to!int - 1; displayWidth(text) > width; c--)
402     {
403         text = text[0 .. toUTFindex(text, c)];
404     }
405     return width > displayWidth(text) ? text ~ "." : text;
406 }
407 
408 private string cutLeft(string text, int width)
409 {
410     if (width <= 0)
411     {
412         return "";
413     }
414     while (displayWidth(text) > width)
415     {
416         text = text[toUTFindex(text, 1) .. $];
417     }
418     return width > displayWidth(text) ? "." ~ text : text;
419 }
420 
421 private string cutBoth(string text, int width)
422 {
423     if (width <= 0)
424     {
425         return "";
426     }
427     bool cutLeftSide = false;
428     while (displayWidth(text) > width)
429     {
430         if (cutLeftSide)
431         {
432             text = text[toUTFindex(text, 1) .. $];
433         }
434         else
435         {
436             text = text[0 .. toUTFindex(text, count(text).to!int - 1)];
437         }
438         cutLeftSide = !cutLeftSide;
439     }
440     return width > displayWidth(text) ? text ~ "." : text;
441 }
442 
443 unittest
444 {
445     string a = "こんにちは";
446 
447     assert(alignment(a, Align.left, 12) == "こんにちは  ");
448     assert(alignment(a, Align.left, 11) == "こんにちは ");
449     assert(alignment(a, Align.left, 10) == "こんにちは");
450     assert(alignment(a, Align.left, 9) == "こんに...");
451     assert(alignment(a, Align.left, 8) == "こんに..");
452     assert(alignment(a, Align.left, 3) == "...");
453     assert(alignment(a, Align.left, 2) == "..");
454     assert(alignment(a, Align.left, 1) == ".");
455     assert(alignment(a, Align.left, 0) == "");
456 
457     assert(alignment(a, Align.center, 12) == " こんにちは ");
458     assert(alignment(a, Align.center, 11) == "こんにちは ");
459     assert(alignment(a, Align.center, 10) == "こんにちは");
460     assert(alignment(a, Align.center, 9) == "..んに...");
461     assert(alignment(a, Align.center, 8) == "..んに..");
462     assert(alignment(a, Align.center, 5) == ".....");
463     assert(alignment(a, Align.center, 4) == "....");
464     assert(alignment(a, Align.center, 3) == "...");
465     assert(alignment(a, Align.center, 2) == "..");
466     assert(alignment(a, Align.center, 1) == ".");
467     assert(alignment(a, Align.center, 0) == "");
468 
469     assert(alignment(a, Align.right, 12) == "  こんにちは");
470     assert(alignment(a, Align.right, 11) == " こんにちは");
471     assert(alignment(a, Align.right, 10) == "こんにちは");
472     assert(alignment(a, Align.right, 9) == "...にちは");
473     assert(alignment(a, Align.right, 8) == "..にちは");
474     assert(alignment(a, Align.right, 3) == "...");
475     assert(alignment(a, Align.right, 2) == "..");
476     assert(alignment(a, Align.right, 1) == ".");
477     assert(alignment(a, Align.right, 0) == "");
478 }
479 
480 private string makeItemLine(T)(
481     in T[] line,
482     in size_t[] widthes,
483     in Align[] aligns,
484     in Ruler ruler,
485     in string leftPadding,
486     in string rightPadding,
487 )
488 {
489     return ruler.left()
490         ~ zip(line, aligns, widthes)
491         .map!(tup => leftPadding ~ alignment(tup[0].to!string, tup[1], tup[2]) ~ rightPadding)
492         .join(ruler.vertical())
493         ~ ruler.right();
494 }
495 
496 unittest
497 {
498     string[] line = ["a", "ab", "abc", "abcd", "abcde"];
499     size_t[] widthes = [6, 5, 4, 3, 2];
500     Ruler ruler = Ruler(Style.markdown);
501     string leftPadding = "*";
502     string rightPadding = "^^";
503     Align[] aligns = [
504         Align.left, Align.right, Align.center, Align.left, Align.right
505     ];
506     assert(makeItemLine(line, widthes, aligns, ruler, leftPadding, rightPadding)
507             == "|*a     ^^|*   ab^^|*abc ^^|*a..^^|*..^^|");
508 }
509 
510 private size_t[] calcWidthes(T)(in T[][] data, in string[] headers)
511 {
512     assert(data.length > 0);
513     assert(data[0].length == headers.length);
514 
515     auto widthes = headers.map!(h => displayWidth(h)).array();
516 
517     foreach (line; data)
518     {
519         assert(line.length == widthes.length);
520         foreach (i; 0 .. widthes.length)
521         {
522             widthes[i] = max(widthes[i], displayWidth(line[i]));
523         }
524     }
525     return widthes;
526 }
527 
528 /// Nethack(vi) style function naming
529 private struct Ruler
530 {
531     Style style;
532 
533     enum Index
534     {
535         HL, // ─
536         JK, // │
537         JL, // ┌
538         HJ, // ┐
539         HK, // ┘
540         KL, // └
541         JKL, // ├
542         HJL, // ┬
543         HJK, // ┤
544         HKL, // ┴
545         HJKL, // ┼
546     }
547 
548     private static immutable string[] simpleLiterals = [
549         "-", " ", "", "", "", "", "", "", "", "", " "
550     ];
551     private static immutable string[] markdownLiterals = [
552         "-", "|", "", "", "", "", "|", "", "|", "", "|"
553     ];
554     private static immutable string[] gridLiterals = [
555         "─", "│", "┌", "┐", "┘", "└", "├", "┬", "┤", "┴",
556         "┼"
557     ];
558 
559     private immutable(string)[] select() const @nogc nothrow pure
560     {
561         with (Style) final switch (style)
562         {
563         case simple:
564             return simpleLiterals;
565         case markdown:
566             return markdownLiterals;
567         case grid:
568             return gridLiterals;
569         }
570     }
571 
572     string get(Index index) const
573     {
574         const target = select();
575         return target[index.to!int];
576     }
577 
578     string horizontalItemSeperator(const size_t[] widthes) const
579     {
580         with (Style) final switch (style)
581         {
582         case simple, markdown:
583             return null;
584         case grid:
585             return makeHorizontal(widthes, get(Index.HL), get(Index.HJKL), get(Index.JKL), get(
586                     Index.HJK));
587         }
588     }
589 
590     string headerItemSeperator(const size_t[] widthes) const
591     {
592         return makeHorizontal(widthes, get(Index.HL), get(Index.HJKL), get(Index.JKL), get(
593                 Index.HJK));
594     }
595 
596     string left() const
597     {
598         with (Style) final switch (style)
599         {
600         case simple:
601             return "";
602         case markdown, grid:
603             return get(Index.JK);
604         }
605     }
606 
607     string right() const
608     {
609         with (Style) final switch (style)
610         {
611         case simple:
612             return "";
613         case markdown, grid:
614             return get(Index.JK);
615         }
616     }
617 
618     string vertical() const
619     {
620         return get(Index.JK);
621     }
622 
623     string top(const size_t[] widthes) const
624     {
625         with (Style) final switch (style)
626         {
627         case simple, markdown:
628             return null;
629         case grid:
630             return makeHorizontal(widthes, get(Index.HL), get(Index.HJL), get(Index.JL), get(
631                     Index.HJ));
632         }
633     }
634 
635     string bottom(const size_t[] widthes) const
636     {
637         with (Style) final switch (style)
638         {
639         case simple, markdown:
640             return null;
641         case grid:
642             return makeHorizontal(widthes, get(Index.HL), get(Index.HKL), get(Index.KL), get(
643                     Index.HK));
644         }
645     }
646 
647     private static string makeHorizontal(const size_t[] widthes, string h, string p, string l, string r)
648     {
649         return format!"%-s%-s%-s"(l, widthes.map!(w => h.repeat(w).join()).join(p), r);
650     }
651 }
652 
653 unittest
654 {
655     const ruler = Ruler(Style.simple);
656     size_t[] widthes = [1, 2, 3];
657 
658     assert(ruler.horizontalItemSeperator(widthes) is null);
659     assert(ruler.headerItemSeperator(widthes) == "- -- ---");
660     assert(ruler.top(widthes) is null);
661     assert(ruler.bottom(widthes) is null);
662     assert(ruler.left() == "");
663     assert(ruler.right() == "");
664     assert(ruler.vertical() == " ");
665 }
666 
667 unittest
668 {
669     const ruler = Ruler(Style.markdown);
670     size_t[] widthes = [1, 2, 3];
671 
672     assert(ruler.horizontalItemSeperator(widthes) is null);
673     assert(ruler.headerItemSeperator(widthes) == "|-|--|---|");
674     assert(ruler.top(widthes) is null);
675     assert(ruler.bottom(widthes) is null);
676     assert(ruler.left() == "|");
677     assert(ruler.right() == "|");
678     assert(ruler.vertical() == "|");
679 }
680 
681 unittest
682 {
683     const ruler = Ruler(Style.grid);
684     size_t[] widthes = [1, 2, 3];
685 
686     assert(ruler.horizontalItemSeperator(widthes) == "├─┼──┼───┤");
687     assert(ruler.headerItemSeperator(widthes) == "├─┼──┼───┤");
688     assert(ruler.top(widthes) == "┌─┬──┬───┐");
689     assert(ruler.bottom(widthes) == "└─┴──┴───┘");
690     assert(ruler.left() == "│");
691     assert(ruler.right() == "│");
692     assert(ruler.vertical() == "│");
693 }