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     // work around https://issues.dlang.org/show_bug.cgi?id=18839
180     public auto empty()(Fence _ = Fence(), string file = __FILE__, size_t line = __LINE__)
181     {
182         return this.empty_(Fence(), file, line);
183     }
184 
185     private enum CHAIN_TERMINATED = int.max;
186 
187     private @property ref int refCount()
188     {
189         if (this.refCount_ is null)
190         {
191             this.refCount_ = new int;
192             *this.refCount_ = 0;
193         }
194         return *this.refCount_;
195     }
196 }
197 
198 /**
199  * Ensures that the given range is empty.
200  * Specified here due to https://issues.dlang.org/show_bug.cgi?id=18839
201  */
202 public void empty_(Should)(Should should, Fence _ = Fence(), string file = __FILE__, size_t line = __LINE__)
203 if (isInstanceOf!(ShouldType, Should))
204 {
205     import std.range : empty;
206 
207     with (should)
208     {
209         allowOnlyWords!("be", "not").before!"empty";
210         requireWord!"be".before!"empty";
211 
212         terminateChain;
213 
214         auto got = should.got();
215 
216         static if (hasWord!"not")
217         {
218             check(!got.empty, "nonempty range", format!"%s"(got), file, line);
219         }
220         else
221         {
222             check(got.empty, "empty range", format!"%s"(got), file, line);
223         }
224     }
225 }
226 
227 ///
228 unittest
229 {
230     import dshould.basic;
231 
232     (int[]).init.should.be.empty;
233     [5].should.not.be.empty;
234 }
235 
236 private class FluentExceptionImpl(T : Exception) : T
237 {
238     private const string expectedPart = null; // before reason
239     public const string reason = null;
240     private const string butGotPart = null; // after reason
241 
242     invariant
243     {
244         assert(!this.expectedPart.empty);
245     }
246 
247     public this(string expectedPart, string butGotPart, string file, size_t line) pure @safe
248     in
249     {
250         assert(!expectedPart.empty);
251         assert(!butGotPart.empty);
252     }
253     do
254     {
255         this.expectedPart = expectedPart;
256         this.butGotPart = butGotPart;
257 
258         super(combinedMessage, file, line);
259     }
260 
261     public this(string expectedPart, string reason, string butGotPart, string file, size_t line) pure @safe
262     in
263     {
264         assert(!expectedPart.empty);
265         assert(!butGotPart.empty);
266     }
267     do
268     {
269         this.expectedPart = expectedPart;
270         this.reason = reason;
271         this.butGotPart = butGotPart;
272 
273         super(combinedMessage, file, line);
274     }
275 
276     public this(string msg, string file, size_t line) pure @safe
277     in
278     {
279         assert(!msg.empty);
280     }
281     do
282     {
283         this.expectedPart = msg;
284 
285         super(combinedMessage, file, line);
286     }
287 
288     public FluentException because(string reason) pure @safe
289     {
290         return new FluentException(this.expectedPart, reason, this.butGotPart, this.file, this.line);
291     }
292 
293     private @property string combinedMessage() pure @safe
294     {
295         string message = format!`Test failed: expected %s`(this.expectedPart);
296 
297         if (!this.reason.empty)
298         {
299             message ~= format!` because %s`(this.reason);
300         }
301 
302         if (!this.butGotPart.empty)
303         {
304             message ~= format!`, but got %s`(this.butGotPart);
305         }
306 
307         return message;
308     }
309 }
310 
311 /**
312  * When a fluent exception is thrown during the evaluation of the left-hand side of this word,
313  * then the reason for the test is set to the `reason` parameter.
314  *
315  * Usage: 2.should.be(2).because("math is sane");
316  */
317 public T because(T)(lazy T value, string reason)
318 {
319     try
320     {
321         return value;
322     }
323     catch (FluentException fluentException)
324     {
325         throw fluentException.because(reason);
326     }
327 }
328 
329 static if (__traits(compiles, { import unit_threaded.should : UnitTestException; }))
330 {
331     import unit_threaded.should : UnitTestException;
332 
333     /**
334      * Indicates a fluent assert has failed, as well as what was tested, why it was tested, and what the outcome was.
335      * When unit_threaded is provided, FluentException is a unit_threaded test exception.
336      */
337     public alias FluentException = FluentExceptionImpl!UnitTestException;
338 }
339 else
340 {
341     public alias FluentException = FluentExceptionImpl!Exception;
342 }