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