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 }