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