Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 2 additions & 23 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"strings"
"unicode"

"github.com/dusk-network/pituitary/internal/pathselector"
"github.com/dusk-network/pituitary/sdk"
)

Expand Down Expand Up @@ -900,7 +901,7 @@ func validateSourcePatterns(errs *validationErrors, label string, field string,
continue
}
if filesystemSource {
if err := validateFilesystemSourcePattern(trimmed); err != nil {
if err := pathselector.Validate(trimmed); err != nil {
errs.add("%s.%s: invalid pattern %q: %v", label, field, pattern, err)
continue
}
Expand All @@ -912,28 +913,6 @@ func validateSourcePatterns(errs *validationErrors, label string, field string,
}
}

func validateFilesystemSourcePattern(pattern string) error {
normalized := filepath.ToSlash(pattern)
if filepath.IsAbs(pattern) || pathpkg.IsAbs(normalized) {
return fmt.Errorf("must be relative to the source root")
}
parts := strings.Split(normalized, "/")
for _, part := range parts {
switch part {
case "":
return fmt.Errorf("must not contain empty path segments")
case ".", "..":
return fmt.Errorf("must not contain %q path segments", part)
case "**":
continue
}
if _, err := pathpkg.Match(part, "placeholder"); err != nil {
return err
}
}
return nil
}

func resolveAndValidateSourcePaths(cfg *Config, errs *validationErrors, label string, source *Source, primaryRepoID string, filesystemSource bool) {
repoID := primaryRepoID
repoRootPath := cfg.Workspace.RootPath
Expand Down
132 changes: 132 additions & 0 deletions internal/pathselector/pathselector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package pathselector

import (
"fmt"
pathpkg "path"
"path/filepath"
"strings"
)

// Pattern is a compiled filesystem source selector.
type Pattern struct {
raw string
parts []string
recursive bool
}

// Compile validates and compiles a slash-separated source-relative selector.
func Compile(pattern string) (Pattern, error) {
normalized := filepath.ToSlash(strings.TrimSpace(pattern))
if normalized == "" {
return Pattern{}, fmt.Errorf("value must not be empty")
}
if filepath.IsAbs(normalized) || pathpkg.IsAbs(normalized) {
return Pattern{}, fmt.Errorf("must be relative to the source root")
}

parts := strings.Split(normalized, "/")
recursive := false
for _, part := range parts {
switch part {
case "":
return Pattern{}, fmt.Errorf("must not contain empty path segments")
case ".", "..":
return Pattern{}, fmt.Errorf("must not contain %q path segments", part)
case "**":
recursive = true
continue
}
if _, err := pathpkg.Match(part, "placeholder"); err != nil {
return Pattern{}, err
}
}
return Pattern{raw: normalized, parts: parts, recursive: recursive}, nil
}

// Validate reports whether pattern can be compiled as a source selector.
func Validate(pattern string) error {
_, err := Compile(pattern)
return err
}

// Match reports whether relPath matches pattern.
func Match(pattern, relPath string) (bool, error) {
compiled, err := Compile(pattern)
if err != nil {
return false, err
}
return compiled.Match(relPath)
}

// Match reports whether relPath matches the compiled selector.
func (p Pattern) Match(relPath string) (bool, error) {
relPath = filepath.ToSlash(strings.TrimSpace(relPath))
if !p.recursive {
return pathpkg.Match(p.raw, relPath)
}
pathParts, err := splitRelativePath(relPath)
if err != nil {
return false, err
}
return matchParts(p.parts, pathParts)
}

func splitRelativePath(value string) ([]string, error) {
if value == "" {
return nil, fmt.Errorf("value must not be empty")
}
parts := strings.Split(value, "/")
for _, part := range parts {
if part == "" {
return nil, fmt.Errorf("must not contain empty path segments")
}
if part == "." || part == ".." {
return nil, fmt.Errorf("must not contain %q path segments", part)
}
}
return parts, nil
}

func matchParts(patternParts, pathParts []string) (bool, error) {
type matchState struct {
patternIndex int
pathIndex int
}
memo := make(map[matchState]bool)
var match func(patternIndex, pathIndex int) (bool, error)
match = func(patternIndex, pathIndex int) (bool, error) {
state := matchState{patternIndex: patternIndex, pathIndex: pathIndex}
if failed, ok := memo[state]; ok && failed {
return false, nil
}
if patternIndex == len(patternParts) {
return pathIndex == len(pathParts), nil
}
if patternParts[patternIndex] == "**" {
for nextPathIndex := pathIndex; nextPathIndex <= len(pathParts); nextPathIndex++ {
if ok, err := match(patternIndex+1, nextPathIndex); ok || err != nil {
return ok, err
}
}
memo[state] = true
return false, nil
}
if pathIndex == len(pathParts) {
memo[state] = true
return false, nil
}
ok, err := pathpkg.Match(patternParts[patternIndex], pathParts[pathIndex])
if err != nil || !ok {
if err == nil {
memo[state] = true
}
return ok, err
}
ok, err = match(patternIndex+1, pathIndex+1)
if err == nil && !ok {
memo[state] = true
}
return ok, err
}
return match(0, 0)
}
69 changes: 69 additions & 0 deletions internal/pathselector/pathselector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package pathselector

import (
"strings"
"testing"
)

func TestMatchRecursiveSelectors(t *testing.T) {
t.Parallel()

tests := []struct {
name string
pattern string
path string
want bool
}{
{name: "recursive includes root file", pattern: "**/*.md", path: "root.md", want: true},
{name: "recursive includes nested file", pattern: "**/*.md", path: "guides/deep/nested.md", want: true},
{name: "recursive directory excludes unrelated", pattern: "archive/**", path: "guides/api.md", want: false},
{name: "single star is segment local", pattern: "guides/*.md", path: "guides/deep/nested.md", want: false},
{name: "repeated recursive segments", pattern: "**/**/nested.md", path: "guides/deep/nested.md", want: true},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got, err := Match(tt.pattern, tt.path)
if err != nil {
t.Fatalf("Match(%q, %q) error = %v", tt.pattern, tt.path, err)
}
if got != tt.want {
t.Fatalf("Match(%q, %q) = %v, want %v", tt.pattern, tt.path, got, tt.want)
}
})
}
}

