CharacterNameQuery.java

// © 2026 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html

package com.ibm.icu.impl;

/**
 * For a character name {@code name}, a query string {@code query} matches {@code name} under
 * UAX44-LM2 if and only if the following returns true:
 *
 * <pre>
 *     final var matcher = new CharacterNameQuery(query).matcher();
 *     for (final char c : name) {
 *         if (!matcher.consistentWith(c)) {
 *             return false;
 *         }
 *     }
 *
 *     return matcher.matches();
 * </pre>
 *
 * {@code name} must be an exact character name, in particular, all uppercase, with no underscores,
 * no double hyphens, no isolated hyphens. There are no constraints on {@code query}; for instance,
 * this class will match name="ZANABAZAR SQUARE LETTER -A" with query="Zanabazar-square_letter_-A".
 * {@code Matcher::consistentWith} is not retryable: once it returns false, the {@code Matcher}
 * should not be used again.
 */
class CharacterNameQuery {
    static class Matcher {
        private Matcher(CharacterNameQuery query) {
            this.query = query;
            this.skeletonIterator = 0;
        }

        boolean consistentWith(char c) {
            // Instead of constructing a skeleton from the name as in the constructor of
            // CharacterNameQuery, we check character-by-character if the skeleton we would
            // construct from the name would be consistent with `query->skeleton`.
            // We require that this be called with characters from an actual character name, so we
            // need not worry about case or underscores here.

            if (skeletonIterator == query.skeleton.length()) {
                return false;
            }
            if (c == ' ') {
                // The last hyphen was word-final; check that there is a corresponding significant
                // hyphen in the skeleton.  A hyphen in a character name cannot be both word-final
                // and word-initial by rule R3 in
                // https://unicode.org/versions/Unicode17.0.0/core-spec/chapter-4/#G135165, so we do
                // not have to worry about checking the skeleton for the same significant hyphen
                // twice.
                if (lastChar == '-') {
                    if (query.skeleton.charAt(skeletonIterator++) != lastChar) {
                        return false;
                    }
                }
                lastChar = c;
                return true;
            } else if (c == '-') {
                if (lastChar == ' '
                        || (query.is1180 && skeletonIterator == query.skeleton.length() - 2)) {
                    // If lastChar == ' ', this is a word-initial hyphen, so we know it is
                    // significant.  Check that we are expecting it.
                    // If we are looking for U+1180 and we matched everything but the trailing -E,
                    // this could be that hyphen; move past it.  This could turn out to be a
                    // different character name if there is something other than E afterwards, in
                    // which case we should in principle have ignored the hyphen; but then since the
                    // suffixes will differ, we will return false anyway.
                    // For example, when searching for HANGUL JUNGSEONG O-E and checking consistency
                    // with HANGUL JUNGSEONG O-U, if we computed both skeleta and compared them,
                    // the skeleta would be HANGULJUNGSEONGO-E and HANGULJUNGSEONGOU, and the
                    // comparison would fail on - vs. U.
                    // Here, while feeding HANGUL JUNGSEONG O-U character-by-character, we assume
                    // its skeleton would be HANGULJUNGSEONGO-, and fail on E vs. U.
                    if (query.skeleton.charAt(skeletonIterator++) != c) {
                        return false;
                    }
                }
                // If lastChar is not ' ', we do not know whether this hyphen is word-final, so we
                // cannot check against the skeleton.
                lastChar = c;
                return true;
            } else {
                if (query.skeleton.charAt(skeletonIterator++) != c) {
                    return false;
                }
                lastChar = c;
                return true;
            }
        }

        boolean consistentWith(CharSequence substring) {
            for (int i = 0; i < substring.length(); ++i) {
                if (!consistentWith(substring.charAt(i))) {
                    return false;
                }
            }
            return true;
        }

