Repository: gobuffalo/buffalo
Pull request #2180 (2021-11-27 19:57:10)

Add reload.FS for hot reloading during development
This adds an implementation of `fs.FS` that can do both: hot reload and embedding of files. `embed.go` is automatically hidden from the embedded file system.

There is a related PR here: https://github.com/gobuffalo/cli/pull/60

This PR updates the new command to generate `embed.go` files that use the `reload.FS`.


Patch:
diff --git a/fs.go b/fs.go
new file mode 100644
index 00000000..e7436c3c
--- /dev/null
+++ b/fs.go
@@ -0,0 +1,76 @@
+package buffalo
+
+import (
+	"fmt"
+	"io/fs"
+	"os"
+)
+
+// FS wraps a directory and an embed FS that are expected to have the same contents.
+// it prioritizes the directory FS and falls back to the embedded FS if the file cannot
+// be found on disk. This is useful during development or when deploying with
+// assets not embedded in the binary.
+//
+// Additionally FS hiddes any file named embed.go from the FS.
+type FS struct {
+	embed fs.FS
+	dir   fs.FS
+}
+
+// NewFS returns a new FS that wraps the given directory and embedded FS.
+// the embed.FS is expected to embed the same files as the directory FS.
+func NewFS(embed fs.ReadDirFS, dir string) FS {
+	return FS{
+		embed: embed,
+		dir:   os.DirFS(dir),
+	}
+}
+
+// Open implements the FS interface.
+func (f FS) Open(name string) (fs.File, error) {
+	if name == "embed.go" {
+		return nil, fs.ErrNotExist
+	}
+	file, err := f.getFile(name)
+	if name == "." {
+		return rootFile{file}, err
+	}
+	return file, err
+}
+
+func (f FS) getFile(name string) (fs.File, error) {
+	file, err := f.dir.Open(name)
+	if err == nil {
+		return file, nil
+	}
+
+	return f.embed.Open(name)
+}
+
+// rootFile wraps the "." directory for hidding the embed.go file.
+type rootFile struct {
+	fs.File
+}
+
+// ReadDir implements the fs.ReadDirFile interface.
+func (f rootFile) ReadDir(n int) (entries []fs.DirEntry, err error) {
+	dir, ok := f.File.(fs.ReadDirFile)
+	if !ok {
+		return nil, fmt.Errorf("%T is not a directory", f.File)
+	}
+
+	entries, err = dir.ReadDir(n)
+	entries = hideEmbedFile(entries)
+	return entries, err
+}
+
+func hideEmbedFile(entries []fs.DirEntry) []fs.DirEntry {
+	result := make([]fs.DirEntry, 0, len(entries))
+
+	for _, entry := range entries {
+		if entry.Name() != "embed.go" {
+			result = append(result, entry)
+		}
+	}
+	return result
+}