func TestValidateRejectsInvalidRelativePatterns(t *testing.T) {
t.Parallel()

tests := []struct {
name string
pattern string
want string
}{
{name: "empty", pattern: "", want: "value must not be empty"},
{name: "absolute", pattern: "/**/*.md", want: "must be relative to the source root"},
{name: "empty segment", pattern: "guides//*.md", want: "must not contain empty path segments"},
{name: "current segment", pattern: "guides/./*.md", want: `must not contain "." path segments`},
{name: "parent segment", pattern: "guides/../*.md", want: `must not contain ".." path segments`},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := Validate(tt.pattern)
if err == nil {
t.Fatalf("Validate(%q) error = nil, want error", tt.pattern)
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate(%q) error = %q, want %q", tt.pattern, err, tt.want)
}
})
}
}
91 changes: 2 additions & 89 deletions internal/source/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ package source
import (
"fmt"
"os"
pathpkg "path"
"path/filepath"
"strings"

"github.com/dusk-network/pituitary/internal/config"
"github.com/dusk-network/pituitary/internal/model"
"github.com/dusk-network/pituitary/internal/pathselector"
)

const (
Expand Down Expand Up @@ -410,100 +410,13 @@ func evaluateSourcePathSelection(source config.Source, relPath string) (sourcePa
}

func matchSourceSelector(kind, pattern, relPath string) (bool, error) {
ok, err := matchSourceSelectorPattern(pattern, relPath)
ok, err := pathselector.Match(pattern, relPath)
if err != nil {
return false, fmt.Errorf("%s pattern %q is invalid: %w", kind, pattern, err)
}
return ok, nil
}

func matchSourceSelectorPattern(pattern, relPath string) (bool, error) {
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
relPath = filepath.ToSlash(strings.TrimSpace(relPath))
patternParts, err := splitSelectorPath(pattern)
if err != nil {
return false, err
}
hasRecursiveSegment := false
for _, part := range patternParts {
if part == "**" {
hasRecursiveSegment = true
continue
}
if _, err := pathpkg.Match(part, "placeholder"); err != nil {
return false, err
}
}
if !hasRecursiveSegment {
return pathpkg.Match(pattern, relPath)
}
pathParts, err := splitSelectorPath(relPath)
if err != nil {
return false, err
}
return matchSourceSelectorParts(patternParts, pathParts)
}

func matchSourceSelectorParts(patternParts, pathParts []string) (bool, error) {
type matchState struct {
patternIndex int
pathIndex int
}
memo := make(map[matchState]bool)
var match func(patternIndex, pathIndex int) (bool, error)
match = func(patternIndex, pathIndex int) (bool, error) {
state := matchState{patternIndex: patternIndex, pathIndex: pathIndex}
if failed, ok := memo[state]; ok && failed {
return false, nil
}
if patternIndex == len(patternParts) {
return pathIndex == len(pathParts), nil
}
if patternParts[patternIndex] == "**" {
for nextPathIndex := pathIndex; nextPathIndex <= len(pathParts); nextPathIndex++ {
if ok, err := match(patternIndex+1, nextPathIndex); ok || err != nil {
return ok, err
}
}
memo[state] = true
return false, nil
}
if pathIndex == len(pathParts) {
memo[state] = true
return false, nil
}
ok, err := pathpkg.Match(patternParts[patternIndex], pathParts[pathIndex])
if err != nil || !ok {
if err == nil {
memo[state] = true
}
return ok, err
}
ok, err = match(patternIndex+1, pathIndex+1)
if err == nil && !ok {
memo[state] = true
}
return ok, err
}
return match(0, 0)
}

func splitSelectorPath(value string) ([]string, error) {
if value == "" {
return nil, fmt.Errorf("value must not be empty")
}
parts := strings.Split(value, "/")
for _, part := range parts {
if part == "" {
return nil, fmt.Errorf("must be a relative path without empty segments")
}
if part == "." || part == ".." {
return nil, fmt.Errorf("must not contain %q path segments", part)
}
}
return parts, nil
}

func populateSelectionMatches(explanation *SourceFileExplanation, selection sourcePathSelection) {
explanation.FilesMatched = append([]string(nil), selection.FilesMatched...)
explanation.IncludeMatches = append([]string(nil), selection.IncludeMatches...)
Expand Down
Loading
Loading