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) {
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)
}
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
--- /dev/null
+// 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")
+}
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
}
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()
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
}
}
-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
}
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
}
return i
}
}
- panic(fmt.Sprintf("no language found for name %q", name))
+ return -1
}
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
}