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[] {"&", "<", ">"});
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("<");
392 }
393 else if (codepoint == '>') {
394 escaped.append(">");
395 }
396 else if (codepoint == '&') {
397 escaped.append("&");
398 }
399 else if (codepoint == '\'') {
400 escaped.append("'");
401 }
402 else if (codepoint == '\"') {
403 escaped.append(""");
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("<");
470 }
471 else if (codepoint == '&') {
472 escaped.append("&");
473 }
474 else if (codepoint == '\"') {
475 escaped.append(""");
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 }