commit d13ee5cf13d1ef54ee465ad1c626560f775c9ac2 Author: Aneurin Barker Snook Date: Mon Aug 31 22:25:00 2026 +0100 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e57fd1b --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Sqan + +A simple package to help scan files in a directory. + +```go +s := NewScanner("my/dir"). + WithExtensions("md", "txt"). + WithIgnorePatterns("**/.private/**") + +for file := range s.Scan() { + fmt.Println(file) +} +``` diff --git a/file.go b/file.go new file mode 100644 index 0000000..b97e5d7 --- /dev/null +++ b/file.go @@ -0,0 +1,37 @@ +package sqan + +import ( + "errors" + "time" +) + +type File struct { + RootDir string `json:"rootDir,omitempty"` + AbsolutePath string `json:"absolutePath,omitempty"` + RelativeDir string `json:"relativeDir,omitempty"` + RelativePath string `json:"relativePath,omitempty"` + Filename string `json:"filename,omitempty"` + Extension string `json:"extension,omitempty"` + Hash string `json:"hash,omitempty"` + Size int64 `json:"size,omitempty"` + Modified time.Time `json:"modified,omitempty"` +} + +func (f *File) Process() error { + return errors.New("unimplemented") +} + +func NewFile(rootDir, absPath string) *File { + relPath, relDir, filename, extension := ParsePath(rootDir, absPath) + + f := &File{ + RootDir: rootDir, + AbsolutePath: absPath, + RelativeDir: relDir, + RelativePath: relPath, + Filename: filename, + Extension: extension, + } + + return f +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..41d1a1d --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.aneur.in/go/sqan + +go 1.25.7 + +require github.com/gobwas/glob v1.0.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be1b8f6 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg= +github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc= diff --git a/path.go b/path.go new file mode 100644 index 0000000..a93fbf4 --- /dev/null +++ b/path.go @@ -0,0 +1,27 @@ +package sqan + +import "strings" + +func ParsePath(rootDir, absPath string) (relPath, relDir, filename, extension string) { + if absPath[0:len(rootDir)] != rootDir { + return + } + + relPath = absPath[len(rootDir):] + + if i := strings.LastIndexByte(relPath, '/'); i > -1 { + if i > 0 { + relDir = relPath[0:i] + } else { + relDir = "/" + } + + filename = relPath[i+1:] + } + + if i := strings.LastIndexByte(filename, '.'); i > -1 { + extension = strings.ToLower(filename[i+1:]) + } + + return +} diff --git a/result.go b/result.go new file mode 100644 index 0000000..9b3eb83 --- /dev/null +++ b/result.go @@ -0,0 +1,5 @@ +package sqan + +type Result struct { + Files map[string]*File +} diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..ac6757a --- /dev/null +++ b/scanner.go @@ -0,0 +1,127 @@ +package sqan + +import ( + "iter" + "os" + "path" + "slices" + "strings" + + "github.com/gobwas/glob" +) + +type Scanner struct { + rootDir string + extensions []string + ignorePatterns []string + + compiledPatterns []*glob.Pattern + + onError func(error) +} + +func (s *Scanner) OnError(onError func(error)) *Scanner { + s.onError = onError + return s +} + +func (s *Scanner) Scan() iter.Seq[*File] { + if err := s.compile(); err != nil { + s.onError(err) + } + + return func(yield func(*File) bool) { + s.scan(s.rootDir, s.rootDir, yield) + } +} + +func (s *Scanner) compile() error { + compiledPatterns := []*glob.Pattern{} + for _, text := range s.ignorePatterns { + pattern, err := glob.Compile(text) + if err != nil { + return err + } + + compiledPatterns = append(compiledPatterns, pattern) + } + + s.compiledPatterns = compiledPatterns + return nil +} + +func (s *Scanner) scan(rootDir, dir string, yield func(*File) bool) { + entries, err := os.ReadDir(dir) + if err != nil { + s.onError(err) + return + } + + for _, entry := range entries { + absPath := path.Join(dir, entry.Name()) + ignore := false + for _, pattern := range s.compiledPatterns { + if pattern.Match(absPath) { + ignore = true + break + } + } + if ignore { + continue + } + + if entry.IsDir() { + s.scan(rootDir, absPath, yield) + continue + } + + f := NewFile(rootDir, absPath) + if len(s.extensions) > 0 && !slices.Contains(s.extensions, f.Extension) { + continue + } + + info, err := entry.Info() + if err != nil { + s.onError(err) + continue + } + + f.Size = info.Size() + f.Modified = info.ModTime() + if !yield(f) { + return + } + } +} + +func (s *Scanner) WithExtensions(exts ...string) *Scanner { + lower := []string{} + for _, ext := range exts { + lower = append(lower, strings.ToLower(ext)) + } + + s.extensions = append(s.extensions, lower...) + + return s +} + +func (s *Scanner) WithIgnorePatterns(patterns ...string) *Scanner { + s.ignorePatterns = append(s.ignorePatterns, patterns...) + + return s +} + +func NewScanner(rootDir string) *Scanner { + if rootDir[0] != '/' { + cwd, _ := os.Getwd() + rootDir = path.Join(cwd, rootDir) + } + + s := &Scanner{ + rootDir: rootDir, + + onError: func(err error) {}, + } + + return s +}