diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index c34b6d1..7e9bc37 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -3,13 +3,13 @@
#
# Created: 18th August 2025
-# Updated: 19th August 2026
+# Updated: 3rd September 2026
#
name: Go
env:
- GO_MIN_VERSION: '1.21'
+ GO_MIN_VERSION: '1.23'
VERBOSITY: 1
on:
@@ -32,7 +32,9 @@ jobs:
build:
strategy:
- # Set fail-fast to false to ensure that feedback is delivered for all matrix combinations. Consider changing this to true when your workflow is stable.
+ # Set fail-fast to false to ensure that feedback is delivered for all
+ # matrix combinations. Consider changing this to true when your
+ # workflow is stable.
fail-fast: false
matrix:
@@ -41,8 +43,6 @@ jobs:
- '1.25'
- '1.24'
- '1.23'
- - '1.22'
- - '1.21'
os:
- macos-latest
- ubuntu-latest
@@ -69,7 +69,6 @@ jobs:
lint:
runs-on: ubuntu-latest
-
steps:
- uses: actions/checkout@v4
@@ -78,8 +77,7 @@ jobs:
with:
go-version: '${{ env.GO_MIN_VERSION }}'
- - name: "Run golangci-lint"
+ - name: "golangci-lint"
uses: golangci/golangci-lint-action@v6
with:
args: --fast
- verify: false
diff --git a/.sis/script_info_lines.txt b/.sis/script_info_lines.txt
index 3e4ab16..08dabc5 100644
--- a/.sis/script_info_lines.txt
+++ b/.sis/script_info_lines.txt
@@ -1,3 +1,3 @@
recls.Go is a platform-independent recursive file-system search library for Go
-Copyright (c) 2019-2024, Matthew Wilson and Synesis Information Systems
+Copyright (c) 2019-2026, Matthew Wilson and Synesis Information Systems
Copyright (c) 2015-2019, Matthew Wilson and Synesis Software
diff --git a/CHANGES.md b/CHANGES.md
index b3e95d1..7a71e9a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,28 +1,24 @@
# recls.Go - Changes
-## 0.0.2 - 2nd September 2026
-
-* updated dependencies;
-
+## 0.1.0-alpha1 - 3rd September 2026
-## 0.0.1 - 20th August 2026
+* First functional file-system search release (recls2-shaped);
+* Added **Entry**, **Stat**, **Search** / **SearchFunc** (DFS, `iter.Seq2`);
+* **Search** / **SearchFunc** accept **`PatternSource`** (`string | []string`);
+* Path fields from **libpath.Go** `PathDescriptor`; patterns via **shwild.Go**;
+* Core search flags: type filter, recursive, hidden, access failure, symlinks,
+ infinite-loop guard, mark-dirs, details-later, tilde-on-empty-root;
+* Unit and component tests; examples **hard_links**, **libver**, **rls**,
+ **search_simple**, **stat**;
+* **Entry.LinkCount** (Unix `Stat_t.Nlink`; Windows `BY_HANDLE_FILE_INFORMATION`);
+* Migrated version API to **ver2go** 0.2+; CI matrix and lint job;
-* added **Version()** (replacing the **Version** constant), formed by **ver2go.CombineVersion()**;
-* documented **VersionString()**;
-* **VersionAB** now uses **ver2go.Release**;
-* updated **ver2go** to 0.2.0-beta1;
-* updated **examples/libver.go** to use **VersionString()**;
-* version string updated for the 0.0.1 release;
+## 0.0.2 - 2nd September 2026
-## 0.0.0.5 - 20th August 2026
-
-* CI modernisation (matrix + lint);
-* CI reliability fixes (macOS test linking; golangci-lint config verification disabled in CI);
-* boilerplate additions (scripts, markdown docs, project identity);
-* removed retired Go Report Card badge from README;
-* version string updated for the 0.0.0.5 release;
+* Version API migrated toward **ver2go** 0.2 facilities;
+* Boilerplate / helper-script updates;
## 0.0.0.4 - 18th August 2025
diff --git a/EXAMPLES.md b/EXAMPLES.md
index 21194ad..e818a28 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -1,9 +1,13 @@
# recls.Go - Examples
-| Name | Source & Description | Summary |
-| ---------- | ---------------------------------------- | -------------------------------------------------------- |
-| **libver** | [examples/libver.go](./examples/libver.go)
[examples/libver.md](./examples/libver.md) | Displays the **recls.Go** library version and terminates |
+| Name | Source & Description | Details |
+| ----------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
+| **hard_links** | [examples/hard_links/main.go](./examples/hard_links/main.go)
[examples/hard_links.md](./examples/hard_links.md) | Entries with hard-link count > 1 |
+| **libver** | [examples/libver/main.go](./examples/libver/main.go)
[examples/libver.md](./examples/libver.md) | Prints the **recls.Go** version string and those of the dependency libraries |
+| **rls** | [examples/rls/main.go](./examples/rls/main.go)
[examples/rls.md](./examples/rls.md) | Listing tool inspired by Synesis **rls** (Ruby) |
+| **search_simple** | [examples/search_simple/main.go](./examples/search_simple/main.go)
[examples/search_simple.md](./examples/search_simple.md) | Recursive file search with an optional pattern |
+| **stat** | [examples/stat/main.go](./examples/stat/main.go)
[examples/stat.md](./examples/stat.md) | `Stat` on a file or directory path |
diff --git a/NEWS.md b/NEWS.md
index 9f090f7..869dde4 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,12 +1,13 @@
# recls.Go - News
-| Date | News Item |
-| ---------------- | --------------------------------------------------------------------------------------------------- |
-| 20th August 2026 | Release of [**recls.Go** 0.0.1](https://github.com/synesissoftware/recls.Go/releases/tag/0.0.1) |
-| 20th August 2026 | Release of [**recls.Go** 0.0.0.5](https://github.com/synesissoftware/recls.Go/releases/tag/0.0.0.5) |
-| 18th August 2025 | Release of [**recls.Go** 0.0.0.4](https://github.com/synesissoftware/recls.Go/releases/tag/0.0.0.4) |
-| 13th August 2025 | Release of **recls.Go** 0.0.0.3 |
+| Date | News Item | Details |
+| ------------------ | ------------------------------------------------------------------------------------------------- | ------- |
+| 3rd September 2026 | [**recls.Go** 0.1.0-alpha1](https://github.com/synesissoftware/recls.Go) released | First functional alpha (Search + Stat; `PatternSource`) |
+| 20th August 2026 | [**recls.Go** 0.0.1](https://github.com/synesissoftware/recls.Go/releases/tag/0.0.1) released | |
+| 20th August 2026 | [**recls.Go** 0.0.0.5](https://github.com/synesissoftware/recls.Go/releases/tag/0.0.0.5) released | |
+| 18th August 2025 | [**recls.Go** 0.0.0.4](https://github.com/synesissoftware/recls.Go/releases/tag/0.0.0.4) released | |
+| 13th August 2025 | **recls.Go** 0.0.0.3 released | |
diff --git a/README.md b/README.md
index 8783e2c..8e710da 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,8 @@
The platform-independent file-system recursive search library, for Go.
-
+
+[](https://go.dev/)
[](https://opensource.org/licenses/BSD-3-Clause)
[](https://github.com/synesissoftware/recls.Go/releases/latest)
[](https://github.com/synesissoftware/recls.Go/commits/master)
@@ -10,38 +11,112 @@ The platform-independent file-system recursive search library, for Go.
[](https://pkg.go.dev/github.com/synesissoftware/recls.Go)
-## Table of contents
+## Table of Contents
- [Introduction](#introduction)
- [Installation](#installation)
+- [Components](#components)
+ - [Entry](#entry)
+ - [Stat](#stat)
+ - [Search](#search)
+ - [Flags](#flags)
- [Examples](#examples)
- [Project Information](#project-information)
- - [Where to get help](#where-to-get-help)
- - [Contribution guidelines](#contribution-guidelines)
- - [Dependencies](#dependencies)
- - [Development/Example/Testing Dependencies](#developmentexampletesting-dependencies)
- - [Related projects](#related-projects)
- - [License](#license)
-
+ - [Where to get help](#where-to-get-help)
+ - [Contribution guidelines](#contribution-guidelines)
+ - [Dependencies](#dependencies)
+ - [Development / Testing Dependencies](#development--testing-dependencies)
+ - [Related projects](#related-projects)
+ - [License](#license)
## Introduction
-**recls** - **rec**ursive **ls** - is a platform-independent recursive file-system search library implemented in C and C++ with a C-API, and a C++ binding. It implemented in several languages: **recls.Go** is the **Go** implementation.
+**recls** — **rec**ursive **ls** — is a platform-independent recursive
+file-system search library. **recls.Go** is the Go implementation, shaped
+around the recls2 architecture:
-**recls** is completely free and includes source released under a BSD-style license.
+* **libpath.Go** — path decomposition (`PathDescriptor`) for Entry fields;
+* **stdlib** (`os`, `io/fs`) — directory traversal and metadata;
+* **shwild.Go** — shell-compatible pattern matching (not `filepath.Match`).
+
+**recls** is free software released under a BSD-style license.
## Installation
-```Go
+```go
import recls "github.com/synesissoftware/recls.Go"
```
+## Components
+
+
+### Entry
+
+`Entry` embeds **libpath** `PathDescriptor` for path decomposition, adds
+search-relative fields, and implements `fs.FileInfo`. Nature predicates are
+methods (`Exists()`, `IsDir()`, `IsFile()`, `IsLink()`, `IsHidden()`,
+`IsReadonly()`); use `Path()` for the recls name of `FullPath`.
+
+
+### Stat
+
+```go
+e, err := recls.Stat(path, recls.DirectoryParts|recls.MarkDirs)
+```
+
+`DetailsLater` allows a path-only entry when the path does not exist.
+
+
+### Search
+
+Depth-first recursive search. Patterns are matched against the **entry
+basename** via **shwild**. `patterns` is a **`PatternSource`**: either a
+multi-pattern string (split on `|` or the platform path-list separator —
+`:` on Unix, `;` on Windows) or a `[]string` of discrete patterns (not
+re-split).
+
+```go
+opts := recls.SearchOptions{Flags: recls.Files | recls.Recursive}
+for e, err := range recls.Search(root, "*.go", opts) {
+ if err != nil { /* ... */ }
+ fmt.Println(e.SearchRelativePath)
+}
+```
+
+Or callback style, with a string or a slice:
+
+```go
+err := recls.SearchFunc(root, "*.go|*.md", opts, func(e recls.Entry) error {
+ fmt.Println(e.Path())
+ return nil
+})
+
+err = recls.SearchFunc(root, []string{"*.go", "*.md"}, opts, func(e recls.Entry) error {
+ fmt.Println(e.Path())
+ return nil
+})
+```
+
+
+### Flags
+
+Core flags (aligned with C `RECLS_F_*`): `Files` (default type),
+`Directories`, `Links`, `Recursive`, `DirectoryParts`, `MarkDirs`,
+`IgnoreHiddenEntries`, `StopOnAccessFailure`, `NoFollowLinks`,
+`NoBreakInfiniteLoops`, `DirProgress`, `UseTildeOnNoSearchRoot`,
+`DetailsLater`.
+
+Explicitly out of scope for v0.1 (see **TODO.md**): FTP, devices, sockets,
+BFS, `RemoveDirectory`, checksums, Windows reparse-dir edge cases.
+
+
## Examples
-Examples are provided in the ```examples``` directory, along with a markdown description for each. A detailed list TOC of them is provided in [EXAMPLES.md](./EXAMPLES.md).
+Examples are provided in the `examples` directory, along with a markdown
+description for each. A detailed TOC is in [EXAMPLES.md](./EXAMPLES.md).
## Project Information
@@ -49,12 +124,13 @@ Examples are provided in the ```examples``` directory, along with a markdown des
### Where to get help
-[GitHub Page](https://github.com/synesissoftware/recls.Go "GitHub Page")
+[GitHub Page](https://github.com/synesissoftware/recls.Go)
### Contribution guidelines
-Defect reports, feature requests, and pull requests are welcome on https://github.com/synesissoftware/recls.Go.
+Defect reports, feature requests, and pull requests are welcome on
+https://github.com/synesissoftware/recls.Go.
### Dependencies
@@ -64,10 +140,8 @@ Defect reports, feature requests, and pull requests are welcome on https://githu
* [**ver2go**](https://github.com/synesissoftware/ver2go/);
-#### Development/Example/Testing Dependencies
+#### Development / Testing Dependencies
-* [**CLASP.Go**](https://github.com/synesissoftware/CLASP.Go/);
-* [**STEGoL**](https://github.com/synesissoftware/STEGoL/);
* [**testify**](https://github.com/stretchr/testify);
@@ -81,7 +155,8 @@ Defect reports, feature requests, and pull requests are welcome on https://githu
### License
-**recls.Go** is released under the 3-clause BSD license. See [LICENSE](./LICENSE) for details.
+**recls.Go** is released under the 3-clause BSD license. See [LICENSE](./LICENSE)
+for details.
diff --git a/TODO.md b/TODO.md
index 901556f..aea5dda 100644
--- a/TODO.md
+++ b/TODO.md
@@ -10,18 +10,35 @@
## Functional improvements
-* \
+* [ ] BFS search order (recls.NET-style);
+* [ ] `RemoveDirectory` helper;
+* [ ] Devices / sockets type filters;
+* [x] ~~~Link count metadata~~~ - ✅ (`Entry.LinkCount`);
+* [ ] Node index metadata;
+* [ ] FTP search (`RECLS_F_PASSIVE_FTP`);
+* [ ] CalcChecksum;
+* [ ] Windows `AllowReparseDirs` edge cases;
+* [ ] Windows flag so leading-dot names are not treated as hidden (apply in `ProbeHidden` / `IsHiddenName`; Unix-style `.name` convention becomes optional on Windows);
+* [ ] Windows file attributes (system, archive, compressed, …) via a Windows-only interface (`//go:build windows`); wire into **rls** example attribute letters (S/T/V/A/E/C per Ruby **rls**);
+* [ ] Public path helpers (`CombinePaths`, `DeriveRelativePath`, `CanonicalisePath`) — currently internal / stdlib;
+* [ ] Upstream `derive_relative_path` / trailing-separator helpers into **libpath.Go** (recls currently carries thin internal copies);
+* [ ] Upstream path compare / case-fold helpers into **libpath.Go** (Ruby has `make_compare_path`; use for Windows-aware `pathElementsEqual`);
+* [ ] `DirProgress` example and richer progress API;
+* [ ] Consider **recls.NET**-style callbacks to filter / decide fate of inaccessible directories (beyond binary StopOnAccessFailure vs skip);
+* [x] ~~~Consider generics so `patterns` may be a `string` or an array of strings~~~ - ✅ (`PatternSource`);
## Performance improvements
-* \
+* [ ] Consider caching compiled patterns across multi-root searches;
+* [ ] Optimise patterns: if a wildcards-all (`*`) is present, elide all other patterns;
+* [ ] Optional read-ahead / buffered directory reads for large trees;
## Packaging improvements
-* [ ] Before the next official release: confirm **`go.mod`** (`go 1.21`) and the CI Go-version matrix, bump Synesis `require`s to newly published tags, then run **`go mod tidy`** (not against currently published tags). Prior Synesis Go releases, in order:
- * **ver2go**;
+* [ ] Align **NEWS.md** Details column with umbrella packaging programme;
+* [x] ~~~Drop local `replace` directives once published dependency versions cover CI~~~ - ✅;
diff --git a/constants_unix.go b/constants_unix.go
new file mode 100644
index 0000000..381da23
--- /dev/null
+++ b/constants_unix.go
@@ -0,0 +1,23 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build unix
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 3rd September 2026
+ */
+
+package recls
+
+import (
+ libpath_unix "github.com/synesissoftware/libpath.Go/util/unix"
+)
+
+const (
+ // Platform path-name (directory) separator.
+ PathNameSeparator = string(libpath_unix.PathElementSeparator)
+ // Platform path-list separator (e.g. for multi-pattern search strings).
+ PathSeparator = string(libpath_unix.PathSeparator)
+)
diff --git a/constants_windows.go b/constants_windows.go
new file mode 100644
index 0000000..b67646a
--- /dev/null
+++ b/constants_windows.go
@@ -0,0 +1,23 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build windows
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 3rd September 2026
+ */
+
+package recls
+
+import (
+ libpath_windows "github.com/synesissoftware/libpath.Go/util/windows"
+)
+
+const (
+ // Platform path-name (directory) separator.
+ PathNameSeparator = string(libpath_windows.PathElementSeparator)
+ // Platform path-list separator (e.g. for multi-pattern search strings).
+ PathSeparator = string(libpath_windows.PathSeparator)
+)
diff --git a/deps.go b/deps.go
deleted file mode 100644
index 89b2c55..0000000
--- a/deps.go
+++ /dev/null
@@ -1,3 +0,0 @@
-package recls
-
-import _ "github.com/synesissoftware/libpath.Go"
diff --git a/entry.go b/entry.go
new file mode 100644
index 0000000..4a32f29
--- /dev/null
+++ b/entry.go
@@ -0,0 +1,130 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 4th September 2026
+ */
+
+package recls
+
+import (
+ "github.com/synesissoftware/recls.Go/internal"
+
+ libpath_common "github.com/synesissoftware/libpath.Go/parse/common"
+
+ "io/fs"
+ "os"
+ "time"
+)
+
+// A file-system entry discovered by Stat or Search. Embeds libpath's
+// PathDescriptor for path decomposition; search-relative fields and
+// file-system nature are layered on top. Implements fs.FileInfo.
+type Entry struct {
+ libpath_common.PathDescriptor
+
+ // Absolute search root when produced by Search; empty for Stat.
+ SearchRoot string
+ // Path relative to SearchRoot.
+ SearchRelativePath string
+ // Location relative to SearchRoot.
+ SearchRelativeDirectory string
+ // Split SearchRelativeDirectory (parts end with PathNameSeparator).
+ SearchRelativeDirectoryParts []string
+
+ fileInfo os.FileInfo
+ hidden bool
+ readonly bool
+}
+
+// Fullest establishable path (recls name for PathDescriptor.FullPath).
+func (e Entry) Path() string {
+ return e.FullPath
+}
+
+// True when file-system metadata was obtained.
+func (e Entry) Exists() bool {
+ return e.fileInfo != nil
+}
+
+// True when the entry is a regular file.
+func (e Entry) IsFile() bool {
+ return e.fileInfo != nil && e.fileInfo.Mode().IsRegular()
+}
+
+// True when the entry is a symbolic link.
+func (e Entry) IsLink() bool {
+ return e.fileInfo != nil && e.fileInfo.Mode()&fs.ModeSymlink != 0
+}
+
+// True when the entry is considered hidden.
+func (e Entry) IsHidden() bool {
+ return e.hidden
+}
+
+// True when the entry is not writable by the owner (Unix: mode lacks the
+// owner-write bit; Windows modelling is incomplete in v0.1).
+func (e Entry) IsReadonly() bool {
+ return e.readonly
+}
+
+// fs.FileInfo
+func (e Entry) Name() string {
+ return e.EntryName
+}
+
+func (e Entry) Size() int64 {
+ if e.fileInfo == nil {
+ return 0
+ } else {
+ return e.fileInfo.Size()
+ }
+}
+
+func (e Entry) Mode() fs.FileMode {
+ if e.fileInfo == nil {
+ return 0
+ } else {
+ return e.fileInfo.Mode()
+ }
+}
+
+func (e Entry) ModTime() time.Time {
+ if e.fileInfo == nil {
+ return time.Time{}
+ } else {
+ return e.fileInfo.ModTime()
+ }
+}
+
+func (e Entry) IsDir() bool {
+ return e.fileInfo != nil && e.fileInfo.IsDir()
+}
+
+func (e Entry) Sys() any {
+ if e.fileInfo == nil {
+ return nil
+ } else {
+ return e.fileInfo.Sys()
+ }
+}
+
+// Underlying os.FileInfo when Exists(); otherwise nil.
+func (e Entry) FileInfo() os.FileInfo {
+ return e.fileInfo
+}
+
+// Hard-link count for the entry. Returns 0 when metadata is unavailable or
+// the platform could not obtain the count. On Unix, directories normally
+// report at least 2 (`.` and `..`); a regular file with a single name
+// reports 1.
+func (e Entry) LinkCount() uint64 {
+ n, ok := internal.LinkCount(e.Path(), e.fileInfo)
+ if !ok {
+ return 0
+ } else {
+ return n
+ }
+}
diff --git a/entry_build.go b/entry_build.go
new file mode 100644
index 0000000..b335bfa
--- /dev/null
+++ b/entry_build.go
@@ -0,0 +1,113 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 3rd September 2026
+ */
+
+package recls
+
+import (
+ "github.com/synesissoftware/recls.Go/internal"
+
+ libpath_parse "github.com/synesissoftware/libpath.Go/parse"
+ libpath_common "github.com/synesissoftware/libpath.Go/parse/common"
+
+ "os"
+)
+
+// Constructs an Entry from an absolute (or fullest) path, optional
+// FileInfo, and optional search root.
+func buildEntry(
+ entryPath string,
+ referenceDir string,
+ searchRoot string,
+ info os.FileInfo,
+ flags SearchFlags,
+) (Entry, error) {
+
+ pd, err := libpath_parse.ParsePathString(entryPath, referenceDir)
+ if err != nil {
+ return Entry{}, &InvalidPathError{Path: entryPath, Err: err}
+ }
+
+ e := entryFromDescriptor(pd, flags)
+
+ if searchRoot != "" {
+ e.SearchRoot = searchRoot
+ relPath := internal.DeriveRelativePath(searchRoot, e.FullPath, PathNameSeparator)
+ relDir := internal.DeriveRelativePath(searchRoot, e.Location, PathNameSeparator)
+ e.SearchRelativePath = relPath
+ e.SearchRelativeDirectory = relDir
+ // Always populate search-relative parts for discoverability in
+ // v0.1; DirectoryParts still gates absolute DirectoryParts.
+ e.SearchRelativeDirectoryParts = internal.SplitDirectoryParts(
+ ensureDirPartsForm(relDir),
+ PathNameSeparator,
+ )
+ }
+
+ if info != nil {
+ applyFileInfo(&e, info)
+ e.hidden = internal.ProbeHidden(e.FullPath, e.EntryName, info)
+ }
+
+ if e.IsDir() && 0 != (flags&MarkDirs) {
+ e.FullPath = internal.EnsureTrailingSep(e.FullPath, PathNameSeparator)
+ if e.SearchRelativePath != "" {
+ e.SearchRelativePath = internal.EnsureTrailingSep(e.SearchRelativePath, PathNameSeparator)
+ }
+ }
+
+ return e, nil
+}
+
+func entryFromDescriptor(
+ pd libpath_common.PathDescriptor,
+ flags SearchFlags,
+) Entry {
+
+ e := Entry{
+ PathDescriptor: pd,
+ }
+ if 0 == (flags & DirectoryParts) {
+ e.DirectoryParts = nil
+ }
+ return e
+}
+
+func ensureDirPartsForm(dir string) string {
+ if dir == "" || dir == "." {
+ return dir
+ } else {
+ return internal.EnsureTrailingSep(dir, PathNameSeparator)
+ }
+}
+
+func applyFileInfo(e *Entry, info os.FileInfo) {
+ e.fileInfo = info
+ e.readonly = info.Mode().Perm()&0200 == 0
+}
+
+// Obtains FileInfo according to flags. Returns (nil, nil) when the path
+// does not exist and DetailsLater is set; otherwise returns the error.
+func statInfo(path string, flags SearchFlags) (os.FileInfo, error) {
+ var (
+ info os.FileInfo
+ err error
+ )
+ if 0 != (flags & NoFollowLinks) {
+ info, err = os.Lstat(path)
+ } else {
+ info, err = os.Stat(path)
+ }
+ if err == nil {
+ return info, nil
+ }
+ if os.IsNotExist(err) && 0 != (flags&DetailsLater) {
+ return nil, nil
+ }
+ return nil, err
+}
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..27c8fa4
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,59 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+ * Created: 19th February 2025
+ * Updated: 3rd September 2026
+ */
+
+package recls
+
+import (
+ "errors"
+ "fmt"
+)
+
+// Indicates that a search has no further entries, or that an empty pattern
+// string was supplied (matching C RECLS_RC_NO_MORE_DATA).
+var ErrNoMoreData = errors.New("recls: no more data")
+
+// Indicates that "." or ".." was used as a pattern in a recursive search.
+var ErrDotRecursiveSearch = errors.New("recls: dot/dot-dot pattern not allowed in recursive search")
+
+// Reports that a directory or entry could not be accessed when
+// StopOnAccessFailure was set.
+type AccessDeniedError struct {
+ Path string
+ Err error
+}
+
+func (e *AccessDeniedError) Error() string {
+ if e.Err != nil {
+ return fmt.Sprintf("recls: access denied for %q: %v", e.Path, e.Err)
+ } else {
+ return fmt.Sprintf("recls: access denied for %q", e.Path)
+ }
+}
+
+func (e *AccessDeniedError) Unwrap() error {
+ return e.Err
+}
+
+// Reports that a path string could not be interpreted.
+type InvalidPathError struct {
+ Path string
+ Err error
+}
+
+func (e *InvalidPathError) Error() string {
+ if e.Err != nil {
+ return fmt.Sprintf("recls: invalid path %q: %v", e.Path, e.Err)
+ } else {
+ return fmt.Sprintf("recls: invalid path %q", e.Path)
+ }
+}
+
+func (e *InvalidPathError) Unwrap() error {
+ return e.Err
+}
diff --git a/examples/hard_links.md b/examples/hard_links.md
new file mode 100644
index 0000000..77984d4
--- /dev/null
+++ b/examples/hard_links.md
@@ -0,0 +1,28 @@
+# recls.Go - Example - **hard_links**
+
+
+## Summary
+
+Recursively searches for files and directories under a root (default `.`)
+whose hard-link count is greater than 1, printing `linkCount`, a short
+kind tag (`file` / `dir` / `link`), and the search-relative path.
+
+On Unix, directories normally report a link count of at least 2 (`.` and
+`..`), so most directories will appear; hard-linked **files** (same inode,
+multiple names) are the usual signal of interest.
+
+
+## Source
+
+* [examples/hard_links/main.go](./hard_links/main.go)
+
+
+## Execution
+
+```bash
+go run ./examples/hard_links
+go run ./examples/hard_links /tmp
+```
+
+
+
diff --git a/examples/hard_links/main.go b/examples/hard_links/main.go
new file mode 100644
index 0000000..f712607
--- /dev/null
+++ b/examples/hard_links/main.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ recls "github.com/synesissoftware/recls.Go"
+
+ "fmt"
+ "os"
+)
+
+func main() {
+ root := "."
+ if len(os.Args) > 1 {
+ root = os.Args[1]
+ }
+
+ opts := recls.SearchOptions{
+ Flags: recls.Files | recls.Recursive | recls.MarkDirs,
+ // Flags: recls.Files | recls.Directories | recls.Recursive | recls.MarkDirs,
+ }
+
+ err := recls.SearchFunc(root, "*", opts, func(e recls.Entry) error {
+ n := e.LinkCount()
+ if n <= 1 {
+ return nil
+ }
+
+ kind := "file"
+ if e.IsDir() {
+ kind = "dir"
+ } else if e.IsLink() {
+ kind = "link"
+ }
+
+ fmt.Printf("%6d %-4s %s\n", n, kind, e.SearchRelativePath)
+ return nil
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "hard_links: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/examples/libver.go b/examples/libver.go
deleted file mode 100644
index 4fadb94..0000000
--- a/examples/libver.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package main
-
-import (
- recls "github.com/synesissoftware/recls.Go"
- "github.com/synesissoftware/ver2go"
-
- "fmt"
-)
-
-func main() {
- fmt.Printf("recls v%s\n", recls.VersionString())
- fmt.Printf("ver2go v%s\n", ver2go.VersionString())
-}
diff --git a/examples/libver.md b/examples/libver.md
index b6d7159..e6452ae 100644
--- a/examples/libver.md
+++ b/examples/libver.md
@@ -3,18 +3,19 @@
## Summary
-Displays the **recls.Go** library version and terminates.
+Prints the **recls.Go** version string and those of the dependency
+libraries.
## Source
-See [examples/libver.go](./examples/libver.go).
+* [examples/libver/main.go](./libver/main.go)
## Execution
```bash
-go run ./examples/libver.go
+go run ./examples/libver
```
diff --git a/examples/libver/main.go b/examples/libver/main.go
new file mode 100644
index 0000000..6282719
--- /dev/null
+++ b/examples/libver/main.go
@@ -0,0 +1,17 @@
+package main
+
+import (
+ libpath "github.com/synesissoftware/libpath.Go"
+ recls "github.com/synesissoftware/recls.Go"
+ shwild "github.com/synesissoftware/shwild.Go"
+ ver2go "github.com/synesissoftware/ver2go"
+
+ "fmt"
+)
+
+func main() {
+ fmt.Printf("recls v%s\n", recls.VersionString())
+ fmt.Printf("libpath v%s\n", libpath.VersionString())
+ fmt.Printf("shwild v%s\n", shwild.VersionString())
+ fmt.Printf("ver2go v%s\n", ver2go.VersionString())
+}
diff --git a/examples/rls.md b/examples/rls.md
new file mode 100644
index 0000000..6d99ae9
--- /dev/null
+++ b/examples/rls.md
@@ -0,0 +1,29 @@
+# recls.Go - Example - **rls**
+
+
+## Summary
+
+A compact Go listing tool inspired by Synesis **`rls`** (`~/.bin/rls.rb`):
+recursive search via **recls.Go**, printing modification time, a short
+attribute string, size, and path (or path-only in succinct mode).
+
+This is an example, not a full port — multi-root `|` / path-list specs,
+directory-size (`-z`), and Windows-only attribute letters are omitted.
+
+
+## Source
+
+* [examples/rls/main.go](./rls/main.go)
+
+
+## Execution
+
+```bash
+go run ./examples/rls
+go run ./examples/rls -s -T . '*.go'
+go run ./examples/rls -m --dirs --files -c /tmp
+go run ./examples/rls -h
+```
+
+
+
diff --git a/examples/rls/main.go b/examples/rls/main.go
new file mode 100644
index 0000000..ecb8403
--- /dev/null
+++ b/examples/rls/main.go
@@ -0,0 +1,273 @@
+package main
+
+import (
+ recls "github.com/synesissoftware/recls.Go"
+
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+const timeFormat = "02/01/2006 03:04:05 PM"
+
+func main() {
+ var (
+ displayNameOnly bool
+ displaySearchRoots bool
+ displayTotals bool
+ displayWithCommas bool
+ markDirectories bool
+ succinct bool
+ includeDirectories bool
+ includeHidden bool
+ includeFiles bool
+ nonRecursive bool
+ trimToCwd bool
+ trimToSearchRoot bool
+ showTotalSize bool
+ minSize int64
+ maxSize int64
+ )
+
+ flag.BoolVar(&displayNameOnly, "f", false, "display entry name only")
+ flag.BoolVar(&displayNameOnly, "display-name-only", false, "display entry name only")
+ flag.BoolVar(&displaySearchRoots, "d", false, "display search roots (and skip listing)")
+ flag.BoolVar(&displaySearchRoots, "display-search-roots", false, "display search roots (and skip listing)")
+ flag.BoolVar(&displayTotals, "c", false, "display totals")
+ flag.BoolVar(&displayTotals, "n", false, "display totals")
+ flag.BoolVar(&displayTotals, "display-totals", false, "display totals")
+ flag.BoolVar(&displayWithCommas, ",", false, "insert commas into sizes and totals")
+ flag.BoolVar(&displayWithCommas, "display-with-commas", false, "insert commas into sizes and totals")
+ flag.BoolVar(&markDirectories, "m", false, "mark directories with a trailing separator")
+ flag.BoolVar(&markDirectories, "mark-directories", false, "mark directories with a trailing separator")
+ flag.BoolVar(&succinct, "s", false, "succinct output — path only")
+ flag.BoolVar(&succinct, "succinct", false, "succinct output — path only")
+ flag.BoolVar(&includeDirectories, "dirs", false, "include directories")
+ flag.BoolVar(&includeDirectories, "directories", false, "include directories")
+ flag.BoolVar(&includeDirectories, "include-directories", false, "include directories")
+ flag.BoolVar(&includeHidden, "hidden", false, "include hidden entries")
+ flag.BoolVar(&includeHidden, "include-hidden", false, "include hidden entries")
+ flag.BoolVar(&includeFiles, "files", false, "include files")
+ flag.BoolVar(&includeFiles, "include-files", false, "include files")
+ flag.BoolVar(&nonRecursive, "R", false, "non-recursive search")
+ flag.BoolVar(&nonRecursive, "non-recursive-search", false, "non-recursive search")
+ flag.BoolVar(&trimToCwd, "t", false, "trim path relative to the current directory")
+ flag.BoolVar(&trimToCwd, "trim-to-cwd", false, "trim path relative to the current directory")
+ flag.BoolVar(&trimToSearchRoot, "T", false, "trim path relative to the search root")
+ flag.BoolVar(&trimToSearchRoot, "trim-to-search-root", false, "trim path relative to the search root")
+ flag.BoolVar(&showTotalSize, "Z", false, "display total size of listed entries")
+ flag.BoolVar(&showTotalSize, "show-total-size", false, "display total size of listed entries")
+ flag.Int64Var(&minSize, "min-size", -1, "minimum file size (bytes); ignored for directories")
+ flag.Int64Var(&maxSize, "max-size", -1, "maximum file size (bytes); ignored for directories")
+
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "Usage: %s [flags] [root [pattern ...]]\n", filepath.Base(os.Args[0]))
+ fmt.Fprintf(os.Stderr, "\nInspired by Synesis rls (Ruby); recursive listing via recls.Go.\n\n")
+ flag.PrintDefaults()
+ }
+ flag.Parse()
+
+ if trimToCwd && trimToSearchRoot {
+ fmt.Fprintln(os.Stderr, "rls: cannot specify both -t/--trim-to-cwd and -T/--trim-to-search-root")
+ os.Exit(2)
+ }
+ if minSize >= 0 && maxSize >= 0 && minSize > maxSize {
+ fmt.Fprintln(os.Stderr, "rls: max-size cannot be smaller than min-size")
+ os.Exit(2)
+ }
+
+ root := "."
+ patterns := []string{"*"}
+ args := flag.Args()
+ if len(args) >= 1 {
+ root = args[0]
+ }
+ if len(args) >= 2 {
+ patterns = args[1:]
+ }
+
+ var flags recls.SearchFlags
+ if includeFiles {
+ flags |= recls.Files
+ }
+ if includeDirectories {
+ flags |= recls.Directories
+ }
+ if 0 == (flags & (recls.Files | recls.Directories | recls.Links)) {
+ flags |= recls.Files
+ }
+ if !nonRecursive {
+ flags |= recls.Recursive
+ }
+ if !includeHidden {
+ flags |= recls.IgnoreHiddenEntries
+ }
+ if markDirectories {
+ flags |= recls.MarkDirs
+ }
+
+ if displaySearchRoots {
+ fmt.Printf("searching %q with %q\n", root, strings.Join(patterns, "|"))
+ return
+ }
+
+ cwd, err := os.Getwd()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "rls: %v\n", err)
+ os.Exit(1)
+ }
+
+ tty := isTTY()
+ var numFound int64
+ var totalSize int64
+
+ opts := recls.SearchOptions{Flags: flags}
+ err = recls.SearchFunc(root, patterns, opts, func(e recls.Entry) error {
+ if e.IsFile() {
+ if minSize >= 0 && e.Size() < minSize {
+ return nil
+ }
+ if maxSize >= 0 && e.Size() > maxSize {
+ return nil
+ }
+ }
+
+ numFound++
+
+ var path string
+ switch {
+ case trimToCwd:
+ rel, relErr := filepath.Rel(cwd, e.Path())
+ if relErr != nil {
+ path = e.Path()
+ } else {
+ path = rel
+ }
+ case trimToSearchRoot:
+ path = e.SearchRelativePath
+ case displayNameOnly:
+ path = e.EntryName
+ default:
+ path = e.Path()
+ }
+
+ if succinct {
+ fmt.Println(path)
+ } else {
+ date := e.ModTime().Format(timeFormat)
+ attr := makeAttr(e)
+ sizeStr := ""
+ if !e.IsDir() {
+ sizeStr = formatSize(e.Size(), displayWithCommas)
+ totalSize += e.Size()
+ }
+ if tty {
+ sizeStr = padLeft(sizeStr, 20)
+ }
+ fmt.Printf("%s\t%s\t%s\t%s\n", date, attr, sizeStr, path)
+ }
+
+ return nil
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "rls: %v\n", err)
+ os.Exit(1)
+ }
+
+ if showTotalSize {
+ sizeStr := formatSize(totalSize, true)
+ if tty {
+ sep := strings.Repeat("-", len(sizeStr))
+ fmt.Printf("%s%s\n", strings.Repeat(" ", 40), padLeft(sep, 20))
+ fmt.Printf("%s%s\n", strings.Repeat(" ", 40), padLeft(sizeStr, 20))
+ } else {
+ fmt.Printf("\t\t\t%s\n", sizeStr)
+ }
+ }
+
+ if displayTotals {
+ n := strconv.FormatInt(numFound, 10)
+ if displayWithCommas {
+ n = insertCommas(n)
+ }
+ suffix := "s"
+ if numFound == 1 {
+ suffix = ""
+ }
+ fmt.Printf("\t%s file%s found\n", n, suffix)
+ }
+}
+
+func makeAttr(e recls.Entry) string {
+ r := []byte("--------")
+ if e.IsReadonly() {
+ r[0] = 'R'
+ }
+ if e.IsHidden() {
+ r[1] = 'H'
+ }
+ if e.IsDir() {
+ r[4] = 'D'
+ }
+ return string(r)
+}
+
+func formatSize(n int64, withCommas bool) string {
+ s := strconv.FormatInt(n, 10)
+ if withCommas {
+ return insertCommas(s)
+ } else {
+ return s
+ }
+}
+
+func insertCommas(s string) string {
+ neg := false
+ if strings.HasPrefix(s, "-") {
+ neg = true
+ s = s[1:]
+ }
+ if len(s) <= 3 {
+ if neg {
+ return "-" + s
+ } else {
+ return s
+ }
+ }
+
+ var b strings.Builder
+ lead := len(s) % 3
+ if lead == 0 {
+ lead = 3
+ }
+ b.WriteString(s[:lead])
+ for i := lead; i < len(s); i += 3 {
+ b.WriteByte(',')
+ b.WriteString(s[i : i+3])
+ }
+ if neg {
+ return "-" + b.String()
+ } else {
+ return b.String()
+ }
+}
+
+func padLeft(s string, width int) string {
+ if len(s) >= width {
+ return s
+ } else {
+ return strings.Repeat(" ", width-len(s)) + s
+ }
+}
+
+func isTTY() bool {
+ fi, err := os.Stdout.Stat()
+ if err != nil {
+ return false
+ } else {
+ return (fi.Mode() & os.ModeCharDevice) != 0
+ }
+}
diff --git a/examples/search_simple.md b/examples/search_simple.md
new file mode 100644
index 0000000..d87e0b1
--- /dev/null
+++ b/examples/search_simple.md
@@ -0,0 +1,23 @@
+# recls.Go - Example - **search_simple**
+
+
+## Summary
+
+Recursively searches for files under a root (default `.`) matching a
+pattern (default `*`), printing each entry's search-relative path.
+
+
+## Source
+
+* [examples/search_simple/main.go](./search_simple/main.go)
+
+
+## Execution
+
+```bash
+go run ./examples/search_simple
+go run ./examples/search_simple /tmp '*.go'
+```
+
+
+
diff --git a/examples/search_simple/main.go b/examples/search_simple/main.go
new file mode 100644
index 0000000..1c57821
--- /dev/null
+++ b/examples/search_simple/main.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+ recls "github.com/synesissoftware/recls.Go"
+
+ "fmt"
+ "os"
+)
+
+func main() {
+ root := "."
+ if len(os.Args) > 1 {
+ root = os.Args[1]
+ }
+ pattern := "*"
+ if len(os.Args) > 2 {
+ pattern = os.Args[2]
+ }
+
+ opts := recls.SearchOptions{
+ Flags: recls.Files | recls.Recursive | recls.DirectoryParts,
+ }
+
+ err := recls.SearchFunc(root, pattern, opts, func(e recls.Entry) error {
+ fmt.Println(e.SearchRelativePath)
+ return nil
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "search: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/examples/stat.md b/examples/stat.md
new file mode 100644
index 0000000..cf8849f
--- /dev/null
+++ b/examples/stat.md
@@ -0,0 +1,22 @@
+# recls.Go - Example - **stat**
+
+
+## Summary
+
+Calls `recls.Stat` on a path (default `.`) and prints core Entry fields.
+
+
+## Source
+
+* [examples/stat/main.go](./stat/main.go)
+
+
+## Execution
+
+```bash
+go run ./examples/stat
+go run ./examples/stat /etc/hosts
+```
+
+
+
diff --git a/examples/stat/main.go b/examples/stat/main.go
new file mode 100644
index 0000000..678629f
--- /dev/null
+++ b/examples/stat/main.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ recls "github.com/synesissoftware/recls.Go"
+
+ "fmt"
+ "os"
+)
+
+func main() {
+ path := "."
+ if len(os.Args) > 1 {
+ path = os.Args[1]
+ }
+
+ e, err := recls.Stat(path, recls.DirectoryParts|recls.MarkDirs)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "stat: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("path: %s\n", e.Path())
+ fmt.Printf("location: %s\n", e.Location)
+ fmt.Printf("entry: %s\n", e.EntryName)
+ fmt.Printf("stem: %s\n", e.Stem)
+ fmt.Printf("extension: %s\n", e.Extension)
+ fmt.Printf("exists: %v\n", e.Exists())
+ fmt.Printf("is_dir: %v\n", e.IsDir())
+ fmt.Printf("is_file: %v\n", e.IsFile())
+ fmt.Printf("size: %d\n", e.Size())
+}
diff --git a/flags.go b/flags.go
new file mode 100644
index 0000000..3e9e125
--- /dev/null
+++ b/flags.go
@@ -0,0 +1,81 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 3rd September 2026
+ */
+
+package recls
+
+// Control search and Stat behaviour. Values align with the C recls
+// RECLS_F_* flags where practical.
+type SearchFlags uint32
+
+const (
+ // Includes regular files. Default when no type flag is set.
+ Files SearchFlags = 0x0000_0001
+ // Includes directories.
+ Directories SearchFlags = 0x0000_0002
+ // Includes symbolic links (Unix).
+ Links SearchFlags = 0x0000_0004
+ // Reserved; not implemented in v0.1.
+ Devices SearchFlags = 0x0000_0008
+ // Reserved; not implemented in v0.1.
+ Sockets SearchFlags = 0x0000_0010
+ // Selects the entry-type filter bits.
+ TypeMask SearchFlags = 0x0000_0FFF
+
+ // Requests per-directory progress callbacks (via
+ // SearchOptions.OnDirectory when provided).
+ DirProgress SearchFlags = 0x0000_1000
+ // Aborts when a directory cannot be read or an entry cannot be stated;
+ // default is skip-and-continue.
+ StopOnAccessFailure SearchFlags = 0x0000_2000
+ // Reserved; not implemented in v0.1.
+ LinkCount SearchFlags = 0x0000_4000
+ // Reserved; not implemented in v0.1.
+ NodeIndex SearchFlags = 0x0000_8000
+
+ // Searches subdirectories depth-first.
+ Recursive SearchFlags = 0x0001_0000
+ // Uses Lstat for metadata and does not descend into directory
+ // symlinks.
+ NoFollowLinks SearchFlags = 0x0002_0000
+ // Populates DirectoryParts and search-relative parts.
+ DirectoryParts SearchFlags = 0x0004_0000
+ // Obtains a path-only Entry without requiring existence.
+ DetailsLater SearchFlags = 0x0008_0000
+
+ // Appends a trailing path-name separator to directory paths.
+ MarkDirs SearchFlags = 0x0020_0000
+ // Treats an empty search root as the home directory rather than the
+ // current working directory.
+ UseTildeOnNoSearchRoot SearchFlags = 0x0400_0000
+ // Skips hidden entries (leading '.' on Unix; FILE_ATTRIBUTE_HIDDEN on
+ // Windows).
+ IgnoreHiddenEntries SearchFlags = 0x0800_0000
+ // Disables the Unix/macOS device+inode loop guard; by default the
+ // guard is active.
+ NoBreakInfiniteLoops SearchFlags = 0x1000_0000
+)
+
+// Alias for SearchFlags used with Stat.
+type StatOptions = SearchFlags
+
+// Holds flags and optional search hooks.
+type SearchOptions struct {
+ Flags SearchFlags
+ // Invoked for each traversed directory when DirProgress is set. A
+ // non-nil return aborts the search.
+ OnDirectory func(dir string) error
+}
+
+// Applies recls defaults: when no type bit is set, Files is assumed.
+func NormaliseFlags(flags SearchFlags) SearchFlags {
+ if 0 == (flags & TypeMask) {
+ flags |= Files
+ }
+ return flags
+}
diff --git a/go.mod b/go.mod
index 5e3a80c..9f41bd2 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,15 @@
module github.com/synesissoftware/recls.Go
-go 1.21
+go 1.23.0
require (
github.com/stretchr/testify v1.12.1
github.com/synesissoftware/libpath.Go v0.0.2
+ github.com/synesissoftware/shwild.Go v0.2.8
github.com/synesissoftware/ver2go v0.2.0
)
-require go.yaml.in/yaml/v3 v3.0.5 // indirect
+require (
+ github.com/synesissoftware/ANGoLS v0.11.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.5 // indirect
+)
diff --git a/go.sum b/go.sum
index da3f2a0..9fdcbd7 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,12 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/synesissoftware/ANGoLS v0.11.0 h1:/XFXs6DqnQTxrsUBUz6FuKRC0eq5cpGnWMpTGwH2HnA=
github.com/synesissoftware/ANGoLS v0.11.0/go.mod h1:F0ewcL/0BxYXtMuBFn5l+FYK365nBHy1copCgASlPkQ=
+github.com/synesissoftware/STEGoL v0.4.0 h1:aufaw9c8Xq2aNpgOLAtgtbU9GX8pFFD3hK1j6cTcCIw=
+github.com/synesissoftware/STEGoL v0.4.0/go.mod h1:jQfaaAIwBtFhs8qHY8h4+ZTn05xvs5H2ZepAK2nHwMU=
github.com/synesissoftware/libpath.Go v0.0.2 h1:/oiQJwE0kqAfvevsiaQedMXrtAihSdwo6lC688ZYpVo=
github.com/synesissoftware/libpath.Go v0.0.2/go.mod h1:SSfdanLz4RBHjUzwAdh+yTtULu6+s6kaPDgd1EMOEp8=
+github.com/synesissoftware/shwild.Go v0.2.8 h1:b7dJ/kaidBezLMI5mFxxpKPb9O48b/nGjWNGLiCZRX0=
+github.com/synesissoftware/shwild.Go v0.2.8/go.mod h1:xpJPlba6DC3VGYWfWeKvFKGO45gbiFK78AZsxMtUgrA=
github.com/synesissoftware/ver2go v0.2.0 h1:pya2BVE9qXgF3p5M3NgibVih2/iZ7osTBwZZzM1Ho6k=
github.com/synesissoftware/ver2go v0.2.0/go.mod h1:JlZS4ms4D4cCaYuRh+fbBaNv7BwqrAP4m4/hZzGJ8hU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
diff --git a/internal/hidden_unix.go b/internal/hidden_unix.go
new file mode 100644
index 0000000..231f471
--- /dev/null
+++ b/internal/hidden_unix.go
@@ -0,0 +1,42 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build unix
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 4th September 2026
+ */
+
+package internal
+
+import (
+ "os"
+)
+
+// Reports whether a Unix entry name is hidden (leading '.'), excluding
+// "." and "..".
+func IsHiddenName(name string) bool {
+ switch name {
+ case "":
+ return false
+ case ".", "..":
+ return false
+ default:
+ return name[0] == '.'
+ }
+}
+
+// Reports whether a Unix entry is hidden by name.
+func ProbeHidden(
+ path string,
+ name string,
+ info os.FileInfo,
+) bool {
+
+ _ = path
+ _ = info
+
+ return IsHiddenName(name)
+}
diff --git a/internal/hidden_windows.go b/internal/hidden_windows.go
new file mode 100644
index 0000000..02d6f47
--- /dev/null
+++ b/internal/hidden_windows.go
@@ -0,0 +1,77 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build windows
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 4th September 2026
+ */
+
+package internal
+
+import (
+ "os"
+ "syscall"
+)
+
+// Reports whether a Windows entry name looks hidden by convention
+// (leading '.'). Full attribute checks use file attributes from
+// os.FileInfo when available.
+func IsHiddenName(name string) bool {
+ switch name {
+ case "":
+ return false
+ case ".", "..":
+ return false
+ default:
+ return name[0] == '.'
+ }
+}
+
+// Reports whether info carries FILE_ATTRIBUTE_HIDDEN. Prefers attributes
+// already present on FileInfo.Sys(); falls back to GetFileAttributes only
+// when Sys() is unavailable.
+func IsHiddenFile(
+ path string,
+ info os.FileInfo,
+) bool {
+
+ if info != nil {
+ switch sys := info.Sys().(type) {
+ case *syscall.Win32FileAttributeData:
+ return sys.FileAttributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0
+ case *syscall.Win32finddata:
+ return sys.FileAttributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0
+ }
+ }
+
+ ptr, err := syscall.UTF16PtrFromString(path)
+ if err != nil {
+ return false
+ }
+ attrs, err := syscall.GetFileAttributes(ptr)
+ if err != nil {
+ return false
+ }
+ return attrs&syscall.FILE_ATTRIBUTE_HIDDEN != 0
+}
+
+// Combines name and attribute checks on Windows.
+func ProbeHidden(
+ path string,
+ name string,
+ info os.FileInfo,
+) bool {
+
+ if IsHiddenFile(path, info) {
+ return true
+ }
+
+ if IsHiddenName(name) {
+ return true
+ }
+
+ return false
+}
diff --git a/internal/linkcount_unix.go b/internal/linkcount_unix.go
new file mode 100644
index 0000000..19e2fdb
--- /dev/null
+++ b/internal/linkcount_unix.go
@@ -0,0 +1,45 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build unix
+
+/*
+ * Created: 4th September 2026
+ * Updated: 4th September 2026
+ */
+
+package internal
+
+import (
+ "os"
+ "syscall"
+)
+
+// Returns the hard-link count from info when Sys() is *syscall.Stat_t.
+// On Unix, directories normally report at least 2 (`.` and `..`).
+//
+// Parameters:
+// - path — unused on Unix (count comes from info);
+// - info — file metadata from Stat / Lstat / ReadDir;
+//
+// Returns:
+// - the link count, and true when available;
+func LinkCount(
+ path string,
+ info os.FileInfo,
+) (uint64, bool) {
+
+ _ = path
+
+ if info == nil {
+ return 0, false
+ }
+
+ st, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return 0, false
+ } else {
+ return uint64(st.Nlink), true
+ }
+}
diff --git a/internal/linkcount_windows.go b/internal/linkcount_windows.go
new file mode 100644
index 0000000..07e8cec
--- /dev/null
+++ b/internal/linkcount_windows.go
@@ -0,0 +1,64 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build windows
+
+/*
+ * Created: 4th September 2026
+ * Updated: 4th September 2026
+ */
+
+package internal
+
+import (
+ "os"
+ "syscall"
+)
+
+// Returns the hard-link count via GetFileInformationByHandle.
+// Win32FileAttributeData / Win32finddata do not carry nNumberOfLinks.
+//
+// Parameters:
+// - path — absolute or openable path of the entry;
+// - info — unused on Windows (count requires a handle);
+//
+// Returns:
+// - the link count, and true when available;
+func LinkCount(
+ path string,
+ info os.FileInfo,
+) (uint64, bool) {
+
+ _ = info
+
+ if path == "" {
+ return 0, false
+ }
+
+ p, err := syscall.UTF16PtrFromString(path)
+ if err != nil {
+ return 0, false
+ }
+
+ h, err := syscall.CreateFile(
+ p,
+ 0,
+ syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
+ nil,
+ syscall.OPEN_EXISTING,
+ syscall.FILE_FLAG_BACKUP_SEMANTICS,
+ 0,
+ )
+ if err != nil {
+ return 0, false
+ }
+ defer syscall.CloseHandle(h)
+
+ var fi syscall.ByHandleFileInformation
+ if err := syscall.GetFileInformationByHandle(h, &fi); err != nil {
+ return 0, false
+ } else {
+ return uint64(fi.NumberOfLinks), true
+ }
+}
diff --git a/internal/loopguard_unix.go b/internal/loopguard_unix.go
new file mode 100644
index 0000000..3f6be93
--- /dev/null
+++ b/internal/loopguard_unix.go
@@ -0,0 +1,62 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build unix
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 3rd September 2026
+ */
+
+package internal
+
+import (
+ "os"
+ "syscall"
+)
+
+// A device+inode pair used to break symlink / hard-link directory cycles.
+type DirIdentity struct {
+ Dev uint64
+ Ino uint64
+}
+
+// Returns the device+inode identity for path, or ok=false.
+func IdentityOf(path string) (DirIdentity, bool) {
+ fi, err := os.Lstat(path)
+ if err != nil {
+ return DirIdentity{}, false
+ }
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ if !ok {
+ return DirIdentity{}, false
+ }
+ return DirIdentity{Dev: uint64(st.Dev), Ino: uint64(st.Ino)}, true
+}
+
+// Remembers visited directory identities so recursive search does not
+// follow a cycle (symlink or bind-mount) forever. Matches C recls
+// ReclsFileSearchDirectoryControlPreventInfiniteLoops. Disabled when
+// NoBreakInfiniteLoops is set. No-op on Windows (see loopguard_windows.go).
+type LoopGuard struct {
+ seen map[DirIdentity]struct{}
+}
+
+// Constructs an empty guard.
+func NewLoopGuard() *LoopGuard {
+ return &LoopGuard{seen: make(map[DirIdentity]struct{})}
+}
+
+// Records path's identity. Returns false if already seen (cycle).
+func (g *LoopGuard) Enter(path string) bool {
+ id, ok := IdentityOf(path)
+ if !ok {
+ return true
+ }
+ if _, exists := g.seen[id]; exists {
+ return false
+ }
+ g.seen[id] = struct{}{}
+ return true
+}
diff --git a/internal/loopguard_windows.go b/internal/loopguard_windows.go
new file mode 100644
index 0000000..3dd6a42
--- /dev/null
+++ b/internal/loopguard_windows.go
@@ -0,0 +1,27 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build windows
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 3rd September 2026
+ */
+
+package internal
+
+// No-op on Windows (reparse-point loops are out of scope for v0.1).
+type LoopGuard struct{}
+
+// Constructs a no-op guard.
+func NewLoopGuard() *LoopGuard {
+ return &LoopGuard{}
+}
+
+// Always returns true on Windows.
+func (g *LoopGuard) Enter(path string) bool {
+ _ = path
+
+ return true
+}
diff --git a/internal/path.go b/internal/path.go
new file mode 100644
index 0000000..912fc57
--- /dev/null
+++ b/internal/path.go
@@ -0,0 +1,263 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 3rd September 2026
+ */
+
+package internal
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Appends a trailing path-name separator if missing.
+func EnsureTrailingSep(
+ path string,
+ sep string,
+) string {
+ if path == "" {
+ return path
+ }
+ if strings.HasSuffix(path, sep) {
+ return path
+ }
+ // Also accept the alternate separator already present.
+ if sep == "/" && strings.HasSuffix(path, "\\") {
+ return path
+ }
+ if sep == "\\" && strings.HasSuffix(path, "/") {
+ return path
+ }
+ return path + sep
+}
+
+// Removes trailing path-name separators, preserving a root path such as "/"
+// or "C:\".
+func StripTrailingSeps(path string) string {
+ if path == "" {
+ return path
+ }
+ for len(path) > 1 {
+ last := path[len(path)-1]
+ if last != '/' && last != '\\' {
+ break
+ }
+ // Keep Windows drive root "C:\"
+ if len(path) == 3 && path[1] == ':' {
+ break
+ }
+ path = path[:len(path)-1]
+ }
+ return path
+}
+
+// Expands a leading "~" or "~/" using the home directory.
+func ExpandTilde(path string) (string, error) {
+ if path == "" || path[0] != '~' {
+ return path, nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return path, err
+ }
+ if path == "~" {
+ return home, nil
+ }
+ if path[1] == '/' || path[1] == '\\' {
+ return filepath.Join(home, path[2:]), nil
+ }
+ return path, nil
+}
+
+// Resolves an empty/relative/tilde search root to an absolute directory
+// path that ends with a trailing separator.
+func ResolveSearchRoot(
+ root string,
+ useTildeOnEmpty bool,
+ sep string,
+) (string, error) {
+ if root == "" {
+ if useTildeOnEmpty {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return EnsureTrailingSep(home, sep), nil
+ }
+ wd, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ return EnsureTrailingSep(wd, sep), nil
+ }
+
+ expanded, err := ExpandTilde(root)
+ if err != nil {
+ return "", err
+ }
+
+ abs, err := filepath.Abs(expanded)
+ if err != nil {
+ return "", err
+ }
+ return EnsureTrailingSep(filepath.Clean(abs), sep), nil
+}
+
+// Expands tilde and makes path absolute relative to the given reference
+// directory (usually the search root or cwd).
+func ResolveEntryPath(
+ path string,
+ referenceDir string,
+) (string, error) {
+ expanded, err := ExpandTilde(path)
+ if err != nil {
+ return "", err
+ }
+ if filepath.IsAbs(expanded) {
+ return filepath.Clean(expanded), nil
+ }
+ ref := referenceDir
+ if ref == "" {
+ ref, err = os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ }
+ ref = StripTrailingSeps(ref)
+ return filepath.Clean(filepath.Join(ref, expanded)), nil
+}
+
+// Reports whether name is "." or "..".
+func IsDots(name string) bool {
+ return name == "." || name == ".."
+}
+
+// Splits a directory string into parts that each end with the given
+// separator (recls convention), preserving a leading root separator as its
+// own part when present.
+func SplitDirectoryParts(
+ directory string,
+ sep string,
+) []string {
+ if directory == "" {
+ return nil
+ }
+ // Prefer libpath-produced parts when available; this helper is a
+ // fallback for search-relative directories.
+ raw := strings.SplitAfter(directory, sep)
+ parts := make([]string, 0, len(raw))
+ for _, p := range raw {
+ if p == "" {
+ continue
+ }
+ parts = append(parts, p)
+ }
+ return parts
+}
+
+// Returns path relative to origin, following recls.Ruby
+// Ximpl::Util.derive_relative_path semantics (not filepath.Rel alone).
+func DeriveRelativePath(
+ origin string,
+ path string,
+ sep string,
+) string {
+ if path == "" {
+ return ""
+ }
+ if origin == "" {
+ return path
+ }
+
+ trailing := ""
+ if strings.HasSuffix(path, "/") || strings.HasSuffix(path, "\\") {
+ trailing = sep
+ }
+
+ origin = filepath.Clean(origin)
+ path = filepath.Clean(path)
+
+ // Bare "." origin → path as-is with optional trailing semantics handled
+ // by caller; Ruby returns path when origin matches /^\.[\\\/]*$/.
+ if origin == "." {
+ if trailing != "" && !strings.HasSuffix(path, sep) {
+ return path + trailing
+ }
+ return path
+ }
+
+ origin = StripTrailingSeps(origin)
+ path = StripTrailingSeps(path)
+
+ // Windows: different drives → absolute path.
+ if len(path) >= 2 && len(origin) >= 2 && path[1] == ':' && origin[1] == ':' {
+ if strings.EqualFold(path[:1], origin[:1]) == false {
+ return path + trailing
+ }
+ }
+
+ pathParts := splitPathElements(path)
+ originParts := splitPathElements(origin)
+
+ for len(pathParts) > 0 && len(originParts) > 0 {
+ if !pathElementsEqual(pathParts[0], originParts[0]) {
+ break
+ }
+ pathParts = pathParts[1:]
+ originParts = originParts[1:]
+ }
+
+ if len(pathParts) == 0 && len(originParts) == 0 {
+ return "." + trailing
+ }
+
+ var b strings.Builder
+ for range originParts {
+ b.WriteString("..")
+ b.WriteString(sep)
+ }
+ for i, p := range pathParts {
+ b.WriteString(p)
+ if i+1 < len(pathParts) {
+ b.WriteString(sep)
+ }
+ }
+ result := b.String()
+ if trailing != "" && !strings.HasSuffix(result, sep) {
+ result += trailing
+ }
+ return result
+}
+
+func splitPathElements(path string) []string {
+ path = strings.ReplaceAll(path, "\\", "/")
+ parts := strings.Split(path, "/")
+ out := make([]string, 0, len(parts))
+ for i, p := range parts {
+ if p == "" {
+ // Keep a single empty leading element to represent root "/".
+ if i == 0 {
+ out = append(out, "")
+ }
+ continue
+ }
+ out = append(out, p)
+ }
+ return out
+}
+
+func pathElementsEqual(
+ a string,
+ b string,
+) bool {
+ // Exact match for now. Windows should use case-insensitive comparison
+ // (e.g. strings.EqualFold); prefer upstreaming compare-path / case-fold
+ // helpers into libpath.Go (Ruby already has make_compare_path) rather
+ // than inventing a third variant here — see TODO.md.
+ return a == b
+}
diff --git a/internal/patterns.go b/internal/patterns.go
new file mode 100644
index 0000000..717da6c
--- /dev/null
+++ b/internal/patterns.go
@@ -0,0 +1,177 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 4th September 2026
+ */
+
+package internal
+
+import (
+ "github.com/synesissoftware/shwild.Go"
+
+ "strings"
+)
+
+// Accepted by NormalisePatterns: a multi-pattern string, or a slice of
+// discrete pattern strings.
+type PatternSource interface {
+ string | []string
+}
+
+// Normalises patterns into a non-empty slice of discrete pattern strings.
+// A string is split on '|' and the path-list separator (see SplitPatterns).
+// A slice is treated as already-discrete patterns: each element is trimmed
+// and empty elements are dropped; elements are not re-split on '|' or the
+// path-list separator. An empty string or empty/blank-only slice yields
+// ["*"].
+//
+// Parameters:
+// - patterns — a string or []string pattern source;
+// - separator — the platform path-list separator (used only for strings);
+//
+// Returns:
+// - a slice of pattern strings;
+func NormalisePatterns[P PatternSource](
+ patterns P,
+ separator string,
+) []string {
+
+ switch v := any(patterns).(type) {
+ case string:
+ return SplitPatterns(v, separator)
+ case []string:
+ return normalisePatternSlice(v)
+ default:
+ panic("recls: unexpected pattern source type")
+ }
+}
+
+// Splits a multi-pattern string on '|' and the platform path-list
+// separator. An empty/blank patterns string yields ["*"]. Does not invent
+// additional separators beyond those two.
+//
+// Parameters:
+// - patterns — a multi-pattern string;
+// - separator — the platform path-list separator;
+//
+// Returns:
+// - a slice of pattern strings;
+func SplitPatterns(
+ patterns string,
+ separator string,
+) []string {
+
+ if patterns == "" {
+ return []string{"*"}
+ }
+
+ s := patterns
+ if separator != "" && separator != "|" {
+ s = strings.ReplaceAll(s, separator, "|")
+ }
+
+ raw := strings.Split(s, "|")
+ out := make([]string, 0, len(raw))
+ seen := make(map[string]struct{}, len(raw))
+ for _, p := range raw {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ if _, ok := seen[p]; ok {
+ continue
+ }
+ seen[p] = struct{}{}
+ out = append(out, p)
+ }
+ if len(out) == 0 {
+ return []string{"*"}
+ }
+ return out
+}
+
+func normalisePatternSlice(patterns []string) []string {
+ if len(patterns) == 0 {
+ return []string{"*"}
+ }
+
+ out := make([]string, 0, len(patterns))
+ seen := make(map[string]struct{}, len(patterns))
+ for _, p := range patterns {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ if _, ok := seen[p]; ok {
+ continue
+ }
+ seen[p] = struct{}{}
+ out = append(out, p)
+ }
+ if len(out) == 0 {
+ return []string{"*"}
+ }
+ return out
+}
+
+// Holds precompiled shwild patterns for a search.
+type CompiledPatterns struct {
+ Patterns []shwild.CompiledPattern
+ Raw []string
+}
+
+// Compiles each pattern string with shwild.
+func CompilePatterns(patterns []string) (CompiledPatterns, error) {
+ cps := make([]shwild.CompiledPattern, 0, len(patterns))
+ for _, p := range patterns {
+ cp, err := shwild.Compile(p)
+ if err != nil {
+ return CompiledPatterns{}, err
+ }
+ cps = append(cps, cp)
+ }
+ return CompiledPatterns{Patterns: cps, Raw: patterns}, nil
+}
+
+// Rejects "." / ".." patterns under Recursive.
+func ValidatePatternsForFlags(
+ patterns []string,
+ recursive bool,
+) error {
+
+ if !recursive {
+ return nil
+ }
+ for _, p := range patterns {
+ if p == "." || p == ".." {
+ return errDotRecursive
+ }
+ }
+ return nil
+}
+
+var errDotRecursive = errDotRecursiveType{}
+
+type errDotRecursiveType struct{}
+
+func (errDotRecursiveType) Error() string {
+ return "recls: dot/dot-dot pattern not allowed in recursive search"
+}
+
+// Reports whether name matches any compiled pattern. Matching is against
+// the entry name (basename), not the full path.
+func (cp CompiledPatterns) MatchesAny(name string) (bool, error) {
+ for _, p := range cp.Patterns {
+ ok, err := p.Match(name)
+ if err != nil {
+ return false, err
+ }
+ if ok {
+ return true, nil
+ }
+ }
+ return false, nil
+}
diff --git a/search.go b/search.go
new file mode 100644
index 0000000..1233bda
--- /dev/null
+++ b/search.go
@@ -0,0 +1,251 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 4th September 2026
+ */
+
+package recls
+
+import (
+ "github.com/synesissoftware/recls.Go/internal"
+
+ "errors"
+ "io/fs"
+ "iter"
+ "os"
+ "path/filepath"
+)
+
+// Accepted by Search and SearchFunc: either a multi-pattern string (split
+// on '|' and the platform path-list separator) or a slice of discrete
+// pattern strings (not re-split). Empty string or empty/blank-only slice
+// matches all names ("*"). Matching is against the entry basename via
+// shwild, not filepath.Match.
+type PatternSource interface {
+ string | []string
+}
+
+// Returns a depth-first sequence of matching entries under root.
+//
+// Parameters:
+// - root — the root directory to search;
+// - patterns — a PatternSource (string or []string; see PatternSource);
+// - opts — options that moderate the search;
+func Search[P PatternSource](
+ root string,
+ patterns P,
+ opts SearchOptions,
+) iter.Seq2[Entry, error] {
+
+ return func(yield func(Entry, error) bool) {
+ err := SearchFunc(root, patterns, opts, func(e Entry) error {
+ if !yield(e, nil) {
+ return errStopIteration
+ }
+ return nil
+ })
+
+ if err != nil && !errors.Is(err, errStopIteration) {
+ yield(Entry{}, err)
+ }
+ }
+}
+
+var errStopIteration = errors.New("recls: stop iteration")
+
+// Invokes fn for each matching entry under root, depth-first. A non-nil
+// error from fn aborts the search and is returned.
+//
+// Parameters:
+// - root — the root directory to search;
+// - patterns — a PatternSource (string or []string; see PatternSource);
+// - opts — options that moderate the search;
+// - fn — callback invoked for each matching entry;
+func SearchFunc[P PatternSource](
+ root string,
+ patterns P,
+ opts SearchOptions,
+ fn func(Entry) error,
+) error {
+
+ flags := NormaliseFlags(opts.Flags)
+
+ searchRoot, err := internal.ResolveSearchRoot(
+ root,
+ 0 != (flags&UseTildeOnNoSearchRoot),
+ PathNameSeparator,
+ )
+ if err != nil {
+ return err
+ }
+
+ rawPatterns := internal.NormalisePatterns(patterns, PathSeparator)
+ if err := internal.ValidatePatternsForFlags(rawPatterns, 0 != (flags&Recursive)); err != nil {
+ return ErrDotRecursiveSearch
+ }
+ compiled, err := internal.CompilePatterns(rawPatterns)
+ if err != nil {
+ return err
+ }
+
+ var guard *internal.LoopGuard
+ if 0 == (flags & NoBreakInfiniteLoops) {
+ guard = internal.NewLoopGuard()
+ if !guard.Enter(searchRoot) {
+ return nil
+ }
+ }
+
+ return searchDirectory(searchRoot, searchRoot, compiled, flags, opts.OnDirectory, guard, fn)
+}
+
+func searchDirectory(
+ searchRoot string,
+ dir string,
+ patterns internal.CompiledPatterns,
+ flags SearchFlags,
+ onDirectory func(string) error,
+ guard *internal.LoopGuard,
+ fn func(Entry) error,
+) error {
+
+ dir = internal.EnsureTrailingSep(dir, PathNameSeparator)
+
+ if 0 != (flags&DirProgress) && onDirectory != nil {
+ if err := onDirectory(dir); err != nil {
+ return err
+ }
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ if 0 != (flags & StopOnAccessFailure) {
+ return &AccessDeniedError{Path: dir, Err: err}
+ }
+ return nil
+ }
+
+ type pendingDir struct {
+ path string
+ info os.FileInfo
+ }
+ var subdirs []pendingDir
+
+ for _, de := range entries {
+ name := de.Name()
+ if internal.IsDots(name) {
+ continue
+ }
+
+ entryPath := filepath.Join(internal.StripTrailingSeps(dir), name)
+
+ info, err := dirEntryInfo(de, entryPath, flags)
+ if err != nil {
+ if 0 != (flags & StopOnAccessFailure) {
+ return &AccessDeniedError{Path: entryPath, Err: err}
+ }
+ continue
+ }
+
+ hidden := internal.ProbeHidden(entryPath, name, info)
+ if hidden && 0 != (flags&IgnoreHiddenEntries) {
+ continue
+ }
+
+ match, err := patterns.MatchesAny(name)
+ if err != nil {
+ return err
+ }
+
+ if match && typeMatches(info, flags) {
+ e, err := buildEntry(entryPath, searchRoot, searchRoot, info, flags)
+ if err != nil {
+ if 0 != (flags & StopOnAccessFailure) {
+ return err
+ }
+ continue
+ }
+ if err := fn(e); err != nil {
+ return err
+ }
+ }
+
+ if info.IsDir() && 0 != (flags&Recursive) {
+ // Do not descend into directory symlinks when NoFollowLinks.
+ if isSymlink(info) && 0 != (flags&NoFollowLinks) {
+ continue
+ }
+ if hidden && 0 != (flags&IgnoreHiddenEntries) {
+ continue
+ }
+ subdirs = append(subdirs, pendingDir{path: entryPath, info: info})
+ }
+ }
+
+ for _, sd := range subdirs {
+ if guard != nil && !guard.Enter(sd.path) {
+ continue
+ }
+ if err := searchDirectory(searchRoot, sd.path, patterns, flags, onDirectory, guard, fn); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func dirEntryInfo(
+ de os.DirEntry,
+ path string,
+ flags SearchFlags,
+) (os.FileInfo, error) {
+
+ if 0 != (flags & NoFollowLinks) {
+ return os.Lstat(path)
+ }
+ // Prefer Info() but fall back to Stat for follow-symlink semantics.
+ info, err := de.Info()
+ if err != nil {
+ return os.Stat(path)
+ }
+ if isSymlink(info) {
+ return os.Stat(path)
+ }
+ return info, nil
+}
+
+func isSymlink(info os.FileInfo) bool {
+ return info.Mode()&fs.ModeSymlink != 0
+}
+
+func typeMatches(info os.FileInfo, flags SearchFlags) bool {
+ types := flags & TypeMask
+ if types == 0 {
+ types = Files
+ }
+
+ isLink := isSymlink(info)
+ isDir := info.IsDir()
+ isFile := info.Mode().IsRegular()
+
+ // When following links, Stat may report the target type; Lstat keeps
+ // the link itself. Count links toward Links when NoFollowLinks is set.
+ if isLink && 0 != (flags&Links) && 0 != (flags&NoFollowLinks) {
+ return true
+ }
+
+ matched := false
+ if 0 != (types&Files) && isFile {
+ matched = true
+ }
+ if 0 != (types&Directories) && isDir {
+ matched = true
+ }
+ if 0 != (types&Links) && isLink {
+ matched = true
+ }
+ return matched
+}
diff --git a/stat.go b/stat.go
new file mode 100644
index 0000000..ebf87ea
--- /dev/null
+++ b/stat.go
@@ -0,0 +1,56 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+ * Created: 3rd September 2026
+ * Updated: 3rd September 2026
+ */
+
+package recls
+
+import (
+ "github.com/synesissoftware/recls.Go/internal"
+
+ "os"
+)
+
+// Returns an Entry for path. When no type flags are set, Files is assumed
+// (via NormaliseFlags) but does not filter Stat results — Stat always
+// describes the named path.
+//
+// Parameters:
+// - path — the path to examine;
+// - opts — options that moderate Stat (e.g. DetailsLater, MarkDirs);
+func Stat(
+ path string,
+ opts StatOptions,
+) (Entry, error) {
+
+ flags := NormaliseFlags(opts)
+
+ ref, err := os.Getwd()
+ if err != nil {
+ return Entry{}, err
+ }
+ ref = internal.EnsureTrailingSep(ref, PathNameSeparator)
+
+ abs, err := internal.ResolveEntryPath(path, ref)
+ if err != nil {
+ return Entry{}, err
+ }
+
+ info, err := statInfo(abs, flags)
+ if err != nil {
+ return Entry{}, err
+ }
+ // DetailsLater: info may be nil when path does not exist.
+
+ // Leave SearchRoot empty — Stat is free of search context (matches
+ // recls.Ruby Stat without search_dir).
+ e, err := buildEntry(abs, ref, "", info, flags|DirectoryParts)
+ if err != nil {
+ return Entry{}, err
+ }
+ return e, nil
+}
diff --git a/test/component/search_test.go b/test/component/search_test.go
new file mode 100644
index 0000000..58e5349
--- /dev/null
+++ b/test/component/search_test.go
@@ -0,0 +1,156 @@
+package recls_test
+
+import (
+ recls "github.com/synesissoftware/recls.Go"
+
+ "github.com/stretchr/testify/require"
+
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func makeTree(t *testing.T) string {
+ t.Helper()
+ root := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "sub", "deep"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "a.txt"), []byte("a"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "b.go"), []byte("b"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "sub", "c.txt"), []byte("c"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "sub", "deep", "d.txt"), []byte("d"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, ".hidden"), []byte("h"), 0o644))
+ require.NoError(t, os.Mkdir(filepath.Join(root, ".hiddendir"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, ".hiddendir", "x.txt"), []byte("x"), 0o644))
+ return root
+}
+
+func collect[P recls.PatternSource](
+ t *testing.T,
+ root string,
+ patterns P,
+ opts recls.SearchOptions,
+) []recls.Entry {
+
+ t.Helper()
+ var out []recls.Entry
+ err := recls.SearchFunc(root, patterns, opts, func(e recls.Entry) error {
+ out = append(out, e)
+ return nil
+ })
+ require.NoError(t, err)
+ return out
+}
+
+func names(entries []recls.Entry) []string {
+ out := make([]string, 0, len(entries))
+ for _, e := range entries {
+ out = append(out, e.EntryName)
+ }
+ return out
+}
+
+func Test_Search_NON_RECURSIVE_PATTERN(t *testing.T) {
+ root := makeTree(t)
+ got := collect(t, root, "*.txt", recls.SearchOptions{})
+ require.Equal(t, []string{"a.txt"}, names(got))
+}
+
+func Test_Search_PATTERN_SLICE(t *testing.T) {
+ root := makeTree(t)
+ got := collect(t, root, []string{"*.txt", "*.go"}, recls.SearchOptions{})
+ require.ElementsMatch(t, []string{"a.txt", "b.go"}, names(got))
+}
+
+func Test_Search_Recursive_PATTERN(t *testing.T) {
+ root := makeTree(t)
+ got := collect(t, root, "*.txt", recls.SearchOptions{Flags: recls.Recursive})
+ // Without IgnoreHiddenEntries, .hiddendir/x.txt is included.
+ require.ElementsMatch(t, []string{"a.txt", "c.txt", "d.txt", "x.txt"}, names(got))
+}
+
+func Test_Search_Directories_AND_MarkDirs(t *testing.T) {
+ root := makeTree(t)
+ got := collect(t, root, "*", recls.SearchOptions{
+ Flags: recls.Directories | recls.Recursive | recls.MarkDirs,
+ })
+ require.NotEmpty(t, got)
+ for _, e := range got {
+ require.True(t, e.IsDir())
+ require.Equal(t, recls.PathNameSeparator, string(e.Path()[len(e.Path())-1]))
+ require.NotEmpty(t, e.SearchRelativePath)
+ }
+}
+
+func Test_Search_IgnoreHidden(t *testing.T) {
+ root := makeTree(t)
+ got := collect(t, root, "*", recls.SearchOptions{
+ Flags: recls.Files | recls.Recursive | recls.IgnoreHiddenEntries,
+ })
+ for _, e := range got {
+ require.False(t, e.IsHidden(), "unexpected hidden entry %q", e.Path())
+ require.NotEqual(t, ".hidden", e.EntryName)
+ require.NotEqual(t, "x.txt", e.EntryName)
+ }
+ require.ElementsMatch(t, []string{"a.txt", "b.go", "c.txt", "d.txt"}, names(got))
+}
+
+func Test_Search_MULTI_PATTERN(t *testing.T) {
+ root := makeTree(t)
+ got := collect(t, root, "*.txt|*.go", recls.SearchOptions{Flags: recls.Recursive})
+ require.ElementsMatch(t, []string{"a.txt", "b.go", "c.txt", "d.txt", "x.txt"}, names(got))
+}
+
+func Test_Search_iter(t *testing.T) {
+ root := makeTree(t)
+ var count int
+ for e, err := range recls.Search(root, "*.txt", recls.SearchOptions{Flags: recls.Recursive}) {
+ require.NoError(t, err)
+ require.Equal(t, ".txt", e.Extension)
+ count++
+ }
+ require.Equal(t, 4, count) // a,c,d,x (hidden dir still traversed without IgnoreHidden)
+}
+
+func Test_Search_StopOnAccessFailure(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based access denial is Unix-specific")
+ }
+ root := t.TempDir()
+ denied := filepath.Join(root, "denied")
+ require.NoError(t, os.Mkdir(denied, 0o000))
+ t.Cleanup(func() { _ = os.Chmod(denied, 0o755) })
+
+ err := recls.SearchFunc(root, "*", recls.SearchOptions{
+ Flags: recls.Recursive | recls.StopOnAccessFailure | recls.Files | recls.Directories,
+ }, func(e recls.Entry) error {
+ return nil
+ })
+ var ade *recls.AccessDeniedError
+ require.True(t, errors.As(err, &ade))
+ require.NotEmpty(t, ade.Path)
+ require.Error(t, ade.Err)
+}
+
+func Test_Search_Symlink_NoFollow(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink policy coverage focused on Unix")
+ }
+ root := t.TempDir()
+ outside := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(outside, "inside.txt"), []byte("i"), 0o644))
+ link := filepath.Join(root, "linkdir")
+ require.NoError(t, os.Symlink(outside, link))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "local.txt"), []byte("l"), 0o644))
+
+ got := collect(t, root, "*", recls.SearchOptions{
+ Flags: recls.Files | recls.Directories | recls.Links | recls.Recursive | recls.NoFollowLinks,
+ })
+ var namesFound []string
+ for _, e := range got {
+ namesFound = append(namesFound, e.EntryName)
+ }
+ require.Contains(t, namesFound, "local.txt")
+ require.NotContains(t, namesFound, "inside.txt")
+}
diff --git a/test/component/stat_test.go b/test/component/stat_test.go
new file mode 100644
index 0000000..aa4f954
--- /dev/null
+++ b/test/component/stat_test.go
@@ -0,0 +1,59 @@
+package recls_test
+
+import (
+ recls "github.com/synesissoftware/recls.Go"
+
+ "github.com/stretchr/testify/require"
+
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func Test_Stat_FILE(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "hello.txt")
+ require.NoError(t, os.WriteFile(path, []byte("hi"), 0o644))
+
+ e, err := recls.Stat(path, 0)
+ require.NoError(t, err)
+ require.True(t, e.Exists())
+ require.True(t, e.IsFile())
+ require.False(t, e.IsDir())
+ require.Equal(t, "hello.txt", e.EntryName)
+ require.Equal(t, "hello", e.Stem)
+ require.Equal(t, ".txt", e.Extension)
+ require.Equal(t, int64(2), e.Size())
+ require.NotEmpty(t, e.Path())
+ require.NotEmpty(t, e.Location)
+}
+
+func Test_Stat_Directory_MarkDirs(t *testing.T) {
+ dir := t.TempDir()
+
+ e, err := recls.Stat(dir, recls.Directories|recls.MarkDirs|recls.DirectoryParts)
+ require.NoError(t, err)
+ require.True(t, e.Exists())
+ require.True(t, e.IsDir())
+ require.True(t, len(e.Path()) > 0)
+ require.Equal(t, recls.PathNameSeparator, string(e.Path()[len(e.Path())-1]))
+ require.NotNil(t, e.DirectoryParts)
+}
+
+func Test_Stat_DetailsLater_MISSING(t *testing.T) {
+ dir := t.TempDir()
+ missing := filepath.Join(dir, "no-such-file")
+
+ e, err := recls.Stat(missing, recls.DetailsLater|recls.DirectoryParts)
+ require.NoError(t, err)
+ require.False(t, e.Exists())
+ require.Equal(t, "no-such-file", e.EntryName)
+}
+
+func Test_Stat_MISSING_WITHOUT_DetailsLater(t *testing.T) {
+ dir := t.TempDir()
+ missing := filepath.Join(dir, "no-such-file")
+
+ _, err := recls.Stat(missing, 0)
+ require.Error(t, err)
+}
diff --git a/test/unit/flags_test.go b/test/unit/flags_test.go
new file mode 100644
index 0000000..b78ea71
--- /dev/null
+++ b/test/unit/flags_test.go
@@ -0,0 +1,30 @@
+package recls_test
+
+import (
+ recls "github.com/synesissoftware/recls.Go"
+
+ "github.com/stretchr/testify/require"
+
+ "testing"
+)
+
+func Test_NormaliseFlags_DEFAULTS_TO_Files(t *testing.T) {
+ require.Equal(t, recls.Files, recls.NormaliseFlags(0))
+ require.Equal(t, recls.Files|recls.Recursive, recls.NormaliseFlags(recls.Recursive))
+}
+
+func Test_NormaliseFlags_PRESERVES_TYPES(t *testing.T) {
+ flags := recls.Directories | recls.Recursive
+ require.Equal(t, flags, recls.NormaliseFlags(flags))
+
+ both := recls.Files | recls.Directories
+ require.Equal(t, both, recls.NormaliseFlags(both))
+}
+
+func Test_PathNameSeparator_NON_EMPTY(t *testing.T) {
+ require.NotEmpty(t, recls.PathNameSeparator)
+}
+
+func Test_PathSeparator_NON_EMPTY(t *testing.T) {
+ require.NotEmpty(t, recls.PathSeparator)
+}
diff --git a/test/unit/linkcount_test.go b/test/unit/linkcount_test.go
new file mode 100644
index 0000000..5423e4f
--- /dev/null
+++ b/test/unit/linkcount_test.go
@@ -0,0 +1,49 @@
+package recls_test
+
+import (
+ recls "github.com/synesissoftware/recls.Go"
+
+ "github.com/stretchr/testify/require"
+
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func Test_Entry_LinkCount_FILE(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "a.txt")
+ require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
+
+ e, err := recls.Stat(path, 0)
+ require.NoError(t, err)
+ require.Equal(t, uint64(1), e.LinkCount())
+
+ linkPath := filepath.Join(root, "a-link.txt")
+ if err := os.Link(path, linkPath); err != nil {
+ if runtime.GOOS == "windows" {
+ t.Skipf("os.Link unavailable: %v", err)
+ }
+ require.NoError(t, err)
+ }
+
+ e, err = recls.Stat(path, 0)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, e.LinkCount(), uint64(2))
+
+ e2, err := recls.Stat(linkPath, 0)
+ require.NoError(t, err)
+ require.Equal(t, e.LinkCount(), e2.LinkCount())
+}
+
+func Test_Entry_LinkCount_DIRECTORY(t *testing.T) {
+ root := t.TempDir()
+ e, err := recls.Stat(root, 0)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, e.LinkCount(), uint64(1))
+ if runtime.GOOS != "windows" {
+ // Unix directories report at least 2 (`.` and `..`).
+ require.GreaterOrEqual(t, e.LinkCount(), uint64(2))
+ }
+}
diff --git a/test/unit/path_test.go b/test/unit/path_test.go
new file mode 100644
index 0000000..8378548
--- /dev/null
+++ b/test/unit/path_test.go
@@ -0,0 +1,58 @@
+package recls_test
+
+import (
+ "github.com/synesissoftware/recls.Go/internal"
+
+ "github.com/stretchr/testify/require"
+
+ "testing"
+)
+
+func Test_DeriveRelativePath_SAME_DIR(t *testing.T) {
+ sep := "/"
+ origin := "/Users/matthewwilson/dev/freelibs/recls/100/recls.Ruby/trunk"
+ path := "/Users/matthewwilson/dev/freelibs/recls/100/recls.Ruby/trunk"
+ require.Equal(t, ".", internal.DeriveRelativePath(origin, path, sep))
+ require.Equal(t, "./", internal.DeriveRelativePath(origin, path+"/", sep))
+}
+
+func Test_DeriveRelativePath_UNDER_HOME(t *testing.T) {
+ // libpath.Ruby test_recls_stat_case_2 shape: search root = home,
+ // entry = pwd under home.
+ sep := "/"
+ home := "/Users/matthewwilson"
+ pwd := "/Users/matthewwilson/dev/freelibs/recls/100/recls.Ruby/trunk"
+ got := internal.DeriveRelativePath(home, pwd+"/", sep)
+ require.Equal(t, "dev/freelibs/recls/100/recls.Ruby/trunk/", got)
+}
+
+func Test_DeriveRelativePath_UP_TO_HOME(t *testing.T) {
+ // libpath.Ruby test_recls_stat_case_3 shape: search root = pwd,
+ // entry = home (ancestor).
+ sep := "/"
+ home := "/Users/matthewwilson"
+ pwd := "/Users/matthewwilson/dev/freelibs/recls/100/recls.Ruby/trunk"
+ got := internal.DeriveRelativePath(pwd, home+"/", sep)
+ require.Equal(t, "../../../../../../", got)
+}
+
+func Test_DeriveRelativePath_EMPTY_ORIGIN(t *testing.T) {
+ require.Equal(t, "/tmp/x", internal.DeriveRelativePath("", "/tmp/x", "/"))
+}
+
+func Test_EnsureTrailingSep_TRAILING_SEP(t *testing.T) {
+ require.Equal(t, "/tmp/", internal.EnsureTrailingSep("/tmp", "/"))
+ require.Equal(t, "/tmp/", internal.EnsureTrailingSep("/tmp/", "/"))
+}
+
+func Test_IsHiddenName_DOT_DOT(t *testing.T) {
+ require.False(t, internal.IsHiddenName("."))
+ require.False(t, internal.IsHiddenName(".."))
+ require.False(t, internal.IsHiddenName("visible"))
+ require.True(t, internal.IsHiddenName(".hidden"))
+}
+
+func Test_SplitDirectoryParts_DIRECTORY_PARTS(t *testing.T) {
+ parts := internal.SplitDirectoryParts("dev/freelibs/trunk/", "/")
+ require.Equal(t, []string{"dev/", "freelibs/", "trunk/"}, parts)
+}
diff --git a/test/unit/patterns_test.go b/test/unit/patterns_test.go
new file mode 100644
index 0000000..519f69d
--- /dev/null
+++ b/test/unit/patterns_test.go
@@ -0,0 +1,88 @@
+package recls_test
+
+import (
+ "github.com/synesissoftware/recls.Go/internal"
+
+ "github.com/stretchr/testify/require"
+
+ "runtime"
+ "testing"
+)
+
+func Test_SplitPatterns_EMPTY_IS_WILDCARDS_ALL(t *testing.T) {
+ require.Equal(t, []string{"*"}, internal.SplitPatterns("", ":"))
+}
+
+func Test_SplitPatterns_NORMATIVE(t *testing.T) {
+ require.Equal(t, []string{"Gemfile"}, internal.SplitPatterns("Gemfile", "|"))
+ require.Equal(t, []string{"Gemfile", "Gemfile.lock"}, internal.SplitPatterns("Gemfile|Gemfile.lock", "|"))
+ require.Equal(t, []string{"*.go", "*.md"}, internal.SplitPatterns("*.go|*.md", ":"))
+ require.Equal(t, []string{"*.go", "*.md"}, internal.SplitPatterns("*.go|*.md", ":"))
+}
+
+func Test_SplitPatterns_EDGE_CASES(t *testing.T) {
+ require.Equal(t, []string{"Gemfile"}, internal.SplitPatterns("|Gemfile", "|"))
+ require.Equal(t, []string{"Gemfile"}, internal.SplitPatterns("Gemfile|", "|"))
+ require.Equal(t, []string{"Gemfile"}, internal.SplitPatterns("Gemfile||||", "|"))
+ require.Equal(t, []string{"Gemfile", "Gemfile.lock"}, internal.SplitPatterns("Gemfile|||||Gemfile.lock", "|"))
+}
+
+func Test_SplitPatterns_PIPE_AND_COLON(t *testing.T) {
+ require.Equal(t, []string{"*.go", "*.md"}, internal.SplitPatterns("*.go|*.md", ":"))
+ require.Equal(t, []string{"*.go", "*.md"}, internal.SplitPatterns("*.go:*.md", ":"))
+}
+
+func Test_SplitPatterns_SEMICOLON_ONLY_WHEN_OnlyWhenPathListSep(t *testing.T) {
+ // ';' is the Windows path-list separator; on Unix it is not special.
+ require.Equal(t, []string{"*.go;*.md"}, internal.SplitPatterns("*.go;*.md", ":"))
+ require.Equal(t, []string{"*.go", "*.md"}, internal.SplitPatterns("*.go;*.md", ";"))
+ if runtime.GOOS == "windows" {
+ require.Equal(t, ";", string(';'))
+ } else {
+ require.Equal(t, ";", string(';'))
+
+ }
+}
+
+func Test_SplitPatterns_Dedup(t *testing.T) {
+ require.Equal(t, []string{"*.go"}, internal.SplitPatterns("*.go|*.go", ":"))
+}
+
+func Test_NormalisePatterns_STRING(t *testing.T) {
+ require.Equal(t, []string{"*.go", "*.md"}, internal.NormalisePatterns("*.go|*.md", ":"))
+ require.Equal(t, []string{"*"}, internal.NormalisePatterns("", ":"))
+}
+
+func Test_NormalisePatterns_SLICE(t *testing.T) {
+ require.Equal(t, []string{"*"}, internal.NormalisePatterns([]string{}, ":"))
+ require.Equal(t, []string{"*"}, internal.NormalisePatterns([]string{"", " "}, ":"))
+ require.Equal(t, []string{"*.go", "*.md"}, internal.NormalisePatterns([]string{"*.go", "*.md"}, ":"))
+ require.Equal(t, []string{"*.go"}, internal.NormalisePatterns([]string{"*.go", "*.go"}, ":"))
+ // Slice elements are discrete: '|' / ':' inside an element are not split.
+ require.Equal(t, []string{"*.go|*.md"}, internal.NormalisePatterns([]string{"*.go|*.md"}, ":"))
+ require.Equal(t, []string{"a:b"}, internal.NormalisePatterns([]string{"a:b"}, ":"))
+}
+
+func Test_CompileAndMatch_EntryName(t *testing.T) {
+ cp, err := internal.CompilePatterns([]string{"*.txt", "readme"})
+ require.NoError(t, err)
+
+ ok, err := cp.MatchesAny("notes.txt")
+ require.NoError(t, err)
+ require.True(t, ok)
+
+ ok, err = cp.MatchesAny("readme")
+ require.NoError(t, err)
+ require.True(t, ok)
+
+ ok, err = cp.MatchesAny("notes.go")
+ require.NoError(t, err)
+ require.False(t, ok)
+}
+
+func Test_ValidatePatternsForFlags_DotRecursive(t *testing.T) {
+ require.Error(t, internal.ValidatePatternsForFlags([]string{"."}, true))
+ require.Error(t, internal.ValidatePatternsForFlags([]string{".."}, true))
+ require.NoError(t, internal.ValidatePatternsForFlags([]string{"."}, false))
+ require.NoError(t, internal.ValidatePatternsForFlags([]string{"*.go"}, true))
+}
diff --git a/version.go b/version.go
index f81bb08..89f0ce6 100644
--- a/version.go
+++ b/version.go
@@ -1,12 +1,21 @@
+// Copyright 2019-2026, Matthew Wilson and Synesis Information Systems. All
+// rights reserved. Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+ * Created: 19th August 2025
+ * Updated: 4th September 2026
+ */
+
package recls
import "github.com/synesissoftware/ver2go"
const (
VersionMajor uint16 = 0
- VersionMinor uint16 = 0
- VersionPatch uint16 = 2
- VersionAB uint16 = ver2go.Release
+ VersionMinor uint16 = 1
+ VersionPatch uint16 = 0
+ VersionAB uint16 = ver2go.Alpha1
)
var (
@@ -14,19 +23,18 @@ var (
versionString string = ver2go.CalcVersionString(VersionMajor, VersionMinor, VersionPatch, VersionAB)
)
-// Version returns this library's version as a packed 64-bit integer, formed
-// by ver2go.CombineVersion from VersionMajor, VersionMinor, VersionPatch,
-// and VersionAB. The result is suitable for numeric comparison: a later
-// release has a strictly greater value than an earlier one that uses the
-// same packing.
+// Returns this library's version as a packed 64-bit integer, formed by
+// ver2go.CombineVersion from VersionMajor, VersionMinor, VersionPatch, and
+// VersionAB. Suitable for numeric comparison: a later release has a
+// strictly greater value than an earlier one that uses the same packing.
func Version() uint64 {
return version
}
-// VersionString returns this library's version as a human-readable string,
-// formed by ver2go.CalcVersionString from VersionMajor, VersionMinor,
-// VersionPatch, and VersionAB. For a final (non-prerelease) version the
-// result is of the form "MAJOR.MINOR.PATCH", e.g. "0.0.1".
+// Returns this library's version as a human-readable string, formed by
+// ver2go.CalcVersionString from VersionMajor, VersionMinor, VersionPatch,
+// and VersionAB. For a final (non-prerelease) version the result is of the
+// form "MAJOR.MINOR.PATCH", e.g. "0.1.0".
func VersionString() string {
return versionString
}
diff --git a/version_test.go b/version_test.go
index 3cec76a..bfa3a0a 100644
--- a/version_test.go
+++ b/version_test.go
@@ -10,9 +10,9 @@ import (
const (
Expected_VersionMajor uint16 = 0
- Expected_VersionMinor uint16 = 0
- Expected_VersionPatch uint16 = 2
- Expected_VersionAB uint16 = 0xFFFF
+ Expected_VersionMinor uint16 = 1
+ Expected_VersionPatch uint16 = 0
+ Expected_VersionAB uint16 = 0x4001 // ver2go.Alpha1
)
func Test_Version_Elements(t *testing.T) {
@@ -23,9 +23,9 @@ func Test_Version_Elements(t *testing.T) {
}
func Test_Version(t *testing.T) {
- require.Equal(t, uint64(0x0000_0000_0002_FFFF), recls.Version())
+ require.Equal(t, uint64(0x0000_0001_0000_4001), recls.Version())
}
func Test_Version_String(t *testing.T) {
- require.Equal(t, "0.0.2", recls.VersionString())
+ require.Equal(t, "0.1.0-alpha1", recls.VersionString())
}