        boolean matches() {
            // If a character name could end with a HYPHEN-MINUS, that HYPHEN-MINUS would be
            // significant: we would need to check that if lastChar == '-',
            // *skeletonIterator == '-', and then advance skeletonIterator.
            // However, this is disallowed by rule R3 in
            // https://unicode.org/versions/Unicode17.0.0/core-spec/chapter-4/#G135165.
            assert (lastChar != '-');
            return skeletonIterator == query.skeleton.length();
        }

        CharSequence remainingSignificantCharacters() {
            return query.skeleton.subSequence(skeletonIterator, query.skeleton.length());
        }

        private CharacterNameQuery query;
        private int skeletonIterator;
        // Initialized to ' ' so that a leading hyphen is treated like a hyphen that follows a
        // space (non-medial and thus not ignorable).  There are in fact no leading hyphens in
        // character names by rule R3 in
        // https://unicode.org/versions/Unicode17.0.0/core-spec/chapter-4/#G135165, and technically
        // we do not read the value of this variable before writing to it because the first call to
        // consistentWith never has c=' ' either by R4, but it seems cleanest to initialize lastChar
        // nonetheless.
        char lastChar = ' ';
    }

    CharacterNameQuery(CharSequence query) {
        // Construct a skeleton obtained by
        // 1. removing medial hyphens (except the one in the name of U+1180);
        // 2. removing spaces and underscores;
        // 3. uppercasing,
        // as described in https://www.unicode.org/reports/tr44/#UAX44-LM2.
        // We do all three in a single pass.
        for (int i = 0; i < query.length(); ++i) {
            assert (skeleton.length() < skeleton.capacity());
            if (skeleton.length() >= skeleton.capacity()) {
                // The caller should limit the query length appropriately; if they do not, assert,
                // and if assertions are disabled, query for the empty string (which will quickly
                // find nothing).
                skeleton.setLength(0);
                break;
            }
            if (query.charAt(i) == ' ' || query.charAt(i) == '_') {
                continue;
            }
            if (query.charAt(i) == '-') {
                boolean isMedial;
                boolean is1180MedialHyphen = false;
                if (i == 0 || i == query.length() - 1) {
                    isMedial = false;
                } else {
                    isMedial =
                            isASCIILetterOrDigit(query.charAt(i - 1))
                                    && isASCIILetterOrDigit(query.charAt(i + 1));
                }
                // A medial hyphen is the hyphen in the name of U+1180 HANGUL JUNGSEONG O-E if what
                // comes before skeletonizes to HANGULJUNGSEONGO and what comes after skeletonizes
                // to E.
                if (isMedial
                        && Character.toUpperCase(query.charAt(i + 1)) == 'E'
                        && "HANGULJUNGSEONGO".contentEquals(skeleton)) {
                    is1180MedialHyphen = true;
                    // If there is anything significant after the E, the part of the name after the
                    // hyphen does not skeletonize to E, and thus this is not U+1180.
                    // There can be no hyphens there: if there is one, it is either non-medial, or
                    // there is another letter beyond the E.  The insignificant characters are thus
                    // only spaces and underscores.
                    for (int j = i + 2; j < query.length(); ++j) {
                        if (query.charAt(j) != ' ' && query.charAt(j) != '_') {
                            is1180MedialHyphen = false;
                        }
                    }
                }
                if (!isMedial || is1180MedialHyphen) {
                    skeleton.append(query.charAt(i));
                }
            } else {
                skeleton.append(Character.toUpperCase(query.charAt(i)));
            }
        }
        // This can be true even if we never went through the is1180MedialHyphen path, e.g., if the
        // query was "HANGUL JUNGSEONG O -E".
        is1180 = "HANGULJUNGSEONGO-E".contentEquals(skeleton);
    }

    Matcher matcher() {
        return new Matcher(this);
    }

    private static boolean isASCIILetterOrDigit(char c) {
        return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
    }

    StringBuilder skeleton = new StringBuilder(120);
    boolean is1180;
}