001package org.jsoup.nodes;
002
003import org.jsoup.helper.Validate;
004import org.jsoup.internal.Normalizer;
005import org.jsoup.internal.QuietAppendable;
006import org.jsoup.internal.SharedConstants;
007import org.jsoup.internal.StringUtil;
008import org.jsoup.nodes.Document.OutputSettings.Syntax;
009import org.jspecify.annotations.Nullable;
010
011import java.io.IOException;
012import java.util.Arrays;
013import java.util.Map;
014import java.util.Objects;
015import java.util.regex.Pattern;
016
017/**
018 A single key + value attribute. (Only used for presentation.)
019 */
020public class Attribute implements Map.Entry<String, String>, Cloneable  {
021    private static final String[] booleanAttributes = {
022            "allowfullscreen", "async", "autofocus", "checked", "compact", "declare", "default", "defer", "disabled",
023            "formnovalidate", "hidden", "inert", "ismap", "itemscope", "multiple", "muted", "nohref", "noresize",
024            "noshade", "novalidate", "nowrap", "open", "readonly", "required", "reversed", "seamless", "selected",
025            "sortable", "truespeed", "typemustmatch"
026    };
027
028    private String key;
029    @Nullable private String val;
030    @Nullable Attributes parent; // used to update the holding Attributes when the key / value is changed via this interface
031
032    /**
033     * Create a new attribute from unencoded (raw) key and value.
034     * @param key attribute key; case is preserved.
035     * @param value attribute value (may be null)
036     * @see #createFromEncoded
037     */
038    public Attribute(String key, @Nullable String value) {
039        this(key, value, null);
040    }
041
042    /**
043     * Create a new attribute from unencoded (raw) key and value.
044     * @param key attribute key; case is preserved.
045     * @param val attribute value (may be null)
046     * @param parent the containing Attributes (this Attribute is not automatically added to said Attributes)
047     * @see #createFromEncoded*/
048    public Attribute(String key, @Nullable String val, @Nullable Attributes parent) {
049        Validate.notNull(key);
050        key = key.trim();
051        Validate.notEmpty(key); // trimming could potentially make empty, so validate here
052        this.key = key;
053        this.val = val;
054        this.parent = parent;
055    }
056
057    /**
058     Get the attribute's key (aka name).
059     @return the attribute key
060     */
061    @Override
062    public String getKey() {
063        return key;
064    }
065
066    /**
067     Set the attribute key; case is preserved.
068     @param key the new key; must not be null
069     */
070    public void setKey(String key) {
071        Validate.notNull(key);
072        key = key.trim();
073        Validate.notEmpty(key); // trimming could potentially make empty, so validate here
074        if (parent != null) {
075            int i = parent.indexOfKey(this.key);
076            if (i != Attributes.NotFound) {
077                parent.keys[i] = key;
078                // Source ranges are index-aligned in the parent, so a key update keeps the same range.
079            }
080        }
081        this.key = key;
082    }
083
084    /**
085     Get the attribute value. Will return an empty string if the value is not set.
086     @return the attribute value
087     */
088    @Override
089    public String getValue() {
090        return Attributes.checkNotNull(val);
091    }
092
093    /**
094     * Check if this Attribute has a value. Set boolean attributes have no value.
095     * @return if this is a boolean attribute / attribute without a value
096     */
097    public boolean hasDeclaredValue() {
098        return val != null;
099    }
100
101    /**
102     Set the attribute value.
103     @param val the new attribute value; may be null (to set an enabled boolean attribute)
104     @return the previous value (if was null; an empty string)
105     */
106    @Override public String setValue(@Nullable String val) {
107        String oldVal = this.val;
108        if (parent != null) {
109            int i = parent.indexOfKey(this.key);
110            if (i != Attributes.NotFound) {
111                oldVal = parent.get(this.key); // trust the container more
112                parent.vals[i] = val;
113            }
114        }
115        this.val = val;
116        return Attributes.checkNotNull(oldVal);
117    }
118
119    /**
120     Get this attribute's key prefix, if it has one; else the empty string.
121     <p>For example, the attribute {@code og:title} has prefix {@code og}, and local {@code title}.</p>
122
123     @return the tag's prefix
124     @since 1.20.1
125     */
126    public String prefix() {
127        int pos = key.indexOf(':');
128        if (pos == -1) return "";
129        else return key.substring(0, pos);
130    }
131
132    /**
133     Get this attribute's local name. The local name is the name without the prefix (if any).
134     <p>For example, the attribute key {@code og:title} has local name {@code title}.</p>
135
136     @return the tag's local name
137     @since 1.20.1
138     */
139    public String localName() {
140        int pos = key.indexOf(':');
141        if (pos == -1) return key;
142        else return key.substring(pos + 1);
143    }
144
145    /**
146     Get this attribute's namespace URI, if the attribute was prefixed with a defined namespace name. Otherwise, returns
147     the empty string. These will only be defined if using the XML parser.
148     @return the tag's namespace URI, or empty string if not defined
149     @since 1.20.1
150     */
151    public String namespace() {
152        // set as el.attributes.userData(SharedConstants.XmlnsAttr + prefix, ns)
153        if (parent != null) {
154            String ns = (String) parent.userData(SharedConstants.XmlnsAttr + prefix());
155            if (ns != null)
156                return ns;
157        }
158        return "";
159    }
160
161    /**
162     Get the HTML representation of this attribute; e.g. {@code href="index.html"}.
163     @return HTML
164     */
165    public String html() {
166        StringBuilder sb = StringUtil.borrowBuilder();
167        html(QuietAppendable.wrap(sb), new Document.OutputSettings());
168        return StringUtil.releaseBuilder(sb);
169    }
170
171    /**
172     Get the source ranges (start to end positions) in the original input source from which this attribute's <b>name</b>
173     and <b>value</b> were parsed.
174     <p>Position tracking must be enabled prior to parsing the content.</p>
175     @return the ranges for the attribute's name and value, or {@code untracked} if the attribute does not exist or its range
176     was not tracked.
177     @see org.jsoup.parser.Parser#setTrackPosition(boolean)
178     @see Attributes#sourceRange(String)
179     @see Node#sourceRange()
180     @see Element#endSourceRange()
181     @since 1.17.1
182     */
183    public Range.AttributeRange sourceRange() {
184        if (parent == null) return Range.AttributeRange.UntrackedAttr;
185        return parent.sourceRange(key);
186    }
187
188    void html(QuietAppendable accum, Document.OutputSettings out) {
189        html(key, val, accum, out);
190    }
191
192    static void html(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) {
193        key = getValidKey(key, out.syntax());
194        if (key == null) return; // can't write it :(
195        htmlNoValidate(key, val, accum, out);
196    }
197
198    /** @deprecated internal method; use {@link #html(String, String, QuietAppendable, Document.OutputSettings)} with {@link org.jsoup.internal.QuietAppendable#wrap(Appendable)} instead. Will be removed in jsoup 1.24.1. */
199    @Deprecated
200    protected void html(Appendable accum, Document.OutputSettings out) throws IOException {
201        html(key, val, accum, out);
202    }
203
204    /** @deprecated internal method; use {@link #html(String, String, QuietAppendable, Document.OutputSettings)} with {@link org.jsoup.internal.QuietAppendable#wrap(Appendable)} instead. Will be removed in jsoup 1.24.1. */
205    @Deprecated
206    protected static void html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException {
207        html(key, val, QuietAppendable.wrap(accum), out);
208    }
209
210    static void htmlNoValidate(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) {
211        // structured like this so that Attributes can check we can write first, so it can add whitespace correctly
212        accum.append(key);
213        if (!shouldCollapseAttribute(key, val, out)) {
214            accum.append("=\"");
215            Entities.escape(accum, Attributes.checkNotNull(val), out, Entities.ForAttribute); // preserves whitespace
216            accum.append('"');
217        }
218    }
219
220    private static final Pattern xmlKeyReplace = Pattern.compile("[^-a-zA-Z0-9_:.]+");
221    private static final Pattern htmlKeyReplace = Pattern.compile("[\\x00-\\x1f\\x7f-\\x9f \"'/=]+");
222    /**
223     * Get a valid attribute key for the given syntax. If the key is not valid, it will be coerced into a valid key.
224     * @param key the original attribute key
225     * @param syntax HTML or XML
226     * @return the original key if it's valid; a key with invalid characters replaced with "_" otherwise; or null if a valid key could not be created.
227     */
228    @Nullable public static String getValidKey(String key, Syntax syntax) {
229        if (syntax == Syntax.xml && !isValidXmlKey(key)) {
230            key = xmlKeyReplace.matcher(key).replaceAll("_");
231            return isValidXmlKey(key) ? key : null; // null if could not be coerced
232        }
233        else if (syntax == Syntax.html && !isValidHtmlKey(key)) {
234            key = htmlKeyReplace.matcher(key).replaceAll("_");
235            return isValidHtmlKey(key) ? key : null; // null if could not be coerced
236        }
237        return key;
238    }
239
240    // perf critical in html() so using manual scan vs regex:
241    // note that we aren't using anything in supplemental space, so OK to iter charAt
242    private static boolean isValidXmlKey(String key) {
243        // =~ [a-zA-Z_:][-a-zA-Z0-9_:.]*
244        final int length = key.length();
245        if (length == 0) return false;
246        char c = key.charAt(0);
247        if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == ':'))
248            return false;
249        for (int i = 1; i < length; i++) {
250            c = key.charAt(i);
251            if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ':' || c == '.'))
252                return false;
253        }
254        return true;
255    }
256
257    private static boolean isValidHtmlKey(String key) {
258        // =~ [\x00-\x1f\x7f-\x9f "'/=]+
259        final int length = key.length();
260        if (length == 0) return false;
261        for (int i = 0; i < length; i++) {
262            char c = key.charAt(i);
263            if ((c <= 0x1f) || (c >= 0x7f && c <= 0x9f) || c == ' ' || c == '"' || c == '\'' || c == '/' || c == '=')
264                return false;
265        }
266        return true;
267    }
268
269    /**
270     Get the string representation of this attribute, implemented as {@link #html()}.
271     @return string
272     */
273    @Override
274    public String toString() {
275        return html();
276    }
277
278    /**
279     * Create a new Attribute from an unencoded key and a HTML attribute encoded value.
280     * @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars.
281     * @param encodedValue HTML attribute encoded value
282     * @return attribute
283     */
284    public static Attribute createFromEncoded(String unencodedKey, String encodedValue) {
285        String value = Entities.unescape(encodedValue, true);
286        return new Attribute(unencodedKey, value, null); // parent will get set when Put
287    }
288
289    protected boolean isDataAttribute() {
290        return isDataAttribute(key);
291    }
292
293    protected static boolean isDataAttribute(String key) {
294        return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length();
295    }
296
297    /**
298     * Collapsible if it's a boolean attribute and value is empty or same as name
299     * 
300     * @param out output settings
301     * @return  Returns whether collapsible or not
302     * @deprecated internal method; use {@link #shouldCollapseAttribute(String, String, Document.OutputSettings)} instead. Will be removed in jsoup 1.24.1.
303     */
304    @Deprecated
305    protected final boolean shouldCollapseAttribute(Document.OutputSettings out) {
306        return shouldCollapseAttribute(key, val, out);
307    }
308
309    // collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others
310    protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) {
311        return (out.syntax() == Syntax.html &&
312                (val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key)));
313    }
314
315    /**
316     * Checks if this attribute name is defined as a boolean attribute in HTML5
317     */
318    public static boolean isBooleanAttribute(final String key) {
319        return Arrays.binarySearch(booleanAttributes, Normalizer.lowerCase(key)) >= 0;
320    }
321
322    @Override
323    public boolean equals(@Nullable Object o) { // note parent not considered
324        if (this == o) return true;
325        if (o == null || getClass() != o.getClass()) return false;
326        Attribute attribute = (Attribute) o;
327        return Objects.equals(key, attribute.key) && Objects.equals(val, attribute.val);
328    }
329
330    @Override
331    public int hashCode() { // note parent not considered
332        return Objects.hash(key, val);
333    }
334
335    @Override
336    public Attribute clone() {
337        try {
338            return (Attribute) super.clone();
339        } catch (CloneNotSupportedException e) {
340            throw new RuntimeException(e);
341        }
342    }
343}