001package org.jsoup.nodes;
002
003import org.jsoup.SerializationException;
004import org.jsoup.helper.Validate;
005import org.jsoup.internal.Normalizer;
006import org.jsoup.internal.StringUtil;
007import org.jsoup.nodes.Document.OutputSettings.Syntax;
008import org.jspecify.annotations.Nullable;
009
010import java.io.IOException;
011import java.util.Arrays;
012import java.util.Map;
013import java.util.regex.Pattern;
014
015/**
016 A single key + value attribute. (Only used for presentation.)
017 */
018public class Attribute implements Map.Entry<String, String>, Cloneable  {
019    private static final String[] booleanAttributes = {
020            "allowfullscreen", "async", "autofocus", "checked", "compact", "declare", "default", "defer", "disabled",
021            "formnovalidate", "hidden", "inert", "ismap", "itemscope", "multiple", "muted", "nohref", "noresize",
022            "noshade", "novalidate", "nowrap", "open", "readonly", "required", "reversed", "seamless", "selected",
023            "sortable", "truespeed", "typemustmatch"
024    };
025
026    private String key;
027    @Nullable private String val;
028    @Nullable Attributes parent; // used to update the holding Attributes when the key / value is changed via this interface
029
030    /**
031     * Create a new attribute from unencoded (raw) key and value.
032     * @param key attribute key; case is preserved.
033     * @param value attribute value (may be null)
034     * @see #createFromEncoded
035     */
036    public Attribute(String key, @Nullable String value) {
037        this(key, value, null);
038    }
039
040    /**
041     * Create a new attribute from unencoded (raw) key and value.
042     * @param key attribute key; case is preserved.
043     * @param val attribute value (may be null)
044     * @param parent the containing Attributes (this Attribute is not automatically added to said Attributes)
045     * @see #createFromEncoded*/
046    public Attribute(String key, @Nullable String val, @Nullable Attributes parent) {
047        Validate.notNull(key);
048        key = key.trim();
049        Validate.notEmpty(key); // trimming could potentially make empty, so validate here
050        this.key = key;
051        this.val = val;
052        this.parent = parent;
053    }
054
055    /**
056     Get the attribute key.
057     @return the attribute key
058     */
059    @Override
060    public String getKey() {
061        return key;
062    }
063
064    /**
065     Set the attribute key; case is preserved.
066     @param key the new key; must not be null
067     */
068    public void setKey(String key) {
069        Validate.notNull(key);
070        key = key.trim();
071        Validate.notEmpty(key); // trimming could potentially make empty, so validate here
072        if (parent != null) {
073            int i = parent.indexOfKey(this.key);
074            if (i != Attributes.NotFound) {
075                String oldKey = parent.keys[i];
076                parent.keys[i] = key;
077
078                // if tracking source positions, update the key in the range map
079                Map<String, Range.AttributeRange> ranges = parent.getRanges();
080                if (ranges != null) {
081                    Range.AttributeRange range = ranges.remove(oldKey);
082                    ranges.put(key, range);
083                }
084            }
085        }
086        this.key = key;
087    }
088
089    /**
090     Get the attribute value. Will return an empty string if the value is not set.
091     @return the attribute value
092     */
093    @Override
094    public String getValue() {
095        return Attributes.checkNotNull(val);
096    }
097
098    /**
099     * Check if this Attribute has a value. Set boolean attributes have no value.
100     * @return if this is a boolean attribute / attribute without a value
101     */
102    public boolean hasDeclaredValue() {
103        return val != null;
104    }
105
106    /**
107     Set the attribute value.
108     @param val the new attribute value; may be null (to set an enabled boolean attribute)
109     @return the previous value (if was null; an empty string)
110     */
111    @Override public String setValue(@Nullable String val) {
112        String oldVal = this.val;
113        if (parent != null) {
114            int i = parent.indexOfKey(this.key);
115            if (i != Attributes.NotFound) {
116                oldVal = parent.get(this.key); // trust the container more
117                parent.vals[i] = val;
118            }
119        }
120        this.val = val;
121        return Attributes.checkNotNull(oldVal);
122    }
123
124    /**
125     Get the HTML representation of this attribute; e.g. {@code href="index.html"}.
126     @return HTML
127     */
128    public String html() {
129        StringBuilder sb = StringUtil.borrowBuilder();
130        
131        try {
132                html(sb, (new Document("")).outputSettings());
133        } catch(IOException exception) {
134                throw new SerializationException(exception);
135        }
136        return StringUtil.releaseBuilder(sb);
137    }
138
139    /**
140     Get the source ranges (start to end positions) in the original input source from which this attribute's <b>name</b>
141     and <b>value</b> were parsed.
142     <p>Position tracking must be enabled prior to parsing the content.</p>
143     @return the ranges for the attribute's name and value, or {@code untracked} if the attribute does not exist or its range
144     was not tracked.
145     @see org.jsoup.parser.Parser#setTrackPosition(boolean)
146     @see Attributes#sourceRange(String)
147     @see Node#sourceRange()
148     @see Element#endSourceRange()
149     @since 1.17.1
150     */
151    public Range.AttributeRange sourceRange() {
152        if (parent == null) return Range.AttributeRange.UntrackedAttr;
153        return parent.sourceRange(key);
154    }
155
156    protected void html(Appendable accum, Document.OutputSettings out) throws IOException {
157        html(key, val, accum, out);
158    }
159
160    protected static void html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException {
161        key = getValidKey(key, out.syntax());
162        if (key == null) return; // can't write it :(
163        htmlNoValidate(key, val, accum, out);
164    }
165
166    static void htmlNoValidate(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException {
167        // structured like this so that Attributes can check we can write first, so it can add whitespace correctly
168        accum.append(key);
169        if (!shouldCollapseAttribute(key, val, out)) {
170            accum.append("=\"");
171            Entities.escape(accum, Attributes.checkNotNull(val) , out, true, false, false, false);
172            accum.append('"');
173        }
174    }
175
176    private static final Pattern xmlKeyValid = Pattern.compile("[a-zA-Z_:][-a-zA-Z0-9_:.]*");
177    private static final Pattern xmlKeyReplace = Pattern.compile("[^-a-zA-Z0-9_:.]");
178    private static final Pattern htmlKeyValid = Pattern.compile("[^\\x00-\\x1f\\x7f-\\x9f \"'/=]+");
179    private static final Pattern htmlKeyReplace = Pattern.compile("[\\x00-\\x1f\\x7f-\\x9f \"'/=]");
180
181    @Nullable public static String getValidKey(String key, Syntax syntax) {
182        // we consider HTML attributes to always be valid. XML checks key validity
183        if (syntax == Syntax.xml && !xmlKeyValid.matcher(key).matches()) {
184            key = xmlKeyReplace.matcher(key).replaceAll("");
185            return xmlKeyValid.matcher(key).matches() ? key : null; // null if could not be coerced
186        }
187        else if (syntax == Syntax.html && !htmlKeyValid.matcher(key).matches()) {
188            key = htmlKeyReplace.matcher(key).replaceAll("");
189            return htmlKeyValid.matcher(key).matches() ? key : null; // null if could not be coerced
190        }
191        return key;
192    }
193
194    /**
195     Get the string representation of this attribute, implemented as {@link #html()}.
196     @return string
197     */
198    @Override
199    public String toString() {
200        return html();
201    }
202
203    /**
204     * Create a new Attribute from an unencoded key and a HTML attribute encoded value.
205     * @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars.
206     * @param encodedValue HTML attribute encoded value
207     * @return attribute
208     */
209    public static Attribute createFromEncoded(String unencodedKey, String encodedValue) {
210        String value = Entities.unescape(encodedValue, true);
211        return new Attribute(unencodedKey, value, null); // parent will get set when Put
212    }
213
214    protected boolean isDataAttribute() {
215        return isDataAttribute(key);
216    }
217
218    protected static boolean isDataAttribute(String key) {
219        return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length();
220    }
221
222    /**
223     * Collapsible if it's a boolean attribute and value is empty or same as name
224     * 
225     * @param out output settings
226     * @return  Returns whether collapsible or not
227     */
228    protected final boolean shouldCollapseAttribute(Document.OutputSettings out) {
229        return shouldCollapseAttribute(key, val, out);
230    }
231
232    // collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others
233    protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) {
234        return (
235            out.syntax() == Syntax.html &&
236                (val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key)));
237    }
238
239    /**
240     * Checks if this attribute name is defined as a boolean attribute in HTML5
241     */
242    public static boolean isBooleanAttribute(final String key) {
243        return Arrays.binarySearch(booleanAttributes, Normalizer.lowerCase(key)) >= 0;
244    }
245
246    @Override
247    public boolean equals(@Nullable Object o) { // note parent not considered
248        if (this == o) return true;
249        if (o == null || getClass() != o.getClass()) return false;
250        Attribute attribute = (Attribute) o;
251        if (key != null ? !key.equals(attribute.key) : attribute.key != null) return false;
252        return val != null ? val.equals(attribute.val) : attribute.val == null;
253    }
254
255    @Override
256    public int hashCode() { // note parent not considered
257        int result = key != null ? key.hashCode() : 0;
258        result = 31 * result + (val != null ? val.hashCode() : 0);
259        return result;
260    }
261
262    @Override
263    public Attribute clone() {
264        try {
265            return (Attribute) super.clone();
266        } catch (CloneNotSupportedException e) {
267            throw new RuntimeException(e);
268        }
269    }
270}