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. -![Language](https://img.shields.io/badge/Go-00ADD8?style=flat&logo=go&logoColor=white) + +[![Language](https://img.shields.io/badge/Language-Go-blue)](https://go.dev/) [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![GitHub release](https://img.shields.io/github/v/release/synesissoftware/recls.Go.svg)](https://github.com/synesissoftware/recls.Go/releases/latest) [![Last Commit](https://img.shields.io/github/last-commit/synesissoftware/recls.Go)](https://github.com/synesissoftware/recls.Go/commits/master) @@ -10,38 +11,112 @@ The platform-independent file-system recursive search library, for Go. [![Go Reference](https://pkg.go.dev/badge/github.com/synesissoftware/recls.Go.svg)](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()) }