diff --git a/.gitignore b/.gitignore index 031c751..a65aac4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store .idea -out \ No newline at end of file +out +/build/ diff --git a/README.md b/README.md index 0b11b6d..1ce8599 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,36 @@ Use your IDE. Preferences/Plugins/Browse repositories and search for "camelcase" ## Build Just clone this repo and open the project it in IntelliJ IDEA. + +### Local WordUtils hotfix (IDEA 2026.2) + +The conversion code now capitalizes words without Apache Commons Lang, fixing +[upstream #36](https://github.com/netnexus/camelcaseplugin/issues/36). + +On macOS, build and test with the installed IDEA JDK and original CamelCase 3.0.12. +Download the original Marketplace artifact once (or pass its path with `--base-jar`): + +```sh +mkdir -p build/base +curl -fL -o build/base/CamelCasePlugin-3.0.12.jar https://plugins.jetbrains.com/files/7160/153616/CamelCasePlugin.jar +python3 scripts/build-local.py +``` + +Optional arguments: `--ide /path/to/IDE.app/Contents` and `--base-jar /path/to/CamelCasePlugin.jar`. +Keep a copy of the original 3.0.12 JAR for rebuilding and rollback. + +This is a hotfix package, not a full source build: the script replaces only +`Conversion.class` and updates the version/platform metadata in the original JAR. +For packaging it uses `scripts/compat-3.0.12/.../Conversion.java`, taken from +upstream commit `dfb9512` with only the WordUtils replacement. The current upstream +source uses a different method signature and cannot replace the released class. +It preserves the original compiled settings UI because the checked-in generated +UI source is stale. Tests run without Commons Lang, and all other JAR entries +are checked for identical content. A separate test is compiled against the original +JAR and executed against the finished package to verify binary compatibility. +The installed plugin is not modified. + +Output: `build/distributions/CamelCasePlugin-3.0.12.2-local.jar` (IDE build 262+). +Do not install `3.0.12.1-local`: that earlier package has an incompatible method signature. +Install via **Settings → Plugins → ⚙ → Install Plugin from Disk…**, then restart. +The plugin ID is unchanged, so this replaces CamelCase rather than adding a second action. diff --git a/scripts/build-local.py b/scripts/build-local.py new file mode 100644 index 0000000..09edc68 --- /dev/null +++ b/scripts/build-local.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Build a local hotfix using the binary-compatible 3.0.12 conversion source.""" +import argparse +from copy import copy +from pathlib import Path +import re +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from zipfile import ZipFile + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--ide', type=Path, default=Path('/Applications/IntelliJ IDEA.app/Contents')) +parser.add_argument('--base-jar', type=Path, default=root / 'build/base/CamelCasePlugin-3.0.12.jar') +args = parser.parse_args() +jdk = args.ide / 'jbr/Contents/Home/bin' +annotations = args.ide / 'lib/annotations.jar' +class_path = 'de/netnexus/CamelCasePlugin/Conversion.class' +descriptor_path = 'META-INF/plugin.xml' +output = root / 'build/distributions/CamelCasePlugin-3.0.12.2-local.jar' +if args.base_jar.resolve() == output.resolve(): + parser.error('The output must not overwrite the base JAR.') + +with ZipFile(args.base_jar) as original, tempfile.TemporaryDirectory() as temp: + descriptor = original.read(descriptor_path).decode('utf-8') + metadata = ET.fromstring(descriptor) + if metadata.findtext('id') != 'de.netnexus.camelcaseplugin' or metadata.findtext('version') != '3.0.12': + parser.error('Expected the original CamelCase 3.0.12 JAR.') + if any(n.upper().endswith(('.SF', '.RSA', '.DSA', '.EC')) for n in original.namelist()): + parser.error('Signed base JARs are not supported.') + subprocess.run([str(jdk / 'javac'), '--release', '17', '-encoding', 'UTF-8', + '-cp', str(annotations), '-d', temp, + str(root / 'src/de/netnexus/CamelCasePlugin/Conversion.java'), + str(root / 'tests/de/netnexus/CamelCasePlugin/ConversionTest.java')], check=True) + # Run without the IDE or Commons Lang on the runtime classpath. + subprocess.run([str(jdk / 'java'), '-cp', temp, + 'de.netnexus.CamelCasePlugin.ConversionTest'], check=True) + # Compile the caller against the ORIGINAL binary, not our replacement source. + # Running this caller against the output catches NoSuchMethodError regressions. + probe = Path(temp) / 'probe' + subprocess.run([str(jdk / 'javac'), '--release', '11', '-encoding', 'UTF-8', + '-cp', str(args.base_jar), '-d', str(probe), + str(root / 'tests/de/netnexus/CamelCasePlugin/LegacyBinaryTest.java')], check=True) + legacy = Path(temp) / 'legacy' + subprocess.run([str(jdk / 'javac'), '--release', '11', '-encoding', 'UTF-8', + '-cp', str(annotations), '-d', str(legacy), + str(root / 'scripts/compat-3.0.12/de/netnexus/CamelCasePlugin/Conversion.java')], check=True) + replacement = (legacy / class_path).read_bytes() + assert b'org/apache/commons/lang/WordUtils' not in replacement + descriptor = descriptor.replace('3.0.12', '3.0.12.2-local') + # This local artifact targets the affected 2026.2 platform, not older IDEs. + descriptor = re.sub(r']*/>', '', descriptor) + output.parent.mkdir(parents=True, exist_ok=True) + with ZipFile(output, 'w') as patched: + for entry in original.infolist(): + data = original.read(entry.filename) + if entry.filename == class_path: + data = replacement + elif entry.filename == descriptor_path: + data = descriptor.encode('utf-8') + patched.writestr(copy(entry), data) + with ZipFile(output) as patched: + assert patched.testzip() is None + assert patched.namelist() == original.namelist() + for name in original.namelist(): + if name not in (class_path, descriptor_path): + assert patched.read(name) == original.read(name), name + subprocess.run([str(jdk / 'java'), '-cp', str(probe) + ':' + str(output), + 'de.netnexus.CamelCasePlugin.LegacyBinaryTest'], check=True) +print(output) diff --git a/scripts/compat-3.0.12/de/netnexus/CamelCasePlugin/Conversion.java b/scripts/compat-3.0.12/de/netnexus/CamelCasePlugin/Conversion.java new file mode 100644 index 0000000..dee9ebc --- /dev/null +++ b/scripts/compat-3.0.12/de/netnexus/CamelCasePlugin/Conversion.java @@ -0,0 +1,217 @@ +package de.netnexus.CamelCasePlugin; + +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static java.lang.Character.isLowerCase; +import static java.lang.Character.isUpperCase; + +class Conversion { + + private static final String CONVERSION_SPACE_CASE = "space case"; + private static final String CONVERSION_KEBAB_CASE = "kebab-case"; + private static final String CONVERSION_UPPER_SNAKE_CASE = "SNAKE_CASE"; + private static final String CONVERSION_PASCAL_CASE = "CamelCase"; + private static final String CONVERSION_CAMEL_CASE = "camelCase"; + private static final String CONVERSION_PASCAL_CASE_SPACE = "Camel Case"; + private static final String CONVERSION_LOWER_SNAKE_CASE = "snake_case"; + + @NotNull + static String transform(String text, + boolean usePascalCaseWithSpace, + boolean useSpaceCase, + boolean useKebabCase, + boolean useUpperSnakeCase, + boolean usePascalCase, + boolean useCamelCase, + boolean useLowerSnakeCase, + String[] conversionList) { + String newText, appendText = ""; + boolean repeat = true; + int iterations = 0; + String next = null; + + Pattern p = Pattern.compile("^\\W+"); + Matcher m = p.matcher(text); + if (m.find()) { + appendText = m.group(0); + } + //remove all special chars + text = text.replaceAll("^\\W+", ""); + + do { + newText = text; + boolean isLowerCase = text.equals(text.toLowerCase()); + boolean isUpperCase = text.equals(text.toUpperCase()); + + if (isLowerCase && text.contains("_")) { + // snake_case to space case + if (next == null) { + next = getNext(CONVERSION_LOWER_SNAKE_CASE, conversionList); + } else { + if (next.equals(CONVERSION_SPACE_CASE)) { + repeat = !useSpaceCase; + next = getNext(CONVERSION_SPACE_CASE, conversionList); + } + } + newText = text.replace('_', ' '); + + } else if (isLowerCase && text.contains(" ")) { + // space case to Camel Case + if (next == null) { + next = getNext(CONVERSION_SPACE_CASE, conversionList); + } else { + newText = capitalize(text); + if (next.equals(CONVERSION_PASCAL_CASE_SPACE)) { + repeat = !usePascalCaseWithSpace; + next = getNext(CONVERSION_PASCAL_CASE_SPACE, conversionList); + } + } + + } else if (isUpperCase(text.charAt(0)) && isLowerCase(text.charAt(1)) && text.contains(" ")) { + // Camel Case to kebab-case + if (next == null) { + next = getNext(CONVERSION_PASCAL_CASE_SPACE, conversionList); + } else { + newText = text.toLowerCase().replace(' ', '-'); + if (next.equals(CONVERSION_KEBAB_CASE)) { + repeat = !useKebabCase; + next = getNext(CONVERSION_KEBAB_CASE, conversionList); + } + } + + } else if (isLowerCase && text.contains("-") || (isLowerCase && !text.contains(" "))) { + // kebab-case to SNAKE_CASE + if (next == null) { + next = getNext(CONVERSION_KEBAB_CASE, conversionList); + } else { + newText = text.replace('-', '_').toUpperCase(); + if (next.equals(CONVERSION_UPPER_SNAKE_CASE)) { + repeat = !useUpperSnakeCase; + next = getNext(CONVERSION_UPPER_SNAKE_CASE, conversionList); + } + } + + } else if ((isUpperCase && text.contains("_")) || (isLowerCase && !text.contains("_") && !text.contains(" ")) || (isUpperCase && !text.contains(" "))) { + // SNAKE_CASE to PascalCase + if (next == null) { + next = getNext(CONVERSION_UPPER_SNAKE_CASE, conversionList); + } else { + newText = Conversion.toCamelCase(text.toLowerCase()); + if (next.equals(CONVERSION_PASCAL_CASE)) { + repeat = !usePascalCase; + next = getNext(CONVERSION_PASCAL_CASE, conversionList); + } + } + + } else if (!isUpperCase && text.substring(0, 1).equals(text.substring(0, 1).toUpperCase()) && !text.contains("_")) { + // PascalCase to camelCase + if (next == null) { + next = getNext(CONVERSION_PASCAL_CASE, conversionList); + } else { + newText = text.substring(0, 1).toLowerCase() + text.substring(1); + if (next.equals(CONVERSION_CAMEL_CASE)) { + repeat = !useCamelCase; + next = getNext(CONVERSION_CAMEL_CASE, conversionList); + } + } + } else { + // camelCase to snake_case + if (next == null) { + next = getNext(CONVERSION_CAMEL_CASE, conversionList); + } else { + newText = Conversion.toSnakeCase(text); + if (next.equals(CONVERSION_LOWER_SNAKE_CASE)) { + repeat = !useLowerSnakeCase; + next = getNext(CONVERSION_LOWER_SNAKE_CASE, conversionList); + } + } + } + if (iterations++ > 20) { + repeat = false; + } + text = newText; + } while (repeat); + + return appendText + newText; + } + + /** + * Return next conversion (or wrap to first) + * + * @param conversion String + * @param conversions Array of strings + * @return next conversion + */ + private static String getNext(String conversion, String[] conversions) { + int index; + index = Arrays.asList(conversions).indexOf(conversion) + 1; + if (index < conversions.length) { + return conversions[index]; + } else { + return conversions[0]; + } + } + + /** + * Convert a string (CamelCase) to snake_case + * + * @param in CamelCase string + * @return snake_case String + */ + private static String toSnakeCase(String in) { + in = in.replaceAll(" +", ""); + StringBuilder result = new StringBuilder("" + Character.toLowerCase(in.charAt(0))); + for (int i = 1; i < in.length(); i++) { + char c = in.charAt(i); + if (isUpperCase(c)) { + result.append("_").append(Character.toLowerCase(c)); + } else { + result.append(c); + } + } + return result.toString(); + } + + /** + * Convert a string (snake_case) to CamelCase + * + * @param in snake_case String + * @return CamelCase string + */ + private static String toCamelCase(String in) { + StringBuilder camelCased = new StringBuilder(); + String[] tokens = in.split("_"); + for (String token : tokens) { + if (token.length() >= 1) { + camelCased.append(token.substring(0, 1).toUpperCase()).append(token.substring(1)); + } else { + camelCased.append("_"); + } + } + return camelCased.toString(); + } + // Match WordUtils.capitalize: title-case each whitespace-delimited word, + // preserving whitespace and all remaining characters (upstream issue #36). + private static String capitalize(String text) { + StringBuilder result = new StringBuilder(text.length()); + boolean capitalizeNext = true; + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (Character.isWhitespace(ch)) { + result.append(ch); + capitalizeNext = true; + } else if (capitalizeNext) { + result.append(Character.toTitleCase(ch)); + capitalizeNext = false; + } else { + result.append(ch); + } + } + return result.toString(); + } + +} diff --git a/src/de/netnexus/CamelCasePlugin/Conversion.java b/src/de/netnexus/CamelCasePlugin/Conversion.java index 692c8b7..efadfb6 100644 --- a/src/de/netnexus/CamelCasePlugin/Conversion.java +++ b/src/de/netnexus/CamelCasePlugin/Conversion.java @@ -1,6 +1,5 @@ package de.netnexus.CamelCasePlugin; -import org.apache.commons.lang.WordUtils; import org.jetbrains.annotations.NotNull; import java.util.Arrays; @@ -46,7 +45,7 @@ static String transform(String text, String target) { // snake_case to space case case CONVERSION_LOWER_SNAKE_CASE -> text = text.replace('_', ' '); // space case to Camel Case - case CONVERSION_SPACE_CASE -> text = WordUtils.capitalize(text); + case CONVERSION_SPACE_CASE -> text = capitalize(text); // Camel Case to kebab-case case CONVERSION_PASCAL_CASE_SPACE -> text = text.toLowerCase().replace(' ', '-'); // kebab-case to SNAKE_CASE @@ -123,6 +122,26 @@ private static String toCamelCase(String in) { return camelCased.toString(); } + // Match WordUtils.capitalize: title-case each whitespace-delimited word, + // preserving whitespace and all remaining characters (upstream issue #36). + private static String capitalize(String text) { + StringBuilder result = new StringBuilder(text.length()); + boolean capitalizeNext = true; + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (Character.isWhitespace(ch)) { + result.append(ch); + capitalizeNext = true; + } else if (capitalizeNext) { + result.append(Character.toTitleCase(ch)); + capitalizeNext = false; + } else { + result.append(ch); + } + } + return result.toString(); + } + /** * Get a string case type * diff --git a/tests/de/netnexus/CamelCasePlugin/ConversionTest.java b/tests/de/netnexus/CamelCasePlugin/ConversionTest.java new file mode 100644 index 0000000..86db182 --- /dev/null +++ b/tests/de/netnexus/CamelCasePlugin/ConversionTest.java @@ -0,0 +1,32 @@ +package de.netnexus.CamelCasePlugin; + +public class ConversionTest { + private static void check(String expected, String actual) { + if (!expected.equals(actual)) { + throw new AssertionError("Expected [" + expected + "], got [" + actual + "]"); + } + } + + private static void cycle(String... values) { + String text = values[0]; + String[] cases = Conversion.ConversionList.toArray(new String[0]); + for (int i = 1; i <= values.length * 2; i++) { + text = Conversion.transform(text, Conversion.getNext(Conversion.CaseType(text), cases)); + check(values[i % values.length], text); + } + } + + public static void main(String[] args) throws Exception { + cycle("RESOLVED", "Resolved", "resolved"); + cycle("FieldMode", "fieldMode", "field_mode", "field mode", "Field Mode", "field-mode", "FIELD_MODE"); + // Skipping disabled formats still traverses the capitalization step. + check("FIELD_MODE", Conversion.transform("field_mode", "SNAKE_CASE")); + check("FieldMode", Conversion.transform("field_mode", "CamelCase")); + var capitalize = Conversion.class.getDeclaredMethod("capitalize", String.class); + capitalize.setAccessible(true); + check("", (String) capitalize.invoke(null, "")); + check(" \tField MODE\nNext\r\nWord", (String) capitalize.invoke(null, " \tfield mODE\nnext\r\nword")); + check("Džuro", (String) capitalize.invoke(null, "džuro")); + System.out.println("PASS: repeated single/multi-word cycles, skipped formats, whitespace and title case"); + } +} diff --git a/tests/de/netnexus/CamelCasePlugin/LegacyBinaryTest.java b/tests/de/netnexus/CamelCasePlugin/LegacyBinaryTest.java new file mode 100644 index 0000000..59fa755 --- /dev/null +++ b/tests/de/netnexus/CamelCasePlugin/LegacyBinaryTest.java @@ -0,0 +1,34 @@ +package de.netnexus.CamelCasePlugin; + +// Compile against the released JAR and run against the packaged hotfix. +public class LegacyBinaryTest { + private static final String[] CASES = { + "kebab-case", "SNAKE_CASE", "CamelCase", "camelCase", "snake_case", "space case", "Camel Case" + }; + + private static String next(String text) { + return Conversion.transform(text, true, true, true, true, true, true, true, CASES); + } + + public static void main(String[] args) throws Exception { + for (String start : new String[]{"RESOLVED", "FieldMode", "field_mode", "field mode"}) { + String text = start; + boolean returned = false; + for (int i = 0; i < 28; i++) { + String converted = next(text); + if (converted.equals(text)) throw new AssertionError("Stuck at " + text); + if (converted.equals(start)) returned = true; + text = converted; + } + if (!returned) throw new AssertionError("Did not cycle back to " + start); + System.out.println("PASS released binary API cycle: " + start); + } + String converted = Conversion.transform("field_mode", false, false, false, true, false, false, false, CASES); + if (!"FIELD_MODE".equals(converted)) throw new AssertionError(converted); + var capitalize = Conversion.class.getDeclaredMethod("capitalize", String.class); + capitalize.setAccessible(true); + String whitespace = (String) capitalize.invoke(null, " \tfield mODE\nnext"); + if (!" \tField MODE\nNext".equals(whitespace)) throw new AssertionError(whitespace); + System.out.println("PASS packaged legacy API: skipped formats and whitespace"); + } +}