Repository: jhy/jsoup
Pull request #1990 (2023-07-29 09:23:20)

Use computeIfAbsent()


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index d7559c2e..9e221e90 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,35 +1,46 @@
 # jsoup Changelog
 
-## 1.17.2 (Pending)
+## 1.18.1 (Pending)
 
 ### Improvements
 
-* Added `Element.attribute(String)` and `Attributes.attribute(String)` to more simply obtain an `Attribute` object.
-  [2069](https://github.com/jhy/jsoup/issues/2069)
-* If source tracking is on, and an Attribute's key is changed (via `Attribute.setKey(String)`), the source range is
-  now still tracked in `Attribute.sourceRange()`. [2070](https://github.com/jhy/jsoup/issues/2070)
-* Added support for the `[*]` element with any attribute selector. And also restored support for selecting by an empty
-  attribute name prefix (`[^]`). [2079](https://github.com/jhy/jsoup/issues/2079)
+* Added `Path` accepting parse methods: `Jsoup.parse(Path)`, `Jsoup.parse(path, charsetName, baseUri, parser)`,
+  etc. [2055](https://github.com/jhy/jsoup/pull/2055)
+
+---
+
+## 1.17.2 (2023-Dec-29)
+
+### Improvements
+
+* **Attribute object accessors**: Added `Element.attribute(String)` and `Attributes.attribute(String)` to more simply
+  obtain an `Attribute` object. [2069](https://github.com/jhy/jsoup/issues/2069)
+* **Attribute source tracking**: If source tracking is on, and an Attribute's key is changed (
+  via `Attribute.setKey(String)`), the source range is now still tracked
+  in `Attribute.sourceRange()`. [2070](https://github.com/jhy/jsoup/issues/2070)
+* **Wildcard attribute selector**: Added support for the `[*]` element with any attribute selector. And also restored
+  support for selecting by an empty attribute name prefix (`[^]`). [2079](https://github.com/jhy/jsoup/issues/2079)
 
 ### Bug Fixes
 
-* When tracking the source position of attributes, if source attribute name was mix-cased but the parser was
-  lower-case normalizing attribute names, the source position for that attribute was not tracked
-  correctly. [2067](https://github.com/jhy/jsoup/issues/2067)
-* When tracking the source position of a body fragment parse, a null pointer exception was
-  thrown. [2068](https://github.com/jhy/jsoup/issues/2068)
-* A multi-point encoded emoji entity may be incorrectly decoded to the replacement
+* **Mixed-cased source position**: When tracking the source position of attributes, if the source attribute name was
+  mix-cased but the parser was lower-case normalizing attribute names, the source position for that attribute was not
+  tracked correctly. [2067](https://github.com/jhy/jsoup/issues/2067)
+* **Source position NPE**: When tracking the source position of a body fragment parse, a null pointer
+  exception was thrown. [2068](https://github.com/jhy/jsoup/issues/2068)
+* **Multi-point emoji entity**: A multi-point encoded emoji entity may be incorrectly decoded to the replacement
   character. [2074](https://github.com/jhy/jsoup/issues/2074)
-* (Regression) in a selector like `parent [attr=va], other`, the `, OR` was binding to `[attr=va]` instead of
-  `parent [attr=va]`, causing incorrect selections. The fix includes a EvaluatorDebug class that generates a sexpr
-  to represent the query, allowing simpler and more thorough query parse
+* **Selector sub-expressions**: (Regression) in a selector like `parent [attr=va], other`, the `, OR` was binding
+  to `[attr=va]` instead of `parent [attr=va]`, causing incorrect selections. The fix includes a EvaluatorDebug class
+  that generates a sexpr to represent the query, allowing simpler and more thorough query parse
   tests. [2073](https://github.com/jhy/jsoup/issues/2073)
-* When generating XML-syntax output from parsed HTML, script nodes containing (pseudo) CData sections would have an
-  extraneous CData section added, causing script execution errors. Now, the data content is emitted in a HTML/XML/XHTML
-  polyglot format, if the data is not already within a CData section. [2078](https://github.com/jhy/jsoup/issues/2078)
-* The `:has` evaluator held a non-thread-safe Iterator, and so if an Evaluator object was shared across multiple
-  concurrent threads, a NoSuchElement exception may be thrown, and the selected results may be incorrect. Now, the
-  iterator object is a thread-local. [2088](https://github.com/jhy/jsoup/issues/2088)
+* **XML CData output**: When generating XML-syntax output from parsed HTML, script nodes containing (pseudo) CData
+  sections would have an extraneous CData section added, causing script execution errors. Now, the data content is
+  emitted in a HTML/XML/XHTML polyglot format, if the data is not already within a CData
+  section. [2078](https://github.com/jhy/jsoup/issues/2078)
+* **Thread safety**: The `:has` evaluator held a non-thread-safe Iterator, and so if an Evaluator object was
+  shared across multiple concurrent threads, a NoSuchElement exception may be thrown, and the selected results may be
+  incorrect. Now, the iterator object is a thread-local. [2088](https://github.com/jhy/jsoup/issues/2088)
 
 ---
 Older changes for versions 0.1.1 (2010-Jan-31) through 1.17.1 (2023-Nov-27) may be found in
diff --git a/pom.xml b/pom.xml
index d6eab402..f1945fc2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
 
   <groupId>org.jsoup</groupId>
   <artifactId>jsoup</artifactId>
-  <version>1.17.2-SNAPSHOT</version><!-- remember to update previous version below for japicmp -->
+  <version>1.18.1-SNAPSHOT</version><!-- remember to update previous version below for japicmp -->
   <url>https://jsoup.org/</url>
   <description>jsoup is a Java library that simplifies working with real-world HTML and XML. It offers an easy-to-use API for URL fetching, data parsing, extraction, and manipulation using DOM API methods, CSS, and xpath selectors. jsoup implements the WHATWG HTML5 specification, and parses HTML to the same DOM as modern browsers.</description>
   <inceptionYear>2009</inceptionYear>
@@ -88,13 +88,21 @@
                 <version>2.3.3_r2</version>
               </signature>
               <ignores>
+                <ignore>java.io.File</ignore> <!-- File#toPath() -->
+                <ignore>java.nio.file.*</ignore>
+                <ignore>java.nio.channels.SeekableByteChannel</ignore>
                 <ignore>java.util.function.*</ignore>
                 <ignore>java.util.stream.*</ignore>
+                <ignore>java.lang.Throwable</ignore> <!-- Throwable#addSuppressed(Throwable) -->
                 <ignore>java.lang.ThreadLocal</ignore>
                 <ignore>java.io.UncheckedIOException</ignore>
+                <ignore>java.util.Comparator</ignore> <!-- Comparator.comparingInt() -->
                 <ignore>java.util.List</ignore> <!-- List#stream() -->
+                <ignore>java.util.LinkedHashMap</ignore> <!-- LinkedHashMap#computeIfAbsent() -->
+                <ignore>java.util.Map</ignore> <!-- Map#computeIfAbsent() -->
                 <ignore>java.util.Objects</ignore>
                 <ignore>java.util.Optional</ignore>
+                <ignore>java.util.Set</ignore> <!-- Set#stream() -->
                 <ignore>java.util.Spliterator</ignore>
                 <ignore>java.util.Spliterators</ignore>
 
diff --git a/src/main/java/org/jsoup/Jsoup.java b/src/main/java/org/jsoup/Jsoup.java
index 29acbafb..e20311bc 100644
--- a/src/main/java/org/jsoup/Jsoup.java
+++ b/src/main/java/org/jsoup/Jsoup.java
@@ -13,6 +13,7 @@ import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URL;
+import java.nio.file.Path;
 
 /**
  The core public access point to the jsoup functionality.
@@ -183,6 +184,72 @@ Connection con3 = session.newRequest();
         return DataUtil.load(file, charsetName, baseUri, parser);
     }
 
+    /**
+     Parse the contents of a file as HTML.
+
+     @param path          file to load HTML from. Supports gzipped files (ending in .z or .gz).
+     @param charsetName (optional) character set of file contents. Set to {@code null} to determine from {@code http-equiv} meta tag, if
+     present, or fall back to {@code UTF-8} (which is often safe to do).
+     @param baseUri     The URL where the HTML was retrieved from, to resolve relative links against.
+     @return sane HTML
+
+     @throws IOException if the file could not be found, or read, or if the charsetName is invalid.
+     @since 1.18.1
+     */
+    public static Document parse(Path path, @Nullable String charsetName, String baseUri) throws IOException {
+        return DataUtil.load(path, charsetName, baseUri);
+    }
+
+    /**
+     Parse the contents of a file as HTML. The location of the file is used as the base URI to qualify relative URLs.
+
+     @param path        file to load HTML from. Supports gzipped files (ending in .z or .gz).
+     @param charsetName (optional) character set of file contents. Set to {@code null} to determine from {@code http-equiv} meta tag, if
+     present, or fall back to {@code UTF-8} (which is often safe to do).
+     @return sane HTML
+
+     @throws IOException if the file could not be found, or read, or if the charsetName is invalid.
+     @see #parse(File, String, String) parse(file, charset, baseUri)
+     @since 1.18.1
+     */
+    public static Document parse(Path path, @Nullable String charsetName) throws IOException {
+        return DataUtil.load(path, charsetName, path.toAbsolutePath().toString());
+    }
+
+    /**
+     Parse the contents of a file as HTML. The location of the file is used as the base URI to qualify relative URLs.
+     The charset used to read the file will be determined by the byte-order-mark (BOM), or a {@code <meta charset>} tag,
+     or if neither is present, will be {@code UTF-8}.
+
+     <p>This is the equivalent of calling {@link #parse(File, String) parse(file, null)}</p>
+
+     @param path the file to load HTML from. Supports gzipped files (ending in .z or .gz).
+     @return sane HTML
+     @throws IOException if the file could not be found or read.
+     @see #parse(Path, String, String) parse(file, charset, baseUri)
+     @since 1.18.1
+     */
+    public static Document parse(Path path) throws IOException {
+        return DataUtil.load(path, null, path.toAbsolutePath().toString());
+    }
+
+    /**
+     Parse the contents of a file as HTML.
+
+     @param path          file to load HTML from. Supports gzipped files (ending in .z or .gz).
+     @param charsetName (optional) character set of file contents. Set to {@code null} to determine from {@code http-equiv} meta tag, if
+     present, or fall back to {@code UTF-8} (which is often safe to do).
+     @param baseUri     The URL where the HTML was retrieved from, to resolve relative links against.
+     @param parser alternate {@link Parser#xmlParser() parser} to use.
+     @return sane HTML
+
+     @throws IOException if the file could not be found, or read, or if the charsetName is invalid.
+     @since 1.18.1
+     */
+    public static Document parse(Path path, @Nullable String charsetName, String baseUri, Parser parser) throws IOException {
+        return DataUtil.load(path, charsetName, baseUri, parser);
+    }
+
      /**
      Read an input stream, and parse it to a Document.
 
diff --git a/src/main/java/org/jsoup/helper/DataUtil.java b/src/main/java/org/jsoup/helper/DataUtil.java
index c1c79105..58f44fb7 100644
--- a/src/main/java/org/jsoup/helper/DataUtil.java
+++ b/src/main/java/org/jsoup/helper/DataUtil.java
@@ -2,7 +2,6 @@ package org.jsoup.helper;
 
 import org.jsoup.internal.ControllableInputStream;
 import org.jsoup.internal.Normalizer;
-import org.jsoup.internal.SharedConstants;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.nodes.Comment;
 import org.jsoup.nodes.Document;
@@ -16,7 +15,6 @@ import org.jspecify.annotations.Nullable;
 import java.io.BufferedReader;
 import java.io.CharArrayReader;
 import java.io.File;
-import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
@@ -25,8 +23,12 @@ import java.io.UncheckedIOException;
 import java.nio.Buffer;
 import java.nio.ByteBuffer;
 import java.nio.CharBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.SeekableByteChannel;
 import java.nio.charset.Charset;
 import java.nio.charset.IllegalCharsetNameException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Locale;
 import java.util.Random;
 import java.util.regex.Matcher;
@@ -63,7 +65,7 @@ public final class DataUtil {
      * @throws IOException on IO error
      */
     public static Document load(File file, @Nullable String charsetName, String baseUri) throws IOException {
-        return load(file, charsetName, baseUri, Parser.htmlParser());
+        return load(file.toPath(), charsetName, baseUri);
     }
 
     /**
@@ -81,18 +83,48 @@ public final class DataUtil {
      * @since 1.14.2
      */
     public static Document load(File file, @Nullable String charsetName, String baseUri, Parser parser) throws IOException {
-        InputStream stream = new FileInputStream(file);
-        String name = Normalizer.lowerCase(file.getName());
-        if (name.endsWith(".gz") || name.endsWith(".z")) {
-            // unfortunately file input streams don't support marks (why not?), so we will close and reopen after read
-            boolean zipped;
-            try {
-                zipped = (stream.read() == 0x1f && stream.read() == 0x8b); // gzip magic bytes
-            } finally {
-                stream.close();
+        return load(file.toPath(), charsetName, baseUri, parser);
+    }
+
+    /**
+     * Loads and parses a file to a Document, with the HtmlParser. Files that are compressed with gzip (and end in {@code .gz} or {@code .z})
+     * are supported in addition to uncompressed files.
+     *
+     * @param path file to load
+     * @param charsetName (optional) character set of input; specify {@code null} to attempt to autodetect. A BOM in
+     *     the file will always override this setting.
+     * @param baseUri base URI of document, to resolve relative links against
+     * @return Document
+     * @throws IOException on IO error
+     */
+    public static Document load(Path path, @Nullable String charsetName, String baseUri) throws IOException {
+        return load(path, charsetName, baseUri, Parser.htmlParser());
+    }
 
+    /**
+     * Loads and parses a file to a Document. Files that are compressed with gzip (and end in {@code .gz} or {@code .z})
+     * are supported in addition to uncompressed files.
+     *
+     * @param path file to load
+     * @param charsetName (optional) character set of input; specify {@code null} to attempt to autodetect. A BOM in
+     *     the file will always override this setting.
+     * @param baseUri base URI of document, to resolve relative links against
+     * @param parser alternate {@link Parser#xmlParser() parser} to use.
+
+     * @return Document
+     * @throws IOException on IO error
+     * @since 1.17.2
+     */
+    public static Document load(Path path, @Nullable String charsetName, String baseUri, Parser parser) throws IOException {
+        final SeekableByteChannel byteChannel = Files.newByteChannel(path);
+        InputStream stream = Channels.newInputStream(byteChannel);
+        String name = Normalizer.lowerCase(path.getFileName().toString());
+        if (name.endsWith(".gz") || name.endsWith(".z")) {
+            final boolean zipped = (stream.read() == 0x1f && stream.read() == 0x8b); // gzip magic bytes
+            byteChannel.position(0); // reset to start of file
+            if (zipped) {
+                stream = new GZIPInputStream(stream);
             }
-            stream = zipped ? new GZIPInputStream(new FileInputStream(file)) : new FileInputStream(file);
         }
         return parseInputStream(stream, charsetName, baseUri, parser);
     }
@@ -139,16 +171,15 @@ public final class DataUtil {
     static Document parseInputStream(@Nullable InputStream input, @Nullable String charsetName, String baseUri, Parser parser) throws IOException  {
         if (input == null) // empty body
             return new Document(baseUri);
-        input = ControllableInputStream.wrap(input, DefaultBufferSize, 0);
 
         @Nullable Document doc = null;
 
         // read the start of the stream and look for a BOM or meta charset
-        try {
-            input.mark(DefaultBufferSize);
-            ByteBuffer firstBytes = readToByteBuffer(input, firstReadBufferSize - 1); // -1 because we read one more to see if completed. First read is < buffer size, so can't be invalid.
-            boolean fullyRead = (input.read() == -1);
-            input.reset();
+        try (InputStream wrappedInputStream = ControllableInputStream.wrap(input, DefaultBufferSize, 0)) {
+            wrappedInputStream.mark(DefaultBufferSize);
+            ByteBuffer firstBytes = readToByteBuffer(wrappedInputStream, firstReadBufferSize - 1); // -1 because we read one more to see if completed. First read is < buffer size, so can't be invalid.
+            boolean fullyRead = (wrappedInputStream.read() == -1);
+            wrappedInputStream.reset();
 
             // look for BOM - overrides any other header or input
             BomCharset bomCharset = detectCharsetFromBom(firstBytes);
@@ -189,9 +220,8 @@ public final class DataUtil {
                         if (comment.isXmlDeclaration())
                             decl = comment.asXmlDeclaration();
                     }
-                    if (decl != null) {
-                        if (decl.name().equalsIgnoreCase("xml"))
-                            foundCharset = decl.attr("encoding");
+                    if (decl != null && decl.name().equalsIgnoreCase("xml")) {
+                        foundCharset = decl.attr("encoding");
                     }
                 }
                 foundCharset = validateCharset(foundCharset);
@@ -208,8 +238,7 @@ public final class DataUtil {
             if (doc == null) {
                 if (charsetName == null)
                     charsetName = defaultCharsetName;
-                BufferedReader reader = new BufferedReader(new InputStreamReader(input, Charset.forName(charsetName)), DefaultBufferSize); // Android level does not allow us try-with-resources
-                try {
+                try (BufferedReader reader = new BufferedReader(new InputStreamReader(wrappedInputStream, Charset.forName(charsetName)), DefaultBufferSize)) {
                     if (bomCharset != null && bomCharset.offset) { // creating the buffered reader ignores the input pos, so must skip here
                         long skipped = reader.skip(1);
                         Validate.isTrue(skipped == 1); // WTF if this fails.
@@ -227,14 +256,8 @@ public final class DataUtil {
                         doc.charset(UTF_8);
                     }
                 }
-                finally {
-                    reader.close();
-                }
             }
         }
-        finally {
-            input.close();
-        }
         return doc;
     }
 
diff --git a/src/main/java/org/jsoup/helper/HttpConnection.java b/src/main/java/org/jsoup/helper/HttpConnection.java
index fc9467ae..ef3d2024 100644
--- a/src/main/java/org/jsoup/helper/HttpConnection.java
+++ b/src/main/java/org/jsoup/helper/HttpConnection.java
@@ -5,6 +5,7 @@ import org.jsoup.HttpStatusException;
 import org.jsoup.UncheckedIOException;
 import org.jsoup.UnsupportedMimeTypeException;
 import org.jsoup.internal.ControllableInputStream;
+import org.jsoup.internal.Functions;
 import org.jsoup.internal.SharedConstants;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.nodes.Document;
@@ -39,6 +40,7 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 import java.util.regex.Pattern;
 import java.util.zip.GZIPInputStream;
 import java.util.zip.Inflater;
@@ -1109,13 +1111,8 @@ public class HttpConnection implements Connection {
                 if (key == null || val == null)
                     continue; // skip http1.1 line
 
-                if (headers.containsKey(key))
-                    headers.get(key).add(val);
-                else {
-                    final ArrayList<String> vals = new ArrayList<>();
-                    vals.add(val);
-                    headers.put(key, vals);
-                }
+                final List<String> vals = headers.computeIfAbsent(key, Functions.listFunction());
+                vals.add(val);
             }
             return headers;
         }
diff --git a/src/main/java/org/jsoup/internal/Functions.java b/src/main/java/org/jsoup/internal/Functions.java
new file mode 100644
index 00000000..40227d84
--- /dev/null
+++ b/src/main/java/org/jsoup/internal/Functions.java
@@ -0,0 +1,40 @@
+package org.jsoup.internal;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * An internal class containing functions for use with {@link Map#computeIfAbsent(Object, Function)}.
+ */
+@SuppressWarnings({"rawtypes", "unchecked"})
+public final class Functions {
+    private static final Function ListFunction = key -> new ArrayList<>();
+    private static final Function SetFunction = key -> new HashSet<>();
+    private static final Function MapFunction = key -> new HashMap<>();
+    private static final Function IdentityMapFunction = key -> new IdentityHashMap<>();
+
+    private Functions() {
+    }
+
+    public static <T, U> Function<T, List<U>> listFunction() {
+        return (Function<T, List<U>>) ListFunction;
+    }
+
+    public static <T, U> Function<T, Set<U>> setFunction() {
+        return (Function<T, Set<U>>) SetFunction;
+    }
+
+    public static <T, K, V> Function<T, Map<K, V>> mapFunction() {
+        return (Function<T, Map<K, V>>) MapFunction;
+    }
+
+    public static <T, K, V> Function<T, IdentityHashMap<K, V>> identityMapFunction() {
+        return (Function<T, IdentityHashMap<K, V>>) IdentityMapFunction;
+    }
+}
diff --git a/src/main/java/org/jsoup/internal/StringUtil.java b/src/main/java/org/jsoup/internal/StringUtil.java
index 85e104ef..3be5f7d8 100644
--- a/src/main/java/org/jsoup/internal/StringUtil.java
+++ b/src/main/java/org/jsoup/internal/StringUtil.java
@@ -10,6 +10,8 @@ import java.util.Collection;
 import java.util.Iterator;
 import java.util.Stack;
 import java.util.regex.Pattern;
+import java.util.stream.Collector;
+import java.util.stream.Collectors;
 
 /**
  A minimal String utility class. Designed for <b>internal</b> jsoup use only - the API and outcome may change without
@@ -375,6 +377,23 @@ public final class StringUtil {
         return string;
     }
 
+    /**
+     * Return a {@link Collector} similar to the one returned by {@link Collectors#joining(CharSequence)},
+     * but backed by jsoup's {@link StringJoiner}, which allows for more efficient garbage collection.
+     *
+     * @param delimiter The delimiter for separating the strings.
+     * @return A {@code Collector} which concatenates CharSequence elements, separated by the specified delimiter
+     */
+    public static Collector<CharSequence, ?, String> joining(String delimiter) {
+        return Collector.of(() -> new StringJoiner(delimiter),
+            StringJoiner::add,
+            (j1, j2) -> {
+                j1.append(j2.complete());
+                return j1;
+            },
+            StringJoiner::complete);
+    }
+
     private static final int MaxCachedBuilderSize = 8 * 1024;
     private static final int MaxIdleBuilders = 8;
 }
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index 0a63d73b..a947b2d2 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -6,6 +6,7 @@ import org.jsoup.internal.StringUtil;
 import org.jsoup.parser.ParseSettings;
 import org.jsoup.parser.Parser;
 import org.jsoup.parser.Tag;
+import org.jsoup.parser.TokenQueue;
 import org.jsoup.select.Collector;
 import org.jsoup.select.Elements;
 import org.jsoup.select.Evaluator;
@@ -935,12 +936,9 @@ public class Element extends Node {
         // Escape tagname, and translate HTML namespace ns:tag to CSS namespace syntax ns|tag
         String tagName = escapeCssIdentifier(tagName()).replace("\\:", "|");
         StringBuilder selector = StringUtil.borrowBuilder().append(tagName);
-        // String classes = StringUtil.join(classNames().stream().map(TokenQueue::escapeCssIdentifier).iterator(), ".");
-        // todo - replace with ^^ in 1.16.1 when we enable Android support for stream etc
-        StringUtil.StringJoiner escapedClasses = new StringUtil.StringJoiner(".");
-        for (String name : classNames()) escapedClasses.add(escapeCssIdentifier(name));
-        String classes = escapedClasses.complete();
-        if (classes.length() > 0)
+        String classes = classNames().stream().map(TokenQueue::escapeCssIdentifier)
+                .collect(StringUtil.joining("."));
+        if (!classes.isEmpty())
             selector.append('.').append(classes);
 
         if (parent() == null || parent() instanceof Document) // don't add Document to selector, as will always have a html node
diff --git a/src/main/java/org/jsoup/safety/Safelist.java b/src/main/java/org/jsoup/safety/Safelist.java
index eb1281ba..629522fc 100644
--- a/src/main/java/org/jsoup/safety/Safelist.java
+++ b/src/main/java/org/jsoup/safety/Safelist.java
@@ -6,6 +6,7 @@ package org.jsoup.safety;
  */
 
 import org.jsoup.helper.Validate;
+import org.jsoup.internal.Functions;
 import org.jsoup.internal.Normalizer;
 import org.jsoup.nodes.Attribute;
 import org.jsoup.nodes.Attributes;
@@ -304,12 +305,8 @@ public class Safelist {
             Validate.notEmpty(key);
             attributeSet.add(AttributeKey.valueOf(key));
         }
-        if (this.attributes.containsKey(tagName)) {
-            Set<AttributeKey> currentSet = this.attributes.get(tagName);
-            currentSet.addAll(attributeSet);
-        } else {
-            this.attributes.put(tagName, attributeSet);
-        }
+        Set<AttributeKey> currentSet = this.attributes.computeIfAbsent(tagName, Functions.setFunction());
+        currentSet.addAll(attributeSet);
         return this;
     }
 
@@ -382,13 +379,8 @@ public class Safelist {
         AttributeKey attrKey = AttributeKey.valueOf(attribute);
         AttributeValue attrVal = AttributeValue.valueOf(value);
 
-        if (enforcedAttributes.containsKey(tagName)) {
-            enforcedAttributes.get(tagName).put(attrKey, attrVal);
-        } else {
-            Map<AttributeKey, AttributeValue> attrMap = new HashMap<>();
-            attrMap.put(attrKey, attrVal);
-            enforcedAttributes.put(tagName, attrMap);
-        }
+        Map<AttributeKey, AttributeValue> attrMap = enforcedAttributes.computeIfAbsent(tagName, Functions.mapFunction());
+        attrMap.put(attrKey, attrVal);
         return this;
     }
 
@@ -458,21 +450,9 @@ public class Safelist {
 
         TagName tagName = TagName.valueOf(tag);
         AttributeKey attrKey = AttributeKey.valueOf(attribute);
-        Map<AttributeKey, Set<Protocol>> attrMap;
-        Set<Protocol> protSet;
-
-        if (this.protocols.containsKey(tagName)) {
-            attrMap = this.protocols.get(tagName);
-        } else {
-            attrMap = new HashMap<>();
-            this.protocols.put(tagName, attrMap);
-        }
-        if (attrMap.containsKey(attrKey)) {
-            protSet = attrMap.get(attrKey);
-        } else {
-            protSet = new HashSet<>();
-            attrMap.put(attrKey, protSet);
-        }
+        Map<AttributeKey, Set<Protocol>> attrMap = this.protocols.computeIfAbsent(tagName, Functions.mapFunction());
+        Set<Protocol> protSet = attrMap.computeIfAbsent(attrKey, Functions.setFunction());
+
         for (String protocol : protocols) {
             Validate.notEmpty(protocol);
             Protocol prot = Protocol.valueOf(protocol);
diff --git a/src/main/java/org/jsoup/select/CombiningEvaluator.java b/src/main/java/org/jsoup/select/CombiningEvaluator.java
index 25e5eac2..ea442f0d 100644
--- a/src/main/java/org/jsoup/select/CombiningEvaluator.java
+++ b/src/main/java/org/jsoup/select/CombiningEvaluator.java
@@ -7,15 +7,15 @@ import org.jspecify.annotations.Nullable;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Comparator;
+import java.util.List;
 
 /**
  * Base combining (and, or) evaluator.
  */
 public abstract class CombiningEvaluator extends Evaluator {
     final ArrayList<Evaluator> evaluators; // maintain original order so that #toString() is sensible
-    final ArrayList<Evaluator> sortedEvaluators; // cost ascending order
+    final List<Evaluator> sortedEvaluators; // cost ascending order
     int num = 0;
     int cost = 0;
 
@@ -62,12 +62,9 @@ public abstract class CombiningEvaluator extends Evaluator {
         }
         sortedEvaluators.clear();
         sortedEvaluators.addAll(evaluators);
-        Collections.sort(sortedEvaluators, costComparator);
+        sortedEvaluators.sort(Comparator.comparingInt(Evaluator::cost));
     }
 
-    private static final Comparator<Evaluator> costComparator = (o1, o2) -> o1.cost() - o2.cost();
-    // ^ comparingInt, sortedEvaluators.sort not available in targeted version
-
     public static final class And extends CombiningEvaluator {
         And(Collection<Evaluator> evaluators) {
             super(evaluators);
diff --git a/src/main/java/org/jsoup/select/StructuralEvaluator.java b/src/main/java/org/jsoup/select/StructuralEvaluator.java
index ce5051fb..61b2c419 100644
--- a/src/main/java/org/jsoup/select/StructuralEvaluator.java
+++ b/src/main/java/org/jsoup/select/StructuralEvaluator.java
@@ -1,11 +1,13 @@
 package org.jsoup.select;
 
+import org.jsoup.internal.Functions;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.nodes.Element;
 import org.jsoup.nodes.NodeIterator;
 
 import java.util.ArrayList;
 import java.util.IdentityHashMap;
+import java.util.Map;
 
 /**
  * Base structural evaluator.
@@ -23,19 +25,9 @@ abstract class StructuralEvaluator extends Evaluator {
         threadMemo = ThreadLocal.withInitial(IdentityHashMap::new);
 
     boolean memoMatches(final Element root, final Element element) {
-        // not using computeIfAbsent, as the lambda impl requires a new Supplier closure object on every hit: tons of GC
-        IdentityHashMap<Element, IdentityHashMap<Element, Boolean>> rootMemo = threadMemo.get();
-        IdentityHashMap<Element, Boolean> memo = rootMemo.get(root);
-        if (memo == null) {
-            memo = new IdentityHashMap<>();
-            rootMemo.put(root, memo);
-        }
-        Boolean matches = memo.get(element);
-        if (matches == null) {
-            matches = evaluator.matches(root, element);
-            memo.put(element, matches);
-        }
-        return matches;
+        Map<Element, IdentityHashMap<Element, Boolean>> rootMemo = threadMemo.get();
+        Map<Element, Boolean> memo = rootMemo.computeIfAbsent(root, Functions.identityMapFunction());
+        return memo.computeIfAbsent(element, key -> evaluator.matches(root, key));
     }
 
     @Override protected void reset() {


Test patch:
diff --git a/src/test/java/org/jsoup/helper/DataUtilTest.java b/src/test/java/org/jsoup/helper/DataUtilTest.java
index 10074d4c..61627aac 100644
--- a/src/test/java/org/jsoup/helper/DataUtilTest.java
+++ b/src/test/java/org/jsoup/helper/DataUtilTest.java
@@ -11,8 +11,10 @@ import java.nio.ByteBuffer;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.Path;
 
 import static org.jsoup.integration.ParseTest.getFile;
+import static org.jsoup.integration.ParseTest.getPath;
 import static org.junit.jupiter.api.Assertions.*;
 
 public class DataUtilTest {
@@ -207,13 +209,21 @@ public class DataUtilTest {
 
 
     @Test
-    public void lLoadsGzipFile() throws IOException {
+    public void loadsGzipFile() throws IOException {
         File in = getFile("/htmltests/gzip.html.gz");
         Document doc = Jsoup.parse(in, null);
         assertEquals("Gzip test", doc.title());
         assertEquals("This is a gzipped HTML file.", doc.selectFirst("p").text());
     }
 
+    @Test
+    public void loadsGzipPath() throws IOException {
+        Path in = getPath("/htmltests/gzip.html.gz");
+        Document doc = Jsoup.parse(in, null);
+        assertEquals("Gzip test", doc.title());
+        assertEquals("This is a gzipped HTML file.", doc.selectFirst("p").text());
+    }
+
     @Test
     public void loadsZGzipFile() throws IOException {
         // compressed on win, with z suffix
@@ -223,6 +233,15 @@ public class DataUtilTest {
         assertEquals("This is a gzipped HTML file.", doc.selectFirst("p").text());
     }
 
+    @Test
+    public void loadsZGzipPath() throws IOException {
+        // compressed on win, with z suffix
+        Path in = getPath("/htmltests/gzip.html.z");
+        Document doc = Jsoup.parse(in, null);
+        assertEquals("Gzip test", doc.title());
+        assertEquals("This is a gzipped HTML file.", doc.selectFirst("p").text());
+    }
+
     @Test
     public void handlesFakeGzipFile() throws IOException {
         File in = getFile("/htmltests/fake-gzip.html.gz");
@@ -231,6 +250,14 @@ public class DataUtilTest {
         assertEquals("And should still be readable.", doc.selectFirst("p").text());
     }
 
+    @Test
+    public void handlesFakeGzipPath() throws IOException {
+        Path in = getPath("/htmltests/fake-gzip.html.gz");
+        Document doc = Jsoup.parse(in, null);
+        assertEquals("This is not gzipped", doc.title());
+        assertEquals("And should still be readable.", doc.selectFirst("p").text());
+    }
+
     // an input stream to give a range of output sizes, that changes on each read
     static class VaryingReadInputStream extends InputStream {
         final InputStream in;
diff --git a/src/test/java/org/jsoup/integration/ParseTest.java b/src/test/java/org/jsoup/integration/ParseTest.java
index 0c5cb2b1..d84c1034 100644
--- a/src/test/java/org/jsoup/integration/ParseTest.java
+++ b/src/test/java/org/jsoup/integration/ParseTest.java
@@ -15,6 +15,8 @@ import java.net.URL;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.zip.GZIPInputStream;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -133,6 +135,15 @@ public class ParseTest {
         }
     }
 
+    public static Path getPath(String resourceName) {
+        try {
+            URL resource = ParseTest.class.getResource(resourceName);
+            return resource != null ? Paths.get(resource.toURI()) : Paths.get("/404");
+        } catch (URISyntaxException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
     public static InputStream inputStreamFrom(String s) {
         return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8));
     }
diff --git a/src/test/java/org/jsoup/nodes/BuildEntities.java b/src/test/java/org/jsoup/nodes/BuildEntities.java
index 423c2680..c5eb554a 100644
--- a/src/test/java/org/jsoup/nodes/BuildEntities.java
+++ b/src/test/java/org/jsoup/nodes/BuildEntities.java
@@ -105,12 +105,6 @@ class BuildEntities {
         return Integer.toString(d, Entities.codepointRadix);
     }
 
-    private static class ByName implements Comparator<CharacterRef> {
-        public int compare(CharacterRef o1, CharacterRef o2) {
-            return o1.name.compareTo(o2.name);
-        }
-    }
-
     private static class ByCode implements Comparator<CharacterRef> {
         public int compare(CharacterRef o1, CharacterRef o2) {
             int[] c1 = o1.codepoints;
@@ -131,6 +125,6 @@ class BuildEntities {
         }
     }
 
-    private static ByName byName = new ByName();
-    private static ByCode byCode = new ByCode();
+    private static final Comparator<CharacterRef> byName = Comparator.comparing(ref -> ref.name);
+    private static final ByCode byCode = new ByCode();
 }