Skip to content

Commit

Permalink
Merge pull request #81 from evankanderson/iofs
Browse files Browse the repository at this point in the history
Add wrapper for io/fs
  • Loading branch information
pjbgf committed Sep 27, 2024
2 parents 9745bbb + b50bc97 commit c1ee0b9
Show file tree
Hide file tree
Showing 2 changed files with 296 additions and 0 deletions.
140 changes: 140 additions & 0 deletions helper/iofs/iofs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Package iofs provides an adapter from billy.Filesystem to a the
// standard library io.fs.FS interface.
package iofs

import (
"io"
"io/fs"
"path/filepath"

billyfs "github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/helper/polyfill"
)

// Wrap adapts a billy.Filesystem to a io.fs.FS.
func New(fs billyfs.Basic) fs.FS {
return &adapterFs{fs: polyfill.New(fs)}
}

type adapterFs struct {
fs billyfs.Filesystem
}

// Type assertion that adapterFS implements the following interfaces:
var _ fs.FS = (*adapterFs)(nil)
var _ fs.ReadDirFS = (*adapterFs)(nil)
var _ fs.StatFS = (*adapterFs)(nil)
var _ fs.ReadFileFS = (*adapterFs)(nil)

// TODO: implement fs.GlobFS, which will be a fair bit more code.

