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 }