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 }