diff --git a/bench_traversal_test.go b/bench_traversal_test.go
index de84bcd..8cb673b 100644
--- a/bench_traversal_test.go
+++ b/bench_traversal_test.go
@@ -2,6 +2,8 @@ package goquery
import (
"testing"
+
+ "github.com/andybalholm/cascadia"
)
func BenchmarkFind(b *testing.B) {
@@ -800,3 +802,21 @@ func BenchmarkClosestNodes(b *testing.B) {
b.Fatalf("want 2, got %d", n)
}
}
+
+func BenchmarkSingleMatcher(b *testing.B) {
+ doc := Doc()
+ multi := cascadia.MustCompile(`div`)
+ single := SingleMatcher(multi)
+ b.ResetTimer()
+
+ b.Run("multi", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _ = doc.FindMatcher(multi)
+ }
+ })
+ b.Run("single", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _ = doc.FindMatcher(single)
+ }
+ })
+}
diff --git a/example_test.go b/example_test.go
index bc97f05..924e8d1 100644
--- a/example_test.go
+++ b/example_test.go
@@ -80,3 +80,31 @@ func ExampleNewDocumentFromReader_string() {
// Output: Header
}
+
+func ExampleSingle() {
+ html := `
+
+
+ 1
+ 2
+ 3
+
+
+`
+ doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // By default, the selector string selects all matching nodes
+ multiSel := doc.Find("div")
+ fmt.Println(multiSel.Text())
+
+ // Using goquery.Single, only the first match is selected
+ singleSel := doc.FindMatcher(goquery.Single("div"))
+ fmt.Println(singleSel.Text())
+
+ // Output:
+ // 123
+ // 1
+}
diff --git a/type.go b/type.go
index 6ad51db..6646c14 100644
--- a/type.go
+++ b/type.go
@@ -7,7 +7,6 @@ import (
"net/url"
"github.com/andybalholm/cascadia"
-
"golang.org/x/net/html"
)
@@ -122,6 +121,45 @@ type Matcher interface {
Filter([]*html.Node) []*html.Node
}
+// Single compiles a selector string to a Matcher that stops after the first
+// match is found.
+//
+// By default, Selection.Find and other functions that accept a selector string
+// to select nodes will use all matches corresponding to that selector. By
+// using the Matcher returned by Single, at most the first match will be
+// selected.
+//
+// For example, those two statements are semantically equivalent:
+//
+// sel1 := doc.Find("a").First()
+// sel2 := doc.FindMatcher(goquery.Single("a"))
+//
+// The one using Single is optimized to be potentially much faster on large
+// documents.
+//
+// Only the behaviour of the MatchAll method of the Matcher interface is
+// altered compared to standard Matchers. This means that the single-selection
+// property of the Matcher only applies for Selection methods where the Matcher
+// is used to select nodes, not to filter or check if a node matches the
+// Matcher - in those cases, the behaviour of the Matcher is unchanged (e.g.
+// FilterMatcher(Single("div")) will still result in a Selection with multiple
+// "div"s if there were many "div"s in the Selection to begin with).
+func Single(selector string) Matcher {
+ return singleMatcher{compileMatcher(selector)}
+}
+
+// SingleMatcher returns a Matcher matches the same nodes as m, but that stops
+// after the first match is found.
+//
+// See the documentation of function Single for more details.
+func SingleMatcher(m Matcher) Matcher {
+ if _, ok := m.(singleMatcher); ok {
+ // m is already a singleMatcher
+ return m
+ }
+ return singleMatcher{m}
+}
+
// compileMatcher compiles the selector string s and returns
// the corresponding Matcher. If s is an invalid selector string,
// it returns a Matcher that fails all matches.
@@ -133,6 +171,30 @@ func compileMatcher(s string) Matcher {
return cs
}
+type singleMatcher struct {
+ Matcher
+}
+
+func (m singleMatcher) MatchAll(n *html.Node) []*html.Node {
+ // Optimized version - stops finding at the first match (cascadia-compiled
+ // matchers all use this code path).
+ if mm, ok := m.Matcher.(interface{ MatchFirst(*html.Node) *html.Node }); ok {
+ node := mm.MatchFirst(n)
+ if node == nil {
+ return nil
+ }
+ return []*html.Node{node}
+ }
+
+ // Fallback version, for e.g. test mocks that don't provide the MatchFirst
+ // method.
+ nodes := m.Matcher.MatchAll(n)
+ if len(nodes) > 0 {
+ return nodes[:1:1]
+ }
+ return nil
+}
+
// invalidMatcher is a Matcher that always fails to match.
type invalidMatcher struct{}
diff --git a/type_test.go b/type_test.go
index 798d4ea..84080a9 100644
--- a/type_test.go
+++ b/type_test.go
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
+ "github.com/andybalholm/cascadia"
"golang.org/x/net/html"
)
@@ -208,3 +209,47 @@ func TestIssue103(t *testing.T) {
}
t.Log(text)
}
+
+func TestSingle(t *testing.T) {
+ data := `
+
+
+ 1
+ 2
+ 3
+ 4
+
+
+`
+ doc, err := NewDocumentFromReader(strings.NewReader(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ text := doc.FindMatcher(Single("div")).Text()
+ if text != "1" {
+ t.Fatalf("want %q, got %q", "1", text)
+ }
+
+ // Verify semantic equivalence
+ sel1 := doc.Find("div").First()
+ sel2 := doc.FindMatcher(Single("div"))
+ if sel1.Text() != sel2.Text() {
+ t.Fatalf("want sel1 to equal sel2")
+ }
+
+ // Here, the Single has no effect as the selector is used to filter
+ // from the existing selection, not to find nodes in the document.
+ divs := doc.Find("div")
+ text = divs.FilterMatcher(Single(".a")).Text()
+ if text != "23" {
+ t.Fatalf("want %q, got %q", "23", text)
+ }
+
+ classA := cascadia.MustCompile(".a")
+ classB := cascadia.MustCompile(".b")
+ text = doc.FindMatcher(classB).AddMatcher(SingleMatcher(classA)).Text()
+ if text != "142" {
+ t.Fatalf("want %q, got %q", "142", text)
+ }
+}