001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.camel.util;
018
019import java.util.ArrayList;
020import java.util.Collections;
021import java.util.List;
022import java.util.Locale;
023import java.util.Objects;
024import java.util.Optional;
025import java.util.function.Function;
026import java.util.regex.Matcher;
027import java.util.regex.Pattern;
028
029/**
030 * Helper methods for working with Strings.
031 */
032public final class StringHelper {
033
034    /**
035     * Constructor of utility class should be private.
036     */
037    private StringHelper() {
038    }
039
040    /**
041     * Ensures that <code>s</code> is friendly for a URL or file system.
042     *
043     * @param s String to be sanitized.
044     * @return sanitized version of <code>s</code>.
045     * @throws NullPointerException if <code>s</code> is <code>null</code>.
046     */
047    public static String sanitize(String s) {
048        return s
049            .replace(':', '-')
050            .replace('_', '-')
051            .replace('.', '-')
052            .replace('/', '-')
053            .replace('\\', '-');
054    }
055
056    /**
057     * Remove carriage return and line feeds from a String, replacing them with an empty String.
058     * @param s String to be sanitized of carriage return / line feed characters
059     * @return sanitized version of <code>s</code>.
060     * @throws NullPointerException if <code>s</code> is <code>null</code>.
061     */
062    public static String removeCRLF(String s) {
063        return s
064            .replace("\r", "")
065            .replace("\n", "");
066    }
067
068    /**
069     * Counts the number of times the given char is in the string
070     *
071     * @param s  the string
072     * @param ch the char
073     * @return number of times char is located in the string
074     */
075    public static int countChar(String s, char ch) {
076        return countChar(s, ch, -1);
077    }
078
079    /**
080     * Counts the number of times the given char is in the string
081     *
082     * @param s  the string
083     * @param ch the char
084     * @param end end index
085     * @return number of times char is located in the string
086     */
087    public static int countChar(String s, char ch, int end) {
088        if (s == null || s.isEmpty()) {
089            return 0;
090        }
091
092        int matches = 0;
093        int len = end < 0 ? s.length() : end;
094        for (int i = 0; i < len; i++) {
095            char c = s.charAt(i);
096            if (ch == c) {
097                matches++;
098            }
099        }
100
101        return matches;
102    }
103
104    /**
105     * Limits the length of a string
106     *
107     * @param s the string
108     * @param maxLength the maximum length of the returned string
109     * @return s if the length of s is less than maxLength or the first maxLength characters of s
110     */
111    public static String limitLength(String s, int maxLength) {
112        if (ObjectHelper.isEmpty(s)) {
113            return s;
114        }
115        return s.length() <= maxLength ? s : s.substring(0, maxLength);
116    }
117
118    /**
119     * Removes all quotes (single and double) from the string
120     *
121     * @param s  the string
122     * @return the string without quotes (single and double)
123     */
124    public static String removeQuotes(String s) {
125        if (ObjectHelper.isEmpty(s)) {
126            return s;
127        }
128
129        s = replaceAll(s, "'", "");
130        s = replaceAll(s, "\"", "");
131        return s;
132    }
133
134    /**
135     * Removes all leading and ending quotes (single and double) from the string
136     *
137     * @param s  the string
138     * @return the string without leading and ending quotes (single and double)
139     */
140    public static String removeLeadingAndEndingQuotes(String s) {
141        if (ObjectHelper.isEmpty(s)) {
142            return s;
143        }
144
145        String copy = s.trim();
146        if (copy.startsWith("'") && copy.endsWith("'")) {
147            return copy.substring(1, copy.length() - 1);
148        }
149        if (copy.startsWith("\"") && copy.endsWith("\"")) {
150            return copy.substring(1, copy.length() - 1);
151        }
152
153        // no quotes, so return as-is
154        return s;
155    }
156
157    /**
158     * Whether the string starts and ends with either single or double quotes.
159     *
160     * @param s the string
161     * @return <tt>true</tt> if the string starts and ends with either single or double quotes.
162     */
163    public static boolean isQuoted(String s) {
164        if (ObjectHelper.isEmpty(s)) {
165            return false;
166        }
167
168        if (s.startsWith("'") && s.endsWith("'")) {
169            return true;
170        }
171        if (s.startsWith("\"") && s.endsWith("\"")) {
172            return true;
173        }
174
175        return false;
176    }
177
178    /**
179     * Encodes the text into safe XML by replacing < > and & with XML tokens
180     *
181     * @param text  the text
182     * @return the encoded text
183     */
184    public static String xmlEncode(String text) {
185        if (text == null) {
186            return "";
187        }
188        // must replace amp first, so we dont replace &lt; to amp later
189        text = replaceAll(text, "&", "&amp;");
190        text = replaceAll(text, "\"", "&quot;");
191        text = replaceAll(text, "<", "&lt;");
192        text = replaceAll(text, ">", "&gt;");
193        return text;
194    }
195
196    /**
197     * Determines if the string has at least one letter in upper case
198     * @param text the text
199     * @return <tt>true</tt> if at least one letter is upper case, <tt>false</tt> otherwise
200     */
201    public static boolean hasUpperCase(String text) {
202        if (text == null) {
203            return false;
204        }
205
206        for (int i = 0; i < text.length(); i++) {
207            char ch = text.charAt(i);
208            if (Character.isUpperCase(ch)) {
209                return true;
210            }
211        }
212
213        return false;
214    }
215
216    /**
217     * Determines if the string is a fully qualified class name
218     */
219    public static boolean isClassName(String text) {
220        boolean result = false;
221        if (text != null) {
222            String[] split = text.split("\\.");
223            if (split.length > 0) {
224                String lastToken = split[split.length - 1];
225                if (lastToken.length() > 0) {
226                    result = Character.isUpperCase(lastToken.charAt(0));
227                }
228            }
229        }
230        return result;
231    }
232
233    /**
234     * Does the expression have the language start token?
235     *
236     * @param expression the expression
237     * @param language the name of the language, such as simple
238     * @return <tt>true</tt> if the expression contains the start token, <tt>false</tt> otherwise
239     */
240    public static boolean hasStartToken(String expression, String language) {
241        if (expression == null) {
242            return false;
243        }
244
245        // for the simple language the expression start token could be "${"
246        if ("simple".equalsIgnoreCase(language) && expression.contains("${")) {
247            return true;
248        }
249
250        if (language != null && expression.contains("$" + language + "{")) {
251            return true;
252        }
253
254        return false;
255    }
256
257    /**
258     * Replaces all the from tokens in the given input string.
259     * <p/>
260     * This implementation is not recursive, not does it check for tokens in the replacement string.
261     *
262     * @param input  the input string
263     * @param from   the from string, must <b>not</b> be <tt>null</tt> or empty
264     * @param to     the replacement string, must <b>not</b> be empty
265     * @return the replaced string, or the input string if no replacement was needed
266     * @throws IllegalArgumentException if the input arguments is invalid
267     */
268    public static String replaceAll(String input, String from, String to) {
269        // TODO: Use String.replace instead of this method when using JDK11 as minimum (as its much faster in JDK 11 onwards)
270
271        if (ObjectHelper.isEmpty(input)) {
272            return input;
273        }
274        if (from == null) {
275            throw new IllegalArgumentException("from cannot be null");
276        }
277        if (to == null) {
278            // to can be empty, so only check for null
279            throw new IllegalArgumentException("to cannot be null");
280        }
281
282        // fast check if there is any from at all
283        if (!input.contains(from)) {
284            return input;
285        }
286
287        final int len = from.length();
288        final int max = input.length();
289        StringBuilder sb = new StringBuilder(max);
290        for (int i = 0; i < max;) {
291            if (i + len <= max) {
292                String token = input.substring(i, i + len);
293                if (from.equals(token)) {
294                    sb.append(to);
295                    // fast forward
296                    i = i + len;
297                    continue;
298                }
299            }
300
301            // append single char
302            sb.append(input.charAt(i));
303            // forward to next
304            i++;
305        }
306        return sb.toString();
307    }
308
309    /**
310     * Creates a json tuple with the given name/value pair.
311     *
312     * @param name  the name
313     * @param value the value
314     * @param isMap whether the tuple should be map
315     * @return the json
316     */
317    public static String toJson(String name, String value, boolean isMap) {
318        if (isMap) {
319            return "{ " + StringQuoteHelper.doubleQuote(name) + ": " + StringQuoteHelper.doubleQuote(value) + " }";
320        } else {
321            return StringQuoteHelper.doubleQuote(name) + ": " + StringQuoteHelper.doubleQuote(value);
322        }
323    }
324
325    /**
326     * Asserts whether the string is <b>not</b> empty.
327     *
328     * @param value  the string to test
329     * @param name   the key that resolved the value
330     * @return the passed {@code value} as is
331     * @throws IllegalArgumentException is thrown if assertion fails
332     */
333    public static String notEmpty(String value, String name) {
334        if (ObjectHelper.isEmpty(value)) {
335            throw new IllegalArgumentException(name + " must be specified and not empty");
336        }
337
338        return value;
339    }
340
341    /**
342     * Asserts whether the string is <b>not</b> empty.
343     *
344     * @param value  the string to test
345     * @param on     additional description to indicate where this problem occurred (appended as toString())
346     * @param name   the key that resolved the value
347     * @return the passed {@code value} as is
348     * @throws IllegalArgumentException is thrown if assertion fails
349     */
350    public static String notEmpty(String value, String name, Object on) {
351        if (on == null) {
352            ObjectHelper.notNull(value, name);
353        } else if (ObjectHelper.isEmpty(value)) {
354            throw new IllegalArgumentException(name + " must be specified and not empty on: " + on);
355        }
356
357        return value;
358    }
359    
360    public static String[] splitOnCharacter(String value, String needle, int count) {
361        String[] rc = new String[count];
362        rc[0] = value;
363        for (int i = 1; i < count; i++) {
364            String v = rc[i - 1];
365            int p = v.indexOf(needle);
366            if (p < 0) {
367                return rc;
368            }
369            rc[i - 1] = v.substring(0, p);
370            rc[i] = v.substring(p + 1);
371        }
372        return rc;
373    }
374
375    /**
376     * Removes any starting characters on the given text which match the given
377     * character
378     *
379     * @param text the string
380     * @param ch the initial characters to remove
381     * @return either the original string or the new substring
382     */
383    public static String removeStartingCharacters(String text, char ch) {
384        int idx = 0;
385        while (text.charAt(idx) == ch) {
386            idx++;
387        }
388        if (idx > 0) {
389            return text.substring(idx);
390        }
391        return text;
392    }
393
394    /**
395     * Capitalize the string (upper case first character)
396     *
397     * @param text  the string
398     * @return the string capitalized (upper case first character)
399     */
400    public static String capitalize(String text) {
401        return capitalize(text, false);
402    }
403
404    /**
405     * Capitalize the string (upper case first character)
406     *
407     * @param text  the string
408     * @param dashToCamelCase whether to also convert dash format into camel case (hello-great-world -> helloGreatWorld)
409     * @return the string capitalized (upper case first character)
410     */
411    public static String capitalize(String text, boolean dashToCamelCase) {
412        if (dashToCamelCase) {
413            text = dashToCamelCase(text);
414        }
415        if (text == null) {
416            return null;
417        }
418        int length = text.length();
419        if (length == 0) {
420            return text;
421        }
422        String answer = text.substring(0, 1).toUpperCase(Locale.ENGLISH);
423        if (length > 1) {
424            answer += text.substring(1, length);
425        }
426        return answer;
427    }
428
429    /**
430     * Converts the string from dash format into camel case (hello-great-world -> helloGreatWorld)
431     *
432     * @param text  the string
433     * @return the string camel cased
434     */
435    public static String dashToCamelCase(String text) {
436        if (text == null) {
437            return null;
438        }
439        int length = text.length();
440        if (length == 0) {
441            return text;
442        }
443        if (text.indexOf('-') == -1) {
444            return text;
445        }
446
447        StringBuilder sb = new StringBuilder();
448
449        for (int i = 0; i < text.length(); i++) {
450            char c = text.charAt(i);
451            if (c == '-') {
452                i++;
453                sb.append(Character.toUpperCase(text.charAt(i)));
454            } else {
455                sb.append(c);
456            }
457        }
458        return sb.toString();
459    }
460
461    /**
462     * Returns the string after the given token
463     *
464     * @param text  the text
465     * @param after the token
466     * @return the text after the token, or <tt>null</tt> if text does not contain the token
467     */
468    public static String after(String text, String after) {
469        int pos = text.indexOf(after);
470        if (pos == -1) {
471            return null;
472        }
473        return text.substring(pos + after.length());
474    }
475
476    /**
477     * Returns an object after the given token
478     *
479     * @param text  the text
480     * @param after the token
481     * @param mapper a mapping function to convert the string after the token to type T
482     * @return an Optional describing the result of applying a mapping function to the text after the token.
483     */
484    public static <T> Optional<T> after(String text, String after, Function<String, T> mapper) {
485        String result = after(text, after);
486        if (result == null) {
487            return Optional.empty();
488        } else {
489            return Optional.ofNullable(mapper.apply(result));
490        }
491    }
492
493    /**
494     * Returns the string before the given token
495     *
496     * @param text the text
497     * @param before the token
498     * @return the text before the token, or <tt>null</tt> if text does not
499     *         contain the token
500     */
501    public static String before(String text, String before) {
502        int pos = text.indexOf(before);
503        return pos == -1 ? null : text.substring(0, pos);
504    }
505
506    /**
507     * Returns an object before the given token
508     *
509     * @param text  the text
510     * @param before the token
511     * @param mapper a mapping function to convert the string before the token to type T
512     * @return an Optional describing the result of applying a mapping function to the text before the token.
513     */
514    public static <T> Optional<T> before(String text, String before, Function<String, T> mapper) {
515        String result = before(text, before);
516        if (result == null) {
517            return Optional.empty();
518        } else {
519            return Optional.ofNullable(mapper.apply(result));
520        }
521    }
522
523    /**
524     * Returns the string between the given tokens
525     *
526     * @param text  the text
527     * @param after the before token
528     * @param before the after token
529     * @return the text between the tokens, or <tt>null</tt> if text does not contain the tokens
530     */
531    public static String between(String text, String after, String before) {
532        text = after(text, after);
533        if (text == null) {
534            return null;
535        }
536        return before(text, before);
537    }
538
539    /**
540     * Returns an object between the given token
541     *
542     * @param text  the text
543     * @param after the before token
544     * @param before the after token
545     * @param mapper a mapping function to convert the string between the token to type T
546     * @return an Optional describing the result of applying a mapping function to the text between the token.
547     */
548    public static <T> Optional<T> between(String text, String after, String before, Function<String, T> mapper) {
549        String result = between(text, after, before);
550        if (result == null) {
551            return Optional.empty();
552        } else {
553            return Optional.ofNullable(mapper.apply(result));
554        }
555    }
556
557    /**
558     * Returns the string between the most outer pair of tokens
559     * <p/>
560     * The number of token pairs must be evenly, eg there must be same number of before and after tokens, otherwise <tt>null</tt> is returned
561     * <p/>
562     * This implementation skips matching when the text is either single or double quoted.
563     * For example:
564     * <tt>${body.matches("foo('bar')")</tt>
565     * Will not match the parenthesis from the quoted text.
566     *
567     * @param text  the text
568     * @param after the before token
569     * @param before the after token
570     * @return the text between the outer most tokens, or <tt>null</tt> if text does not contain the tokens
571     */
572    public static String betweenOuterPair(String text, char before, char after) {
573        if (text == null) {
574            return null;
575        }
576
577        int pos = -1;
578        int pos2 = -1;
579        int count = 0;
580        int count2 = 0;
581
582        boolean singleQuoted = false;
583        boolean doubleQuoted = false;
584        for (int i = 0; i < text.length(); i++) {
585            char ch = text.charAt(i);
586            if (!doubleQuoted && ch == '\'') {
587                singleQuoted = !singleQuoted;
588            } else if (!singleQuoted && ch == '\"') {
589                doubleQuoted = !doubleQuoted;
590            }
591            if (singleQuoted || doubleQuoted) {
592                continue;
593            }
594
595            if (ch == before) {
596                count++;
597            } else if (ch == after) {
598                count2++;
599            }
600
601            if (ch == before && pos == -1) {
602                pos = i;
603            } else if (ch == after) {
604                pos2 = i;
605            }
606        }
607
608        if (pos == -1 || pos2 == -1) {
609            return null;
610        }
611
612        // must be even paris
613        if (count != count2) {
614            return null;
615        }
616
617        return text.substring(pos + 1, pos2);
618    }
619
620    /**
621     * Returns an object between the most outer pair of tokens
622     *
623     * @param text  the text
624     * @param after the before token
625     * @param before the after token
626     * @param mapper a mapping function to convert the string between the most outer pair of tokens to type T
627     * @return an Optional describing the result of applying a mapping function to the text between the most outer pair of tokens.
628     */
629    public static <T> Optional<T> betweenOuterPair(String text, char before, char after, Function<String, T> mapper) {
630        String result = betweenOuterPair(text, before, after);
631        if (result == null) {
632            return Optional.empty();
633        } else {
634            return Optional.ofNullable(mapper.apply(result));
635        }
636    }
637
638    /**
639     * Returns true if the given name is a valid java identifier
640     */
641    public static boolean isJavaIdentifier(String name) {
642        if (name == null) {
643            return false;
644        }
645        int size = name.length();
646        if (size < 1) {
647            return false;
648        }
649        if (Character.isJavaIdentifierStart(name.charAt(0))) {
650            for (int i = 1; i < size; i++) {
651                if (!Character.isJavaIdentifierPart(name.charAt(i))) {
652                    return false;
653                }
654            }
655            return true;
656        }
657        return false;
658    }
659
660    /**
661     * Cleans the string to a pure Java identifier so we can use it for loading class names.
662     * <p/>
663     * Especially from Spring DSL people can have \n \t or other characters that otherwise
664     * would result in ClassNotFoundException
665     *
666     * @param name the class name
667     * @return normalized classname that can be load by a class loader.
668     */
669    public static String normalizeClassName(String name) {
670        StringBuilder sb = new StringBuilder(name.length());
671        for (char ch : name.toCharArray()) {
672            if (ch == '.' || ch == '[' || ch == ']' || ch == '-' || Character.isJavaIdentifierPart(ch)) {
673                sb.append(ch);
674            }
675        }
676        return sb.toString();
677    }
678
679    /**
680     * Compares old and new text content and report back which lines are changed
681     *
682     * @param oldText  the old text
683     * @param newText  the new text
684     * @return a list of line numbers that are changed in the new text
685     */
686    public static List<Integer> changedLines(String oldText, String newText) {
687        if (oldText == null || oldText.equals(newText)) {
688            return Collections.emptyList();
689        }
690
691        List<Integer> changed = new ArrayList<>();
692
693        String[] oldLines = oldText.split("\n");
694        String[] newLines = newText.split("\n");
695
696        for (int i = 0; i < newLines.length; i++) {
697            String newLine = newLines[i];
698            String oldLine = i < oldLines.length ? oldLines[i] : null;
699            if (oldLine == null) {
700                changed.add(i);
701            } else if (!newLine.equals(oldLine)) {
702                changed.add(i);
703            }
704        }
705
706        return changed;
707    }
708
709    /**
710     * Removes the leading and trailing whitespace and if the resulting
711     * string is empty returns {@code null}. Examples:
712     * <p>
713     * Examples:
714     * <blockquote><pre>
715     * trimToNull("abc") -> "abc"
716     * trimToNull(" abc") -> "abc"
717     * trimToNull(" abc ") -> "abc"
718     * trimToNull(" ") -> null
719     * trimToNull("") -> null
720     * </pre></blockquote>
721     */
722    public static String trimToNull(final String given) {
723        if (given == null) {
724            return null;
725        }
726
727        final String trimmed = given.trim();
728
729        if (trimmed.isEmpty()) {
730            return null;
731        }
732
733        return trimmed;
734    }
735    
736    /**
737     * Checks if the src string contains what
738     *
739     * @param src  is the source string to be checked
740     * @param what is the string which will be looked up in the src argument 
741     * @return true/false
742     */
743    public static boolean containsIgnoreCase(String src, String what) {
744        if (src == null || what == null) {
745            return false;
746        }
747        
748        final int length = what.length();
749        if (length == 0) {
750            return true; // Empty string is contained
751        }
752
753        final char firstLo = Character.toLowerCase(what.charAt(0));
754        final char firstUp = Character.toUpperCase(what.charAt(0));
755
756        for (int i = src.length() - length; i >= 0; i--) {
757            // Quick check before calling the more expensive regionMatches() method:
758            final char ch = src.charAt(i);
759            if (ch != firstLo && ch != firstUp) {
760                continue;
761            }
762
763            if (src.regionMatches(true, i, what, 0, length)) {
764                return true;
765            }
766        }
767
768        return false;
769    }
770
771    /**
772     * Outputs the bytes in human readable format in units of KB,MB,GB etc.
773     *
774     * @param locale The locale to apply during formatting. If l is {@code null} then no localization is applied.
775     * @param bytes number of bytes
776     * @return human readable output
777     * @see java.lang.String#format(Locale, String, Object...)
778     */
779    public static String humanReadableBytes(Locale locale, long bytes) {
780        int unit = 1024;
781        if (bytes < unit) {
782            return bytes + " B";
783        }
784        int exp = (int) (Math.log(bytes) / Math.log(unit));
785        String pre = "KMGTPE".charAt(exp - 1) + "";
786        return String.format(locale, "%.1f %sB", bytes / Math.pow(unit, exp), pre);
787    }
788
789    /**
790     * Outputs the bytes in human readable format in units of KB,MB,GB etc.
791     *
792     * The locale always used is the one returned by {@link java.util.Locale#getDefault()}. 
793     *
794     * @param bytes number of bytes
795     * @return human readable output
796     * @see org.apache.camel.util.StringHelper#humanReadableBytes(Locale, long)
797     */
798    public static String humanReadableBytes(long bytes) {
799        return humanReadableBytes(Locale.getDefault(), bytes);
800    }
801
802    /**
803     * Check for string pattern matching with a number of strategies in the
804     * following order:
805     *
806     * - equals
807     * - null pattern always matches
808     * - * always matches
809     * - Ant style matching
810     * - Regexp
811     *
812     * @param pattern the pattern
813     * @param target the string to test
814     * @return true if target matches the pattern
815     */
816    public static boolean matches(String pattern, String target) {
817        if (Objects.equals(pattern, target)) {
818            return true;
819        }
820
821        if (Objects.isNull(pattern)) {
822            return true;
823        }
824
825        if (Objects.equals("*", pattern)) {
826            return true;
827        }
828
829        if (AntPathMatcher.INSTANCE.match(pattern, target)) {
830            return true;
831        }
832
833        Pattern p = Pattern.compile(pattern);
834        Matcher m = p.matcher(target);
835
836        return m.matches();
837    }
838
839    public static String camelCaseToDash(String text) {
840        StringBuilder answer = new StringBuilder();
841
842        Character prev = null;
843        Character next = null;
844        char[] arr = text.toCharArray();
845        for (int i = 0; i < arr.length; i++) {
846            char ch = arr[i];
847            if (i < arr.length - 1) {
848                next = arr[i + 1];
849            } else {
850                next = null;
851            }
852            if (ch == '-' || ch == '_') {
853                answer.append("-");
854            } else if (Character.isUpperCase(ch) && prev != null && !Character.isUpperCase(prev)) {
855                answer.append("-").append(ch);
856            } else if (Character.isUpperCase(ch) && prev != null && next != null && Character.isLowerCase(next)) {
857                answer.append("-").append(ch);
858            } else {
859                answer.append(ch);
860            }
861            prev = ch;
862        }
863        
864        return answer.toString().toLowerCase(Locale.ENGLISH);
865    }
866
867}