1 /**
2     Implements QoL functions to handle user input-output.
3 
4     Unless otherwise stated, all methods make use of `stdin` and
5     `stdout` for input and output, respectively, or this thread's
6     running `ITextMenu`, if any.
7 */
8 module dli.io;
9 
10 import dli.text_menu;
11 import std.conv;
12 import std.exception;
13 import std.meta;
14 import std.stdio : stdin, stdout;
15 import std..string : chomp, format;
16 import std.traits;
17 
18 /** 
19     Requests a text confirmation from the user.
20 
21     Returns: whether or not the user input, stripped of line terminators,
22              exactly matches requiredAnswer.
23 */
24 public bool requestConfirmation(string requestMsg, string requiredAnswer)
25 in
26 {
27     assert(requestMsg !is null);
28     assert(requiredAnswer !is null);
29 }
30 body
31 {
32     import std..string : strip;
33 
34     string answer;
35     return request!string(requestMsg, &answer, (string s){return s == requiredAnswer;});
36 }
37 
38 /// Types supported by the helper 'request' method
39 private alias requestSupportedTypes = AliasSeq!(
40     ubyte,
41     ushort,
42     uint,
43     ulong,
44     byte,
45     short,
46     int,
47     long,
48     char,
49     // wchar, not supported until to!(wchar, string) is available in Phobos
50     dchar,
51     float,
52     double,
53     real,
54     string,
55     wstring,
56     dstring
57 );
58 
59 /**
60     Requests data with the possibility of adding restrictions.
61     User input is stripped of the line terminator before processing is attempted.
62 
63     Params: requestMsg      = message to write out when asking for data.
64             dataDestination = pointer where the input data is to be stored,
65                               if the input can be converted and satifsties 
66                               restriction.
67             restriction     = a callable item that takes a single dataT argument
68                               and returns whether it is valid or not. Use it to
69                               add additional restrictions onto the data being
70                               requested.
71     
72     Returns: whether or not the input data is valid. If `false`, no writing has been
73     performed into dataDestination.
74 */
75 public bool request(dataT, restrictionCheckerT)
76             (string requestMsg,
77             dataT* dataDestination,
78             restrictionCheckerT restriction = (dataT foo){return true;}, // No restrictions by default
79             )
80 if(staticIndexOf!(dataT, requestSupportedTypes) != -1 &&
81    isCallable!restrictionCheckerT &&
82    Parameters!restrictionCheckerT.length == 1 &&
83    is(Parameters!restrictionCheckerT[0] : dataT) &&
84    is(ReturnType!restrictionCheckerT == bool))
85 in
86 {
87     assert(requestMsg !is null);
88     assert(dataDestination !is null);
89     assert(restriction !is null);
90 }
91 body
92 {
93     string input;
94     if(activeTextMenu is null)
95     {
96         stdout.write(requestMsg);
97         input = stdin.readln().chomp();
98     }
99     else
100     {
101         activeTextMenu.write(requestMsg);
102         input = activeTextMenu.readln().chomp();
103     }
104 
105     if (input is null)
106         return false;
107 
108     version(MathExpressionSupport)
109         enum supportMathExpr = true;
110     else
111         enum supportMathExpr = false;
112 
113     try
114     {
115         dataT data = void;
116         static if (supportMathExpr && isNumeric!dataT)
117         {
118             import std..string : isNumeric;
119             if (input.isNumeric)
120                 data = to!dataT(input);
121             else // If the input is not numeric, try to evaluate as a math expression
122             {
123                 import arith_eval : Evaluable;
124 
125                 auto evaluable = Evaluable!()(input);
126                 static if (isFloatingPoint!dataT)
127                     data = evaluable.eval();
128                 else
129                 {
130                     import std.math : abs, round;
131 
132                     immutable float floatResult = evaluable.eval();
133                     immutable float closestInteger = round(floatResult);
134                     enum validThreshold = 0.01f;
135                     if (abs(floatResult - closestInteger) <= validThreshold)
136                         data = to!dataT(evaluable.eval());
137                     else
138                         throw new Exception(
139                             format("Evaluated input \"%s\" is too far from nearest integer %s " ~
140                                 "to be considered a valid integer input.")
141                         );
142                 }
143             }
144         }
145         else
146             data = to!dataT(input);
147         
148         if(restriction(data))
149         {
150             *dataDestination = data;
151             return true;
152         }
153     }
154     catch(Exception e)
155     {
156     }
157 
158     return false;
159 }
160 
161 /**
162     Writes s to the output.
163 
164     Params: s = string to write.
165 */
166 public void write(string s)
167 in
168 {
169     assert(s !is null);
170 }
171 body
172 {
173     if(activeTextMenu is null)
174         stdout.write(s);
175     else
176         activeTextMenu.write(s);
177 }
178 
179 /**
180     Writes s, plus a line terminator, to the output.
181 
182     Params: s = string to write.
183 */
184 public void writeln(string s)
185 in
186 {
187     assert(s !is null);
188 }
189 body
190 {
191     if(activeTextMenu is null)
192         stdout.writeln(s);
193     else
194         activeTextMenu.writeln(s);
195 }
196 
197 // TESTS
198 version(unittest)
199 {
200     import std.exception;
201     import test.dli.mock_menu;
202     import test.dli.mock_menu_item;
203     import unit_threaded;
204 
205     version = MathExpressionSupport;
206 
207     @("requestConfirmation works if called from a running ITextMenu")
208     unittest
209     {
210         auto menu = new MockMenu();
211         immutable string confirmationAnswer = "_CONFIRM_"; // Just a random string   
212         bool confirmed;
213 
214         auto item = new MenuItem("",
215             {
216                 confirmed = requestConfirmation("", confirmationAnswer);
217             }
218         );
219 
220         menu.addItem(item, 1);
221 
222         menu.mock_writeln("1");
223         menu.mock_writeln("asdf"); // Whatever different from confirmationAnswer
224         menu.mock_writeExitRequest();
225         menu.run();
226 
227         assert(!confirmed);
228 
229         menu.mock_writeln("1");
230         menu.mock_writeln(confirmationAnswer ~ "a"); // Contains confirmation answer, but does not match
231         menu.mock_writeExitRequest();
232         menu.run();
233 
234         assert(!confirmed);
235 
236         menu.mock_writeln("1");
237         menu.mock_writeln(confirmationAnswer);
238         menu.mock_writeExitRequest();
239         menu.run();
240 
241         assert(confirmed);
242     }
243 
244     enum isValidInput(string input) = false;
245     enum isValidInput(string input : "a") = true;
246 
247     static foreach (alias supportedType; requestSupportedTypes)
248     {
249         @("request works for type " ~ supportedType.stringof)
250         unittest
251         {
252             auto menu = new MockMenu();
253 
254             supportedType myData;
255             bool dataValid;
256 
257             menu.addItem(
258                 new MenuItem("",
259                              {dataValid = request("", &myData);}
260                             ),
261                 1
262             );
263 
264             enum supportedTypeIsConvertible(T) = is(supportedType : T);
265 
266             //enum isValidInput(string input) = false;
267             //enum isValidInput(string input : "a") = true;
268 
269             // The user inputs an ASCII character
270             enum charInput = "a";
271             menu.mock_writeln("1");
272             menu.mock_writeln(charInput);
273             menu.mock_writeExitRequest();
274             menu.run();
275 
276             enum charIsValidInput = isSomeChar!supportedType ||
277                                     isSomeString!supportedType;
278 
279             assert(dataValid == charIsValidInput);
280             static if (charIsValidInput)
281                 assert(myData == to!supportedType(charInput));
282 
283             // The user inputs a double-byte Unicode character
284             enum wcharInput = "á";
285             menu.mock_writeln("1");
286             menu.mock_writeln(wcharInput);
287             menu.mock_writeExitRequest();
288             menu.run();
289 
290             enum wcharIsValidInput = is(supportedType == wchar) ||
291                                      is(supportedType == dchar) ||
292                                      isSomeString!supportedType;
293 
294             assert(dataValid == wcharIsValidInput);
295             static if (wcharIsValidInput)
296                 assert(myData == to!supportedType(wcharInput));
297 
298             // The user inputs a quadruple-byte Unicode character
299             enum dcharInput = "🙂";
300             menu.mock_writeln("1");
301             menu.mock_writeln(dcharInput);
302             menu.mock_writeExitRequest();
303             menu.run();
304 
305             enum dcharIsValidInput = is(supportedType == dchar) ||
306                                      isSomeString!supportedType;
307 
308             assert(dataValid == dcharIsValidInput);
309             static if (dcharIsValidInput)
310                 assert(myData == to!supportedType(dcharInput));
311 
312             // The user inputs a general string
313             enum stringInput = "This is English. Esto es español. 這是中國人.";
314             menu.mock_writeln("1");
315             menu.mock_writeln(stringInput);
316             menu.mock_writeExitRequest();
317             menu.run();
318 
319             enum stringIsValidInput = isSomeString!supportedType;
320 
321             assert(dataValid == stringIsValidInput);
322             static if (stringIsValidInput)
323                 assert(myData == stringInput);
324 
325             // The user inputs a fractional number
326             enum fractionalInput = "1.23";
327             menu.mock_writeln("1");
328             menu.mock_writeln(fractionalInput);
329             menu.mock_writeExitRequest();
330             menu.run();
331 
332             enum fractionalIsValidInput = isFloatingPoint!supportedType || 
333                                           isSomeString!supportedType;
334 
335             dataValid.shouldEqual(fractionalIsValidInput);
336             static if (fractionalIsValidInput)
337             {
338                 static if (isSomeString!supportedType)
339                     myData.shouldEqual(to!supportedType(fractionalInput));
340                 else static if (isFloatingPoint!supportedType)
341                     myData.shouldApproxEqual(to!supportedType(fractionalInput));
342             }
343 
344             // The user inputs a positive integer
345             enum integerInput = "15";
346             menu.mock_writeln("1");
347             menu.mock_writeln(integerInput);
348             menu.mock_writeExitRequest();
349             menu.run();
350 
351             enum positiveIntegerIsValidInput = isNumeric!supportedType ||
352                                                isSomeString!supportedType;
353 
354             dataValid.shouldEqual(positiveIntegerIsValidInput);
355             static if (positiveIntegerIsValidInput)
356                 myData.shouldEqual(to!supportedType(integerInput));
357 
358             // The user inputs a negative integer
359             enum negativeIntegerInput = "-8";
360             menu.mock_writeln("1");
361             menu.mock_writeln(negativeIntegerInput);
362             menu.mock_writeExitRequest();
363             menu.run();
364 
365             enum negativeIntegerIsValidInput = isSigned!supportedType ||
366                                                isSomeString!supportedType;
367 
368             dataValid.shouldEqual(negativeIntegerIsValidInput);
369             static if (negativeIntegerIsValidInput)
370                 myData.shouldEqual(to!supportedType(negativeIntegerInput));
371 
372             
373             // The user inputs an integer-yielding math expression
374             enum integerYieldingMathExp = "4 / 2";
375             menu.mock_writeln("1");
376             menu.mock_writeln(integerYieldingMathExp);
377             menu.mock_writeExitRequest();
378             menu.run();
379 
380             enum integerYieldingMathExpIsValidInput =
381                 isSomeString!supportedType ||
382                 isNumeric!supportedType;
383             
384             dataValid.shouldEqual(integerYieldingMathExpIsValidInput);
385             static if (integerYieldingMathExpIsValidInput)
386             {
387                 static if (isSomeString!supportedType)
388                     myData.shouldEqual(to!supportedType(integerYieldingMathExp));
389                 else
390                     myData.shouldEqual(to!supportedType("2")); // 4 / 2 = 2;
391             }
392 
393             // The user inputs an integer-yielding math expression
394             enum floatYieldingMathExp = "1 / 3";
395             menu.mock_writeln("1");
396             menu.mock_writeln(floatYieldingMathExp);
397             menu.mock_writeExitRequest();
398             menu.run();
399 
400             enum floatYieldingMathExpIsValidInput =
401                 isSomeString!supportedType ||
402                 isFloatingPoint!supportedType;
403             
404             dataValid.shouldEqual(floatYieldingMathExpIsValidInput);
405             static if (floatYieldingMathExpIsValidInput)
406             {
407                 static if (isSomeString!supportedType)
408                     myData.shouldEqual(to!supportedType(floatYieldingMathExp));
409                 else
410                     myData.shouldApproxEqual(to!supportedType(1f / 3));
411             }
412                 
413         }
414     }
415     
416     @("request!char accepts whitespace")
417     unittest
418     {
419         auto menu = new MockMenu();
420         bool dataValid;
421         char inputChar;
422         auto item = new MenuItem("",
423             {
424                 dataValid = request("", &inputChar);
425             }
426         );
427 
428         menu.addItem(item, 1);
429         menu.mock_writeln("1");
430         menu.mock_writeln(" ");
431         menu.mock_writeExitRequest();
432         menu.run();
433 
434         assert(dataValid);
435         assert(inputChar == ' ');
436     }
437 
438     @("request can take restrictions")
439     unittest
440     {
441         int myInt;
442         bool dataValid;
443         auto menu = new MockMenu();
444 
445         menu.addItem(
446             new MenuItem("",
447                          {
448                              dataValid = request("", &myInt, (int a){return a % 2 == 0;}); // Only accepts even integers
449                          }
450                         ),
451             1
452         );
453 
454         menu.mock_writeln("1");
455         menu.mock_writeln("5"); // Not an even integer
456         menu.mock_writeExitRequest();
457         menu.run();
458 
459         assert(!dataValid);
460 
461         menu.mock_writeln("1");
462         menu.mock_writeln("8"); // Even integer
463         menu.mock_writeExitRequest();
464         menu.run();
465 
466         assert(dataValid);
467         assert(myInt == 8);
468     }
469 
470     @("request supports math expressions")
471     unittest
472     {
473         int userInput;
474         bool dataValid;
475         auto menu = new MockMenu();
476 
477         menu.addItem(
478             new MenuItem("",
479                 {
480                     dataValid = request("", &userInput, (int a){return a % 2 == 0;});
481                 }
482             ), 1
483         );
484 
485         menu.mock_writeln("1");
486         menu.mock_writeln("asdf");
487         menu.mock_writeExitRequest();
488         menu.run();
489 
490         assert(!dataValid);
491 
492         menu.mock_writeln("1");
493         menu.mock_writeln("1 + 2"); // Not valid, odd value
494         menu.mock_writeExitRequest();
495         menu.run();
496 
497         assert(!dataValid);
498 
499         menu.mock_writeln("1");
500         menu.mock_writeln("2 + 2");
501         menu.mock_writeExitRequest();
502         menu.run();
503 
504         assert(dataValid);
505         assert(userInput == 4);
506     }
507 }