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");
+ }
+}