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