001/*
002 * Copyright 2023 the original author or authors.
003 * <p>
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 * <p>
008 * https://www.apache.org/licenses/LICENSE-2.0
009 * <p>
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.tools.lang;
017
018import java.util.Locale;
019
020import de.cuioss.tools.string.MoreStrings;
021import lombok.experimental.UtilityClass;
022
023/**
024 * <p>
025 * Operations to assist when working with a {@link Locale}.
026 * </p>
027 *
028 * <p>
029 * This class tries to handle {@code null} input gracefully. An exception will
030 * not be thrown for a {@code null} input. Each method documents its behavior in
031 * more detail.
032 * </p>
033 *
034 * @author https://github.com/apache/commons-lang/blob/master/src/main/java/org/apache/commons/lang3/LocaleUtils.java
035 *
036 */
037@UtilityClass
038public class LocaleUtils {
039
040    private static final String INVALID_LOCALE_FORMAT = "Invalid locale format: ";
041
042    /**
043     * <p>
044     * Converts a String to a Locale.
045     * </p>
046     *
047     * <p>
048     * This method takes the string format of a locale and creates the locale object
049     * from it.
050     * </p>
051     *
052     * <pre>
053     *   LocaleUtils.toLocale("")           = new Locale("", "")
054     *   LocaleUtils.toLocale("en")         = new Locale("en", "")
055     *   LocaleUtils.toLocale("en_GB")      = new Locale("en", "GB")
056     *   LocaleUtils.toLocale("en_001")     = new Locale("en", "001")
057     *   LocaleUtils.toLocale("en_GB_xxx")  = new Locale("en", "GB", "xxx")   (#)
058     * </pre>
059     *
060     * <p>
061     * This method validates the input strictly. The language code must be
062     * lowercase. The country code must be uppercase. The separator must be an
063     * underscore. The length must be correct.
064     * </p>
065     *
066     * @param str the locale String to convert, null returns null
067     * @return a Locale, null if null input
068     * @throws IllegalArgumentException if the string is an invalid format
069     * @see Locale#forLanguageTag(String)
070     */
071    @SuppressWarnings("squid:S3776") // owolff: Original code
072    public static Locale toLocale(final String str) {
073        if (str == null) {
074            return null;
075        }
076        if (str.isEmpty()) { // LANG-941 - JDK 8 introduced an empty locale where all fields are
077                             // blank
078            return new Locale(MoreStrings.EMPTY, MoreStrings.EMPTY);
079        }
080        if (str.contains("#")) { // LANG-879 - Cannot handle Java 7 script & extensions
081            throw new IllegalArgumentException(INVALID_LOCALE_FORMAT + str);
082        }
083        final var len = str.length();
084        if (len < 2) {
085            throw new IllegalArgumentException(INVALID_LOCALE_FORMAT + str);
086        }
087        final var ch0 = str.charAt(0);
088        if (ch0 == '_') {
089            if (len < 3) {
090                throw new IllegalArgumentException(INVALID_LOCALE_FORMAT + str);
091            }
092            final var ch1 = str.charAt(1);
093            final var ch2 = str.charAt(2);
094            if (!Character.isUpperCase(ch1) || !Character.isUpperCase(ch2)) {
095                throw new IllegalArgumentException(INVALID_LOCALE_FORMAT + str);
096            }
097            if (len == 3) {
098                return new Locale(MoreStrings.EMPTY, str.substring(1, 3));
099            }
100            if (len < 5 || str.charAt(3) != '_') {
101                throw new IllegalArgumentException(INVALID_LOCALE_FORMAT + str);
102            }
103            return new Locale(MoreStrings.EMPTY, str.substring(1, 3), str.substring(4));
104        }
105
106        return parseLocale(str);
107    }
108
109    /**
110     * Tries to parse a locale from the given String.
111     *
112     * @param str the String to parse a locale from.
113     *
114     * @return a Locale instance parsed from the given String.
115     * @throws IllegalArgumentException if the given String can not be parsed.
116     */
117    private static Locale parseLocale(final String str) {
118        if (isISO639LanguageCode(str)) {
119            return new Locale(str);
120        }
121
122        final var segments = str.split("_", -1);
123        final var language = segments[0];
124        if (segments.length == 2) {
125            final var country = segments[1];
126            if (isISO639LanguageCode(language) && isISO3166CountryCode(country) || isNumericAreaCode(country)) {
127                return new Locale(language, country);
128            }
129        } else if (segments.length == 3) {
130            final var country = segments[1];
131            final var variant = segments[2];
132            if (isISO639LanguageCode(language)
133                    && (country.isEmpty() || isISO3166CountryCode(country) || isNumericAreaCode(country))
134                    && !variant.isEmpty()) {
135                return new Locale(language, country, variant);
136            }
137        }
138        throw new IllegalArgumentException(INVALID_LOCALE_FORMAT + str);
139    }
140
141    /**
142     * Checks whether the given String is a ISO 639 compliant language code.
143     *
144     * @param str the String to check.
145     *
146     * @return true, if the given String is a ISO 639 compliant language code.
147     */
148    private static boolean isISO639LanguageCode(final String str) {
149        return MoreStrings.isAllLowerCase(str) && (str.length() == 2 || str.length() == 3);
150    }
151
152    /**
153     * Checks whether the given String is a ISO 3166 alpha-2 country code.
154     *
155     * @param str the String to check
156     *
157     * @return true, is the given String is a ISO 3166 compliant country code.
158     */
159    private static boolean isISO3166CountryCode(final String str) {
160        return MoreStrings.isAllUpperCase(str) && str.length() == 2;
161    }
162
163    /**
164     * Checks whether the given String is a UN M.49 numeric area code.
165     *
166     * @param str the String to check
167     *
168     * @return true, is the given String is a UN M.49 numeric area code.
169     */
170    private static boolean isNumericAreaCode(final String str) {
171        return MoreStrings.isNumeric(str) && str.length() == 3;
172    }
173}