From 37d64502ad2eb1161e5e7ccb063fabaf43aa602d Mon Sep 17 00:00:00 2001 From: Siphalor Date: Mon, 22 Jul 2024 18:00:01 +0200 Subject: [PATCH] Add tweed5-naming-format --- settings.gradle.kts | 1 + .../tweed5/namingformat/api/NamingFormat.java | 8 + .../namingformat/api/NamingFormats.java | 45 +++ .../namingformat/impl/CodePointReader.java | 42 +++ .../namingformat/impl/NamingFormatImpls.java | 287 ++++++++++++++++++ .../namingformat/impl/package-info.java | 5 + .../namingformat/api/NamingFormatsTest.java | 101 ++++++ .../impl/CodePointReaderTest.java | 25 ++ 8 files changed, 514 insertions(+) create mode 100644 tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/api/NamingFormat.java create mode 100644 tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/api/NamingFormats.java create mode 100644 tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/CodePointReader.java create mode 100644 tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/NamingFormatImpls.java create mode 100644 tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/package-info.java create mode 100644 tweed5-naming-format/src/test/java/de/siphalor/tweed5/namingformat/api/NamingFormatsTest.java create mode 100644 tweed5-naming-format/src/test/java/de/siphalor/tweed5/namingformat/impl/CodePointReaderTest.java diff --git a/settings.gradle.kts b/settings.gradle.kts index 947d467..e9df496 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "tweed5" include("tweed5-core") include("tweed5-default-extensions") +include("tweed5-naming-format") include("tweed5-patchwork") include("tweed5-serde-api") include("tweed5-serde-extension") diff --git a/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/api/NamingFormat.java b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/api/NamingFormat.java new file mode 100644 index 0000000..9579c66 --- /dev/null +++ b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/api/NamingFormat.java @@ -0,0 +1,8 @@ +package de.siphalor.tweed5.namingformat.api; + +public interface NamingFormat { + String[] splitIntoWords(String name); + String joinToName(String[] words); + + String name(); +} diff --git a/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/api/NamingFormats.java b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/api/NamingFormats.java new file mode 100644 index 0000000..b1ada6b --- /dev/null +++ b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/api/NamingFormats.java @@ -0,0 +1,45 @@ +package de.siphalor.tweed5.namingformat.api; + +import de.siphalor.tweed5.namingformat.impl.NamingFormatImpls; + +public class NamingFormats { + public static String convert(String text, NamingFormat sourceFormat, NamingFormat targetFormat) { + return targetFormat.joinToName(sourceFormat.splitIntoWords(text)); + } + + public static NamingFormat camelCase() { + return NamingFormatImpls.CAMEL_CASE; + } + + public static NamingFormat pascalCase() { + return NamingFormatImpls.PASCAL_CASE; + } + + public static NamingFormat kebabCase() { + return NamingFormatImpls.KEBAB_CASE; + } + + public static NamingFormat upperKebabCase() { + return NamingFormatImpls.UPPER_KEBAB_CASE; + } + + public static NamingFormat snakeCase() { + return NamingFormatImpls.SNAKE_CASE; + } + + public static NamingFormat upperSnakeCase() { + return NamingFormatImpls.UPPER_SNAKE_CASE; + } + + public static NamingFormat spaceCase() { + return NamingFormatImpls.SPACE_CASE; + } + + public static NamingFormat upperSpaceCase() { + return NamingFormatImpls.UPPER_SPACE_CASE; + } + + public static NamingFormat titleCase() { + return NamingFormatImpls.TITLE_CASE; + } +} diff --git a/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/CodePointReader.java b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/CodePointReader.java new file mode 100644 index 0000000..74918a4 --- /dev/null +++ b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/CodePointReader.java @@ -0,0 +1,42 @@ +package de.siphalor.tweed5.namingformat.impl; + +import lombok.RequiredArgsConstructor; + +import java.util.PrimitiveIterator; + +@RequiredArgsConstructor +public class CodePointReader { + int EMPTY_PEEK = Integer.MIN_VALUE; + + private final PrimitiveIterator.OfInt codePointIterator; + private int peek = EMPTY_PEEK; + + public static CodePointReader ofString(CharSequence input) { + return new CodePointReader(input.codePoints().iterator()); + } + + public boolean hasNext() { + if (peek != EMPTY_PEEK) { + return true; + } else { + return codePointIterator.hasNext(); + } + } + + public int next() { + if (peek != EMPTY_PEEK) { + int codepoint = peek; + peek = EMPTY_PEEK; + return codepoint; + } else { + return codePointIterator.nextInt(); + } + } + + public int peek() { + if (peek == EMPTY_PEEK) { + peek = codePointIterator.nextInt(); + } + return peek; + } +} diff --git a/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/NamingFormatImpls.java b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/NamingFormatImpls.java new file mode 100644 index 0000000..0294cea --- /dev/null +++ b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/NamingFormatImpls.java @@ -0,0 +1,287 @@ +package de.siphalor.tweed5.namingformat.impl; + +import de.siphalor.tweed5.namingformat.api.NamingFormat; + +import java.util.ArrayList; +import java.util.PrimitiveIterator; + +public class NamingFormatImpls { + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + public static final NamingFormat CAMEL_CASE = new NamingFormat() { + @Override + public String name() { + return "camelCase"; + } + + @Override + public String[] splitIntoWords(String name) { + return splitAtUpperCase(name); + } + + @Override + public String joinToName(String[] words) { + if (words.length == 0) { + return ""; + } + + int totalLength = countTotalWordsLength(words); + StringBuilder stringBuilder = new StringBuilder(totalLength); + appendAllLower(stringBuilder, words[0]); + for (int i = 1; i < words.length; i++) { + appendCapitalized(stringBuilder, words[i]); + } + return stringBuilder.toString(); + } + + @Override + public String toString() { + return name(); + } + }; + + public static final NamingFormat PASCAL_CASE = new NamingFormat() { + @Override + public String name() { + return "PascalCase"; + } + + @Override + public String[] splitIntoWords(String name) { + return splitAtUpperCase(name); + } + + @Override + public String joinToName(String[] words) { + if (words.length == 0) { + return ""; + } + + int totalLength = countTotalWordsLength(words); + StringBuilder stringBuilder = new StringBuilder(totalLength); + for (String word : words) { + appendCapitalized(stringBuilder, word); + } + return stringBuilder.toString(); + } + + @Override + public String toString() { + return name(); + } + }; + + public static final NamingFormat KEBAB_CASE = createLowerDelimitedFormat("kebab-case", '-'); + public static final NamingFormat UPPER_KEBAB_CASE = createUpperDelimitedFormat("UPPER-KEBAB-CASE", '-'); + + public static final NamingFormat SNAKE_CASE = createLowerDelimitedFormat("snake_case", '_'); + public static final NamingFormat UPPER_SNAKE_CASE = createUpperDelimitedFormat("UPPER_SNAKE_CASE", '_'); + + public static final NamingFormat SPACE_CASE = createLowerDelimitedFormat("space case", ' '); + public static final NamingFormat UPPER_SPACE_CASE = createUpperDelimitedFormat("UPPER SPACE CASE", ' '); + public static final NamingFormat TITLE_CASE = createCapitalizedDelimitedFormat("Title Case", ' '); + + private static NamingFormat createLowerDelimitedFormat(String formatName, char delimiter) { + return new NamingFormat() { + @Override + public String name() { + return formatName; + } + + @Override + public String[] splitIntoWords(String name) { + return splitAtCharacter(name, delimiter); + } + + @Override + public String joinToName(String[] words) { + return joinAllLower(delimiter, words); + } + + @Override + public String toString() { + return name(); + } + }; + } + + private static NamingFormat createUpperDelimitedFormat(String formatName, char delimiter) { + return new NamingFormat() { + @Override + public String name() { + return formatName; + } + + @Override + public String[] splitIntoWords(String name) { + return splitAtCharacter(name, delimiter); + } + + @Override + public String joinToName(String[] words) { + return joinAllUpper(delimiter, words); + } + + @Override + public String toString() { + return name(); + } + }; + } + + private static NamingFormat createCapitalizedDelimitedFormat(String formatName, char delimiter) { + return new NamingFormat() { + @Override + public String name() { + return formatName; + } + + @Override + public String[] splitIntoWords(String name) { + return splitAtCharacter(name, delimiter); + } + + @Override + public String joinToName(String[] words) { + return joinCapitalized(delimiter, words); + } + + @Override + public String toString() { + return name(); + } + }; + } + + private static String[] splitAtUpperCase(String name) { + ArrayList words = new ArrayList<>(); + + StringBuilder wordBuilder = new StringBuilder(); + + CodePointReader codePointReader = CodePointReader.ofString(name); + while (codePointReader.hasNext()) { + if (wordBuilder.length() == 0) { + wordBuilder.appendCodePoint(codePointReader.next()); + } else if (Character.isUpperCase(codePointReader.peek())) { + words.add(wordBuilder.toString()); + wordBuilder.setLength(0); + } else { + wordBuilder.appendCodePoint(codePointReader.next()); + } + } + if (wordBuilder.length() > 0) { + words.add(wordBuilder.toString()); + } + + return words.toArray(EMPTY_STRING_ARRAY); + } + + private static String[] splitAtCharacter(String text, char delimiter) { + ArrayList words = new ArrayList<>(); + + int index = 0; + while (index < text.length()) { + int delimiterIndex = text.indexOf(delimiter, index); + if (delimiterIndex == -1) { + words.add(text.substring(index)); + break; + } + + words.add(text.substring(index, delimiterIndex)); + index = delimiterIndex + 1; + } + + return words.toArray(EMPTY_STRING_ARRAY); + } + + private static String joinAllLower(char joiner, String[] words) { + if (words.length == 0) { + return ""; + } + + int totalLength = countTotalWordsLength(words) + words.length - 1; + StringBuilder stringBuilder = new StringBuilder(totalLength); + for (String word : words) { + appendAllLower(stringBuilder, word); + if (stringBuilder.length() < totalLength) { + stringBuilder.append(joiner); + } + } + return stringBuilder.toString(); + } + + private static String joinAllUpper(char joiner, String[] words) { + if (words.length == 0) { + return ""; + } + + int totalLength = countTotalWordsLength(words) + words.length - 1; + StringBuilder stringBuilder = new StringBuilder(totalLength); + for (String word : words) { + appendAllUpper(stringBuilder, word); + if (stringBuilder.length() < totalLength) { + stringBuilder.append(joiner); + } + } + return stringBuilder.toString(); + } + + private static String joinCapitalized(char joiner, String[] words) { + if (words.length == 0) { + return ""; + } + + int totalLength = countTotalWordsLength(words) + words.length - 1; + StringBuilder stringBuilder = new StringBuilder(totalLength); + for (String word : words) { + appendCapitalized(stringBuilder, word); + if (stringBuilder.length() < totalLength) { + stringBuilder.append(joiner); + } + } + return stringBuilder.toString(); + } + + private static int countTotalWordsLength(String[] words) { + if (words.length == 0) { + return 0; + } + + int totalLength = 0; + for (String word : words) { + totalLength += word.length(); + } + return totalLength; + } + + private static void appendAllLower(StringBuilder target, CharSequence input) { + if (input.length() == 0) { + return; + } + PrimitiveIterator.OfInt codePointIterator = input.codePoints().iterator(); + while (codePointIterator.hasNext()) { + target.appendCodePoint(Character.toLowerCase(codePointIterator.nextInt())); + } + } + + private static void appendAllUpper(StringBuilder target, CharSequence input) { + if (input.length() == 0) { + return; + } + PrimitiveIterator.OfInt codePointIterator = input.codePoints().iterator(); + while (codePointIterator.hasNext()) { + target.appendCodePoint(Character.toUpperCase(codePointIterator.nextInt())); + } + } + + private static void appendCapitalized(StringBuilder target, CharSequence input) { + if (input.length() == 0) { + return; + } + PrimitiveIterator.OfInt codePointIterator = input.codePoints().iterator(); + target.appendCodePoint(Character.toUpperCase(codePointIterator.nextInt())); + while (codePointIterator.hasNext()) { + target.appendCodePoint(Character.toLowerCase(codePointIterator.nextInt())); + } + } +} diff --git a/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/package-info.java b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/package-info.java new file mode 100644 index 0000000..19eebb7 --- /dev/null +++ b/tweed5-naming-format/src/main/java/de/siphalor/tweed5/namingformat/impl/package-info.java @@ -0,0 +1,5 @@ +@ApiStatus.Internal + +package de.siphalor.tweed5.namingformat.impl; + +import org.jetbrains.annotations.ApiStatus; \ No newline at end of file diff --git a/tweed5-naming-format/src/test/java/de/siphalor/tweed5/namingformat/api/NamingFormatsTest.java b/tweed5-naming-format/src/test/java/de/siphalor/tweed5/namingformat/api/NamingFormatsTest.java new file mode 100644 index 0000000..b6eb080 --- /dev/null +++ b/tweed5-naming-format/src/test/java/de/siphalor/tweed5/namingformat/api/NamingFormatsTest.java @@ -0,0 +1,101 @@ +package de.siphalor.tweed5.namingformat.api; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Arrays; +import java.util.Locale; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class NamingFormatsTest { + + @ParameterizedTest + @MethodSource("allNamingFormats") + void splitIntoWordsEmpty(NamingFormat namingFormat) { + assertArrayEquals(new String[0], namingFormat.splitIntoWords("")); + } + + @ParameterizedTest + @MethodSource("allNamingFormats") + void joinToNameEmpty(NamingFormat namingFormat) { + assertEquals("", namingFormat.joinToName(new String[0])); + } + + @ParameterizedTest + @MethodSource("allNamingFormats") + void isFormatNameStableInOwnFormat(NamingFormat namingFormat) { + assertEquals(namingFormat.name(), NamingFormats.convert(namingFormat.name(), namingFormat, namingFormat)); + } + + @ParameterizedTest + @MethodSource("splitIntoWordsArgs") + void splitIntoWords(NamingFormat namingFormat, String name) { + String[] result = namingFormat.splitIntoWords(name); + // Expecting "not a URL" in any casing + assertEquals(3, result.length, "Words should be 3, but got: " + Arrays.toString(result)); + assertEquals("not", result[0].toLowerCase(Locale.ROOT)); + assertEquals("a", result[1].toLowerCase(Locale.ROOT)); + assertEquals("url", result[2].toLowerCase(Locale.ROOT)); + } + + private static Stream splitIntoWordsArgs() { + return Stream.of( + Arguments.of(NamingFormats.camelCase(), "notAUrl"), + Arguments.of(NamingFormats.pascalCase(), "NotAUrl"), + Arguments.of(NamingFormats.kebabCase(), "not-a-url"), + Arguments.of(NamingFormats.upperKebabCase(), "NOT-A-URL"), + Arguments.of(NamingFormats.snakeCase(), "not_a_url"), + Arguments.of(NamingFormats.upperSnakeCase(), "NOT_A_URL"), + Arguments.of(NamingFormats.spaceCase(), "not a url"), + Arguments.of(NamingFormats.upperSpaceCase(), "NOT A URL"), + Arguments.of(NamingFormats.titleCase(), "Not A Url") + ); + } + + @ParameterizedTest + @MethodSource("joinToNameArgs") + void joinToName(NamingFormat namingFormat, String expectedName) { + assertEquals(expectedName, namingFormat.joinToName(new String[]{ "an", "INTERESTING", "über", "name" })); + } + + @ParameterizedTest + @MethodSource("joinToNameArgs") + void joinToNameLocaleIndependent(NamingFormat namingFormat, String expectedName) { + Locale turkishLocale = Locale.of("tr", "TR"); + Locale.setDefault(turkishLocale); + assertEquals("ı", "I".toLowerCase(), "Turkish locale works"); + + assertEquals(expectedName, namingFormat.joinToName(new String[]{ "an", "INTERESTING", "über", "name" })); + } + + private static Stream joinToNameArgs() { + return Stream.of( + Arguments.of(NamingFormats.camelCase(), "anInterestingÜberName"), + Arguments.of(NamingFormats.pascalCase(), "AnInterestingÜberName"), + Arguments.of(NamingFormats.kebabCase(), "an-interesting-über-name"), + Arguments.of(NamingFormats.upperKebabCase(), "AN-INTERESTING-ÜBER-NAME"), + Arguments.of(NamingFormats.snakeCase(), "an_interesting_über_name"), + Arguments.of(NamingFormats.upperSnakeCase(), "AN_INTERESTING_ÜBER_NAME"), + Arguments.of(NamingFormats.spaceCase(), "an interesting über name"), + Arguments.of(NamingFormats.upperSpaceCase(), "AN INTERESTING ÜBER NAME"), + Arguments.of(NamingFormats.titleCase(), "An Interesting Über Name") + ); + } + + private static NamingFormat[] allNamingFormats() { + return new NamingFormat[] { + NamingFormats.camelCase(), + NamingFormats.pascalCase(), + NamingFormats.kebabCase(), + NamingFormats.upperKebabCase(), + NamingFormats.snakeCase(), + NamingFormats.upperSnakeCase(), + NamingFormats.spaceCase(), + NamingFormats.upperSpaceCase(), + NamingFormats.titleCase() + }; + } +} \ No newline at end of file diff --git a/tweed5-naming-format/src/test/java/de/siphalor/tweed5/namingformat/impl/CodePointReaderTest.java b/tweed5-naming-format/src/test/java/de/siphalor/tweed5/namingformat/impl/CodePointReaderTest.java new file mode 100644 index 0000000..535538e --- /dev/null +++ b/tweed5-naming-format/src/test/java/de/siphalor/tweed5/namingformat/impl/CodePointReaderTest.java @@ -0,0 +1,25 @@ +package de.siphalor.tweed5.namingformat.impl; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CodePointReaderTest { + + @Test + void ofString() { + CodePointReader reader = CodePointReader.ofString("Aüc"); + + assertTrue(reader.hasNext()); + assertEquals('A', reader.next()); + assertTrue(reader.hasNext()); + assertEquals('ü', reader.peek()); + assertEquals('ü', reader.peek()); + assertTrue(reader.hasNext()); + assertEquals('ü', reader.next()); + assertTrue(reader.hasNext()); + assertEquals('c', reader.next()); + assertFalse(reader.hasNext()); + assertThrows(Exception.class, reader::next); + } +} \ No newline at end of file