Skip to content
This repository has been archived by the owner on Feb 24, 2024. It is now read-only.

Add reload.FS for hot reloading during development #2180

Merged
merged 7 commits into from
Nov 28, 2021
Merged
Show file tree
Hide file tree
Changes from all 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
76 changes: 76 additions & 0 deletions fs.go
Original file line number Diff line number Diff line change
@@ -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
}
100 changes: 100 additions & 0 deletions fs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Empty file added internal/testdata/disk/embed.go
Empty file.
1 change: 1 addition & 0 deletions internal/testdata/disk/file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file is on disk.
Empty file.
12 changes: 12 additions & 0 deletions internal/testdata/embedded/embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package embedded

import (
"embed"
)

//go:embed *
var files embed.FS

func FS() embed.FS {
return files
}
1 change: 1 addition & 0 deletions internal/testdata/embedded/file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file is embedded.
1 change: 1 addition & 0 deletions internal/testdata/panic.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file must not be accessible from buffalo.FS.