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         return this.got_();
68     }
69 
70     private this(G got) { this.got_ = got; this.refCount = 1; }
71 
72     /**
73      * Manually initialize a new ShouldType value from an existing one's ref count.
74      * All ShouldTypes of one phrase must use the same reference counter.
75      */
76     public this(G got, ref int refCount) @trusted
77     in
78     {
79         assert(refCount != CHAIN_TERMINATED, "don't copy Should that's been terminated");
80     }
81     do
82     {
83         this.got_ = got;
84         this.refCount_ = &refCount;
85         this.refCount++;
86     }
87 
88     this(this) @trusted
89     in
90     {
91         assert(this.refCount != CHAIN_TERMINATED);
92     }
93     do
94     {
95         this.refCount++;
96     }
97 
98     ~this() @trusted
99     {
100         this.refCount--;
101         assert(this.refCount > 0, "unterminated should-chain!");
102     }
103 
104     /**
105      * Checks a boolean condition for truth, throwing an exception when it fails.
106      * The components making up the exception string are passed lazily.
107      * The message has the form: "Test failed: expected {expected}[ because reason], but got {butGot}"
108      * For instance: "Test failed: expected got.empty() because there should be nothing in there, but got [5]."
109      * In that case, `expected` is "got.empty()" and `butGot` is "[5]".
110      */
111     public void check(bool condition, lazy string expected, lazy string butGot, string file, size_t line) pure @safe
112     {
113         terminateChain;
114 
115         if (!condition)
116         {
117             throw new FluentException(expected, butGot, file, line);
118         }
119     }
120 
121     /**
122      * Mark that the semantic end of this phrase has been reached.
123      * If this is not called, the phrase will error on scope exit.
124      */
125     public void terminateChain()
126     {
127         this.refCount = CHAIN_TERMINATED; // terminate chain, safe ref checker
128     }
129 
130     private static enum isStringLiteral(T...) = T.length == 1 && is(typeof(T[0]) == string);
131 
132     /**
133      * Allows to check that only a select list of words are permitted before the current word.
134      * On failure, an informative error is printed.
135      * Usage: should.allowOnlyWords!("word1", "word2").before!"newWord";
136      */
137     public template allowOnlyWords(allowedWords...)
138     if (allSatisfy!(isStringLiteral, allowedWords))
139     {
140         void before(string newWord)()
141         {
142             static foreach (word; phrase)
143             {
144                 static assert([allowedWords].canFind(word), `bad grammar: "` ~ word ~ ` ` ~ newWord ~ `"`);
145             }
146         }
147     }
148 
149     /**
150      * Allows to check that a specified word appeared in the phrase before the current word.
151      * On failure, an informative error is printed.
152      * Usage: should.requireWord!"word".before!"newWord";
153      */
154     public template requireWord(string requiredWord)
155     {
156         void before(string newWord)()
157         {
158             static assert(
159                 hasWord!requiredWord,
160                 `bad grammar: expected "` ~ requiredWord ~ `" before "` ~ newWord ~ `"`
161             );
162         }
163     }
164 
165     /**
166      * Evaluates to true if the given word exists in the current phrase.
167      */
168     public enum hasWord(string word) = phrase.canFind(word);
169 
170     // work around https://issues.dlang.org/show_bug.cgi?id=18839
171     public auto empty()(Fence _ = Fence(), string file = __FILE__, size_t line = __LINE__)
172     {
173         return this.empty_(Fence(), file, line);
174     }
175 
176     private enum CHAIN_TERMINATED = int.max;
177 
178     private @property ref int refCount()
179     {
180         if (this.refCount_ is null)
181         {
182             this.refCount_ = new int;
183             *this.refCount_ = 0;
184         }
185         return *this.refCount_;
186     }
187 }
188 
189 /**
190  * Ensures that the given range is empty.
191  * Specified here due to https://issues.dlang.org/show_bug.cgi?id=18839
192  */
193 public void empty_(Should)(Should should, Fence _ = Fence(), string file = __FILE__, size_t line = __LINE__)
194 if (isInstanceOf!(ShouldType, Should))
195 {
196     import std.range : empty;
197 
198     with (should)
199     {
200         allowOnlyWords!("be", "not").before!"empty";
201         requireWord!"be".before!"empty";
202 
203         terminateChain;
204 
205         auto got = should.got();
206 
207         static if (hasWord!"not")
208         {
209             check(!got.empty, "nonempty range", format("%s", got), file, line);
210         }
211         else
212         {
213             check(got.empty, "empty range", format("%s", got), file, line);
214         }
215     }
216 }
217 
218 ///
219 unittest
220 {
221     import dshould.basic;
222 
223     [].should.be.empty;
224     [5].should.not.be.empty;
225 }
226 
227 private class FluentExceptionImpl(T : Exception) : T
228 {
229     private const string expectedPart = null; // before reason
230     public const string reason = null;
231     private const string butGotPart = null; // after reason
232 
233     invariant
234     {
235         assert(!this.expectedPart.empty);
236     }
237 
238     public this(string expectedPart, string butGotPart, string file, size_t line) pure @safe
239     in
240     {
241         assert(!expectedPart.empty);
242         assert(!butGotPart.empty);
243     }
244     do
245     {
246         this.expectedPart = expectedPart;
247         this.butGotPart = butGotPart;
248 
249         super(combinedMessage, file, line);
250     }
251 
252     public this(string expectedPart, string reason, 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.reason = reason;
262         this.butGotPart = butGotPart;
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.expectedPart = msg;
275 
276         super(combinedMessage, file, line);
277     }
278 
279     public FluentException because(string reason) pure @safe
280     {
281         return new FluentException(this.expectedPart, reason, this.butGotPart, this.file, this.line);
282     }
283 
284     private @property string combinedMessage() pure @safe
285     {
286         string message = format!`Test failed: expected %s`(this.expectedPart);
287 
288         if (!this.reason.empty)
289         {
290             message ~= format!` because %s`(this.reason);
291         }
292 
293         if (!this.butGotPart.empty)
294         {
295             message ~= format!`, but got %s`(this.butGotPart);
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 }