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        htmlNoValidate(key, val, accum, out);
195    }
196
197    /** @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. */
198    @Deprecated
199    protected void html(Appendable accum, Document.OutputSettings out) throws IOException {
200        html(key, val, accum, out);
201    }
202
203    /** @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. */
204    @Deprecated
205    protected static void html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException {
206        html(key, val, QuietAppendable.wrap(accum), out);
207    }
208
209    static void htmlNoValidate(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) {
210        // structured like this so that Attributes can check we can write first, so it can add whitespace correctly
211        accum.append(key);
212        if (!shouldCollapseAttribute(key, val, out)) {
213            accum.append("=\"");
214            Entities.escape(accum, Attributes.checkNotNull(val), out, Entities.ForAttribute); // preserves whitespace
215            accum.append('"');
216        }
217    }
218
219    private static final Pattern xmlKeyReplace = Pattern.compile("[^-a-zA-Z0-9_:.]+");
220    private static final Pattern htmlKeyReplace = Pattern.compile("[\\x00-\\x1f\\x7f-\\x9f \"'/=]+");
221
222    /**
223     Get a valid key for the output syntax. Invalid character runs are replaced with {@code _}, and XML keys with
224     invalid starts are prefixed with {@code _}.
225
226     @param key    the input key
227     @param syntax HTML or XML
228     @return a valid key for the output syntax
229     */
230    public static String getValidKey(String key, Syntax syntax) {
231        if (key.isEmpty()) return "_";
232        if (syntax == Syntax.xml && !isValidXmlKey(key)) {
233            key = xmlKeyReplace.matcher(key).replaceAll("_");
234            if (!isValidXmlKeyStart(key.charAt(0)))
235                key = StringUtil.concat('_', key);
236        } else if (syntax == Syntax.html && !isValidHtmlKey(key)) {
237            key = htmlKeyReplace.matcher(key).replaceAll("_");
238        }
239        return key;
240    }
241
242    // perf critical in html() so using manual scan vs regex:
243    // note that we aren't using anything in supplemental space, so OK to iter charAt
244    private static boolean isValidXmlKey(String key) {
245        // =~ [a-zA-Z_:][-a-zA-Z0-9_:.]*
246        final int length = key.length();
247        if (length == 0) return false;
248        char c = key.charAt(0);
249        if (!isValidXmlKeyStart(c)) return false;
250        for (int i = 1; i < length; i++) {
251            c = key.charAt(i);
252            if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ':' || c == '.'))
253                return false;
254        }
255        return true;
256    }
257
258    /** Check that the character can start an XML name */
259    private static boolean isValidXmlKeyStart(char c) {
260        return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == ':';
261    }
262
263    private static boolean isValidHtmlKey(String key) {
264        // =~ [\x00-\x1f\x7f-\x9f "'/=]+
265        final int length = key.length();
266        if (length == 0) return false;
267        for (int i = 0; i < length; i++) {
268            char c = key.charAt(i);
269            if ((c <= 0x1f) || (c >= 0x7f && c <= 0x9f) || c == ' ' || c == '"' || c == '\'' || c == '/' || c == '=')
270                return false;
271        }
272        return true;
273    }
274
275    /**
276     Get the string representation of this attribute, implemented as {@link #html()}.
277     @return string
278     */
279    @Override
280    public String toString() {
281        return html();
282    }
283
284    /**
285     * Create a new Attribute from an unencoded key and a HTML attribute encoded value.
286     * @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars.
287     * @param encodedValue HTML attribute encoded value
288     * @return attribute
289     */
290    public static Attribute createFromEncoded(String unencodedKey, String encodedValue) {
291        String value = Entities.unescape(encodedValue, true);
292        return new Attribute(unencodedKey, value, null); // parent will get set when Put
293    }
294
295    protected boolean isDataAttribute() {
296        return isDataAttribute(key);
297    }
298
299    protected static boolean isDataAttribute(String key) {
300        return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length();
301    }
302
303    /**
304     * Collapsible if it's a boolean attribute and value is empty or same as name
305     * 
306     * @param out output settings
307     * @return  Returns whether collapsible or not
308     * @deprecated internal method; use {@link #shouldCollapseAttribute(String, String, Document.OutputSettings)} instead. Will be removed in jsoup 1.24.1.
309     */
310    @Deprecated
311    protected final boolean shouldCollapseAttribute(Document.OutputSettings out) {
312        return shouldCollapseAttribute(key, val, out);
313    }
314
315    // collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others
316    protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) {
317        return (out.syntax() == Syntax.html &&
318                (val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key)));
319    }
320
321    /**
322     * Checks if this attribute name is defined as a boolean attribute in HTML5
323     */
324    public static boolean isBooleanAttribute(final String key) {
325        return Arrays.binarySearch(booleanAttributes, Normalizer.lowerCase(key)) >= 0;
326    }
327
328    @Override
329    public boolean equals(@Nullable Object o) { // note parent not considered
330        if (this == o) return true;
331        if (o == null || getClass() != o.getClass()) return false;
332        Attribute attribute = (Attribute) o;
333        return Objects.equals(key, attribute.key) && Objects.equals(val, attribute.val);
334    }
335
336    @Override
337    public int hashCode() { // note parent not considered
338        return Objects.hash(key, val);
339    }
340
341    @Override
342    public Attribute clone() {
343        try {
344            return (Attribute) super.clone();
345        } catch (CloneNotSupportedException e) {
346            throw new RuntimeException(e);
347        }
348    }
349}