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, false, 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    /**
182     * Get a valid attribute key for the given syntax. If the key is not valid, it will be coerced into a valid key.
183     * @param key the original attribute key
184     * @param syntax HTML or XML
185     * @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.
186     */
187    @Nullable public static String getValidKey(String key, Syntax syntax) {
188        if (syntax == Syntax.xml && !xmlKeyValid.matcher(key).matches()) {
189            key = xmlKeyReplace.matcher(key).replaceAll("_");
190            return xmlKeyValid.matcher(key).matches() ? key : null; // null if could not be coerced
191        }
192        else if (syntax == Syntax.html && !htmlKeyValid.matcher(key).matches()) {
193            key = htmlKeyReplace.matcher(key).replaceAll("_");
194            return htmlKeyValid.matcher(key).matches() ? key : null; // null if could not be coerced
195        }
196        return key;
197    }
198
199    /**
200     Get the string representation of this attribute, implemented as {@link #html()}.
201     @return string
202     */
203    @Override
204    public String toString() {
205        return html();
206    }
207
208    /**
209     * Create a new Attribute from an unencoded key and a HTML attribute encoded value.
210     * @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars.
211     * @param encodedValue HTML attribute encoded value
212     * @return attribute
213     */
214    public static Attribute createFromEncoded(String unencodedKey, String encodedValue) {
215        String value = Entities.unescape(encodedValue, true);
216        return new Attribute(unencodedKey, value, null); // parent will get set when Put
217    }
218
219    protected boolean isDataAttribute() {
220        return isDataAttribute(key);
221    }
222
223    protected static boolean isDataAttribute(String key) {
224        return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length();
225    }
226
227    /**
228     * Collapsible if it's a boolean attribute and value is empty or same as name
229     * 
230     * @param out output settings
231     * @return  Returns whether collapsible or not
232     */
233    protected final boolean shouldCollapseAttribute(Document.OutputSettings out) {
234        return shouldCollapseAttribute(key, val, out);
235    }
236
237    // collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others
238    protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) {
239        return (
240            out.syntax() == Syntax.html &&
241                (val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key)));
242    }
243
244    /**
245     * Checks if this attribute name is defined as a boolean attribute in HTML5
246     */
247    public static boolean isBooleanAttribute(final String key) {
248        return Arrays.binarySearch(booleanAttributes, Normalizer.lowerCase(key)) >= 0;
249    }
250
251    @Override
252    public boolean equals(@Nullable Object o) { // note parent not considered
253        if (this == o) return true;
254        if (o == null || getClass() != o.getClass()) return false;
255        Attribute attribute = (Attribute) o;
256        if (key != null ? !key.equals(attribute.key) : attribute.key != null) return false;
257        return val != null ? val.equals(attribute.val) : attribute.val == null;
258    }
259
260    @Override
261    public int hashCode() { // note parent not considered
262        int result = key != null ? key.hashCode() : 0;
263        result = 31 * result + (val != null ? val.hashCode() : 0);
264        return result;
265    }
266
267    @Override
268    public Attribute clone() {
269        try {
270            return (Attribute) super.clone();
271        } catch (CloneNotSupportedException e) {
272            throw new RuntimeException(e);
273        }
274    }
275}