LegacyMatching.java

package org.jsoup.internal;

import org.jsoup.internal.CssMatching.AttributeOperator;
import org.jsoup.internal.CssMatching.TokenCase;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jspecify.annotations.Nullable;

import static org.jsoup.internal.Normalizer.asciiLowerCase;
import static org.jsoup.internal.Normalizer.lowerCase;
import static org.jsoup.internal.StringUtil.trimAsciiWhitespace;

/**
 Legacy class and attribute matching, with helpers for parsing and formatting attribute selectors.
 Public for access between jsoup packages; not a supported user API.
 Scheduled for removal with legacy query mode in 1.26.1.
 */
public final class LegacyMatching {
    private LegacyMatching() {
    }

    /**
     Detects a trailing {@code i} or {@code s} that could be parsed as an attribute-value case flag.
     */
    public static boolean hasAttributeFlag(String attribute) {
        String trimmed = trimAsciiWhitespace(attribute);
        int end = trimmed.length() - 1;
        if (end < 1) return false;
        char before = trimmed.charAt(end - 1);
        if (!StringUtil.isWhitespace(before) && before != '\'' && before != '"') return false;
        char flag = asciiLowerCase(trimmed.charAt(end));
        return flag == 'i' || flag == 's';
    }

    /**
     Formats a legacy attribute selector, retaining quotes when needed to distinguish its value from a case flag.
     */
    public static String attributeString(String key, String value, String operator, char quote) {
        String output = value;
        if (hasAttributeFlag(value)) {
            char delimiter = quote != 0 ? quote : value.indexOf('"') >= 0 ? '\'' : '"';
            output = delimiter + value + delimiter;
        }
        return String.format("[%s%s%s]", key, operator, output);
    }

    /**
     Gets an attribute value ignoring name case, including the {@code abs:} prefix.
     */
    public static @Nullable String attribute(Node node, String key) {
        key = asciiLowerCase(key);
        return node.hasAttr(key) ? node.attr(key) : null;
    }

    /**
     Matches an attribute using legacy case and empty-value rules.
     The comparison value must already be lower case.
     */
    public static boolean attributeMatches(Node node, String key, String value, AttributeOperator operator) {
        if (operator == AttributeOperator.NotEquals) return !value.equalsIgnoreCase(node.attr(key));
        String actual = attribute(node, key);
        if (actual == null) return false;
        switch (operator) {
            case Equals:
                return value.equalsIgnoreCase(actual);
            case StartsWith:
                return lowerCase(actual).startsWith(value);
            case EndsWith:
                return lowerCase(actual).endsWith(value);
            case Contains:
                return lowerCase(actual).contains(value);
            default:
                throw new IllegalArgumentException("Unknown legacy attribute operator: " + operator);
        }
    }

    /**
     Matches CSS attribute operators with legacy name lookup and default case rules.
     */
    public static boolean cssAttributeMatches(Node node, String key, String value, AttributeOperator operator,
                                              CssMatching.CaseSensitivity flag) {
        String actual = attribute(node, key);
        if (flag == CssMatching.CaseSensitivity.Default) {
            actual = actual == null ? null : lowerCase(actual);
            value = lowerCase(value);
        }
        return CssMatching.matchesAttributeValue(actual, value, operator, flag == CssMatching.CaseSensitivity.Insensitive);
    }

    /**
     Matches class names ignoring Unicode case. Also preserves {@code hasClass("a b")} matching {@code class="a b"}.
     */
    public static boolean hasClass(Element element, String name) {
        if (element.attributesSize() == 0 || name.isEmpty()) return false;
        String classes = element.attributes().getIgnoreCase("class");
        int len = classes.length(), wantLen = name.length();
        if (len < wantLen) return false;
        if (len == wantLen) return name.equalsIgnoreCase(classes);
        return CssMatching.matchesToken(classes, name, TokenCase.UnicodeInsensitive);
    }

}