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