Test patch:
diff --git a/fs_test.go b/fs_test.go
new file mode 100644
index 00000000..11d14276
--- /dev/null
+++ b/fs_test.go
@@ -0,0 +1,100 @@
+package buffalo
+
+import (
+	"io"
+	"io/fs"
+	"testing"
+
+	"github.com/gobuffalo/buffalo/internal/testdata/embedded"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_FS_Disallows_Parent_Folders(t *testing.T) {
+	r := require.New(t)
+
+	fsys := NewFS(embedded.FS(), "internal/testdata/disk")
+	r.NotNil(fsys)
+
+	f, err := fsys.Open("../panic.txt")
+	r.ErrorIs(err, fs.ErrNotExist)
+	r.Nil(f)
+
+	f, err = fsys.Open("try/../to/../trick/../panic.txt")
+	r.ErrorIs(err, fs.ErrNotExist)
+	r.Nil(f)
+}
+
+func Test_FS_Hides_embed_go(t *testing.T) {
+	r := require.New(t)
+
+	fsys := NewFS(embedded.FS(), "internal/testdata/disk")
+	r.NotNil(fsys)
+
+	f, err := fsys.Open("embed.go")
+	r.ErrorIs(err, fs.ErrNotExist)
+	r.Nil(f)
+}
+
+func Test_FS_Prioritizes_Disk(t *testing.T) {
+	r := require.New(t)
+
+	fsys := NewFS(embedded.FS(), "internal/testdata/disk")
+	r.NotNil(fsys)
+
+	f, err := fsys.Open("file.txt")
+	r.NoError(err)
+
+	b, err := io.ReadAll(f)
+	r.NoError(err)
+
+	r.Equal("This file is on disk.", string(b))
+}
+
+func Test_FS_Uses_Embed_If_No_Disk(t *testing.T) {
+	r := require.New(t)
+
+	fsys := NewFS(embedded.FS(), "internal/testdata/empty")
+	r.NotNil(fsys)
+
+	f, err := fsys.Open("file.txt")
+	r.NoError(err)
+
+	b, err := io.ReadAll(f)
+	r.NoError(err)
+
+	r.Equal("This file is embedded.", string(b))
+}
+
+func Test_FS_ReadDirFile(t *testing.T) {
+	r := require.New(t)
+
+	fsys := NewFS(embedded.FS(), "internal/testdata/disk")
+	r.NotNil(fsys)
+
+	f, err := fsys.Open(".")
+	r.NoError(err)
+
+	dir, ok := f.(fs.ReadDirFile)
+	r.True(ok, "folder does not implement fs.ReadDirFile interface")
+
+	// First read should return at most 1 file
+	entries, err := dir.ReadDir(1)
+	r.NoError(err)
+
+	// The actual len will be 0 because the first file read is the embed.go file
+	// this is counter-intuitive, but it's how the fs.ReadDirFile interface is specified;
+	// if err == nil, just continue to call ReadDir until io.EOF is returned.
+	r.LessOrEqual(len(entries), 1, "a call to ReadDir must at most return n entries")
+
+	// Second read should return at most 2 files
+	entries, err = dir.ReadDir(2)
+	r.NoError(err)
+
+	// The actual len will be 2 (file.txt & file2.txt)
+	r.LessOrEqual(len(entries), 2, "a call to ReadDir must at most return n entries")
+
+	// trying to read next 2 files (none left)
+	entries, err = dir.ReadDir(2)
+	r.ErrorIs(err, io.EOF)
+	r.Empty(entries)
+}
diff --git a/internal/testdata/disk/embed.go b/internal/testdata/disk/embed.go
new file mode 100644
index 00000000..e69de29b
diff --git a/internal/testdata/disk/file.txt b/internal/testdata/disk/file.txt
new file mode 100644
index 00000000..e286859c
--- /dev/null
+++ b/internal/testdata/disk/file.txt
@@ -0,0 +1,1 @@
+This file is on disk.
\ No newline at end of file
diff --git a/internal/testdata/disk/file2.txt b/internal/testdata/disk/file2.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/internal/testdata/embedded/embed.go b/internal/testdata/embedded/embed.go
new file mode 100644
index 00000000..c6f4a841
--- /dev/null
+++ b/internal/testdata/embedded/embed.go
@@ -0,0 +1,12 @@
+package embedded
+
+import (
+	"embed"
+)
+
+//go:embed *
+var files embed.FS
+
+func FS() embed.FS {
+	return files
+}
diff --git a/internal/testdata/embedded/file.txt b/internal/testdata/embedded/file.txt
new file mode 100644
index 00000000..0ba5d842
--- /dev/null
+++ b/internal/testdata/embedded/file.txt
@@ -0,0 +1,1 @@
+This file is embedded.
\ No newline at end of file
diff --git a/internal/testdata/panic.txt b/internal/testdata/panic.txt
new file mode 100644
index 00000000..764ccb2f
--- /dev/null
+++ b/internal/testdata/panic.txt
@@ -0,0 +1,1 @@
+This file must not be accessible from buffalo.FS.
\ No newline at end of file