1 module dshould.ShouldType; 2 3 import std.algorithm : map; 4 import std.format : format; 5 import std.meta : allSatisfy; 6 import std.range : iota; 7 import std..string : empty, join; 8 import std.traits : TemplateArgsOf; 9 public import std.traits : isInstanceOf; 10 import std.typecons : Tuple; 11 12 // prevent default arguments from being accidentally filled by regular parameters 13 // void foo(..., Fence _ = Fence(), string file = __FILE__, size_t line = __LINE__) 14 public struct Fence 15 { 16 } 17 18 /** 19 * .should begins every fluent assertion in dshould. It takes no parameters on its own. 20 * Note that leaving a .should phrase unfinished will error at runtime. 21 */ 22 public auto should(T)(lazy T got) pure 23 { 24 T get() pure @safe 25 { 26 return got; 27 } 28 29 return ShouldType!(typeof(&get))(&get); 30 } 31 32 /** 33 * ShouldType is the base type passed between UFCS words in fluent assertions. 34 * It stores the left-hand side expression of the phrase, called "got" in errors, 35 * as well as the words making up the assertion phrase as template arguments. 36 */ 37 public struct ShouldType(G, string[] phrase = []) 38 { 39 import std.algorithm : canFind; 40 41 public G got; 42 43 private int* refCount_ = null; 44 45 /** 46 * Add a word to the phrase. Can be chained. 47 */ 48 public auto addWord(string word)() 49 { 50 return ShouldType!(G, phrase ~ word)(this.got, this.refCount); 51 } 52 53 private this(G got) { this.got = got; this.refCount = 1; } 54 55 /** 56 * Manually initialize a new ShouldType value from an existing one's ref count. 57 * All ShouldTypes of one phrase must use the same reference counter. 58 */ 59 public this(G got, ref int refCount) @trusted 60 in 61 { 62 assert(refCount != CHAIN_TERMINATED, "don't copy Should that's been terminated"); 63 } 64 do 65 { 66 this.got = got; 67 this.refCount_ = &refCount; 68 this.refCount++; 69 } 70 71 this(this) @trusted 72 in 73 { 74 assert(this.refCount != CHAIN_TERMINATED); 75 } 76 do 77 { 78 this.refCount++; 79 } 80 81 ~this() @trusted 82 { 83 this.refCount--; 84 assert(this.refCount > 0, "unterminated should-chain!"); 85 } 86 87 /** 88 * Generate a mixin that checks a format expression for truth, 89 * throwing an exception with a detailed message when it fails. 90 * Params: 91 * TestString = D code representing the expression to be tested as a string. 92 * %s is used for variables. 93 * ArgNames = The names of the local variables to be substituted for the format keys. 94 */ 95 public static enum genCheck(string TestString, string[] ArgNames) = genCheckImpl(TestString, ArgNames); 96 97 private static string genCheckImpl(string testString, string[] argNames) 98 { 99 auto args = format!"%-(%s, %)"(argNames); 100 auto argStrings = format!"%(%s, %)"(argNames); 101 102 return format!q{{ 103 import std.format : format; 104 105 check(mixin(format!q{%s}(%s)), format(": expected %s", %s), null, file, line); 106 }}(testString, argStrings, testString, args); 107 } 108 109 /** 110 * Checks a boolean condition for truth, throwing an exception when it fails. 111 * The components making up the exception string are passed lazily. 112 * The message has the form: "test failed {left} [because reason] {right}" 113 * For instance: "test failed: expected got.empty() because there should be nothing in there, but got [5]." 114 * In that case, left is ": expected got.empty()" and right is "but got [5]". 115 */ 116 public void check(bool condition, lazy string left, lazy string right, string file, size_t line) pure @safe 117 { 118 terminateChain; 119 120 if (!condition) 121 { 122 throw new FluentException("test failed" ~ left, right, file, line); 123 } 124 } 125 126 /** 127 * Mark that the semantic end of this phrase has been reached. 128 * If this is not called, the phrase will error on scope exit. 129 */ 130 public void terminateChain() 131 { 132 this.refCount = CHAIN_TERMINATED; // terminate chain, safe ref checker 133 } 134 135 private static enum isStringLiteral(T...) = T.length == 1 && is(typeof(T[0]) == string); 136 137 /** 138 * Allows to check that only a select list of words are permitted before the current word. 139 * On failure, an informative error is printed. 140 * Usage: should.allowOnlyWords!("word1", "word2").before!"newWord"; 141 */ 142 public template allowOnlyWords(allowedWords...) 143 if (allSatisfy!(isStringLiteral, allowedWords)) 144 { 145 void before(string newWord)() 146 { 147 static foreach (word; phrase) 148 { 149 static assert([allowedWords].canFind(word), `bad grammar: "` ~ word ~ ` ` ~ newWord ~ `"`); 150 } 151 } 152 } 153 154 /** 155 * Allows to check that a specified word appeared in the phrase before the current word. 156 * On failure, an informative error is printed. 157 * Usage: should.requireWord!"word".before!"newWord"; 158 */ 159 public template requireWord(string requiredWord) 160 { 161 void before(string newWord)() 162 { 163 static assert( 164 hasWord!requiredWord, 165 `bad grammar: expected "` ~ requiredWord ~ `" before "` ~ newWord ~ `"` 166 ); 167 } 168 } 169 170 /** 171 * Evaluates to true if the given word exists in the current phrase. 172 */ 173 public enum hasWord(string word) = phrase.canFind(word); 174 175 // work around https://issues.dlang.org/show_bug.cgi?id=18839 176 public auto empty()() { return this.empty_; } 177 178 private enum CHAIN_TERMINATED = int.max; 179 180 private @property ref int refCount() 181 { 182 if (this.refCount_ is null) 183 { 184 this.refCount_ = new int; 185 *this.refCount_ = 0; 186 } 187 return *this.refCount_; 188 } 189 } 190 191 /** 192 * Ensures that the given range is empty. 193 * Specified here due to https://issues.dlang.org/show_bug.cgi?id=18839 194 */ 195 public void empty_(Should)(Should should, Fence _ = Fence(), string file = __FILE__, size_t line = __LINE__) 196 if (isInstanceOf!(ShouldType, Should)) 197 { 198 import std.range : empty; 199 200 with (should) 201 { 202 allowOnlyWords!("be", "not").before!"empty"; 203 requireWord!"be".before!"empty"; 204 205 terminateChain; 206 207 auto got = should.got(); 208 209 static if (hasWord!"not") 210 { 211 check(!got.empty, `: expected nonempty range`, null, file, line); 212 } 213 else 214 { 215 check(got.empty, `: expected empty range`, format(", but got %s", got), file, line); 216 } 217 } 218 } 219 220 /// 221 unittest 222 { 223 import dshould.basic; 224 225 [].should.be.empty; 226 [5].should.not.be.empty; 227 } 228 229 private class FluentExceptionImpl(T : Exception) : T 230 { 231 private const string leftPart = null; // before reason 232 public const string reason = null; 233 private const string rightPart = null; // after reason 234 235 invariant 236 { 237 assert(!this.leftPart.empty); 238 } 239 240 public this(string leftPart, string rightPart, string file, size_t line) pure @safe 241 in 242 { 243 assert(!leftPart.empty); 244 } 245 do 246 { 247 this.leftPart = leftPart; 248 this.rightPart = rightPart; 249 250 super(combinedMessage, file, line); 251 } 252 253 public this(string leftPart, string reason, string rightPart, string file, size_t line) pure @safe 254 in 255 { 256 assert(!leftPart.empty); 257 } 258 do 259 { 260 this.leftPart = leftPart; 261 this.reason = reason; 262 this.rightPart = rightPart; 263 264 super(combinedMessage, file, line); 265 } 266 267 public this(string msg, string file, size_t line) pure @safe 268 in 269 { 270 assert(!msg.empty); 271 } 272 do 273 { 274 this.leftPart = msg; 275 276 super(combinedMessage, file, line); 277 } 278 279 public FluentException because(string reason) pure @safe 280 { 281 return new FluentException(this.leftPart, reason, this.rightPart, this.file, this.line); 282 } 283 284 private @property string combinedMessage() pure @safe 285 { 286 string message = this.leftPart; 287 288 if (!this.reason.empty) 289 { 290 message ~= format!` because %s`(this.reason); 291 } 292 293 if (!this.rightPart.empty) 294 { 295 message ~= this.rightPart; 296 } 297 298 return message; 299 } 300 } 301 302 /** 303 * When a fluent exception is thrown during the evaluation of the left-hand side of this word, 304 * then the reason for the test is set to the `reason` parameter. 305 * 306 * Usage: 2.should.be(2).because("math is sane"); 307 */ 308 public T because(T)(lazy T value, string reason) 309 { 310 try 311 { 312 return value; 313 } 314 catch (FluentException fluentException) 315 { 316 throw fluentException.because(reason); 317 } 318 } 319 320 static if (__traits(compiles, { import unit_threaded.should : UnitTestException; })) 321 { 322 import unit_threaded.should : UnitTestException; 323 324 /** 325 * Indicates a fluent assert has failed, as well as what was tested, why it was tested, and what the outcome was. 326 * When unit_threaded is provided, FluentException is a unit_threaded test exception. 327 */ 328 public alias FluentException = FluentExceptionImpl!UnitTestException; 329 } 330 else 331 { 332 public alias FluentException = FluentExceptionImpl!Exception; 333 }