Repository: jhy/jsoup
Pull request #2369 (2025-07-10 02:34:55)

Exclude internals from Attributes#size() and empty() counts


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 1e77856a..b12d9d72 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -8,6 +8,7 @@
 ### Improvements
 * When pretty-printing, if there are consecutive text nodes (via DOM manipulation), the non-significant whitespace between them will be collapsed. [#2349](https://github.com/jhy/jsoup/pull/2349).
 * Updated `Connection.Response#statusMessage()` to return a simple loggable string message (e.g. "OK") when using the `HttpClient` implementation, which doesn't otherwise return any server-set status message. [#2356](https://github.com/jhy/jsoup/issues/2346) 
+* `Attributes#size()` and `Attributes#isEmpty()` now exclude any internal attributes (such as user data) from their count. This aligns with the attributes' serialized output and iterator. [#2369](https://github.com/jhy/jsoup/pull/2369)
 
 ### Bug Fixes
 * When parsing from an InputStream and a multibyte character happened to straddle a buffer boundary, the stream would not be completely read. [#2353](https://github.com/jhy/jsoup/issues/2353).
diff --git a/src/main/java/org/jsoup/nodes/Attributes.java b/src/main/java/org/jsoup/nodes/Attributes.java
index 342afb51..b08242c7 100644
--- a/src/main/java/org/jsoup/nodes/Attributes.java
+++ b/src/main/java/org/jsoup/nodes/Attributes.java
@@ -41,22 +41,20 @@ import static org.jsoup.nodes.Range.AttributeRange.UntrackedAttr;
  * @author Jonathan Hedley, jonathan@hedley.net
  */
 public class Attributes implements Iterable<Attribute>, Cloneable {
-    // Indicates an internal key. Can't be set via HTML. (It could be set via accessor, but not too worried about
-    // that. Suppressed from list, iter.)
-    static final char InternalPrefix = '/';
-
     // The Attributes object is only created on the first use of an attribute; the Element will just have a null
     // Attribute slot otherwise
-    protected static final String dataPrefix = "data-";
-    private static final int InitialCapacity = 3; // sampling found mean count when attrs present = 1.49; 1.08 overall. 2.6:1 don't have any attrs.
+
+    static final char InternalPrefix = '/'; // Indicates an internal key. Can't be set via HTML. (It could be set via accessor, but not too worried about that. Suppressed from list, iter, size.)
+    protected static final String dataPrefix = "data-"; // data attributes
+    private static final String EmptyString = "";
 
     // manages the key/val arrays
+    private static final int InitialCapacity = 3; // sampling found mean count when attrs present = 1.49; 1.08 overall. 2.6:1 don't have any attrs.
     private static final int GrowthFactor = 2;
     static final int NotFound = -1;
-    private static final String EmptyString = "";
 
     // the number of instance fields is kept as low as possible giving an object size of 24 bytes
-    private int size = 0; // number of slots used (not total capacity, which is keys.length)
+    int size = 0; // number of slots used (not total capacity, which is keys.length). Package visible for actual size (incl internal)
     @Nullable String[] keys = new String[InitialCapacity]; // keys is not null, but contents may be. Same for vals
     @Nullable Object[] vals = new Object[InitialCapacity]; // Genericish: all non-internal attribute values must be Strings and are cast on access.
     // todo - make keys iterable without creating Attribute objects
@@ -328,20 +326,27 @@ public class Attributes implements Iterable<Attribute>, Cloneable {
     }
 
     /**
-     Get the number of attributes in this set, including any jsoup internal-only attributes. Internal attributes are
-     excluded from the {@link #html()}, {@link #asList()}, and {@link #iterator()} methods.
+     Get the number of attributes in this set, excluding any internal-only attributes (e.g. user data).
+     <p>Internal attributes are excluded from the {@link #html()}, {@link #asList()}, and {@link #iterator()}
+     methods.</p>
+
      @return size
      */
     public int size() {
-        return size;
-        // todo - exclude internal attributes from this count - maintain size, count of internals
+        if (size == 0) return 0;
+        int count = 0;
+        for (int i = 0; i < size; i++) {
+            if (!isInternalKey(keys[i]))  count++;
+        }
+        return count;
     }
 
     /**
-     * Test if this Attributes list is empty (size==0).
+     Test if this Attributes list is empty.
+     <p>This does not include internal attributes, such as user data.</p>
      */
     public boolean isEmpty() {
-        return size == 0;
+        return size() == 0;
     }
 
     /**
@@ -349,9 +354,9 @@ public class Attributes implements Iterable<Attribute>, Cloneable {
      @param incoming attributes to add to these attributes.
      */
     public void addAll(Attributes incoming) {
-        if (incoming.size() == 0)
-            return;
-        checkCapacity(size + incoming.size);
+        int incomingSize = incoming.size(); // not adding internal
+        if (incomingSize == 0) return;
+        checkCapacity(size + incomingSize);
 
         boolean needsPut = size != 0; // if this set is empty, no need to check existing set, so can add() vs put()
         // (and save bashing on the indexOfKey()
@@ -585,8 +590,7 @@ public class Attributes implements Iterable<Attribute>, Cloneable {
      * @return number of removed dupes
      */
     public int deduplicate(ParseSettings settings) {
-        if (isEmpty())
-            return 0;
+        if (size == 0) return 0;
         boolean preserve = settings.preserveAttributeCase();
         int dupes = 0;
         for (int i = 0; i < size; i++) {
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index 054ab537..36119e74 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -2013,7 +2013,7 @@ public class Element extends Node implements Iterable<Element> {
     public Element clearAttributes() {
         if (attributes != null) {
             super.clearAttributes(); // keeps internal attributes via iterator
-            if (attributes.size() == 0)
+            if (attributes.size == 0)
                 attributes = null; // only remove entirely if no internal attributes
         }
 


Test patch:
diff --git a/src/test/java/org/jsoup/nodes/AttributesTest.java b/src/test/java/org/jsoup/nodes/AttributesTest.java
index 1eb77bde..e197e92e 100644
--- a/src/test/java/org/jsoup/nodes/AttributesTest.java
+++ b/src/test/java/org/jsoup/nodes/AttributesTest.java
@@ -297,7 +297,7 @@ public class AttributesTest {
         a.put(Attributes.internalKey("last"), "example.com");
         a.remove(Attributes.internalKey("last"));
 
-        assertEquals(4, a.size());
+        assertEquals(2, a.size());
         assertEquals(2, a.asList().size()); // excluded from lists
     }
 
@@ -408,4 +408,27 @@ public class AttributesTest {
         Attributes attrs2 = span2.attributes();
         assertTrue(attrs2.isEmpty());
     }
+
+    @Test void sizeDoesNotIncludeInternal() {
+        Element el = new Element("el");
+        Attributes attrs = el.attributes();
+        assertEquals(0, attrs.size());
+        assertTrue(attrs.isEmpty());
+
+        attrs.userData("foo", "bar");
+        attrs.put(Attributes.internalKey("qux"), "bar");
+        assertEquals(0, attrs.size());
+        assertEquals(2, attrs.size);
+        assertTrue(attrs.isEmpty());
+
+        attrs.put("foo", "bar");
+        attrs.put("qux", "bar");
+        assertEquals(2, attrs.size());
+        assertEquals(4, attrs.size);
+
+        el.clearAttributes();
+        assertEquals(0, attrs.size());
+        assertEquals(2, attrs.size); // we keep the internals
+        assertTrue(attrs.isEmpty());
+    }
 }