1 module cucumber.formatter.pretty;
2 
3 import std.algorithm : max;
4 import std.algorithm.iteration : each, filter, map;
5 import std.array : array, empty, join;
6 import std.conv : to;
7 import std.range : repeat, walkLength;
8 import std.stdio : write, writef, writefln, writeln;
9 import std.string : leftJustifier, replace, split, stripLeft;
10 import std.typecons : Nullable;
11 
12 import cucumber.formatter.base : Formatter;
13 import cucumber.formatter.color : colors, noColors;
14 import cucumber.result : FAILED, SKIPPED, UNDEFINED, PASSED, Result, RunResult,
15     ScenarioResult, StepResult;
16 import gherkin : Feature, Scenario, Step, Examples, TableRow, Comment, Cell;
17 
18 ///
19 class Pretty : Formatter
20 {
21     private bool noColor;
22     private bool noSnippets;
23     private bool noSource;
24 
25     ///
26     this(bool noColor, bool noSnippets, bool noSource)
27     {
28         this.noColor = noColor;
29         this.noSnippets = noSnippets;
30         this.noSource = noSource;
31     }
32 
33     ///
34     override string color(string result)
35     {
36         return noColor ? noColors[result] : colors[result];
37     }
38 
39     ///
40     override void feature(Feature feature)
41     {
42         base(feature);
43     }
44 
45     ///
46     override void scenario(ref Scenario scenario)
47     {
48         writeln();
49         base(scenario, "  ");
50     }
51 
52     ///
53     override void examples(Examples examples)
54     {
55         writeln();
56         base(examples, "    ");
57     }
58 
59     ///
60     override void tableRow(TableRow tableRow, ref TableRow[] table, string resultColor)
61     {
62         if (this.table != table)
63         {
64             setCellSize(table);
65         }
66         writeln("      ", justifyCells(tableRow.cells, resultColor));
67     }
68 
69     ///
70     override void tableRow(TableRow tableRow, ref TableRow[] table, ScenarioResult scenarioResult)
71     {
72         tableRow.comments.each!(c => comment(c, "      "));
73         with (scenarioResult)
74         {
75             this.tableRow(tableRow, table,
76                     (stepResults.filter!(r => r.isUndefined).empty) ? result : UNDEFINED);
77             foreach (stepResult; stepResults)
78             {
79                 error(stepResult.exception, "      ");
80             }
81         }
82     }
83 
84     ///
85     override void step(Step step, StepResult stepResult)
86     {
87         this.step(step, stepResult.result, stepResult.location);
88 
89         if (stepResult.isFailed)
90         {
91             error(stepResult.exception, "    ");
92         }
93     }
94 
95     ///
96     override void comment(Comment comment)
97     {
98         writeln(comment.text);
99     }
100 
101     ///
102     override void summarizeResult(RunResult result)
103     {
104         struct FailingScenario
105         {
106             string text;
107             string source;
108         }
109 
110         FailingScenario[] failingScenarios;
111         ulong maxLength;
112         foreach (featureResult; result.featureResults)
113         {
114             foreach (scenarioResult; featureResult.scenarioResults.filter!(x => x.isFailed))
115             {
116                 auto text = "cucumber " ~ featureResult.feature.uri ~ ":"
117                     ~ scenarioResult.scenario.location.line.to!string;
118                 auto source = scenarioResult.scenario.keyword ~ `: ` ~ (
119                         scenarioResult.scenario.name);
120                 if (!scenarioResult.exampleNumber > 0)
121                 {
122                     source ~= ", Examples (#" ~ scenarioResult.exampleNumber.to!string ~ `)`;
123                 }
124 
125                 auto failingScenario = FailingScenario(text, source);
126                 maxLength = max(maxLength, failingScenario.text.walkLength);
127                 failingScenarios ~= failingScenario;
128             }
129         }
130 
131         if (!failingScenarios.empty)
132         {
133             writeln;
134             writeln(color("failed"), "Failing Scenarios:", color("reset"));
135 
136             foreach (failingScenario; failingScenarios)
137             {
138                 if (noSource)
139                 {
140                     writeln(color("failed"), failingScenario.text, color("reset"));
141                 }
142                 else
143                 {
144                     writeln(color("failed"), leftJustifier(failingScenario.text, maxLength + 1),
145                             color("reset"), color("gray"), "# ",
146                             failingScenario.source, color("reset"));
147                 }
148             }
149         }
150 
151         if (!result.featureResults.empty)
152         {
153             writeln;
154         }
155         runResult(result, noSource);
156     }
157 
158 private:
159 
160     Scenario runningScenario;
161     TableRow[] table;
162     ulong scenarioStringLength;
163     ulong[] cellSizes;
164 
165     void comment(Comment comment, string extraIndent = "")
166     {
167         writeln(extraIndent ~ comment.text.stripLeft);
168     }
169 
170     void base(T)(T element, string extraIndent = "")
171     {
172         with (element)
173         {
174             comments.each!(c => comment(c, extraIndent));
175             if (!tags.empty)
176             {
177                 writeln(extraIndent, color("skipped"), tags.map!(t => t.name)
178                         .join(" "), color("reset"));
179             }
180             string line = extraIndent ~ keyword ~ ": ";
181             static if (is(typeof(element) == Step))
182             {
183                 line ~= text;
184             }
185             else
186             {
187                 line ~= name;
188             }
189             static if (is(typeof(element) == Scenario))
190             {
191                 setScenarioStringLength(element);
192                 if (noSource)
193                 {
194                     write(line);
195                 }
196                 else
197                 {
198                     write(leftJustifier(line, scenarioStringLength + 4));
199                     write(color("gray"), " # ", element.uri, `:`,
200                             element.location.line.to!string, color("reset"));
201                 }
202             }
203             else
204             {
205                 write(line);
206             }
207             writeln;
208 
209             if (!description.empty)
210             {
211                 writeln(description);
212             }
213         }
214     }
215 
216     void step(Step step, string resultColor, string location)
217     {
218         step.comments.each!(c => comment(c, "      "));
219         if (noSource)
220         {
221             write("    ", color(resultColor), step.keyword ~ step.text, color("reset"));
222         }
223         else
224         {
225             write("    ", color(resultColor), leftJustifier(step.keyword ~ step.text,
226                     scenarioStringLength), color("reset"));
227             write(color("gray"), " # ", location, color("reset"));
228         }
229         writeln;
230         if (!step.docString.isNull)
231         {
232             with (step.docString.get)
233             {
234                 writeln("      ", color(resultColor), `"""`);
235                 writeln(content.split("\n").map!(x => x.empty ? `` : ("      " ~ x)).array.join(
236                         "\n"));
237                 writeln("      ", `"""`, color("reset"));
238             }
239         }
240         if (!step.dataTable.empty)
241         {
242             foreach (row; step.dataTable.rows)
243             {
244                 tableRow(row, step.dataTable.rows, SKIPPED);
245             }
246         }
247     }
248 
249     void setScenarioStringLength(ref Scenario scenario)
250     {
251         if (this.runningScenario == scenario)
252         {
253             return;
254         }
255         scenarioStringLength = scenario.keyword.walkLength + scenario.name.walkLength;
256         foreach (step; scenario.steps)
257         {
258             auto stepStringLength = (step.keyword ~ (step.text)).walkLength;
259             scenarioStringLength = max(scenarioStringLength, stepStringLength);
260         }
261         this.runningScenario = scenario;
262     }
263 
264     void setCellSize(TableRow[] rows)
265     {
266         import std.regex : ctRegex, regexReplace = replace;
267 
268         if (rows.empty)
269         {
270             return;
271         }
272         this.table = rows;
273         cellSizes = 0LU.repeat(rows[0].cells.length).array;
274         foreach (i, row; rows)
275         {
276             foreach (j, cell; row.cells)
277             {
278                 if (!cell.value.empty)
279                 {
280                     auto value = cell.value.replace('\\', `\\`).replace("\n",
281                             `\n`).regexReplace(ctRegex!(`(([^\\])\||^\|)`), `$2\|`);
282                     cellSizes[j] = max(cellSizes[j], value.walkLength);
283                 }
284             }
285         }
286     }
287 
288     string justifyCells(Cell[] cells, string resultColor)
289     {
290         import std.regex : ctRegex, regexReplace = replace;
291 
292         string[] cellStrings;
293         foreach (i, cell; cells)
294         {
295             string cellString;
296             if (!cell.value.empty)
297             {
298                 auto value = cell.value.replace('\\', `\\`).replace("\n", `\n`)
299                     .regexReplace(ctRegex!(`(([^\\])\||^\|)`), `$2\|`);
300                 cellString = color(resultColor) ~ value.leftJustifier(cellSizes[i])
301                     .to!string ~ color("reset");
302             }
303             cellStrings ~= cellString;
304         }
305         return "| " ~ cellStrings.join(" | ") ~ " |";
306     }
307 
308     void error(Nullable!Exception exception, string extraIndent = "")
309     {
310         if (exception.isNull)
311         {
312             return;
313         }
314         string message = exception.get.message.split("\n")
315             .map!(l => extraIndent ~ l).array.join("\n");
316         writefln("%s%s%s", color("failed"), message, color("reset"));
317     }
318 }
319 
320 unittest
321 {
322     import std.algorithm : canFind;
323     import std.file : readText;
324     import std.path : baseName;
325     import std.stdio : File, stdout;
326     import unit_threaded.assertions : should;
327     import cucumber.runner : CucumberRunner;
328     import gherkin.util : getFeatureFiles;
329     import gherkin.parser : Parser;
330 
331     const auto ignoredFeatureFiles = [
332         // dfmt off
333         "several_examples", // tags over Examples
334         "tags", // tags over Examples
335         "complex_background", // Rule included
336         "i18n_emoji",
337         "i18n_fr",
338         "i18n_no",
339         "minimal-example", // Example (related to Rule) included
340         "padded_example", // Cucumber-Ruby Scenario Outline issue
341         "rule",
342         "rule_without_name_and_description",
343         "scenario_outline", // Cucumber-Ruby Scenario Outline issue
344         "spaces_in_language",
345         // dfmt on
346     ];
347 
348     foreach (featureFile; getFeatureFiles([
349                 ``, "gherkin-d/cucumber/gherkin/testdata/good/"
350             ]))
351     {
352         if (ignoredFeatureFiles.canFind(baseName(featureFile, ".feature")))
353         {
354             continue;
355         }
356         auto gherkinDocument = Parser.parseFromFile(featureFile);
357         auto formatter = new Pretty(true, true, true);
358         auto runner = new CucumberRunner(formatter, true);
359 
360         auto original = stdout;
361         stdout.open("cucumber-d_formatter_pretty.out", "wt");
362         RunResult result;
363         result += runner.runFeature!"cucumber.formatter.pretty"(gherkinDocument);
364         formatter.summarizeResult(result);
365         stdout = original;
366 
367         const auto expected = readText("testdata/formatter/pretty/" ~ baseName(featureFile));
368         const auto actual = readText("cucumber-d_formatter_pretty.out");
369         actual.should == expected;
370 
371     }
372 }