1 /**
2 BDD markdown parser
3 */
4 module tagion.behaviour.BehaviourParser;
5 
6 import std.conv : to;
7 import std.format;
8 import std.meta;
9 import std.range.primitives : ElementType, isInputRange;
10 import std.regex;
11 import std.string : strip;
12 import std.traits;
13 import std.traits : Fields;
14 import std.uni : toLower;
15 import tagion.behaviour.BehaviourException;
16 import tagion.behaviour.BehaviourFeature;
17 import tagion.behaviour.BehaviourFeature : ActionProperties;
18 import tagion.hibon.HiBONRecord : GetLabel, recordType;
19 
20 enum feature_regex = regex([
21     `^\W*(feature)\W`, /// Feature
22     `^\W*(scenario)\W`, /// Scenario
23     r"^\W*(given|when|then|but)\W", /// Action
24     r"`((?:\w+\.?)+)`", /// Name
25 ], "i");
26 
27 unittest {
28     /// regex_given
29     {
30         const test = "---given when xxx";
31         auto match = test.matchFirst(feature_regex);
32         assert(match[1] == "given");
33         assert(match.whichPattern == Token.ACTION);
34     }
35     /// regex_when
36     {
37         const test = "+++when rrrr when xxx";
38         auto match = test.matchFirst(feature_regex);
39         assert(match[1] == "when");
40         assert(match.whichPattern == Token.ACTION);
41     }
42     /// regex_then
43     {
44         const test = "+-+-then fff rrrr when xxx";
45         auto match = test.matchFirst(feature_regex);
46         assert(match[1] == "then");
47         assert(match.whichPattern == Token.ACTION);
48     }
49     /// regex_feature
50     {
51         const test = "****feature** fff rrrr when xxx";
52         auto match = test.matchFirst(feature_regex);
53         assert(match[1] == "feature");
54         assert(match.whichPattern == Token.FEATURE);
55     }
56     /// regex_scenario
57     {
58         const test = "----++scenario* ddd fff rrrr when xxx";
59         auto match = test.matchFirst(feature_regex);
60         assert(match[1] == "scenario");
61         assert(match.whichPattern == Token.SCENARIO);
62     }
63 }
64 
65 enum Token {
66     NONE,
67     FEATURE,
68     SCENARIO,
69     ACTION,
70     NAME,
71 }
72 
73 @safe
74 bool validAction(scope const(char[]) name) pure {
75     import std.algorithm.searching : any;
76 
77     return !name.any!q{a == '.'};
78 }
79 
80 enum State {
81     Start,
82     Feature,
83     Scenario,
84     Action,
85 }
86 
87 @trusted
88 FeatureGroup parser(string filename, out string[] errors) {
89     import std.stdio : File;
90 
91     auto by_line = File(filename).byLine;
92     return parser(by_line, errors, filename);
93 }
94 
95 @trusted
96 FeatureGroup parser(R)(R range, out string[] errors, string localfile = null)
97         if (isInputRange!R && isSomeString!(ElementType!R)) {
98     import std.algorithm.searching;
99     import std.array;
100     import std.range : enumerate;
101     import std.string;
102 
103     FeatureGroup result;
104     ScenarioGroup scenario_group;
105 
106     Info!Feature info_feature;
107     State state;
108     bool got_feature;
109     int current_action_index = -1;
110 
111     foreach (line_no, line; range.enumerate(1)) {
112         void check_error(const bool flag, string msg) {
113             if (!flag) {
114                 errors ~= format("%s(%d): Error: %s", localfile, line_no, msg);
115             }
116         }
117 
118         auto match = range.front.matchFirst(feature_regex);
119 
120         const Token token = cast(Token)(match.whichPattern);
121         with (Token) {
122         TokenSwitch:
123             final switch (token) {
124             case NONE:
125                 immutable comment = match.post.strip.idup;
126                 final switch (state) {
127                 case State.Feature:
128                     const _comment = comment.stripRight;
129                     if (_comment.length) {
130                         info_feature.property.comments ~= _comment;
131                     }
132                     break;
133                 case State.Scenario:
134                     const _comment = comment.stripRight;
135                     if (_comment.length) {
136                         scenario_group.info.property.comments ~= _comment;
137                     }
138                     break;
139                 case State.Action:
140                     static foreach (index, Field; Fields!ScenarioGroup) {
141                         static if (hasMember!(Field, "infos")) {
142                             with (scenario_group.tupleof[index]) {
143                                 if (current_action_index is index) {
144                                     const _comment = comment.stripRight;
145                                     if (_comment.length) {
146                                         infos[$ - 1].property.comments ~= _comment;
147 
148                                     }
149                                 }
150                             }
151                         }
152                     }
153                     break;
154                 case State.Start:
155                     check_error(0, "Missing feature declaration");
156                 }
157                 break;
158             case FEATURE:
159                 current_action_index = -1;
160                 check_error(state is State.Start, "Feature has already been declared in line");
161                 info_feature.property.description = match.post.strip.idup;
162                 state = State.Feature;
163                 got_feature = true;
164                 break;
165             case NAME:
166                 final switch (state) {
167                 case State.Feature:
168                     info_feature.name = match[1].idup;
169                     break TokenSwitch;
170                 case State.Scenario:
171                     check_error(match[1].validAction,
172                     format("Not a valid action name %s,  '.' is not allowed", match[1]));
173                     scenario_group.info.name = match[1].idup;
174                     break TokenSwitch;
175                 case State.Action:
176                     static foreach (index, Field; Fields!ScenarioGroup) {
177                         static if (hasMember!(Field, "infos")) {
178                             if (current_action_index is index) {
179                                 with (scenario_group.tupleof[index]) {
180                                     check_error(infos[$ - 1].name.length == 0,
181                                     format("Action name '%s' has already been defined for %s", match[0],
182                                     infos[$ - 1].name));
183                                     infos[$ - 1].name = match[1].strip.idup;
184                                 }
185                                 break TokenSwitch;
186                             }
187                         }
188                     }
189                     break TokenSwitch;
190                 case State.Start:
191                     break TokenSwitch;
192                 }
193                 check_error(0, format("No valid action has %s", match[1]));
194                 break;
195             case SCENARIO:
196                 check_error(got_feature, "Scenario without feature");
197                 if (state != State.Feature) {
198                     result.scenarios ~= scenario_group;
199                     scenario_group = ScenarioGroup.init;
200                 }
201                 current_action_index = -1;
202                 scenario_group.info.property.description = match.post.strip.idup;
203                 state = State.Scenario;
204                 break;
205             case ACTION:
206                 state = State.Action;
207                 const action_word = match[1].toLower;
208                 alias ActionGroups = staticMap!(ActionGroup, ActionProperties);
209                 static foreach (int index, Field; Fields!ScenarioGroup) {
210                     {
211                         enum field_index = staticIndexOf!(Field, ActionGroups);
212                         static if (field_index >= 0) {
213                             enum label = GetLabel!(scenario_group.tupleof[index]);
214                             enum action_name = label.name;
215                             static if (hasMember!(Field, "infos")) {
216 
217                                 if (action_word == action_name) {
218                                     with (scenario_group.tupleof[index]) {
219 
220                                         check_error(current_action_index <= index,
221                                                 format("Bad action order for action %s", action_word));
222                                         current_action_index = index;
223                                         infos.length++;
224                                         infos[$ - 1].property.description = match.post.strip.idup;
225                                     }
226                                 }
227                             }
228                         }
229                     }
230                 }
231                 break;
232             }
233         }
234     }
235     result.info = info_feature;
236     if (scenario_group !is scenario_group.init) {
237         result.scenarios ~= scenario_group;
238     }
239     return result;
240 }
241 
242 /// Examples: How to parse a markdown file
243 unittest { /// Convert ProtoDBBTestComments to Feature
244     import std.traits : FunctionTypeOf;
245     import tagion.basic.basic : fileId;
246 
247     enum bddfile_proto = "ProtoBDDTestComments".unitfile;
248     enum bdd_filename = bddfile_proto.setExtension(FileExtension.markdown);
249 
250     auto feature_byline = File(bdd_filename).byLine;
251 
252     string[] errors;
253     auto feature = parser(feature_byline, errors);
254     assert(errors is null);
255 
256     const fileid = fileId!(FunctionTypeOf!parser)(FileExtension.markdown);
257     immutable markdown_filename = fileid.fullpath;
258 
259     import tagion.behaviour.BehaviourIssue;
260 
261     /// Write the markdown file
262     auto fout = File(markdown_filename, "w");
263     auto markdown = Markdown(fout);
264     markdown.issue(feature);
265     fout.close;
266 
267     immutable hibon_filename = markdown_filename
268         .setExtension(FileExtension.hibon);
269 
270     import tagion.hibon.HiBONFile : fread, fwrite;
271 
272     hibon_filename.fwrite(feature);
273 
274     // Check that the feature can be reloaded
275     const expected_feature = hibon_filename.fread!FeatureGroup;
276     assert(feature.toDoc == expected_feature.toDoc);
277     // Reparse the produced markdown and check if it is the same
278     errors = null;
279     auto produced_feature = parser(markdown_filename, errors);
280     "/tmp/produced_feature.hibon".fwrite(produced_feature);
281     assert(errors is null);
282     assert(produced_feature.toDoc == expected_feature.toDoc);
283 }
284 
285 version (unittest) {
286     import std.path;
287     import io = std.stdio;
288     import std.stdio : File;
289     import tagion.basic.Types : FileExtension;
290     import tagion.basic.basic : unitfile;
291     import tagion.hibon.HiBONJSON;
292 }