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