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