// Open opens the named file on the underlying FS, implementing fs.FS (returning a file or error).
func (a *adapterFs) Open(name string) (fs.File, error) {
if name[0] == '/' || name != filepath.Clean(name) {
// fstest.TestFS explicitly checks that these should return error.
// MemFS performs the clean internally, so we need to block that here for testing purposes.
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
stat, err := a.fs.Stat(name)
if err != nil {
return nil, err
}
if stat.IsDir() {
entries, err := a.ReadDir(name)
if err != nil {
return nil, err
}
return makeDir(stat, entries), nil
}
file, err := a.fs.Open(name)
return &adapterFile{file: file, info: stat}, err
}

// ReadDir reads the named directory, implementing fs.ReadDirFS (returning a listing or error).
func (a *adapterFs) ReadDir(name string) ([]fs.DirEntry, error) {
items, err := a.fs.ReadDir(name)
if err != nil {
return nil, err
}
entries := make([]fs.DirEntry, len(items))
for i, item := range items {
entries[i] = fs.FileInfoToDirEntry(item)
}
return entries, nil
}

// Stat returns information on the named file, implementing fs.StatFS (returning FileInfo or error).
func (a *adapterFs) Stat(name string) (fs.FileInfo, error) {
return a.fs.Stat(name)
}

// ReadFile reads the named file and returns its contents, implementing fs.ReadFileFS (returning contents or error).
func (a *adapterFs) ReadFile(name string) ([]byte, error) {
stat, err := a.fs.Stat(name)
if err != nil {
return nil, err
}
b := make([]byte, stat.Size())
file, err := a.Open(name)
if err != nil {
return nil, err
}
defer file.Close()
_, err = file.Read(b)
return b, err
}

type adapterFile struct {
file billyfs.File
info fs.FileInfo
}

var _ fs.File = (*adapterFile)(nil)

// Close closes the file, implementing fs.File (and io.Closer).
func (a *adapterFile) Close() error {
return a.file.Close()
}

// Read reads bytes from the file, implementing fs.File (and io.Reader).
func (a *adapterFile) Read(b []byte) (int, error) {
return a.file.Read(b)
}

// Stat returns file information, implementing fs.File (returning FileInfo or error).
func (a *adapterFile) Stat() (fs.FileInfo, error) {
return a.info, nil
}

type adapterDirFile struct {
adapterFile
entries []fs.DirEntry
}

var _ fs.ReadDirFile = (*adapterDirFile)(nil)

func makeDir(stat fs.FileInfo, entries []fs.DirEntry) *adapterDirFile {
return &adapterDirFile{
adapterFile: adapterFile{info: stat},
entries: entries,
}
}

// Close closes the directory, implementing fs.File (and io.Closer).
// Subtle: note that this is shadowing adapterFile.Close.
func (a *adapterDirFile) Close() error {
return nil
}

// ReadDir reads the directory contents, implementing fs.ReadDirFile (returning directory listing or error).
func (a *adapterDirFile) ReadDir(n int) ([]fs.DirEntry, error) {
if len(a.entries) == 0 && n > 0 {
return nil, io.EOF
}
if n <= 0 || n > len(a.entries) {
n = len(a.entries)
}
entries := a.entries[:n]
a.entries = a.entries[n:]
return entries, nil
}
156 changes: 156 additions & 0 deletions helper/iofs/iofs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package iofs

import (
"errors"
"io/fs"
"path/filepath"
"runtime"
"strings"
"testing"
"testing/fstest"

billyfs "github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
)

type errorList interface {
Unwrap() []error
}

type wrappedError interface {
Unwrap() error
}

// TestWithFSTest leverages the packaged Go fstest package, which seems comprehensive.
func TestWithFSTest(t *testing.T) {
t.Parallel()
memfs := memfs.New()
iofs := New(memfs)

files := map[string]string{
"foo.txt": "hello, world",
"bar.txt": "goodbye, world",
filepath.Join("dir", "baz.txt"): "こんにちわ, world",
}
created_files := make([]string, 0, len(files))
for filename, contents := range files {
makeFile(memfs, t, filename, contents)
created_files = append(created_files, filename)
}

if runtime.GOOS == "windows" {
t.Skip("fstest.TestFS is not yet windows path aware")
}

err := fstest.TestFS(iofs, created_files...)
if err != nil {
checkFsTestError(t, err, files)
}
}

func TestDeletes(t *testing.T) {
t.Parallel()
memfs := memfs.New()
iofs := New(memfs).(fs.ReadFileFS)

makeFile(memfs, t, "foo.txt", "hello, world")
makeFile(memfs, t, "deleted", "nothing to see")

if _, err := iofs.ReadFile("nonexistent"); err == nil {
t.Errorf("expected error for nonexistent file")
}

data, err := iofs.ReadFile("deleted")
if err != nil {
t.Fatalf("failed to read file before delete: %v", err)
}
if string(data) != "nothing to see" {
t.Errorf("unexpected contents before delete: %v", data)
}

if err := memfs.Remove("deleted"); err != nil {
t.Fatalf("failed to remove file: %v", err)
}

if _, err = iofs.ReadFile("deleted"); err == nil {
t.Errorf("file existed after delete!")
}
}

func makeFile(fs billyfs.Basic, t *testing.T, filename string, contents string) {
t.Helper()
file, err := fs.Create(filename)
if err != nil {
t.Fatalf("failed to create file %s: %v", filename, err)
}
defer file.Close()
_, err = file.Write([]byte(contents))
if err != nil {
t.Fatalf("failed to write to file %s: %v", filename, err)
}
}

func checkFsTestError(t *testing.T, err error, files map[string]string) {
t.Helper()

if unwrapped := errors.Unwrap(err); unwrapped != nil {
err = unwrapped
}

// Go >= 1.23 (after https://cs.opensource.google/go/go/+/74cce866f865c3188a34309e4ebc7a5c9ed0683d)
// has nicely-Joined wrapped errors. Try that first.
if errs, ok := err.(errorList); ok {
for _, e := range errs.Unwrap() {

if strings.Contains(e.Error(), "ModTime") {
// Memfs returns the current time for Stat().ModTime(), which triggers
// a diff complaint in fstest. We can ignore this, or store modtimes
// for every file in Memfs (at a cost of 16 bytes / file).
t.Log("Skipping ModTime error (ok).")
} else {
t.Errorf("Unexpected fstest error: %v", e)
}
}
} else {
if runtime.Version() >= "go1.23" {
t.Fatalf("Failed to test fs:\n%v", err)
}
// filter lines from the error text corresponding to the above errors;
// output looks like:
// TestFS found errors:
// bar.txt: mismatch:
// entry.Info() = bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.377023639 +0000 UTC m=+0.002625548
// file.Stat() = bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.376907011 +0000 UTC m=+0.002508970
//
// bar.txt: fs.Stat(...) = bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.381356651 +0000 UTC m=+0.006959191
// want bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.376907011 +0000 UTC m=+0.002508970
// bar.txt: fsys.Stat(...) = bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.381488617 +0000 UTC m=+0.007090346
// want bar.txt IsDir=false Mode=-rw-rw-rw- Size=14 ModTime=2024-09-17 10:09:00.376907011 +0000 UTC m=+0.002508970
// We filter on "empty line" or "ModTime" or "$filename: mismatch" to ignore these.
lines := strings.Split(err.Error(), "\n")
filtered := make([]string, 0, len(lines))
filename_mismatches := make(map[string]struct{}, len(files)*2)
for name := range files {
for dirname := name; dirname != "."; dirname = filepath.Dir(dirname) {
filename_mismatches[dirname+": mismatch:"] = struct{}{}
}
}
if strings.TrimSpace(lines[0]) == "TestFS found errors:" {
lines = lines[1:]
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.Contains(trimmed, "ModTime=") {
continue
}

if _, ok := filename_mismatches[trimmed]; ok {
continue
}
filtered = append(filtered, line)
}
if len(filtered) > 0 {
t.Fatalf("Failed to test fs:\n%s", strings.Join(filtered, "\n"))
}
}
}

0 comments on commit c1ee0b9

Please sign in to comment.