diff --git a/src/main/java/org/apache/commons/text/CasedString.java b/src/main/java/org/apache/commons/text/CasedString.java new file mode 100644 index 0000000000..f298e121e2 --- /dev/null +++ b/src/main/java/org/apache/commons/text/CasedString.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.commons.text; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * Handles converting from one string case to another (e.g. camel case to snake case). + * @since 1.13.0 + */ +public class CasedString { + /** The string of the cased format. */ + private final String string; + /** The case of the string. */ + private final StringCase stringCase; + + /** + * A method to join camel string fragments together. + */ + private static final Function CAMEL_JOINER = a -> { + StringBuilder sb = new StringBuilder(a[0].toLowerCase(Locale.ROOT)); + + for (int i = 1; i < a.length; i++) { + sb.append(WordUtils.capitalize(a[i].toLowerCase(Locale.ROOT))); + } + return sb.toString(); + }; + + /** + * An enumeration of supported string cases. These cases tag strings as having a specific format. + */ + public enum StringCase { + /** + * Camel case tags strings like 'camelCase'. This conversion forces the first character to of the result to be + * lower case. Other than the upper case separating character all other characters are lower cased. + */ + CAMEL(Character::isUpperCase, true, CAMEL_JOINER), + /** + * Snake case tags strings like 'Snake_Case'. This conversion does not change the capitalization of any characters + * in the string. If specific capitalization is required use {@code String.upperCase}, {@code String.upperCase}, + * {@code WordUtils.capitalize()}, or {@code WordUtils.uncapitalize()} as required. + */ + SNAKE(c -> c == '_', false, a -> String.join("_", a)), + /** + * Kebab case tags strings like 'kebab-case'. This conversion does not change the capitalization of any characters + * in the string. If specific capitalization is required use {@code String.upperCase}, {@code String.upperCase}, + * {@code WordUtils.capitalize()}, or {@code WordUtils.uncapitalize()} as required. + */ + KEBAB(c -> c == '-', false, a -> String.join("-", a)), + + /** + * Phrase case tags phrases of words like 'phrase case'. This conversion does not change the capitalization of any characters + * in the string. If specific capitalization is required use {@code String.upperCase}, {@code String.upperCase}, + * {@code WordUtils.capitalize()}, or {@code WordUtils.uncapitalize()} as required. + */ + PHRASE(Character::isWhitespace, false, a -> String.join(" ", a)), + + /** + * Dot case tags phrases of words like 'phrase.case'. This conversion does not change the capitalization of any characters + * in the string. If specific capitalization is required use {@code String.upperCase}, {@code String.upperCase}, + * {@code WordUtils.capitalize()}, or {@code WordUtils.uncapitalize()} as required. + */ + DOT(c -> c == '.', false, a -> String.join(".", a)); + + /** The segment representation of a null String. */ + private static final String[] NULL_STRING = new String[0]; + /** The segment representation of an empty String. */ + private static final String[] EMPTY_STRING = new String[]{""}; + + /** Tests for split position character. */ + private final Predicate splitter; + /** If {@code true} split position character will be preserved in following segment. */ + private final boolean preserveSplit; + /** A function to joining the segments into this case type. */ + private final Function joiner; + + /** + * Defines a String Case. + * @param splitter The predicate that determines when a new word in the cased string begins. This function will never receive + * {@code null} argument. + * @param preserveSplit if {@code true} the character that the splitter detected is preserved as the first character of the new word. + * @param joiner The function to merge a list of strings into the cased String. + */ + StringCase(final Predicate splitter, final boolean preserveSplit, final Function joiner) { + this.splitter = splitter; + this.preserveSplit = preserveSplit; + this.joiner = joiner; + } + + /** + * Creates a cased string from a collection of segments. + * @param segments the segments to create the CasedString from. A {@code null} or zero length argument will result in an empty string. + * @return a string that is formatted for this Cased type. + */ + public String assemble(String[] segments) { + return segments == null || segments.length == 0 ? null : this.joiner.apply(segments); + } + + /** + * Returns an array of each of the segments in this CasedString. Segments are defined as the strings between + * the separators in the CasedString. for the CAMEL case the segments are determined by the presence of a capital letter. + * @return the array of Strings that are segments of the cased string. + */ + public String[] getSegments(String string) { + if (string == null) { + return NULL_STRING; + } + if (string.isEmpty()) { + return EMPTY_STRING; + } + List lst = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + for (char c : string.toCharArray()) { + if (splitter.test(c)) { + if (sb.length() > 0) { + lst.add(sb.toString()); + sb.setLength(0); + } + if (preserveSplit) { + sb.append(c); + } + } else { + sb.append(c); + } + } + if (sb.length() > 0) { + lst.add(sb.toString()); + } + return lst.toArray(new String[0]); + } + } + + /** + * A representation of a cased string and the identified case of that string. + * @param stringCase The {@code StringCase} that the {@code string} argument is in. + * @param string The string. + */ + public CasedString(StringCase stringCase, String string) { + this.string = string == null ? null : stringCase.assemble(stringCase.getSegments(string.trim())); + this.stringCase = stringCase; + } + + /** + * Returns an array of each of the segments in this CasedString. Segments are defined as the strings between + * the separators in the CasedString. for the CAMEL case the segments are determined by the presence of a capital letter. + * @return the array of Strings that are segments of the cased string. + */ + public String[] getSegments() { + return stringCase.getSegments(string); + } + + /** + * Converts this cased string into a {@code String} of another format. + * The upper/lower case of the characters within the string are not modified. + * @param stringCase THe fomrat to convert to. + * @return the String current string represented in the new format. + */ + public String toCase(StringCase stringCase) { + if (stringCase == this.stringCase) { + return string; + } + return string == null ? null : stringCase.joiner.apply(getSegments()); + } + + /** + * Returns the string representation provided in the constructor. + * @return the string representation. + */ + @Override + public String toString() { + return string; + } +} diff --git a/src/test/java/org/apache/commons/text/CasedStringTest.java b/src/test/java/org/apache/commons/text/CasedStringTest.java new file mode 100644 index 0000000000..106a93aae5 --- /dev/null +++ b/src/test/java/org/apache/commons/text/CasedStringTest.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.commons.text; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.apache.commons.text.CasedString.StringCase; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class CasedStringTest { + + private static String helloWorldValue(StringCase stringCase) { + switch (stringCase) { + case CAMEL: + return "helloWorld"; + case KEBAB: + return "hello-World"; + case PHRASE: + return "hello World"; + case SNAKE: + return "hello_World"; + case DOT: + return "hello.World"; + default: + fail("Unsupported StringCase: " + stringCase); + } + return null; // keeps compiler happy + } + + private static final CasedString CAMEL = new CasedString(StringCase.CAMEL, "aCamelString"); + private static final CasedString PHRASE = new CasedString(StringCase.PHRASE, "A test PhrAse"); + private static final CasedString KEBAB = new CasedString(StringCase.KEBAB, "A-kebAb-string"); + private static final CasedString SNAKE = new CasedString(StringCase.SNAKE, "A_snaKE_string"); + private static final CasedString DOT = new CasedString(StringCase.DOT, "A.dOt.string"); + private static final CasedString ABCDEF = new CasedString(StringCase.PHRASE, "a b c @def"); + /** + * tests the conversion from each Cased string type to every other type. + * @param underTest the CasedString being tested. + */ + @ParameterizedTest + @MethodSource("conversionProvider") + public void testCrossProductConversions(CasedString underTest) { + for (StringCase stringCase : StringCase.values()) { + assertEquals(helloWorldValue(stringCase), underTest.toCase(stringCase), () -> "failed converting to " + stringCase); + } + } + /* generates the hello world Cased String for every StringCase */ + private static Stream conversionProvider() { + List lst = new ArrayList<>(); + for (StringCase stringCase : StringCase.values()) { + lst.add(Arguments.of(new CasedString(stringCase, helloWorldValue(stringCase)))); + } + return lst.stream(); + } + + @Test + public void testNullConstructor() { + for (StringCase stringCase : StringCase.values()) { + CasedString underTest = new CasedString(stringCase, null); + assertThat(underTest.toString()).isNull(); + assertThat(underTest.getSegments()).isEmpty(); + // test a null underTest can convert to all others types. + for (CasedString.StringCase otherCase : StringCase.values()) { + assertThat(underTest.toCase(otherCase)).isNull(); + } + } + } + + @Test + public void testToCamelCase() { + assertThat(new CasedString(StringCase.CAMEL, "").toString()).isEqualTo(""); + assertThat(new CasedString(StringCase.CAMEL, " ").toString()).isEqualTo(""); + assertThat(new CasedString(StringCase.CAMEL, "Tocamelcase").toString()).isEqualTo("tocamelcase"); + assertThat(new CasedString(StringCase.PHRASE, "\uD800\uDF00 \uD800\uDF02").toCase(StringCase.CAMEL)).isEqualTo("\uD800\uDF00\uD800\uDF02"); + assertThat(ABCDEF.toCase(StringCase.CAMEL)).isEqualTo("aBC@def"); + assertThat(CAMEL.toCase(StringCase.CAMEL)).isEqualTo("aCamelString"); + assertThat(PHRASE.toCase(StringCase.CAMEL)).isEqualTo("aTestPhrase"); + assertThat(KEBAB.toCase(StringCase.CAMEL)).isEqualTo("aKebabString"); + assertThat(SNAKE.toCase(StringCase.CAMEL)).isEqualTo("aSnakeString"); + assertThat(DOT.toCase(StringCase.CAMEL)).isEqualTo("aDotString"); + assertThat(new CasedString(StringCase.PHRASE, "TO CAMEL CASE").toCase(StringCase.CAMEL)).isEqualTo("toCamelCase"); + assertThat(new CasedString(StringCase.KEBAB, " to-CAMEL-cASE").toCase(StringCase.CAMEL)).isEqualTo("toCamelCase"); + assertThat(new CasedString(StringCase.DOT, "To.Camel.Case").toCase(StringCase.CAMEL)).isEqualTo("toCamelCase"); + } + + @Test + public void testToPhraseTest() { + assertThat(new CasedString(StringCase.PHRASE, "").toString()).isEqualTo(""); + assertThat(new CasedString(StringCase.PHRASE, " ").toString()).isEqualTo(""); + assertThat(ABCDEF.toCase(StringCase.PHRASE)).isEqualTo("a b c @def"); + assertThat(CAMEL.toCase(StringCase.PHRASE)).isEqualTo("a Camel String"); + assertThat(PHRASE.toCase(StringCase.PHRASE)).isEqualTo("A test PhrAse"); + assertThat(KEBAB.toCase(StringCase.PHRASE)).isEqualTo("A kebAb string"); + assertThat(SNAKE.toCase(StringCase.PHRASE)).isEqualTo("A snaKE string"); + assertThat(DOT.toCase(StringCase.PHRASE)).isEqualTo("A dOt string"); + } + + @Test + public void testToKebabTest() { + assertThat(new CasedString(StringCase.KEBAB, "").toString()).isEqualTo(""); + assertThat(new CasedString(StringCase.KEBAB, " ").toString()).isEqualTo(""); + assertThat(ABCDEF.toCase(StringCase.KEBAB)).isEqualTo("a-b-c-@def"); + assertThat(CAMEL.toCase(StringCase.KEBAB)).isEqualTo("a-Camel-String"); + assertThat(PHRASE.toCase(StringCase.KEBAB)).isEqualTo("A-test-PhrAse"); + assertThat(KEBAB.toCase(StringCase.KEBAB)).isEqualTo("A-kebAb-string"); + assertThat(SNAKE.toCase(StringCase.KEBAB)).isEqualTo("A-snaKE-string"); + assertThat(DOT.toCase(StringCase.KEBAB)).isEqualTo("A-dOt-string"); + } + + @Test + public void testToSnakeTest() { + assertThat(new CasedString(StringCase.SNAKE, "").toString()).isEqualTo(""); + assertThat(new CasedString(StringCase.SNAKE, " ").toString()).isEqualTo(""); + assertThat(ABCDEF.toCase(StringCase.SNAKE)).isEqualTo("a_b_c_@def"); + assertThat(CAMEL.toCase(StringCase.SNAKE)).isEqualTo("a_Camel_String"); + assertThat(PHRASE.toCase(StringCase.SNAKE)).isEqualTo("A_test_PhrAse"); + assertThat(KEBAB.toCase(StringCase.SNAKE)).isEqualTo("A_kebAb_string"); + assertThat(SNAKE.toCase(StringCase.SNAKE)).isEqualTo("A_snaKE_string"); + assertThat(DOT.toCase(StringCase.SNAKE)).isEqualTo("A_dOt_string"); + } + + @Test + public void testToDotTest() { + assertThat(new CasedString(StringCase.DOT, "").toString()).isEqualTo(""); + assertThat(new CasedString(StringCase.DOT, " ").toString()).isEqualTo(""); + assertThat(ABCDEF.toCase(StringCase.DOT)).isEqualTo("a.b.c.@def"); + assertThat(CAMEL.toCase(StringCase.DOT)).isEqualTo("a.Camel.String"); + assertThat(PHRASE.toCase(StringCase.DOT)).isEqualTo("A.test.PhrAse"); + assertThat(KEBAB.toCase(StringCase.DOT)).isEqualTo("A.kebAb.string"); + assertThat(SNAKE.toCase(StringCase.DOT)).isEqualTo("A.snaKE.string"); + assertThat(DOT.toCase(StringCase.DOT)).isEqualTo("A.dOt.string"); + } +}