1 module in_any_case;
2 
3 import core.exception;
4 import std.algorithm;
5 import std.array;
6 import std.conv;
7 import std.range;
8 import std.regex;
9 import std.traits;
10 import std.uni;
11 
12 version(unittest) import exceeds_expectations;
13 
14 
15 /// Convert a string to the given case.
16 ///
17 /// If no separator is given, the function will attempt to split the
18 /// input string by spaces, hyphens, or underscores, in that order of
19 /// priority. If none of those characters are found, splits the string
20 /// as if it were camelCase/PascalCase.
21 ///
22 /// If that isn't good enough, pass in an explicit separator as the
23 /// last parameter. To force the function to split words by
24 /// camelCase/PascalCase rules (i.e. when an uppercase letter is
25 /// encountered), pass the empty string `""` as the separator.
26 public String toCase(String)(in String input, in Case casing)
27 pure
28 if (isSomeString!String)
29 {
30     return
31         input
32         .to!string                  // Do work in UTF-8 so we can avoid everything being templated
33         .smartSplit
34         .joinUsingCasing(casing)
35         .to!String;                 // Convert back to the original string type when done
36 }
37 unittest
38 {
39     Case spongebobCase = Case(
40         (words) {
41             string[] result;
42 
43             foreach (string word; words) {
44                 string newWord;
45                 foreach (idx, c; word) {
46                     import std.uni : toUpper, toLower;
47 
48                     if (idx % 2 == 0)
49                         newWord ~= c.toLower;
50                     else
51                         newWord ~= c.toUpper;
52                 }
53                 result ~= newWord;
54             }
55 
56             return result;
57         },
58         " " // Separate words using spaces
59     );
60 
61     expect("hello world".toCase(spongebobCase)).toEqual("hElLo wOrLd");
62 }
63 unittest
64 {
65     // wstrings and dstrings also work
66     expect("hello world"w.toCase(Case.screamingSnake)).toEqual("HELLO_WORLD"w);
67     expect("hello world"d.toCase(Case.screamingSnake)).toEqual("HELLO_WORLD"d);
68 }
69 
70 /// ditto
71 public String toCase(String)(in String input, in Case casing, in String separator)
72 pure
73 if (isSomeString!String)
74 {
75     return
76         input
77         .to!string                  // Do work in UTF-8 so we can avoid everything being templated
78         .dumbSplit(separator.to!string)
79         .joinUsingCasing(casing)
80         .to!String;                 // Convert back to the original string type when done
81 }
82 unittest
83 {
84     // Use "." as the word separator
85     expect("hello.world".toCase(Case.pascal, ".")).toEqual("HelloWorld");
86 
87     // Force the input to be interpreted as camelCase or PascalCase
88     expect("helLo_woRld".toCase(Case.kebab, "")).toEqual("hel-lo_wo-rld");
89 
90     // wstrings and dstrings also work
91     expect("hello.world"w.toCase(Case.screamingSnake, ".")).toEqual("HELLO_WORLD"w);
92     expect("hello.world"d.toCase(Case.screamingSnake, ".")).toEqual("HELLO_WORLD"d);
93 }
94 
95 version(unittest)
96 {
97     private struct TestCase
98     {
99         Case casing;
100         string name;
101         string helloWorld;
102     }
103 
104     private enum TestCase[] testCases = [
105         TestCase(Case.pascal, "PascalCase", "Hello123World"),
106         TestCase(Case.camel, "camelCase", "hello123World"),
107         TestCase(Case.snake, "snake_case", "hello_123_world"),
108         TestCase(Case.screamingSnake, "SCREAMING_SNAKE_CASE", "HELLO_123_WORLD"),
109         TestCase(Case.kebab, "kebab-case", "hello-123-world"),
110         TestCase(Case.sentence, "Sentence case", "Hello 123 world"),
111     ];
112 }
113 
114 static foreach (TestCase from; testCases)
115 {
116     static foreach (TestCase to; testCases)
117     {
118         @("Convert from " ~ from.name ~ " to " ~ to.name ~ ": " ~ from.helloWorld ~ " -> " ~ to.helloWorld)
119         unittest
120         {
121             expect(from.helloWorld.toCase(to.casing)).toEqual(to.helloWorld);
122         }
123     }
124 }
125 
126 
127 
128 private alias Capitalizer = string[] function(in string[] words) pure;
129 
130 private enum Capitalizer sentenceCapitalizer = (in string[] words) {
131     string[] result;
132     foreach (size_t idx, string word; words)
133     {
134         if (idx == 0)
135             result ~= asCapitalized(word).to!string;
136         else
137             result ~= word;
138     }
139     return result;
140 };
141 
142 private enum Capitalizer inverseSentenceCapitalizer = (in string[] words) {
143     string[] result;
144     foreach (size_t idx, string word; words)
145     {
146         if (idx == 0)
147             result ~= word;
148         else
149             result ~= asCapitalized(word).to!string;
150     }
151     return result;
152 };
153 
154 private enum Capitalizer titleCapitalizer = (in string[] words) {
155     return words.map!(word => word.asCapitalized.to!string).array;
156 };
157 
158 public enum Capitalizer allCapsCapitalizer = (in string[] words) => words.map!toUpper.array;
159 
160 public struct Case
161 {
162     public enum Case pascal = Case(titleCapitalizer);
163     public enum Case camel = Case(inverseSentenceCapitalizer);
164     public enum Case snake = Case(words => words.dup, "_");
165     public enum Case screamingSnake = Case(allCapsCapitalizer, "_");
166     public enum Case kebab = Case(words => words.dup, "-");
167     public enum Case sentence = Case(sentenceCapitalizer, " ");
168 
169     Capitalizer capitalizer;
170     string separator = "";
171 
172     invariant(capitalizer !is null, "capitalizer is null");
173 }
174 
175 private string joinUsingCasing(Strings)(in Strings input, in Case casing)
176 pure
177 if (isInputRange!Strings && isSomeString!(ElementType!Strings))
178 {
179     return casing.capitalizer(input.map!toLower.array).join(casing.separator);
180 }
181 
182 @("join a sequence of words in PascalCase")
183 unittest
184 {
185     expect(["hello", "world"].joinUsingCasing(Case.pascal)).toEqual("HelloWorld");
186 }
187 
188 @("join a sequence of words in camelCase")
189 unittest
190 {
191     expect(["hello", "world"].joinUsingCasing(Case.camel)).toEqual("helloWorld");
192 }
193 
194 @("join a sequence of words in snake_case")
195 unittest
196 {
197     expect(["hello", "world"].joinUsingCasing(Case.snake)).toEqual("hello_world");
198 }
199 
200 @("join a sequence of words in SCREAMING_SNAKE_CASE")
201 unittest
202 {
203     expect(["hello", "world"].joinUsingCasing(Case.screamingSnake)).toEqual("HELLO_WORLD");
204 }
205 
206 @("join a sequence of words in kebab-case")
207 unittest
208 {
209     expect(["hello", "world"].joinUsingCasing(Case.kebab)).toEqual("hello-world");
210 }
211 
212 @("join a sequence of words in Sentence case")
213 unittest
214 {
215     expect(["hello", "world"].joinUsingCasing(Case.sentence)).toEqual("Hello world");
216 }
217 
218 @("join words without changing numbers")
219 unittest
220 {
221     expect(["123", "abc", "456"].joinUsingCasing(Case.pascal)).toEqual("123Abc456");
222     expect(["hello", "001010", "world"].joinUsingCasing(Case.pascal)).toEqual("Hello001010World");
223 }
224 
225 /// Attempts to split by spaces, hyphens, or underscores, in that
226 /// order of priority. If none are found, splits the string as if it
227 /// were camelCase/PascalCase. Result is a range of strings.
228 private auto smartSplit(in string input)
229 pure
230 {
231     if (input.canFind(' '))
232         return input.split(" ");
233     else if (input.canFind('-'))
234         return input.split("-");
235     else if (input.canFind('_'))
236         return input.split("_");
237     else
238         return splitOnUpper(input);
239 }
240 
241 /// Splits by `separator`. If separator is not given, treats the input
242 /// as camelCase/PascalCase.  Result is a range of strings.
243 private auto dumbSplit(in string input, in string separator)
244 pure
245 {
246     if (separator == "")
247         return splitOnUpper(input);
248     else
249         return input.split(separator);
250 }
251 
252 private string[] splitOnUpper(in string input)
253 pure
254 {
255     string[] result;
256 
257     string currentWordSoFar;
258     size_t currentStartIdx;
259     bool isCurrentANumber;
260     foreach (size_t idx, const char c; input)
261     {
262         if (isNumber(c))
263         {
264             if (!isCurrentANumber && idx != 0)
265             {
266                 result ~= currentWordSoFar;
267                 currentStartIdx = idx;
268             }
269 
270             isCurrentANumber = true;
271         }
272         else if (isCurrentANumber)
273         {
274             isCurrentANumber = false;
275             result ~= currentWordSoFar;
276             currentStartIdx = idx;
277         }
278         else if (isUpper(c) && idx != 0)
279         {
280             result ~= currentWordSoFar;
281             currentStartIdx = idx;
282         }
283 
284         currentWordSoFar = input[currentStartIdx .. idx + 1];
285     }
286 
287     result ~= currentWordSoFar;
288 
289     return result;
290 }
291 
292 @("split a PascalCase string into a sequence of words")
293 unittest
294 {
295     expect(smartSplit("HelloWorld")).toEqual(["Hello", "World"]);
296 }
297 
298 @("split a camelCase string into a sequence of words")
299 unittest
300 {
301     expect(smartSplit("helloWorld")).toEqual(["hello", "World"]);
302 }
303 
304 @("split a snake_case string into a sequence of words")
305 unittest
306 {
307     expect(smartSplit("hello_world")).toEqual(["hello", "world"]);
308 }
309 
310 @("split a SCREAMING_SNAKE_CASE string into a sequence of words")
311 unittest
312 {
313     expect(smartSplit("HELLO_WORLD")).toEqual(["HELLO", "WORLD"]);
314 }
315 
316 @("split a kebab-case string into a sequence of words")
317 unittest
318 {
319     expect(smartSplit("hello-world")).toEqual(["hello", "world"]);
320 }
321 
322 @("split a Sentence case string into a sequence of words")
323 unittest
324 {
325     expect(smartSplit("Hello world")).toEqual(["Hello", "world"]);
326 }
327 
328 @("split an ambiguous string by spaces")
329 unittest
330 {
331     expect(smartSplit("he-llo wo_rLd")).toEqual(["he-llo", "wo_rLd"]);
332 }
333 
334 @("split numbers apart from words")
335 unittest
336 {
337     expect(smartSplit("123")).toEqual(["123"]);
338     expect(smartSplit("123abc")).toEqual(["123", "abc"]);
339     expect(smartSplit("123Abc")).toEqual(["123", "Abc"]);
340     expect(smartSplit("abc123")).toEqual(["abc", "123"]);
341 }