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