Skip to content
This repository was archived by the owner on Feb 17, 2026. It is now read-only.
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
2 changes: 2 additions & 0 deletions src/pkg/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ module github.com/specvital/core
go 1.24

require github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82

require github.com/bmatcuk/doublestar/v4 v4.8.1
2 changes: 2 additions & 0 deletions src/pkg/go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
216 changes: 216 additions & 0 deletions src/pkg/parser/detector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package parser

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"

"github.com/bmatcuk/doublestar/v4"
)

const (
DefaultMaxFileSize = 10 * 1024 * 1024 // 10MB

jsTestInfix = ".test."
jsSpecInfix = ".spec."
jsTestsDir = "/__tests__/"
jsTestsDirPrefix = "__tests__/"
)

var DefaultSkipPatterns = []string{
"node_modules",
".git",
"vendor",
"dist",
".next",
"__pycache__",
"coverage",
".cache",
}

var ErrInvalidRootPath = errors.New("detector: root path does not exist or is not accessible")

// DetectionResult contains detected test files and any errors encountered during traversal.
type DetectionResult struct {
Errors []error
Files []string
}

type DetectorOptions struct {
SkipPatterns []string
Patterns []string
MaxFileSize int64
}

type DetectorOption func(*DetectorOptions)

func WithSkipPatterns(patterns []string) DetectorOption {
return func(o *DetectorOptions) {
o.SkipPatterns = patterns
}
}

func WithPatterns(patterns []string) DetectorOption {
return func(o *DetectorOptions) {
o.Patterns = patterns
}
}

func WithMaxFileSize(size int64) DetectorOption {
return func(o *DetectorOptions) {
o.MaxFileSize = size
}
}

func DetectTestFiles(ctx context.Context, rootPath string, opts ...DetectorOption) (*DetectionResult, error) {
options := &DetectorOptions{
SkipPatterns: DefaultSkipPatterns,
Patterns: nil,
MaxFileSize: DefaultMaxFileSize,
}

for _, opt := range opts {
opt(options)
}

rootInfo, err := os.Stat(rootPath)
if err != nil {
return nil, ErrInvalidRootPath
}
if !rootInfo.IsDir() {
return nil, ErrInvalidRootPath
}

skipSet := buildSkipSet(options.SkipPatterns)

result := &DetectionResult{
Files: []string{},
Errors: []error{},
}

err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error {
if err := ctx.Err(); err != nil {
return err
}

if walkErr != nil {
result.Errors = append(result.Errors, fmt.Errorf("access error at %s: %w", path, walkErr))
return nil
}

if d.IsDir() {
if shouldSkipDir(path, rootPath, skipSet) {
return filepath.SkipDir
}
return nil
}

if !isTestFileCandidate(path) {
return nil
}

if len(options.Patterns) > 0 {
if !matchesAnyPattern(path, rootPath, options.Patterns) {
return nil
}
}

if options.MaxFileSize > 0 {
info, err := d.Info()
if err != nil {
result.Errors = append(result.Errors, fmt.Errorf("failed to get file info for %s: %w", path, err))
return nil
}
if info.Size() > options.MaxFileSize {
return nil
}
}

result.Files = append(result.Files, path)
return nil
})

if err != nil {
return result, err
}

return result, nil
}

func buildSkipSet(patterns []string) map[string]bool {
skipSet := make(map[string]bool, len(patterns))
for _, p := range patterns {
skipSet[p] = true
}
return skipSet
}

func shouldSkipDir(path, rootPath string, skipSet map[string]bool) bool {
if path == rootPath {
return false
}

base := filepath.Base(path)
return skipSet[base]
}

func isTestFileCandidate(path string) bool {
ext := strings.ToLower(filepath.Ext(path))

switch ext {
case ".ts", ".tsx", ".js", ".jsx":
return isJSTestFile(path)
case ".go":
return isGoTestFile(path)
default:
return false
}
}

func isGoTestFile(path string) bool {
base := filepath.Base(path)
return strings.HasSuffix(base, "_test.go")
}

func isJSTestFile(path string) bool {
base := filepath.Base(path)
lowerBase := strings.ToLower(base)

// *.test.*, *.spec.*
if strings.Contains(lowerBase, jsTestInfix) || strings.Contains(lowerBase, jsSpecInfix) {
return true
}

// __tests__ directory
normalizedPath := filepath.ToSlash(path)
if strings.Contains(normalizedPath, jsTestsDir) || strings.HasPrefix(normalizedPath, jsTestsDirPrefix) {
return true
}

return false
}

func matchesAnyPattern(path, rootPath string, patterns []string) bool {
relPath, err := filepath.Rel(rootPath, path)
if err != nil {
return false
}
relPath = filepath.ToSlash(relPath)

for _, pattern := range patterns {
matched, err := doublestar.Match(pattern, relPath)
if err != nil {
// Invalid pattern syntax - skip this pattern
continue
}
if matched {
return true
}
}
return false
}

Loading
Loading