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     static foreach (TestCase from; testCases)
114     {
115         static foreach (TestCase to; testCases)
116         {
117             @("Convert from " ~ from.name ~ " to " ~ to.name ~ ": " ~ from.helloWorld ~ " -> " ~ to.helloWorld)
118             unittest
119             {
120                 expect(from.helloWorld.toCase(to.casing)).toEqual(to.helloWorld);
121             }
122         }
123     }
124 }
125 
126 private alias Capitalizer = string[] function(in string[] words) pure;
127 
128 private enum Capitalizer sentenceCapitalizer = (in string[] words) {
129     string[] result;
130     foreach (size_t idx, string word; words)
131     {
132         if (idx == 0)
133             result ~= asCapitalized(word).to!string;
134         else
135             result ~= word;
136     }
137     return result;
138 };
139 
140 private enum Capitalizer inverseSentenceCapitalizer = (in string[] words) {
141     string[] result;
142     foreach (size_t idx, string word; words)
143     {
144         if (idx == 0)
145             result ~= word;
146         else
147             result ~= asCapitalized(word).to!string;
148     }
149     return result;
150 };
151 
152 private enum Capitalizer titleCapitalizer = (in string[] words) {
153     return words.map!(word => word.asCapitalized.to!string).array;
154 };
155 
156 public enum Capitalizer allCapsCapitalizer = (in string[] words) => words.map!toUpper.array;
157 
158 public struct Case
159 {
160     public enum Case pascal = Case(titleCapitalizer);
161     public enum Case camel = Case(inverseSentenceCapitalizer);
162     public enum Case snake = Case(words => words.dup, "_");
163     public enum Case screamingSnake = Case(allCapsCapitalizer, "_");
164     public enum Case kebab = Case(words => words.dup, "-");
165     public enum Case sentence = Case(sentenceCapitalizer, " ");
166 
167     Capitalizer capitalizer;
168     string separator = "";
169 
170     invariant(capitalizer !is null, "capitalizer is null");
171 }
172 
173 private string joinUsingCasing(Strings)(in Strings input, in Case casing)
174 pure
175 if (isInputRange!Strings && isSomeString!(ElementType!Strings))
176 {
177     return casing.capitalizer(input.map!toLower.array).join(casing.separator);
178 }
179 
180 @("join a sequence of words in PascalCase")
181 unittest
182 {
183     expect(["hello", "world"].joinUsingCasing(Case.pascal)).toEqual("HelloWorld");
184 }
185 
186 @("join a sequence of words in camelCase")
187 unittest
188 {
189     expect(["hello", "world"].joinUsingCasing(Case.camel)).toEqual("helloWorld");
190 }
191 
192 @("join a sequence of words in snake_case")
193 unittest
194 {
195     expect(["hello", "world"].joinUsingCasing(Case.snake)).toEqual("hello_world");
196 }
197 
198 @("join a sequence of words in SCREAMING_SNAKE_CASE")
199 unittest
200 {
201     expect(["hello", "world"].joinUsingCasing(Case.screamingSnake)).toEqual("HELLO_WORLD");
202 }
203 
204 @("join a sequence of words in kebab-case")
205 unittest
206 {
207     expect(["hello", "world"].joinUsingCasing(Case.kebab)).toEqual("hello-world");
208 }
209 
210 @("join a sequence of words in Sentence case")
211 unittest
212 {
213     expect(["hello", "world"].joinUsingCasing(Case.sentence)).toEqual("Hello world");
214 }
215 
216 @("join words without changing numbers")
217 unittest
218 {
219     expect(["123", "abc", "456"].joinUsingCasing(Case.pascal)).toEqual("123Abc456");
220     expect(["hello", "001010", "world"].joinUsingCasing(Case.pascal)).toEqual("Hello001010World");
221 }
222 
223 /// Attempts to split by spaces, hyphens, or underscores, in that
224 /// order of priority. If none are found, splits the string as if it
225 /// were camelCase/PascalCase. Result is a range of strings.
226 private auto smartSplit(in string input)
227 pure
228 {
229     if (input.canFind(' '))
230         return input.split(" ");
231     else if (input.canFind('-'))
232         return input.split("-");
233     else if (input.canFind('_'))
234         return input.split("_");
235     else
236         return splitOnUpper(input);
237 }
238 
239 /// Splits by `separator`. If separator is not given, treats the input
240 /// as camelCase/PascalCase.  Result is a range of strings.
241 private auto dumbSplit(in string input, in string separator)
242 pure
243 {
244     if (separator == "")
245         return splitOnUpper(input);
246     else
247         return input.split(separator);
248 }
249 
250 private string[] splitOnUpper(in string input)
251 pure
252 {
253     string[] result;
254 
255     string currentWordSoFar;
256     size_t currentStartIdx;
257     bool isCurrentANumber;
258     foreach (size_t idx, const char c; input)
259     {
260         if (isNumber(c))
261         {
262             if (!isCurrentANumber && idx != 0)
263             {
264                 result ~= currentWordSoFar;
265                 currentStartIdx = idx;
266             }
267 
268             isCurrentANumber = true;
269         }
270         else if (isCurrentANumber)
271         {
272             isCurrentANumber = false;
273             result ~= currentWordSoFar;
274             currentStartIdx = idx;
275         }
276         else if (isUpper(c) && idx != 0)
277         {
278             result ~= currentWordSoFar;
279             currentStartIdx = idx;
280         }
281 
282         currentWordSoFar = input[currentStartIdx .. idx + 1];
283     }
284 
285     result ~= currentWordSoFar;
286 
287     return result;
288 }
289 
290 @("split a PascalCase string into a sequence of words")
291 unittest
292 {
293     expect(smartSplit("HelloWorld")).toEqual(["Hello", "World"]);
294 }
295 
296 @("split a camelCase string into a sequence of words")
297 unittest
298 {
299     expect(smartSplit("helloWorld")).toEqual(["hello", "World"]);
300 }
301 
302 @("split a snake_case string into a sequence of words")
303 unittest
304 {
305     expect(smartSplit("hello_world")).toEqual(["hello", "world"]);
306 }
307 
308 @("split a SCREAMING_SNAKE_CASE string into a sequence of words")
309 unittest
310 {
311     expect(smartSplit("HELLO_WORLD")).toEqual(["HELLO", "WORLD"]);
312 }
313 
314 @("split a kebab-case string into a sequence of words")
315 unittest
316 {
317     expect(smartSplit("hello-world")).toEqual(["hello", "world"]);
318 }
319 
320 @("split a Sentence case string into a sequence of words")
321 unittest
322 {
323     expect(smartSplit("Hello world")).toEqual(["Hello", "world"]);
324 }
325 
326 @("split an ambiguous string by spaces")
327 unittest
328 {
329     expect(smartSplit("he-llo wo_rLd")).toEqual(["he-llo", "wo_rLd"]);
330 }
331 
332 @("split numbers apart from words")
333 unittest
334 {
335     expect(smartSplit("123")).toEqual(["123"]);
336     expect(smartSplit("123abc")).toEqual(["123", "abc"]);
337     expect(smartSplit("123Abc")).toEqual(["123", "Abc"]);
338     expect(smartSplit("abc123")).toEqual(["abc", "123"]);
339 }