From 97fb956bd6bfd3c446faafd73b92befebe4dd9f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Mon, 21 Oct 2024 19:50:07 +0200 Subject: [PATCH] Add FileSystem impl that expands Go templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a new FileSystem that evaluates/expands Go templates from *.ftmpl files in the base filesystem. For example if the base filesystem contains file go.mod.ftmpl then the new resultant FS will contain go.mod file instead, and the file would be result of template execution with templateData as the template data. Signed-off-by: Matej VaĊĦek --- pkg/filesystem/filesystem.go | 137 ++++++++++++++++++++++++++++++ pkg/filesystem/filesystem_test.go | 68 +++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/pkg/filesystem/filesystem.go b/pkg/filesystem/filesystem.go index c72cf5ee53..45ae5e0c18 100644 --- a/pkg/filesystem/filesystem.go +++ b/pkg/filesystem/filesystem.go @@ -2,6 +2,8 @@ package filesystem import ( "archive/zip" + "bytes" + "errors" "fmt" "io" "io/fs" @@ -9,6 +11,7 @@ import ( "path" "path/filepath" "strings" + "text/template" billy "github.com/go-git/go-billy/v5" ) @@ -253,6 +256,140 @@ func (m maskingFS) Readlink(link string) (string, error) { return m.fs.Readlink(link) } +// NewTemplatingFS creates a new filesystem that evaluates/expands Go templates from *.ftmpl files in the baseFS. +// For example if the baseFS contains file go.mod.ftmpl then the new resultant FS will contain go.mod file instead, +// and the file would be result of template execution with templateData as the template data. +func NewTemplatingFS(baseFS Filesystem, templateData any) templatingFS { + return templatingFS{ + fs: baseFS, + templateData: templateData, + } +} + +type templatingFS struct { + fs Filesystem + templateData any +} + +type templatingFI struct { + fs.FileInfo + size int64 +} + +func (t templatingFI) Size() int64 { + return t.size +} + +func (t templatingFI) Name() string { + return strings.TrimSuffix(t.FileInfo.Name(), ".ftmpl") +} + +type templatingFile struct { + fi templatingFI + buff bytes.Buffer +} + +func (t *templatingFile) Stat() (fs.FileInfo, error) { + return t.fi, nil +} + +func (t *templatingFile) Read(i []byte) (int, error) { + return t.buff.Read(i) +} + +func (t *templatingFile) Close() error { + return nil +} + +func (t templatingFS) Open(name string) (fs.File, error) { + n := name + ".ftmpl" + + if _, err := t.fs.Stat(n); errors.Is(err, fs.ErrNotExist) { + return t.fs.Open(name) + } + + tmpl, err := template.ParseFS(t.fs, n) + if err != nil { + return nil, err + } + + var f templatingFile + err = tmpl.Execute(&f.buff, t.templateData) + if err != nil { + return nil, err + } + + fi, err := t.fs.Stat(n) + if err != nil { + return nil, err + } + f.fi = templatingFI{FileInfo: fi, size: int64(f.buff.Len())} + + return &f, nil +} + +func (t templatingFS) ReadDir(name string) ([]fs.DirEntry, error) { + des, err := t.fs.ReadDir(name) + if err != nil { + return nil, err + } + + result := make([]fs.DirEntry, len(des)) + for i, de := range des { + var fi fs.FileInfo + if !strings.HasSuffix(de.Name(), ".ftmpl") { + result[i] = de + continue + } + + fi, err = t.Stat(path.Join(name, strings.TrimSuffix(de.Name(), ".ftmpl"))) + if err != nil { + return nil, err + } + result[i] = dirEntry{fi} + + } + return result, nil +} + +func (t templatingFS) Stat(name string) (fs.FileInfo, error) { + n := name + ".ftmpl" + + fi, err := t.fs.Stat(n) + if errors.Is(err, fs.ErrNotExist) { + return t.fs.Stat(name) + } + + var tmpl *template.Template + tmpl, err = template.ParseFS(t.fs, n) + if err != nil { + return nil, err + } + + var w wc + err = tmpl.Execute(&w, t.templateData) + if err != nil { + return nil, err + } + return templatingFI{ + FileInfo: fi, + size: w.written, + }, nil +} + +type wc struct { + written int64 +} + +func (w *wc) Write(p []byte) (n int, err error) { + w.written += int64(len(p)) + return len(p), nil +} + +func (t templatingFS) Readlink(link string) (string, error) { + return t.fs.Readlink(link) +} + // CopyFromFS copies files from the `src` dir on the accessor Filesystem to local filesystem into `dest` dir. // The src path uses slashes as their separator. // The dest path uses OS specific separator. diff --git a/pkg/filesystem/filesystem_test.go b/pkg/filesystem/filesystem_test.go index 6774ee1c24..e78dc9349b 100644 --- a/pkg/filesystem/filesystem_test.go +++ b/pkg/filesystem/filesystem_test.go @@ -382,6 +382,74 @@ func TestCopy(t *testing.T) { } } +func TestTemplatingFS(t *testing.T) { + tfs := filesystem.NewTemplatingFS( + mockFS{files: []FileInfo{ + { + Path: "src", + Typ: fs.ModeDir, + }, + { + Path: "src/hello.txt.ftmpl", + Content: []byte("Hi {{.Name}}!"), + }, + }}, + struct{ Name string }{Name: "John"}, + ) + + expectedContent := "Hi John!" + + fi, err := tfs.Stat("src/hello.txt") + if err != nil { + t.Fatal(err) + } + if fi.Size() != int64(len(expectedContent)) { + t.Errorf("size missmatch") + } + + des, err := tfs.ReadDir("src") + if err != nil { + t.Fatal(err) + } + if len(des) != 1 { + t.Fatal("expected exactly one item in directory") + } + if des[0].Name() != "hello.txt" { + t.Fatalf("unexpected file: %q", des[0].Name()) + } + fi, err = des[0].Info() + if err != nil { + t.Fatal(err) + } + if fi.Size() != int64(len(expectedContent)) { + t.Error("size missmatch") + } + + expectedFiles := []FileInfo{ + { + Path: ".", + Typ: fs.ModeDir, + }, + { + Path: "src", + Typ: fs.ModeDir, + }, + { + Path: "src/hello.txt", + Content: []byte(expectedContent), + }, + } + + actualFiles, err := loadFS(tfs) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(expectedFiles, actualFiles); diff != "" { + t.Error("filesystem content missmatch (-want, +got):", diff) + } +} + // mock for testing symlink functionality type mockFS struct { files []FileInfo