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 }