Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add wrapper for io/fs #81

Merged
merged 5 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions helper/iofs/iofs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// 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 Wrap(fs billyfs.Basic) fs.FS {
evankanderson marked this conversation as resolved.
Show resolved Hide resolved
return &adapterFs{fs: polyfill.New(fs)}
}

type adapterFs struct {
fs billyfs.Filesystem
}

var _ fs.FS = (*adapterFs)(nil)
var _ fs.ReadDirFS = (*adapterFs)(nil)
var _ fs.StatFS = (*adapterFs)(nil)
var _ fs.ReadFileFS = (*adapterFs)(nil)
pjbgf marked this conversation as resolved.
Show resolved Hide resolved

// GlobFS would be harder, we don't implement for now.
pjbgf marked this conversation as resolved.
Show resolved Hide resolved

// Open implements fs.FS.
evankanderson marked this conversation as resolved.
Show resolved Hide resolved
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 is performs the clean internally, so we need to block that here for testing.
evankanderson marked this conversation as resolved.
Show resolved Hide resolved
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 implements fs.ReadDirFS.
evankanderson marked this conversation as resolved.
Show resolved Hide resolved
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 implements fs.StatFS.
func (a *adapterFs) Stat(name string) (fs.FileInfo, error) {
return a.fs.Stat(name)
}

// ReadFile implements fs.ReadFileFS.
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 implements fs.File.
func (a *adapterFile) Close() error {
return a.file.Close()
}

// Read implements fs.File.
func (a *adapterFile) Read(b []byte) (int, error) {
return a.file.Read(b)
}

// Stat implements fs.File.
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 implements fs.File.
// Subtle: note that this is shadowing adapterFile.Close.
func (a *adapterDirFile) Close() error {
return nil
}

// ReadDir implements fs.ReadDirFile.
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)
}
if n > len(a.entries) {
evankanderson marked this conversation as resolved.
Show resolved Hide resolved
n = len(a.entries)
}
entries := a.entries[:n]
a.entries = a.entries[n:]
return entries, nil
}
evankanderson marked this conversation as resolved.
Show resolved Hide resolved
152 changes: 152 additions & 0 deletions helper/iofs/iofs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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 := Wrap(memfs)

files := map[string]string{
"foo.txt": "hello, world",
"bar.txt": "goodbye, world",
"dir/baz.txt": "こんにちわ, world",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be good that tests take into account OS specific things, such as path separators:

Suggested change
"dir/baz.txt": "こんにちわ, world",
filepath.Join("dir","baz.txt"): "こんにちわ, world",

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out that fstest.testFS isn't written in a Windows-aware way (e.g. https://cs.opensource.google/go/go/+/refs/tags/go1.23.1:src/testing/fstest/testfs.go;l=147), so I skipped this test on Windows for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes me sad, but not sad enough to re-implement fstest.testFS in this file, given that we have Linux coverage and billy coverage.

}
created_files := make([]string, 0, len(files))
for filename, contents := range files {
makeFile(memfs, t, filename, contents)
created_files = append(created_files, filename)
}

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

func TestDeletes(t *testing.T) {
t.Parallel()
memfs := memfs.New()
iofs := Wrap(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"))
}
}
}
Loading