]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add range matchers for site matrix vector store filtering
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Thu, 22 Jan 2026 11:03:02 +0000 (12:03 +0100)
committerGitHub <noreply@github.com>
Thu, 22 Jan 2026 11:03:02 +0000 (12:03 +0100)
Fixes #14359

common/predicate/predicate.go
common/predicate/predicate_test.go
common/predicate/rangeop_test.go [new file with mode: 0644]
hugolib/roles/roles.go
hugolib/sitesmatrix/sitematrix_integration_test.go
hugolib/sitesmatrix/vectorstores.go
hugolib/versions/versions.go
langs/config.go

index 3b1c595028f179840f6ff9cccb9b06587fe0c48f..1168316b6290d8cce44dcbc12612fcfed7446306 100644 (file)
@@ -153,6 +153,29 @@ func (p PR[T]) FilterCopy(s []T) []T {
        return result
 }
 
+const (
+       rangeOpNone = iota
+       rangeOpLT
+       rangeOpLTE
+       rangeOpGT
+       rangeOpGTE
+)
+
+func cutRangeOp(s string) (op int, rest string) {
+       switch {
+       case strings.HasPrefix(s, ">= "):
+               return rangeOpGTE, s[3:]
+       case strings.HasPrefix(s, "<= "):
+               return rangeOpLTE, s[3:]
+       case strings.HasPrefix(s, "> "):
+               return rangeOpGT, s[2:]
+       case strings.HasPrefix(s, "< "):
+               return rangeOpLT, s[2:]
+       default:
+               return rangeOpNone, s
+       }
+}
+
 // NewStringPredicateFromGlobs creates a string predicate from the given glob patterns.
 // A glob pattern starting with "!" is a negation pattern which will be ANDed with the rest.
 func NewStringPredicateFromGlobs(patterns []string, getGlob func(pattern string) (glob.Glob, error)) (P[string], error) {
@@ -187,6 +210,78 @@ func NewStringPredicateFromGlobs(patterns []string, getGlob func(pattern string)
        return p.BoolFunc(), nil
 }
 
+// NewIndexStringPredicateFromGlobsAndRanges creates an IndexString predicate from the given glob patterns and range patterns.
+// A glob pattern starting with "!" is a negation pattern which will be ANDed with the rest.
+// A range pattern is one of "> value", ">= value", "< value" or "<= value".
+func NewIndexStringPredicateFromGlobsAndRanges(patterns []string, getIndex func(s string) int, getGlob func(pattern string) (glob.Glob, error)) (P[IndexString], error) {
+       var p PR[IndexString]
+       for _, pattern := range patterns {
+               pattern = strings.TrimSpace(pattern)
+               if pattern == "" {
+                       continue
+               }
+               negate := strings.HasPrefix(pattern, hglob.NegationPrefix)
+               if negate {
+                       pattern = pattern[2:]
+                       g, err := getGlob(pattern)
+                       if err != nil {
+                               return nil, err
+                       }
+                       p = p.And(func(s IndexString) Match {
+                               return BoolMatch(!g.Match(s.String))
+                       })
+               } else {
+                       // This can be either a glob or a value prefixed with one of >, >=, < or <=.
+                       o, v := cutRangeOp(pattern)
+                       if o != rangeOpNone {
+                               i := getIndex(v)
+                               if i == -1 {
+                                       // No match possible.
+                                       p = p.And(func(s IndexString) Match {
+                                               return BoolMatch(false)
+                                       })
+                                       continue
+                               }
+                               switch o {
+                               // The greater values starts at the top with index 0.
+                               case rangeOpGT:
+                                       p = p.And(func(s IndexString) Match {
+                                               return BoolMatch(s.Index < i)
+                                       })
+                               case rangeOpGTE:
+                                       p = p.And(func(s IndexString) Match {
+                                               return BoolMatch(s.Index <= i)
+                                       })
+                               case rangeOpLT:
+                                       p = p.And(func(s IndexString) Match {
+                                               return BoolMatch(s.Index > i)
+                                       })
+                               case rangeOpLTE:
+                                       p = p.And(func(s IndexString) Match {
+                                               return BoolMatch(s.Index >= i)
+                                       })
+                               }
+                       } else {
+                               g, err := getGlob(pattern)
+                               if err != nil {
+                                       return nil, err
+                               }
+                               p = p.Or(func(s IndexString) Match {
+                                       return BoolMatch(g.Match(s.String))
+                               })
+                       }
+
+               }
+       }
+
+       return p.BoolFunc(), nil
+}
+
+type IndexString struct {
+       Index  int
+       String string
+}
+
 type IndexMatcher interface {
-       IndexMatch(match P[string]) (iter.Seq[int], error)
+       IndexMatch(match P[IndexString]) (iter.Seq[int], error)
 }
index 7cfc11d11745ec810e0bee3c0857063b1f308551..cf867dcb6a956cdb0a690f03dda274373d18b3aa 100644 (file)
@@ -146,6 +146,86 @@ func TestNewStringPredicateFromGlobs(t *testing.T) {
        c.Assert(m("anything"), qt.IsFalse)
 }
 
+func TestNewIndexStringPredicateFromGlobsAndRanges(t *testing.T) {
+       c := qt.New(t)
+
+       // Simulate versions: v4.0.0=0, v3.0.0=1, v2.0.0=2, v1.0.0=3
+       // Lower index = greater value.
+       versions := []string{"v4.0.0", "v3.0.0", "v2.0.0", "v1.0.0"}
+       getIndex := func(s string) int {
+               for i, v := range versions {
+                       if v == s {
+                               return i
+                       }
+               }
+               return -1
+       }
+       getGlob := func(pattern string) (glob.Glob, error) {
+               return glob.Compile(pattern)
+       }
+
+       n := func(patterns ...string) predicate.P[predicate.IndexString] {
+               p, err := predicate.NewIndexStringPredicateFromGlobsAndRanges(patterns, getIndex, getGlob)
+               c.Assert(err, qt.IsNil)
+               return p
+       }
+
+       is := func(i int) predicate.IndexString {
+               return predicate.IndexString{Index: i, String: versions[i]}
+       }
+
+       // Test >= v2.0.0 (index 2): should match indices <= 2
+       m := n(">= v2.0.0")
+       c.Assert(m(is(0)), qt.IsTrue)  // v4.0.0
+       c.Assert(m(is(1)), qt.IsTrue)  // v3.0.0
+       c.Assert(m(is(2)), qt.IsTrue)  // v2.0.0
+       c.Assert(m(is(3)), qt.IsFalse) // v1.0.0
+
+       // Test > v2.0.0 (index 2): should match indices < 2
+       m = n("> v2.0.0")
+       c.Assert(m(is(0)), qt.IsTrue)  // v4.0.0
+       c.Assert(m(is(1)), qt.IsTrue)  // v3.0.0
+       c.Assert(m(is(2)), qt.IsFalse) // v2.0.0
+       c.Assert(m(is(3)), qt.IsFalse) // v1.0.0
+
+       // Test < v3.0.0 (index 1): should match indices > 1
+       m = n("< v3.0.0")
+       c.Assert(m(is(0)), qt.IsFalse) // v4.0.0
+       c.Assert(m(is(1)), qt.IsFalse) // v3.0.0
+       c.Assert(m(is(2)), qt.IsTrue)  // v2.0.0
+       c.Assert(m(is(3)), qt.IsTrue)  // v1.0.0
+
+       // Test range: >= v2.0.0 AND <= v3.0.0
+       m = n(">= v2.0.0", "<= v3.0.0")
+       c.Assert(m(is(0)), qt.IsFalse) // v4.0.0 - too high
+       c.Assert(m(is(1)), qt.IsTrue)  // v3.0.0
+       c.Assert(m(is(2)), qt.IsTrue)  // v2.0.0
+       c.Assert(m(is(3)), qt.IsFalse) // v1.0.0 - too low
+
+       // Test glob pattern
+       m = n("v2.*.*")
+       c.Assert(m(is(0)), qt.IsFalse) // v4.0.0
+       c.Assert(m(is(2)), qt.IsTrue)  // v2.0.0
+
+       // Test glob with negation
+       m = n("v*.*.*", "! v3.*.*")
+       c.Assert(m(is(0)), qt.IsTrue)  // v4.0.0
+       c.Assert(m(is(1)), qt.IsFalse) // v3.0.0 - negated
+       c.Assert(m(is(2)), qt.IsTrue)  // v2.0.0
+
+       // Test range with negation: >= v2.0.0 but not v3.0.0
+       m = n(">= v2.0.0", "! v3.0.0")
+       c.Assert(m(is(0)), qt.IsTrue)  // v4.0.0
+       c.Assert(m(is(1)), qt.IsFalse) // v3.0.0 - negated
+       c.Assert(m(is(2)), qt.IsTrue)  // v2.0.0
+       c.Assert(m(is(3)), qt.IsFalse) // v1.0.0 - out of range
+
+       // Test unknown value in range returns no match
+       m = n(">= v99.0.0")
+       c.Assert(m(is(0)), qt.IsFalse)
+       c.Assert(m(is(3)), qt.IsFalse)
+}
+
 func BenchmarkPredicate(b *testing.B) {
        b.Run("and or no match", func(b *testing.B) {
                var p predicate.PR[int] = intP1
diff --git a/common/predicate/rangeop_test.go b/common/predicate/rangeop_test.go
new file mode 100644 (file)
index 0000000..3b282a2
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright 2026 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package predicate
+
+import (
+       "testing"
+
+       qt "github.com/frankban/quicktest"
+)
+
+func TestCutRangeOp(t *testing.T) {
+       c := qt.New(t)
+
+       op, rest := cutRangeOp(">= value")
+       c.Assert(op, qt.Equals, rangeOpGTE)
+       c.Assert(rest, qt.Equals, "value")
+
+       op, rest = cutRangeOp("<= value")
+       c.Assert(op, qt.Equals, rangeOpLTE)
+       c.Assert(rest, qt.Equals, "value")
+
+       op, rest = cutRangeOp("> value")
+       c.Assert(op, qt.Equals, rangeOpGT)
+       c.Assert(rest, qt.Equals, "value")
+
+       op, rest = cutRangeOp("< value")
+       c.Assert(op, qt.Equals, rangeOpLT)
+       c.Assert(rest, qt.Equals, "value")
+
+       op, rest = cutRangeOp("value")
+       c.Assert(op, qt.Equals, rangeOpNone)
+       c.Assert(rest, qt.Equals, "value")
+}
index 2aaa913c5a356b4a7f3bc36134800d7113efd8d8..e381e890ebcc0c39fec9128288e19849711394b3 100644 (file)
@@ -102,14 +102,14 @@ func (r RolesInternal) ResolveIndex(name string) int {
                        return i
                }
        }
-       panic(fmt.Sprintf("no role found for name %q", name))
+       return -1
 }
 
 // IndexMatch returns an iterator for the roles that match the filter.
-func (r RolesInternal) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
+func (r RolesInternal) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
        return func(yield func(i int) bool) {
                for i, role := range r.Sorted {
-                       if match(role.Name) {
+                       if match(predicate.IndexString{Index: i, String: role.Name}) {
                                if !yield(i) {
                                        return
                                }
index 954dbd223cf9f8f16fb087a2c21b1c06e03710aa..8c2e775594a16c727b9287e3400c649f9f6f1a1f 100644 (file)
@@ -1431,6 +1431,115 @@ All.
        b.AssertFileContent("public/guest/en/index.html", "url=https://example.org/guest/\"")
 }
 
+func TestSitesMatrixRangeMatchers(t *testing.T) {
+       t.Parallel()
+       files := `TestSitesMatrixRangeMatchers
+-- hugo.toml --
+baseURL = "https://example.org/"
+defaultContentVersion = "v4.0.0"
+defaultContentVersionInSubDir = true
+disableKinds = ["taxonomy", "term", "rss", "sitemap"]
+
+[languages]
+[languages.en]
+weight = 1
+
+[versions]
+[versions."v1.0.0"]
+[versions."v2.0.0"]
+[versions."v2.1.0"]
+[versions."v3.0.0"]
+[versions."v4.0.0"]
+
+-- content/_index.md --
+---
+title: "Home"
+sites:
+  matrix:
+    versions: ["**"]
+---
+-- content/p1.md --
+---
+title: "P1 from v2.0.0"
+sites:
+  matrix:
+    versions: [">= v2.0.0"]
+---
+Content for v2.0.0 and later.
+-- content/p2.md --
+---
+title: "P2 between v2 and v3"
+sites:
+  matrix:
+    versions: [">= v2.0.0", "<= v3.0.0"]
+---
+Content between v2.0.0 and v3.0.0 (inclusive).
+-- content/p3.md --
+---
+title: "P3 before v3"
+sites:
+  matrix:
+    versions: ["< v3.0.0"]
+---
+Content before v3.0.0.
+-- content/p4.md --
+---
+title: "P4 after v2"
+sites:
+  matrix:
+    versions: ["> v2.0.0"]
+---
+Content after v2.0.0 (exclusive).
+-- content/p5.md --
+---
+title: "P5 range with negation"
+sites:
+  matrix:
+    versions: [">= v2.0.0", "! v2.1.0", "<= v4.0.0"]
+---
+Content from v2.0.0 to v4.0.0 but not v2.1.0.
+-- layouts/all.html --
+Title: {{ .Title }}|
+`
+
+       b := hugolib.Test(t, files)
+
+       // P1: >= v2.0.0 should be in v2.0.0, v2.1.0, v3.0.0, v4.0.0
+       b.AssertFileContent("public/v2.0.0/p1/index.html", "Title: P1 from v2.0.0|")
+       b.AssertFileContent("public/v2.1.0/p1/index.html", "Title: P1 from v2.0.0|")
+       b.AssertFileContent("public/v3.0.0/p1/index.html", "Title: P1 from v2.0.0|")
+       b.AssertFileContent("public/v4.0.0/p1/index.html", "Title: P1 from v2.0.0|")
+       b.AssertFileExists("public/v1.0.0/p1/index.html", false)
+
+       // P2: >= v2.0.0 AND <= v3.0.0 should be in v2.0.0, v2.1.0, v3.0.0
+       b.AssertFileContent("public/v2.0.0/p2/index.html", "Title: P2 between v2 and v3|")
+       b.AssertFileContent("public/v2.1.0/p2/index.html", "Title: P2 between v2 and v3|")
+       b.AssertFileContent("public/v3.0.0/p2/index.html", "Title: P2 between v2 and v3|")
+       b.AssertFileExists("public/v1.0.0/p2/index.html", false)
+       b.AssertFileExists("public/v4.0.0/p2/index.html", false)
+
+       // P3: < v3.0.0 should be in v1.0.0, v2.0.0, v2.1.0
+       b.AssertFileContent("public/v1.0.0/p3/index.html", "Title: P3 before v3|")
+       b.AssertFileContent("public/v2.0.0/p3/index.html", "Title: P3 before v3|")
+       b.AssertFileContent("public/v2.1.0/p3/index.html", "Title: P3 before v3|")
+       b.AssertFileExists("public/v3.0.0/p3/index.html", false)
+       b.AssertFileExists("public/v4.0.0/p3/index.html", false)
+
+       // P4: > v2.0.0 should be in v2.1.0, v3.0.0, v4.0.0
+       b.AssertFileContent("public/v2.1.0/p4/index.html", "Title: P4 after v2|")
+       b.AssertFileContent("public/v3.0.0/p4/index.html", "Title: P4 after v2|")
+       b.AssertFileContent("public/v4.0.0/p4/index.html", "Title: P4 after v2|")
+       b.AssertFileExists("public/v1.0.0/p4/index.html", false)
+       b.AssertFileExists("public/v2.0.0/p4/index.html", false)
+
+       // P5: >= v2.0.0 AND ! v2.1.0 AND <= v4.0.0 should be in v2.0.0, v3.0.0, v4.0.0
+       b.AssertFileContent("public/v2.0.0/p5/index.html", "Title: P5 range with negation|")
+       b.AssertFileContent("public/v3.0.0/p5/index.html", "Title: P5 range with negation|")
+       b.AssertFileContent("public/v4.0.0/p5/index.html", "Title: P5 range with negation|")
+       b.AssertFileExists("public/v1.0.0/p5/index.html", false)
+       b.AssertFileExists("public/v2.1.0/p5/index.html", false) // Excluded by negation
+}
+
 func TestSitesMatrixFileMountOrder(t *testing.T) {
        t.Parallel()
 
index 53a6f2782c2aa0c4830b2ed1eaef47d644b2a85a..4f617df6044cf2a13f27f2e4b9b6a0fd1ebdb2b5 100644 (file)
@@ -760,22 +760,19 @@ func (b *IntSetsBuilder) WithConfig(cfg IntSetsConfig) *IntSetsBuilder {
                        return result, nil
                }
 
-               // Dot separated globs.
-               filter, err := predicate.NewStringPredicateFromGlobs(values, hglob.GetGlobDot)
+               filter, err := predicate.NewIndexStringPredicateFromGlobsAndRanges(values, matcher.ResolveIndex, hglob.GetGlobDot)
                if err != nil {
                        return nil, fmt.Errorf("failed to create filter for %s: %w", what, err)
                }
-               for _, pattern := range values {
-                       iter, err := matcher.IndexMatch(filter)
-                       if err != nil {
-                               return nil, fmt.Errorf("failed to match %s %q: %w", what, pattern, err)
-                       }
-                       for i := range iter {
-                               if result == nil {
-                                       result = hmaps.NewOrderedIntSet()
-                               }
-                               result.Set(i)
+               iter, err := matcher.IndexMatch(filter)
+               if err != nil {
+                       return nil, fmt.Errorf("failed to match %s %q: %w", what, values, err)
+               }
+               for i := range iter {
+                       if result == nil {
+                               result = hmaps.NewOrderedIntSet()
                        }
+                       result.Set(i)
                }
 
                return result, nil
@@ -968,10 +965,10 @@ func (m *testDimension) ForEachIndex() iter.Seq[int] {
        }
 }
 
-func (m *testDimension) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
+func (m *testDimension) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
        return func(yield func(i int) bool) {
                for i, n := range m.names {
-                       if match(n) {
+                       if match(predicate.IndexString{Index: i, String: n}) {
                                if !yield(i) {
                                        return
                                }
index 18b7ec91a442f00dd82cebdf878ce81f5b12713f..ca372c23effba8432ea6c85d84a01e7e243273b5 100644 (file)
@@ -104,14 +104,14 @@ func (r VersionsInternal) ResolveIndex(name string) int {
                        return i
                }
        }
-       panic(fmt.Sprintf("no version found for name %q", name))
+       return -1
 }
 
 // IndexMatch returns an iterator for the versions that match the filter.
-func (r VersionsInternal) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
+func (r VersionsInternal) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
        return func(yield func(i int) bool) {
                for i, version := range r.Sorted {
-                       if match(version.Name) {
+                       if match(predicate.IndexString{Index: i, String: version.Name}) {
                                if !yield(i) {
                                        return
                                }
index bde2c2f112fbe5b41842997354073396327459c6..0ba56ee6ff6451d539d8afcc640593d0870a6385 100644 (file)
@@ -89,7 +89,7 @@ func (ls LanguagesInternal) ResolveIndex(name string) int {
                        return i
                }
        }
-       panic(fmt.Sprintf("no language found for name %q", name))
+       return -1
 }
 
 func (ls LanguagesInternal) Len() int {
@@ -97,10 +97,10 @@ func (ls LanguagesInternal) Len() int {
 }
 
 // IndexMatch returns an iterator for the roles that match the filter.
-func (ls LanguagesInternal) IndexMatch(match predicate.P[string]) (iter.Seq[int], error) {
+func (ls LanguagesInternal) IndexMatch(match predicate.P[predicate.IndexString]) (iter.Seq[int], error) {
        return func(yield func(i int) bool) {
                for i, l := range ls.Sorted {
-                       if match(l.Name) {
+                       if match(predicate.IndexString{Index: i, String: l.Name}) {
                                if !yield(i) {
                                        return
                                }