View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.util;
16  
17  import java.nio.charset.Charset;
18  import java.util.ArrayDeque;
19  import java.util.ArrayList;
20  import java.util.Collections;
21  import java.util.Deque;
22  import java.util.List;
23  import java.util.Locale;
24  import java.util.Map;
25  import java.util.concurrent.ConcurrentHashMap;
26  import java.util.regex.Matcher;
27  import java.util.regex.Pattern;
28  
29  import org.htmlunit.html.impl.Color;
30  
31  /**
32   * String utility class for functions not covered by third-party libraries.
33   *
34   * @author Daniel Gredler
35   * @author Ahmed Ashour
36   * @author Martin Tamme
37   * @author Ronald Brill
38   * @author Ashutosh Panda
39   */
40  public final class StringUtils {
41  
42      /**
43       * The empty String {@code ""}.
44       */
45      public static final String EMPTY_STRING = "";
46  
47      private static final Pattern HEX_COLOR = Pattern.compile("#([\\da-fA-F]{3}|[\\da-fA-F]{6})");
48      private static final Pattern RGB_COLOR =
49          Pattern.compile("rgb\\(\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])%?\\s*,"
50                              + "\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])%?\\s*,"
51                              + "\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])%?\\s*\\)");
52      private static final Pattern RGBA_COLOR =
53              Pattern.compile("rgba\\(\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])%?\\s*,"
54                                   + "\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])%?\\s*,"
55                                   + "\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])%?\\s*,"
56                                   + "\\s*((0?.[1-9])|[01])\\s*\\)");
57      private static final Pattern HSL_COLOR =
58              Pattern.compile("hsl\\(\\s*((0|[1-9]\\d?|[12]\\d\\d?|3[0-5]\\d)(.\\d*)?)\\s*,"
59                                  + "\\s*((0|[1-9]\\d?|100)(.\\d*)?)%\\s*,"
60                                  + "\\s*((0|[1-9]\\d?|100)(.\\d*)?)%\\s*\\)");
61      private static final Pattern ILLEGAL_FILE_NAME_CHARS = Pattern.compile("\\\\|/|\\||:|\\?|\\*|\"|<|>|\\p{Cntrl}");
62  
63      private static final Map<String, String> CAMELIZE_CACHE = new ConcurrentHashMap<>();
64  
65      /**
66       * Disallow instantiation of this class.
67       */
68      private StringUtils() {
69          // Empty.
70      }
71  
72      /**
73       * Returns {@code true} if the given sequence is not {@code null} and is empty.
74       * Unlike {@link org.apache.commons.lang3.StringUtils#isEmpty(CharSequence)}, this returns
75       * {@code false} if the sequence is {@code null}.
76       *
77       * @param s the string to check
78       * @return {@code true} if the string is not {@code null} and has length 0
79       */
80      public static boolean isEmptyString(final CharSequence s) {
81          return s != null && s.length() == 0;
82      }
83  
84      /**
85       * Returns {@code true} if the given sequence is {@code null} or empty.
86       *
87       * @param s the string to check
88       * @return {@code true} if the string is {@code null} or has length 0
89       */
90      public static boolean isEmptyOrNull(final CharSequence s) {
91          return s == null || s.length() == 0;
92      }
93  
94      /**
95       * Returns the given sequence, or the default value if it is empty or {@code null}.
96       *
97       * @param <T> the kind of {@link CharSequence}
98       * @param s the sequence to check
99       * @param defaultString the default value to return if the input is empty or {@code null}
100      * @return the given sequence, or {@code defaultString}
101      */
102     public static <T extends CharSequence> T defaultIfEmptyOrNull(final T s, final T defaultString) {
103         return isEmptyOrNull(s) ? defaultString : s;
104     }
105 
106     /**
107      * Returns {@code true} if the given sequence is {@code null}, empty, or contains only whitespace.
108      *
109      * @param s the sequence to check
110      * @return {@code true} if the sequence is blank
111      */
112     public static boolean isBlank(final CharSequence s) {
113         if (s == null) {
114             return true;
115         }
116 
117         final int length = s.length();
118         if (length == 0) {
119             return true;
120         }
121 
122         for (int i = 0; i < length; i++) {
123             if (!Character.isWhitespace(s.charAt(i))) {
124                 return false;
125             }
126         }
127         return true;
128     }
129 
130     /**
131      * Returns {@code true} if the given sequence is not {@code null}, not empty, and contains
132      * at least one non-whitespace character.
133      *
134      * @param s the sequence to check
135      * @return {@code true} if the sequence is not blank
136      */
137     public static boolean isNotBlank(final CharSequence s) {
138         if (s == null) {
139             return false;
140         }
141 
142         final int length = s.length();
143         if (length == 0) {
144             return false;
145         }
146 
147         for (int i = 0; i < length; i++) {
148             if (!Character.isWhitespace(s.charAt(i))) {
149                 return true;
150             }
151         }
152         return false;
153     }
154 
155     /**
156      * Returns {@code true} if the given sequence has exactly one character and it equals the expected char.
157      *
158      * @param expected the char to compare
159      * @param s the string to check
160      * @return {@code true} if the string has exactly one character matching {@code expected}
161      */
162     public static boolean equalsChar(final char expected, final CharSequence s) {
163         return s != null && s.length() == 1 && expected == s.charAt(0);
164     }
165 
166     /**
167      * Returns {@code true} if the given string starts with the specified prefix, ignoring case.
168      *
169      * @param s the string to check
170      * @param expectedStart the expected prefix (must not be {@code null} or empty)
171      * @return {@code true} if the string starts with the given prefix, ignoring case
172      */
173     public static boolean startsWithIgnoreCase(final String s, final String expectedStart) {
174         if (expectedStart == null || expectedStart.length() == 0) {
175             throw new IllegalArgumentException("Expected start string can't be null or empty");
176         }
177 
178         if (s == null) {
179             return false;
180         }
181         if (s == expectedStart) {
182             return true;
183         }
184 
185         return s.regionMatches(true, 0, expectedStart, 0, expectedStart.length());
186     }
187 
188     /**
189      * Returns {@code true} if the given string ends with the specified suffix, ignoring case.
190      *
191      * @param s the string to check
192      * @param expectedEnd the expected suffix (must not be {@code null} or empty)
193      * @return {@code true} if the string ends with the given suffix, ignoring case
194      */
195     public static boolean endsWithIgnoreCase(final String s, final String expectedEnd) {
196         if (expectedEnd == null) {
197             throw new IllegalArgumentException("Expected end string can't be null or empty");
198         }
199 
200         final int expectedEndLength = expectedEnd.length();
201         if (expectedEndLength == 0) {
202             throw new IllegalArgumentException("Expected end string can't be null or empty");
203         }
204 
205         if (s == null) {
206             return false;
207         }
208         if (s == expectedEnd) {
209             return true;
210         }
211 
212         return s.regionMatches(true, s.length() - expectedEndLength, expectedEnd, 0, expectedEndLength);
213     }
214 
215     /**
216      * Returns {@code true} if the given string contains the specified substring, ignoring case.
217      *
218      * @param s the string to check
219      * @param expected the substring to look for (must not be {@code null} or empty)
220      * @return {@code true} if the string contains the substring, ignoring case
221      */
222     public static boolean containsIgnoreCase(final String s, final String expected) {
223         if (expected == null) {
224             throw new IllegalArgumentException("Expected string can't be null or empty");
225         }
226 
227         final int expectedLength = expected.length();
228         if (expectedLength == 0) {
229             throw new IllegalArgumentException("Expected string can't be null or empty");
230         }
231 
232         if (s == null) {
233             return false;
234         }
235         if (s == expected) {
236             return true;
237         }
238 
239         final int max = s.length() - expectedLength;
240         for (int i = 0; i <= max; i++) {
241             if (s.regionMatches(true, i, expected, 0, expectedLength)) {
242                 return true;
243             }
244         }
245         return false;
246     }
247 
248     /**
249      * Replaces multiple characters in a string in a single pass.
250      * This method can also be used to delete characters by omitting them from {@code replaceChars}.
251      *
252      * @param str the string to process; may be {@code null}
253      * @param searchChars the set of characters to search for; may be {@code null}
254      * @param replaceChars the replacement characters; may be {@code null}
255      * @return the modified string, or the original if no replacement was performed
256      */
257     @SuppressWarnings("null")
258     public static String replaceChars(final String str, final String searchChars, final String replaceChars) {
259         if (isEmptyOrNull(str) || isEmptyOrNull(searchChars)) {
260             return str;
261         }
262 
263         final int replaceCharsLength = replaceChars == null ? 0 : replaceChars.length();
264         final int strLength = str.length();
265 
266         StringBuilder buf = null;
267         int i = 0;
268         for ( ; i < strLength; i++) {
269             final char ch = str.charAt(i);
270             final int index = searchChars.indexOf(ch);
271             if (index != -1) {
272                 buf = new StringBuilder(strLength);
273                 buf.append(str, 0, i);
274                 if (index < replaceCharsLength) {
275                     buf.append(replaceChars.charAt(index));
276                 }
277                 break;
278             }
279         }
280 
281         if (buf == null) {
282             return str;
283         }
284 
285         i++;
286         for ( ; i < strLength; i++) {
287             final char ch = str.charAt(i);
288             final int index = searchChars.indexOf(ch);
289             if (index != -1) {
290                 if (index < replaceCharsLength) {
291                     buf.append(replaceChars.charAt(index));
292                 }
293             }
294             else {
295                 buf.append(ch);
296             }
297         }
298 
299         return buf.toString();
300     }
301 
302     /**
303      * Returns the substring after the first occurrence of the specified separator.
304      * The separator itself is not included.
305      * <p>
306      * Returns {@code null} for a {@code null} input string.
307      * Returns an empty string for an empty input string.
308      * Returns an empty string if the separator is not found.
309      * Returns an empty string if the separator is {@code null} and the input is not {@code null}.
310      * </p>
311      *
312      * @param str the string to search; may be {@code null}
313      * @param find the separator to find; may be {@code null}
314      * @return the substring after the first occurrence of the separator, or {@code null} if input is {@code null}
315      */
316     public static String substringAfter(final String str, final String find) {
317         if (isEmptyOrNull(str)) {
318             return str;
319         }
320         if (find == null) {
321             return EMPTY_STRING;
322         }
323         final int pos = str.indexOf(find);
324         if (pos == -1) {
325             return EMPTY_STRING;
326         }
327         return str.substring(pos + find.length());
328     }
329 
330     /**
331      * Escapes the characters {@code <}, {@code >} and {@code &} into their XML entity equivalents.
332      *
333      * @param s the string to escape
334      * @return the escaped form of the specified string
335      */
336     public static String escapeXmlChars(final String s) {
337         return org.apache.commons.lang3.StringUtils.
338                 replaceEach(s, new String[] {"&", "<", ">"}, new String[] {"&amp;", "&lt;", "&gt;"});
339     }
340 
341     /**
342      * Escapes a string for use as XML 1.0 content, replacing
343      * {@code "}, {@code &}, {@code '}, {@code <}, and {@code >} with their XML entity equivalents.
344      * Invalid XML 1.0 code points are removed.
345      *
346      * @param text the text to escape
347      * @return the escaped value, or {@code null} if the input is {@code null}
348      */
349     public static String escapeXml(final String text) {
350         if (text == null) {
351             return null;
352         }
353 
354         StringBuilder escaped = null;
355 
356         final int offset = 0;
357         final int max = text.length();
358 
359         int readOffset = offset;
360 
361         for (int i = offset; i < max; i++) {
362             final int codepoint = Character.codePointAt(text, i);
363             final boolean codepointValid = supportedByXML10(codepoint);
364 
365             if (!codepointValid
366                     || codepoint == '<'
367                     || codepoint == '>'
368                     || codepoint == '&'
369                     || codepoint == '\''
370                     || codepoint == '"') {
371 
372                 // replacement required
373                 if (escaped == null) {
374                     escaped = new StringBuilder(max);
375                 }
376 
377                 if (i > readOffset) {
378                     escaped.append(text, readOffset, i);
379                 }
380 
381                 if (Character.charCount(codepoint) > 1) {
382                     i++;
383                 }
384                 readOffset = i + 1;
385 
386                 // skip
387                 if (!codepointValid) {
388                     continue;
389                 }
390 
391                 if (codepoint == '<') {
392                     escaped.append("&lt;");
393                 }
394                 else if (codepoint == '>') {
395                     escaped.append("&gt;");
396                 }
397                 else if (codepoint == '&') {
398                     escaped.append("&amp;");
399                 }
400                 else if (codepoint == '\'') {
401                     escaped.append("&apos;");
402                 }
403                 else if (codepoint == '\"') {
404                     escaped.append("&quot;");
405                 }
406             }
407         }
408 
409         if (escaped == null) {
410             return text;
411         }
412 
413         if (max > readOffset) {
414             escaped.append(text, readOffset, max);
415         }
416 
417         return escaped.toString();
418     }
419 
420     /**
421      * Escapes a string for use as an XML attribute value.
422      * Only {@code <}, {@code &}, and {@code "} are escaped, as required by the
423      * <a href="https://www.w3.org/TR/REC-xml/#d0e888">XML specification</a>.
424      * Invalid XML 1.0 code points are removed.
425      *
426      * @param attValue the attribute value to escape
427      * @return the escaped value, or {@code null} if the input is {@code null}
428      */
429     public static String escapeXmlAttributeValue(final String attValue) {
430         if (attValue == null) {
431             return null;
432         }
433 
434         StringBuilder escaped = null;
435 
436         final int offset = 0;
437         final int max = attValue.length();
438 
439         int readOffset = offset;
440 
441         for (int i = offset; i < max; i++) {
442             final int codepoint = Character.codePointAt(attValue, i);
443             final boolean codepointValid = supportedByXML10(codepoint);
444 
445             if (!codepointValid
446                     || codepoint == '<'
447                     || codepoint == '&'
448                     || codepoint == '"') {
449 
450                 // replacement required
451                 if (escaped == null) {
452                     escaped = new StringBuilder(max);
453                 }
454 
455                 if (i > readOffset) {
456                     escaped.append(attValue, readOffset, i);
457                 }
458 
459                 if (Character.charCount(codepoint) > 1) {
460                     i++;
461                 }
462                 readOffset = i + 1;
463 
464                 // skip
465                 if (!codepointValid) {
466                     continue;
467                 }
468 
469                 if (codepoint == '<') {
470                     escaped.append("&lt;");
471                 }
472                 else if (codepoint == '&') {
473                     escaped.append("&amp;");
474                 }
475                 else if (codepoint == '\"') {
476                     escaped.append("&quot;");
477                 }
478             }
479         }
480 
481         if (escaped == null) {
482             return attValue;
483         }
484 
485         if (max > readOffset) {
486             escaped.append(attValue, readOffset, max);
487         }
488 
489         return escaped.toString();
490     }
491 
492     /*
493      * XML 1.0 does not allow control characters or unpaired Unicode surrogate codepoints.
494      * We will remove characters that do not fit in the following ranges:
495      * #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
496      */
497     private static boolean supportedByXML10(final int codepoint) {
498         if (codepoint < 0x20) {
499             return codepoint == 0x9 || codepoint == 0xA || codepoint == 0xD;
500         }
501         if (codepoint <= 0xD7FF) {
502             return true;
503         }
504 
505         if (codepoint < 0xE000) {
506             return false;
507         }
508         if (codepoint <= 0xFFFD) {
509             return true;
510         }
511 
512         return codepoint >= 0x10000;
513     }
514 
515     /**
516      * Returns the index within the specified string of the first occurrence of
517      * the specified search character within the given range.
518      *
519      * @param s the string to search
520      * @param searchChar the character to search for
521      * @param beginIndex the index at which to start the search
522      * @param endIndex the index at which to stop the search (exclusive)
523      * @return the index of the first occurrence, or {@code -1} if not found
524      */
525     public static int indexOf(final String s, final char searchChar, final int beginIndex, final int endIndex) {
526         for (int i = beginIndex; i < endIndex; i++) {
527             if (s.charAt(i) == searchChar) {
528                 return i;
529             }
530         }
531         return -1;
532     }
533 
534     /**
535      * Returns a {@link Color} parsed from the given RGB hexadecimal notation,
536      * or {@code null} if the token is not a valid hex color.
537      *
538      * @param token the token to parse
539      * @return a {@link Color} if the token is a valid hex color; otherwise {@code null}
540      */
541     public static Color asColorHexadecimal(final String token) {
542         if (token == null) {
543             return null;
544         }
545         final Matcher tmpMatcher = HEX_COLOR.matcher(token);
546         final boolean tmpFound = tmpMatcher.matches();
547         if (!tmpFound) {
548             return null;
549         }
550 
551         final String tmpHex = tmpMatcher.group(1);
552         if (tmpHex.length() == 6) {
553             final int tmpRed = Integer.parseInt(tmpHex.substring(0, 2), 16);
554             final int tmpGreen = Integer.parseInt(tmpHex.substring(2, 4), 16);
555             final int tmpBlue = Integer.parseInt(tmpHex.substring(4, 6), 16);
556             return new Color(tmpRed, tmpGreen, tmpBlue);
557         }
558 
559         final int tmpRed = Integer.parseInt(tmpHex.substring(0, 1) + tmpHex.substring(0, 1), 16);
560         final int tmpGreen = Integer.parseInt(tmpHex.substring(1, 2) + tmpHex.substring(1, 2), 16);
561         final int tmpBlue = Integer.parseInt(tmpHex.substring(2, 3) + tmpHex.substring(2, 3), 16);
562         return new Color(tmpRed, tmpGreen, tmpBlue);
563     }
564 
565     /**
566      * Returns a {@link Color} parsed from the first {@code rgb(...)} notation found within the token,
567      * or {@code null} if none is found.
568      *
569      * @param token the token to parse
570      * @return a {@link Color} if an RGB color is found; otherwise {@code null}
571      */
572     public static Color findColorRGB(final String token) {
573         if (token == null) {
574             return null;
575         }
576         final Matcher tmpMatcher = RGB_COLOR.matcher(token);
577         if (!tmpMatcher.find()) {
578             return null;
579         }
580 
581         final int tmpRed = Integer.parseInt(tmpMatcher.group(1));
582         final int tmpGreen = Integer.parseInt(tmpMatcher.group(2));
583         final int tmpBlue = Integer.parseInt(tmpMatcher.group(3));
584         return new Color(tmpRed, tmpGreen, tmpBlue);
585     }
586 
587     /**
588      * Returns a {@link Color} parsed from the first {@code rgba(...)} notation found within the token,
589      * or {@code null} if none is found.
590      *
591      * @param token the token to parse
592      * @return a {@link Color} if an RGBA color is found; otherwise {@code null}
593      */
594     public static Color findColorRGBA(final String token) {
595         if (token == null) {
596             return null;
597         }
598         final Matcher tmpMatcher = RGBA_COLOR.matcher(token);
599         if (!tmpMatcher.find()) {
600             return null;
601         }
602 
603         final int tmpRed = Integer.parseInt(tmpMatcher.group(1));
604         final int tmpGreen = Integer.parseInt(tmpMatcher.group(2));
605         final int tmpBlue = Integer.parseInt(tmpMatcher.group(3));
606         final int tmpAlpha = (int) (Float.parseFloat(tmpMatcher.group(4)) * 255);
607         return new Color(tmpRed, tmpGreen, tmpBlue, tmpAlpha);
608     }
609 
610     /**
611      * Returns a {@link Color} parsed from the first {@code hsl(...)} notation found within the token,
612      * or {@code null} if none is found.
613      *
614      * @param token the token to parse
615      * @return a {@link Color} if an HSL color is found; otherwise {@code null}
616      */
617     public static Color findColorHSL(final String token) {
618         if (token == null) {
619             return null;
620         }
621         final Matcher tmpMatcher = HSL_COLOR.matcher(token);
622         if (!tmpMatcher.find()) {
623             return null;
624         }
625 
626         final float tmpHue = Float.parseFloat(tmpMatcher.group(1)) / 360f;
627         final float tmpSaturation = Float.parseFloat(tmpMatcher.group(4)) / 100f;
628         final float tmpLightness = Float.parseFloat(tmpMatcher.group(7)) / 100f;
629         return hslToRgb(tmpHue, tmpSaturation, tmpLightness);
630     }
631 
632     /**
633      * Converts an HSL color value to an RGB {@link Color}.
634      * Conversion formula adapted from
635      * <a href="https://en.wikipedia.org/wiki/HSL_color_space">Wikipedia: HSL color space</a>.
636      * Assumes {@code h}, {@code s}, and {@code l} are in the range [0, 1].
637      *
638      * @param h the hue
639      * @param s the saturation
640      * @param l the lightness
641      * @return the resulting {@link Color}
642      */
643     private static Color hslToRgb(final float h, final float s, final float l) {
644         if (s == 0f) {
645             return new Color(to255(l), to255(l), to255(l));
646         }
647 
648         final float q = l < 0.5f ? l * (1 + s) : l + s - l * s;
649         final float p = 2 * l - q;
650         final float r = hueToRgb(p, q, h + 1f / 3f);
651         final float g = hueToRgb(p, q, h);
652         final float b = hueToRgb(p, q, h - 1f / 3f);
653 
654         return new Color(to255(r), to255(g), to255(b));
655     }
656 
657     private static float hueToRgb(final float p, final float q, float t) {
658         if (t < 0f) {
659             t += 1f;
660         }
661 
662         if (t > 1f) {
663             t -= 1f;
664         }
665 
666         if (t < 1f / 6f) {
667             return p + (q - p) * 6f * t;
668         }
669 
670         if (t < 1f / 2f) {
671             return q;
672         }
673 
674         if (t < 2f / 3f) {
675             return p + (q - p) * (2f / 3f - t) * 6f;
676         }
677 
678         return p;
679     }
680 
681     private static int to255(final float value) {
682         return (int) Math.min(255, 256 * value);
683     }
684 
685     /**
686      * Formats the specified color as an {@code rgb(...)} string.
687      *
688      * @param color the color to format
689      * @return the specified color formatted as {@code rgb(r, g, b)}
690      */
691     public static String formatColor(final Color color) {
692         return "rgb(" + color.getRed() + ", " + color.getGreen() + ", " + color.getBlue() + ")";
693     }
694 
695     /**
696      * Sanitizes a string for use with {@link java.util.regex.Matcher#appendReplacement}.
697      * Replaces all {@code \} with {@code \\} and {@code $} with {@code \$},
698      * as these are control characters in {@code appendReplacement}.
699      *
700      * @param toSanitize the string to sanitize
701      * @return the sanitized version of the string
702      */
703     public static String sanitizeForAppendReplacement(final String toSanitize) {
704         return org.apache.commons.lang3.StringUtils.replaceEach(toSanitize,
705                                     new String[] {"\\", "$"}, new String[]{"\\\\", "\\$"});
706     }
707 
708     /**
709      * Sanitizes a string for use as a filename by replacing illegal characters
710      * ({@code \}, {@code /}, {@code |}, {@code :}, {@code ?}, {@code *},
711      * {@code "}, {@code <}, {@code >}, control characters) with {@code _}.
712      *
713      * @param toSanitize the string to sanitize
714      * @return the sanitized version of the string
715      */
716     public static String sanitizeForFileName(final String toSanitize) {
717         return ILLEGAL_FILE_NAME_CHARS.matcher(toSanitize).replaceAll("_");
718     }
719 
720     /**
721      * Transforms a delimiter-separated CSS property name (e.g. {@code font-size})
722      * into camel case (e.g. {@code fontSize}).
723      *
724      * @param string the string to camelize
725      * @return the camelized string
726      */
727     public static String cssCamelize(final String string) {
728         if (string == null) {
729             return null;
730         }
731 
732         String result = CAMELIZE_CACHE.get(string);
733         if (null != result) {
734             return result;
735         }
736 
737         // not found in CamelizeCache_; convert and store in cache
738         final int pos = string.indexOf('-');
739         if (pos == -1 || pos == string.length() - 1) {
740             // cache also this strings for performance
741             CAMELIZE_CACHE.put(string, string);
742             return string;
743         }
744 
745         final StringBuilder builder = new StringBuilder(string);
746         builder.deleteCharAt(pos);
747         builder.setCharAt(pos, Character.toUpperCase(builder.charAt(pos)));
748 
749         int i = pos + 1;
750         while (i < builder.length() - 1) {
751             if (builder.charAt(i) == '-') {
752                 builder.deleteCharAt(i);
753                 builder.setCharAt(i, Character.toUpperCase(builder.charAt(i)));
754             }
755             i++;
756         }
757         result = builder.toString();
758         CAMELIZE_CACHE.put(string, result);
759 
760         return result;
761     }
762 
763     /**
764      * Converts the given string to lowercase using the ROOT locale.
765      * Returns {@code null} if the input is {@code null}.
766      *
767      * @param s the string to lowercase
768      * @return the lowercased string, or {@code null}
769      */
770     public static String toRootLowerCase(final String s) {
771         return s == null ? null : s.toLowerCase(Locale.ROOT);
772     }
773 
774     /**
775      * Converts the given string to a byte array using the specified charset.
776      * Returns an empty byte array if the string is {@code null} or empty, or if the charset is unsupported.
777      *
778      * @param charset the charset to use for encoding
779      * @param content the string to convert
780      * @return the string as a byte array, or an empty array if the string is {@code null} or empty
781      */
782     public static byte[] toByteArray(final String content, final Charset charset) {
783         if (content ==  null || content.isEmpty()) {
784             return new byte[0];
785         }
786 
787         return content.getBytes(charset);
788     }
789 
790     /**
791      * Splits the given text on whitespace as defined by {@link Character#isWhitespace(char)}.
792      *
793      * @param str the string to split; may be {@code null}
794      * @return an array of parsed strings, or an empty array if the input is {@code null}
795      */
796     public static String[] splitAtJavaWhitespace(final String str) {
797         final String[] parts = org.apache.commons.lang3.StringUtils.split(str);
798         if (parts == null) {
799             return new String[0];
800         }
801         return parts;
802     }
803 
804     /**
805      * Splits the given text on blank (space) characters.
806      *
807      * @param str the string to split; may be {@code null}
808      * @return an array of parsed strings, or an empty array if the input is {@code null}
809      */
810     public static String[] splitAtBlank(final String str) {
811         final String[] parts = org.apache.commons.lang3.StringUtils.split(str, ' ');
812         if (parts == null) {
813             return new String[0];
814         }
815         return parts;
816     }
817 
818     /**
819      * Splits the given text on comma characters.
820      *
821      * @param str the string to split; may be {@code null}
822      * @return an array of parsed strings, or an empty array if the input is {@code null}
823      */
824     public static String[] splitAtComma(final String str) {
825         final String[] parts = org.apache.commons.lang3.StringUtils.split(str, ',');
826         if (parts == null) {
827             return new String[0];
828         }
829         return parts;
830     }
831 
832     /**
833      * Splits the given text on comma or blank (space) characters.
834      *
835      * @param str the string to split; may be {@code null}
836      * @return an array of parsed strings, or an empty array if the input is {@code null}
837      */
838     public static String[] splitAtCommaOrBlank(final String str) {
839         final String[] parts = org.apache.commons.lang3.StringUtils.split(str, ", ");
840         if (parts == null) {
841             return new String[0];
842         }
843         return parts;
844     }
845 
846     /**
847      * Returns the substring before the first occurrence of the specified separator.
848      * The separator itself is not included in the result.
849      * {@code null} input returns {@code null}; empty input returns an empty string.
850      *
851      * @param str the string to search; may be {@code null}
852      * @param find the separator to find; must not be {@code null} or empty
853      * @return the substring before the first occurrence of the separator,
854      *         or the full string if the separator is not found
855      */
856     public static String substringBefore(final String str, final String find) {
857         if (isEmptyOrNull(find)) {
858             throw new IllegalArgumentException("'find' string parameter has to be not empty and not null");
859         }
860 
861         if (isEmptyString(str)) {
862             return str;
863         }
864 
865         final int pos = str.indexOf(find);
866         if (pos == -1) {
867             return str;
868         }
869         return str.substring(0, pos);
870     }
871 
872     /**
873      * Parses an integer from the given string, returning the default value if parsing fails
874      * or the string is {@code null}.
875      *
876      * @param str the string to parse; may be {@code null}
877      * @param defaultValue the value to return if parsing fails
878      * @return the parsed integer, or {@code defaultValue}
879      */
880     public static int toInt(final String str, final int defaultValue) {
881         try {
882             return Integer.parseInt(str);
883         }
884         catch (final RuntimeException e) {
885             return defaultValue;
886         }
887     }
888 
889     /**
890      * Parses a float from the given string, returning the default value if parsing fails
891      * or the string is {@code null}.
892      *
893      * @param str the string to parse; may be {@code null}
894      * @param defaultValue the value to return if parsing fails
895      * @return the parsed float, or {@code defaultValue}
896      */
897     public static float toFloat(final String str, final float defaultValue) {
898         try {
899             return Float.parseFloat(str);
900         }
901         catch (final RuntimeException e) {
902             return defaultValue;
903         }
904     }
905 
906     /**
907      * Strips trailing whitespace from the given string.
908      * Returns {@code null} for a {@code null} input; returns the string unchanged if it has no trailing whitespace.
909      *
910      * @param str the string to trim; may be {@code null}
911      * @return the trimmed string, or {@code null} if input is {@code null}
912      */
913     public static String trimRight(final String str) {
914         if (isEmptyOrNull(str)) {
915             return str;
916         }
917 
918         int end = str.length();
919         while (end != 0 && Character.isWhitespace(str.charAt(end - 1))) {
920             end--;
921         }
922 
923         if (end == str.length()) {
924             return str;
925         }
926 
927         return str.substring(0, end);
928     }
929 
930     /**
931      * Returns {@code true} if the given sequence is non-{@code null} and contains only characters
932      * from the provided set of valid characters.
933      *
934      * @param cs the sequence to check; may be {@code null}
935      * @param valid the array of valid characters; must not be {@code null} or empty
936      * @return {@code true} if all characters in the sequence are valid
937      */
938     public static boolean containsOnly(final CharSequence cs, final char... valid) {
939         if (valid == null || valid.length == 0) {
940             throw new IllegalArgumentException("Expected valid char[] can't be null or empty");
941         }
942         if (isEmptyOrNull(cs)) {
943             return false;
944         }
945 
946         final int csLength = cs.length();
947         final int validLength = valid.length;
948         for (int i = 0; i < csLength; i++) {
949             final char testChar = cs.charAt(i);
950             int j = 0;
951             for ( ; j < validLength; j++) {
952                 final char validChar = valid[j];
953                 if (validChar == testChar) {
954                     break;
955                 }
956             }
957             if (j == validLength) {
958                 return false;
959             }
960         }
961 
962         return true;
963     }
964 
965     /**
966      * Removes dot segments from a URL path component per
967      * <a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4">RFC 3986, §5.2.4</a>.
968      *
969      * <p>Dot segments are the special path segments {@code .} (current directory)
970      * and {@code ..} (parent directory). Both forms are resolved and removed:
971      * </p>
972      * <ul>
973      *   <li>{@code .} segments are no-ops and are dropped in place.</li>
974      *   <li>{@code ..} segments remove themselves and the preceding non-{@code ..}
975      *       segment. If no such preceding segment exists (i.e. already at root),
976      *       the {@code ..} is left in place rather than producing an invalid path.</li>
977      * </ul>
978      *
979      * <p>A leading {@code /} is preserved in the output if present in the input.</p>
980      *
981      * <p>Examples:</p>
982      * <pre>
983      *   removeDots("/a/./b")         = "/a/b"
984      *   removeDots("/a/b/../c")      = "/a/c"
985      *   removeDots("/a/.")           = "/a"
986      *   removeDots("/../a/b/../c")   = "/a/c"
987      *   removeDots("/a/../../b")     = "/../b"   (excess ".." left in place)
988      *   removeDots("/a/b/./c/../../d") = "/a/d"
989      * </pre>
990      *
991      * <p>This method supersedes a previous regex-based implementation in
992      * {@code WebRequest} that missed trailing single-dot segments (e.g.
993      * {@code /a/.} was incorrectly left unchanged) and paths composed entirely
994      * of dot segments (e.g. {@code /././.} produced {@code /.} instead of
995      * {@code /}).
996      * </p>
997      *
998      * @param path the URL path component to normalize; must not be {@code null}
999      * @return the normalized path with all resolvable dot segments removed;
1000      *         never {@code null}, and structurally equivalent to the input
1001      *         with respect to the resource it addresses
1002      */
1003     public static String removeDots(final String path) {
1004         final boolean leadingSlash = path.startsWith("/");
1005         final String[] segments = (leadingSlash ? path.substring(1) : path).split("/", -1);
1006 
1007         final Deque<String> stack = new ArrayDeque<>();
1008         for (final String seg : segments) {
1009             if ("..".equals(seg)) {
1010                 if (!stack.isEmpty() && !"..".equals(stack.peek())) {
1011                     stack.pop();
1012                 }
1013             }
1014             else if (!".".equals(seg)) {
1015                 stack.push(seg);
1016             }
1017         }
1018 
1019         final List<String> parts = new ArrayList<>(stack);
1020         Collections.reverse(parts);
1021         final String joined = String.join("/", parts);
1022         return leadingSlash ? "/" + joined : joined;
1023     }
1024 
1025     /**
1026      * Escapes a string for use as the text content of an HTML element, as the HTML serialization algorithm
1027      * does: {@code &}, {@code <}, {@code >} and the non-breaking space are replaced by their entities.
1028      * Quotes are not escaped in text, and no characters are removed.
1029      *
1030      * @param text the text to escape
1031      * @return the escaped value, or {@code null} if the input is {@code null}
1032      */
1033     public static String escapeHtmlText(final String text) {
1034         if (text == null) {
1035             return null;
1036         }
1037 
1038         final int max = text.length();
1039         StringBuilder escaped = null;
1040         int readOffset = 0;
1041 
1042         for (int i = 0; i < max; i++) {
1043             final String replacement = switch (text.charAt(i)) {
1044                 case '&' -> "&amp;";
1045                 case '<' -> "&lt;";
1046                 case '>' -> "&gt;";
1047                 case '\u00A0' -> "&nbsp;";
1048                 default -> null;
1049             };
1050 
1051             if (replacement != null) {
1052                 if (escaped == null) {
1053                     escaped = new StringBuilder(max + 16);
1054                 }
1055                 escaped.append(text, readOffset, i).append(replacement);
1056                 readOffset = i + 1;
1057             }
1058         }
1059 
1060         if (escaped == null) {
1061             return text;
1062         }
1063 
1064         return escaped.append(text, readOffset, max).toString();
1065     }
1066 }