From: Bjørn Erik Pedersen Date: Thu, 22 Jan 2026 11:03:02 +0000 (+0100) Subject: Add range matchers for site matrix vector store filtering X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=192e3c453e904cf2e79312d4335a7ee216fe659d;p=brevno-suite%2Fhugo Add range matchers for site matrix vector store filtering Fixes #14359 --- diff --git a/common/predicate/predicate.go b/common/predicate/predicate.go index 3b1c59502..1168316b6 100644 --- a/common/predicate/predicate.go +++ b/common/predicate/predicate.go @@ -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) } diff --git a/common/predicate/predicate_test.go b/common/predicate/predicate_test.go index 7cfc11d11..cf867dcb6 100644 --- a/common/predicate/predicate_test.go +++ b/common/predicate/predicate_test.go @@ -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 index 000000000..3b282a2c5 --- /dev/null +++ b/common/predicate/rangeop_test.go @@ -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") +} diff --git a/hugolib/roles/roles.go b/hugolib/roles/roles.go index 2aaa913c5..e381e890e 100644 --- a/hugolib/roles/roles.go +++ b/hugolib/roles/roles.go @@ -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 } diff --git a/hugolib/sitesmatrix/sitematrix_integration_test.go b/hugolib/sitesmatrix/sitematrix_integration_test.go index 954dbd223..8c2e77559 100644 --- a/hugolib/sitesmatrix/sitematrix_integration_test.go +++ b/hugolib/sitesmatrix/sitematrix_integration_test.go @@ -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() diff --git a/hugolib/sitesmatrix/vectorstores.go b/hugolib/sitesmatrix/vectorstores.go index 53a6f2782..4f617df60 100644 --- a/hugolib/sitesmatrix/vectorstores.go +++ b/hugolib/sitesmatrix/vectorstores.go @@ -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 } diff --git a/hugolib/versions/versions.go b/hugolib/versions/versions.go index 18b7ec91a..ca372c23e 100644 --- a/hugolib/versions/versions.go +++ b/hugolib/versions/versions.go @@ -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 } diff --git a/langs/config.go b/langs/config.go index bde2c2f11..0ba56ee6f 100644 --- a/langs/config.go +++ b/langs/config.go @@ -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 }