summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile4
-rw-r--r--data/datasource.go15
-rw-r--r--data/datasource_file.go37
-rw-r--r--data/datasource_file_test.go36
-rw-r--r--data/datasource_git.go8
-rw-r--r--data/datasource_git_test.go8
-rw-r--r--data/datasource_merge.go4
-rw-r--r--data/datasource_merge_test.go55
-rw-r--r--data/datasource_test.go48
-rw-r--r--env/env.go32
-rw-r--r--env/env_test.go112
-rw-r--r--file/file.go93
-rw-r--r--file/file_test.go114
-rw-r--r--funcs/file.go59
-rw-r--r--funcs/file_test.go134
-rw-r--r--go.mod4
-rw-r--r--go.sum9
-rw-r--r--internal/cmd/config.go23
-rw-r--r--internal/cmd/config_test.go48
-rw-r--r--internal/cmd/main.go15
-rw-r--r--internal/config/configfile.go73
-rw-r--r--internal/config/configfile_test.go232
-rw-r--r--internal/config/types.go5
-rw-r--r--internal/datafs/fsys.go111
-rw-r--r--internal/datafs/fsys_test.go42
-rw-r--r--internal/datafs/wdfs.go275
-rw-r--r--internal/datafs/wdfs_test.go273
-rw-r--r--internal/iohelpers/writers.go55
-rw-r--r--internal/iohelpers/writers_test.go86
-rw-r--r--internal/tests/integration/basic_test.go22
-rw-r--r--internal/tests/integration/config_test.go25
-rw-r--r--internal/tests/integration/gomplateignore_test.go193
-rw-r--r--internal/tests/integration/integration_test.go6
-rw-r--r--render_test.go11
-rw-r--r--template.go171
-rw-r--r--template_test.go95
-rw-r--r--template_unix_test.go31
-rw-r--r--template_windows_test.go52
38 files changed, 1706 insertions, 910 deletions
diff --git a/Makefile b/Makefile
index f491f8b1..be23be3c 100644
--- a/Makefile
+++ b/Makefile
@@ -166,12 +166,14 @@ $(shell go list -f '{{ if not (eq "" (join .TestGoFiles "")) }}testbin/{{.Import
# available. Git must also be configured with a username and email address. See
# the GitHub workflow config in .github/workflows/build.yml for hints.
# A recent PowerShell is also required, such as version 7.3 or later.
+#
+# An F: drive is expected to be available, with a tmp directory.
.SECONDEXPANSION:
$(shell go list -f '{{ if not (eq "" (join .TestGoFiles "")) }}testbin/{{.ImportPath}}.test.exe.remote{{end}}' ./...): $$(shell go list -f '{{.Dir}}' $$(subst testbin/,,$$(subst .test.exe.remote,,$$@)))
@echo $<
@GOOS=windows GOARCH=amd64 $(GO) test -tags timetzdata -c -o $(PREFIX)/testbin/remote-test.exe $<
@scp -q $(PREFIX)/testbin/remote-test.exe $(GO_REMOTE_WINDOWS):/$(shell ssh $(GO_REMOTE_WINDOWS) 'echo %TEMP%' | cut -f2 -d= | sed -e 's#\\#/#g')/
- @ssh $(GO_REMOTE_WINDOWS) '%TEMP%\remote-test.exe'
+ @ssh -o 'SetEnv TMP=F:\tmp' $(GO_REMOTE_WINDOWS) '%TEMP%\remote-test.exe'
# test-remote-windows runs the above target for all packages that have tests
test-remote-windows: $(shell go list -f '{{ if not (eq "" (join .TestGoFiles "")) }}testbin/{{.ImportPath}}.test.exe.remote{{end}}' ./...)
diff --git a/data/datasource.go b/data/datasource.go
index bebef803..d16fe09a 100644
--- a/data/datasource.go
+++ b/data/datasource.go
@@ -3,6 +3,7 @@ package data
import (
"context"
"fmt"
+ "io/fs"
"mime"
"net/http"
"net/url"
@@ -10,9 +11,8 @@ import (
"sort"
"strings"
- "github.com/spf13/afero"
-
"github.com/hairyhenderson/gomplate/v4/internal/config"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/hairyhenderson/gomplate/v4/libkv"
"github.com/hairyhenderson/gomplate/v4/vault"
)
@@ -61,11 +61,16 @@ func (d *Data) registerReaders() {
d.sourceReaders["git+ssh"] = readGit
}
-// lookupReader - return the reader function for the given scheme
+// lookupReader - return the reader function for the given scheme. Empty scheme
+// will return the file reader.
func (d *Data) lookupReader(scheme string) (func(context.Context, *Source, ...string) ([]byte, error), error) {
if d.sourceReaders == nil {
d.registerReaders()
}
+ if scheme == "" {
+ scheme = "file"
+ }
+
r, ok := d.sourceReaders[scheme]
if !ok {
return nil, fmt.Errorf("scheme %s not registered", scheme)
@@ -144,7 +149,7 @@ type Source struct {
Alias string
URL *url.URL
Header http.Header // used for http[s]: URLs, nil otherwise
- fs afero.Fs // used for file: URLs, nil otherwise
+ fs fs.FS // used for file: URLs, nil otherwise
hc *http.Client // used for http[s]: URLs, nil otherwise
vc *vault.Vault // used for vault: URLs, nil otherwise
kv *libkv.LibKV // used for consul:, etcd:, zookeeper: URLs, nil otherwise
@@ -240,7 +245,7 @@ func (d *Data) DefineDatasource(alias, value string) (string, error) {
if d.DatasourceExists(alias) {
return "", nil
}
- srcURL, err := config.ParseSourceURL(value)
+ srcURL, err := datafs.ParseSourceURL(value)
if err != nil {
return "", err
}
diff --git a/data/datasource_file.go b/data/datasource_file.go
index 156ab276..f5c764fe 100644
--- a/data/datasource_file.go
+++ b/data/datasource_file.go
@@ -5,18 +5,22 @@ import (
"context"
"encoding/json"
"fmt"
- "io"
+ "io/fs"
"net/url"
- "os"
"path/filepath"
"strings"
- "github.com/spf13/afero"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
)
-func readFile(_ context.Context, source *Source, args ...string) ([]byte, error) {
+func readFile(ctx context.Context, source *Source, args ...string) ([]byte, error) {
if source.fs == nil {
- source.fs = afero.NewOsFs()
+ fsp := datafs.FSProviderFromContext(ctx)
+ fsys, err := fsp.New(source.URL)
+ if err != nil {
+ return nil, fmt.Errorf("filesystem provider for %q unavailable: %w", source.URL, err)
+ }
+ source.fs = fsys
}
p := filepath.FromSlash(source.URL.Path)
@@ -35,13 +39,18 @@ func readFile(_ context.Context, source *Source, args ...string) ([]byte, error)
source.mediaType = ""
}
+ isDir := strings.HasSuffix(p, string(filepath.Separator))
+ if strings.HasSuffix(p, string(filepath.Separator)) {
+ p = p[:len(p)-1]
+ }
+
// make sure we can access the file
- i, err := source.fs.Stat(p)
+ i, err := fs.Stat(source.fs, p)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", p, err)
}
- if strings.HasSuffix(p, string(filepath.Separator)) {
+ if isDir {
source.mediaType = jsonArrayMimetype
if i.IsDir() {
return readFileDir(source, p)
@@ -49,25 +58,19 @@ func readFile(_ context.Context, source *Source, args ...string) ([]byte, error)
return nil, fmt.Errorf("%s is not a directory", p)
}
- f, err := source.fs.OpenFile(p, os.O_RDONLY, 0)
+ b, err := fs.ReadFile(source.fs, p)
if err != nil {
- return nil, fmt.Errorf("openFile %s: %w", p, err)
- }
-
- defer f.Close()
-
- b, err := io.ReadAll(f)
- if err != nil {
- return nil, fmt.Errorf("readAll %s: %w", p, err)
+ return nil, fmt.Errorf("readFile %s: %w", p, err)
}
return b, nil
}
func readFileDir(source *Source, p string) ([]byte, error) {
- names, err := afero.ReadDir(source.fs, p)
+ names, err := fs.ReadDir(source.fs, p)
if err != nil {
return nil, err
}
+
files := make([]string, len(names))
for i, v := range names {
files[i] = v.Name()
diff --git a/data/datasource_file_test.go b/data/datasource_file_test.go
index 420ca380..7ad1c2a0 100644
--- a/data/datasource_file_test.go
+++ b/data/datasource_file_test.go
@@ -2,10 +2,11 @@ package data
import (
"context"
+ "io/fs"
"testing"
+ "testing/fstest"
- "github.com/spf13/afero"
-
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -14,45 +15,42 @@ func TestReadFile(t *testing.T) {
ctx := context.Background()
content := []byte(`hello world`)
- fs := afero.NewMemMapFs()
-
- _ = fs.Mkdir("/tmp", 0777)
- f, _ := fs.Create("/tmp/foo")
- _, _ = f.Write(content)
- _ = fs.Mkdir("/tmp/partial", 0777)
- f, _ = fs.Create("/tmp/partial/foo.txt")
- _, _ = f.Write(content)
- _, _ = fs.Create("/tmp/partial/bar.txt")
- _, _ = fs.Create("/tmp/partial/baz.txt")
- _ = f.Close()
+ fsys := datafs.WrapWdFS(fstest.MapFS{
+ "tmp": {Mode: fs.ModeDir | 0o777},
+ "tmp/foo": {Data: content},
+ "tmp/partial": {Mode: fs.ModeDir | 0o777},
+ "tmp/partial/foo.txt": {Data: content},
+ "tmp/partial/bar.txt": {},
+ "tmp/partial/baz.txt": {},
+ })
source := &Source{Alias: "foo", URL: mustParseURL("file:///tmp/foo")}
- source.fs = fs
+ source.fs = fsys
actual, err := readFile(ctx, source)
require.NoError(t, err)
assert.Equal(t, content, actual)
source = &Source{Alias: "bogus", URL: mustParseURL("file:///bogus")}
- source.fs = fs
+ source.fs = fsys
_, err = readFile(ctx, source)
assert.Error(t, err)
source = &Source{Alias: "partial", URL: mustParseURL("file:///tmp/partial")}
- source.fs = fs
+ source.fs = fsys
actual, err = readFile(ctx, source, "foo.txt")
require.NoError(t, err)
assert.Equal(t, content, actual)
source = &Source{Alias: "dir", URL: mustParseURL("file:///tmp/partial/")}
- source.fs = fs
+ source.fs = fsys
actual, err = readFile(ctx, source)
require.NoError(t, err)
assert.Equal(t, []byte(`["bar.txt","baz.txt","foo.txt"]`), actual)
source = &Source{Alias: "dir", URL: mustParseURL("file:///tmp/partial/?type=application/json")}
- source.fs = fs
+ source.fs = fsys
actual, err = readFile(ctx, source)
require.NoError(t, err)
assert.Equal(t, []byte(`["bar.txt","baz.txt","foo.txt"]`), actual)
@@ -61,7 +59,7 @@ func TestReadFile(t *testing.T) {
assert.Equal(t, "application/json", mime)
source = &Source{Alias: "dir", URL: mustParseURL("file:///tmp/partial/?type=application/json")}
- source.fs = fs
+ source.fs = fsys
actual, err = readFile(ctx, source, "foo.txt")
require.NoError(t, err)
assert.Equal(t, content, actual)
diff --git a/data/datasource_git.go b/data/datasource_git.go
index c5e5adf8..5c12da4c 100644
--- a/data/datasource_git.go
+++ b/data/datasource_git.go
@@ -248,17 +248,17 @@ func (g gitsource) clone(ctx context.Context, repoURL *url.URL, depth int) (bill
}
// read - reads the provided path out of a git repo
-func (g gitsource) read(fs billy.Filesystem, path string) (string, []byte, error) {
- fi, err := fs.Stat(path)
+func (g gitsource) read(fsys billy.Filesystem, path string) (string, []byte, error) {
+ fi, err := fsys.Stat(path)
if err != nil {
return "", nil, fmt.Errorf("can't stat %s: %w", path, err)
}
if fi.IsDir() || strings.HasSuffix(path, string(filepath.Separator)) {
- out, rerr := g.readDir(fs, path)
+ out, rerr := g.readDir(fsys, path)
return jsonArrayMimetype, out, rerr
}
- f, err := fs.OpenFile(path, os.O_RDONLY, 0)
+ f, err := fsys.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
return "", nil, fmt.Errorf("can't open %s: %w", path, err)
}
diff --git a/data/datasource_git_test.go b/data/datasource_git_test.go
index 157e14a8..a5de9bc4 100644
--- a/data/datasource_git_test.go
+++ b/data/datasource_git_test.go
@@ -331,10 +331,10 @@ func TestOpenFileRepo(t *testing.T) {
overrideFSLoader(repoFS)
defer overrideFSLoader(osfs.New(""))
- fs, _, err := g.clone(ctx, mustParseURL("git+file:///repo"), 0)
+ fsys, _, err := g.clone(ctx, mustParseURL("git+file:///repo"), 0)
assert.NilError(t, err)
- f, err := fs.Open("/foo/bar/hi.txt")
+ f, err := fsys.Open("/foo/bar/hi.txt")
assert.NilError(t, err)
b, _ := io.ReadAll(f)
assert.Equal(t, "hello world", string(b))
@@ -370,10 +370,10 @@ func TestOpenBareFileRepo(t *testing.T) {
overrideFSLoader(repoFS)
defer overrideFSLoader(osfs.New(""))
- fs, _, err := g.clone(ctx, mustParseURL("git+file:///bare.git"), 0)
+ fsys, _, err := g.clone(ctx, mustParseURL("git+file:///bare.git"), 0)
assert.NilError(t, err)
- f, err := fs.Open("/hello.txt")
+ f, err := fsys.Open("/hello.txt")
assert.NilError(t, err)
b, _ := io.ReadAll(f)
assert.Equal(t, "hello world", string(b))
diff --git a/data/datasource_merge.go b/data/datasource_merge.go
index f2c28399..b0d8b6bd 100644
--- a/data/datasource_merge.go
+++ b/data/datasource_merge.go
@@ -6,7 +6,7 @@ import (
"strings"
"github.com/hairyhenderson/gomplate/v4/coll"
- "github.com/hairyhenderson/gomplate/v4/internal/config"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
)
// readMerge demultiplexes a `merge:` datasource. The 'args' parameter currently
@@ -31,7 +31,7 @@ func (d *Data) readMerge(ctx context.Context, source *Source, _ ...string) ([]by
subSource, err := d.lookupSource(part)
if err != nil {
// maybe it's a relative filename?
- u, uerr := config.ParseSourceURL(part)
+ u, uerr := datafs.ParseSourceURL(part)
if uerr != nil {
return nil, uerr
}
diff --git a/data/datasource_merge_test.go b/data/datasource_merge_test.go
index 66365f36..48d1f85e 100644
--- a/data/datasource_merge_test.go
+++ b/data/datasource_merge_test.go
@@ -2,12 +2,15 @@ package data
import (
"context"
+ "io/fs"
"net/url"
"os"
+ "path"
"path/filepath"
"testing"
+ "testing/fstest"
- "github.com/spf13/afero"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -22,31 +25,32 @@ func TestReadMerge(t *testing.T) {
mergedContent := "goodnight: moon\nhello: world\n"
- fs := afero.NewMemMapFs()
-
- _ = fs.Mkdir("/tmp", 0777)
- f, _ := fs.Create("/tmp/jsonfile.json")
- _, _ = f.WriteString(jsonContent)
- f, _ = fs.Create("/tmp/array.json")
- _, _ = f.WriteString(arrayContent)
- f, _ = fs.Create("/tmp/yamlfile.yaml")
- _, _ = f.WriteString(yamlContent)
- f, _ = fs.Create("/tmp/textfile.txt")
- _, _ = f.WriteString(`plain text...`)
-
wd, _ := os.Getwd()
- _ = fs.Mkdir(wd, 0777)
- f, _ = fs.Create(filepath.Join(wd, "jsonfile.json"))
- _, _ = f.WriteString(jsonContent)
- f, _ = fs.Create(filepath.Join(wd, "array.json"))
- _, _ = f.WriteString(arrayContent)
- f, _ = fs.Create(filepath.Join(wd, "yamlfile.yaml"))
- _, _ = f.WriteString(yamlContent)
- f, _ = fs.Create(filepath.Join(wd, "textfile.txt"))
- _, _ = f.WriteString(`plain text...`)
+
+ // MapFS doesn't support windows path separators, so we use / exclusively
+ // in this test
+ vol := filepath.VolumeName(wd)
+ if vol != "" && wd != vol {
+ wd = wd[len(vol)+1:]
+ } else if wd[0] == '/' {
+ wd = wd[1:]
+ }
+ wd = filepath.ToSlash(wd)
+
+ fsys := datafs.WrapWdFS(fstest.MapFS{
+ "tmp": {Mode: fs.ModeDir | 0o777},
+ "tmp/jsonfile.json": {Data: []byte(jsonContent)},
+ "tmp/array.json": {Data: []byte(arrayContent)},
+ "tmp/yamlfile.yaml": {Data: []byte(yamlContent)},
+ "tmp/textfile.txt": {Data: []byte(`plain text...`)},
+ path.Join(wd, "jsonfile.json"): {Data: []byte(jsonContent)},
+ path.Join(wd, "array.json"): {Data: []byte(arrayContent)},
+ path.Join(wd, "yamlfile.yaml"): {Data: []byte(yamlContent)},
+ path.Join(wd, "textfile.txt"): {Data: []byte(`plain text...`)},
+ })
source := &Source{Alias: "foo", URL: mustParseURL("merge:file:///tmp/jsonfile.json|file:///tmp/yamlfile.yaml")}
- source.fs = fs
+ source.fs = fsys
d := &Data{
Sources: map[string]*Source{
"foo": source,
@@ -68,6 +72,11 @@ func TestReadMerge(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, mergedContent, string(actual))
+ source.URL = mustParseURL("merge:jsonfile.json|baz")
+ actual, err = d.readMerge(ctx, source)
+ require.NoError(t, err)
+ assert.Equal(t, mergedContent, string(actual))
+
source.URL = mustParseURL("merge:./jsonfile.json|baz")
actual, err = d.readMerge(ctx, source)
require.NoError(t, err)
diff --git a/data/datasource_test.go b/data/datasource_test.go
index c0d0c70a..43d59c85 100644
--- a/data/datasource_test.go
+++ b/data/datasource_test.go
@@ -7,9 +7,10 @@ import (
"net/url"
"runtime"
"testing"
+ "testing/fstest"
"github.com/hairyhenderson/gomplate/v4/internal/config"
- "github.com/spf13/afero"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -45,26 +46,23 @@ func TestNewData(t *testing.T) {
func TestDatasource(t *testing.T) {
setup := func(ext, mime string, contents []byte) *Data {
fname := "foo." + ext
- fs := afero.NewMemMapFs()
var uPath string
- var f afero.File
if runtime.GOOS == osWindows {
- _ = fs.Mkdir("C:\\tmp", 0777)
- f, _ = fs.Create("C:\\tmp\\" + fname)
uPath = "C:/tmp/" + fname
} else {
- _ = fs.Mkdir("/tmp", 0777)
- f, _ = fs.Create("/tmp/" + fname)
uPath = "/tmp/" + fname
}
- _, _ = f.Write(contents)
+
+ fsys := datafs.WrapWdFS(fstest.MapFS{
+ "tmp/" + fname: &fstest.MapFile{Data: contents},
+ })
sources := map[string]*Source{
"foo": {
Alias: "foo",
URL: &url.URL{Scheme: "file", Path: uPath},
mediaType: mime,
- fs: fs,
+ fs: fsys,
},
}
return &Data{Sources: sources}
@@ -102,31 +100,28 @@ func TestDatasource(t *testing.T) {
func TestDatasourceReachable(t *testing.T) {
fname := "foo.json"
- fs := afero.NewMemMapFs()
var uPath string
- var f afero.File
if runtime.GOOS == osWindows {
- _ = fs.Mkdir("C:\\tmp", 0777)
- f, _ = fs.Create("C:\\tmp\\" + fname)
uPath = "C:/tmp/" + fname
} else {
- _ = fs.Mkdir("/tmp", 0777)
- f, _ = fs.Create("/tmp/" + fname)
uPath = "/tmp/" + fname
}
- _, _ = f.Write([]byte("{}"))
+
+ fsys := datafs.WrapWdFS(fstest.MapFS{
+ "tmp/" + fname: &fstest.MapFile{Data: []byte("{}")},
+ })
sources := map[string]*Source{
"foo": {
Alias: "foo",
URL: &url.URL{Scheme: "file", Path: uPath},
mediaType: jsonMimetype,
- fs: fs,
+ fs: fsys,
},
"bar": {
Alias: "bar",
URL: &url.URL{Scheme: "file", Path: "/bogus"},
- fs: fs,
+ fs: fsys,
},
}
data := &Data{Sources: sources}
@@ -148,27 +143,24 @@ func TestInclude(t *testing.T) {
ext := "txt"
contents := "hello world"
fname := "foo." + ext
- fs := afero.NewMemMapFs()
var uPath string
- var f afero.File
if runtime.GOOS == osWindows {
- _ = fs.Mkdir("C:\\tmp", 0777)
- f, _ = fs.Create("C:\\tmp\\" + fname)
uPath = "C:/tmp/" + fname
} else {
- _ = fs.Mkdir("/tmp", 0777)
- f, _ = fs.Create("/tmp/" + fname)
uPath = "/tmp/" + fname
}
- _, _ = f.Write([]byte(contents))
+
+ fsys := datafs.WrapWdFS(fstest.MapFS{
+ "tmp/" + fname: &fstest.MapFile{Data: []byte(contents)},
+ })
sources := map[string]*Source{
"foo": {
Alias: "foo",
URL: &url.URL{Scheme: "file", Path: uPath},
mediaType: textMimetype,
- fs: fs,
+ fs: fsys,
},
}
data := &Data{
@@ -185,7 +177,6 @@ func (e errorReader) Read(_ []byte) (n int, err error) {
return 0, fmt.Errorf("error")
}
-// nolint: megacheck
func TestDefineDatasource(t *testing.T) {
d := &Data{}
_, err := d.DefineDatasource("", "foo.json")
@@ -204,8 +195,7 @@ func TestDefineDatasource(t *testing.T) {
s := d.Sources["data"]
require.NoError(t, err)
assert.Equal(t, "data", s.Alias)
- assert.Equal(t, "file", s.URL.Scheme)
- assert.True(t, s.URL.IsAbs())
+ assert.EqualValues(t, &url.URL{Path: "foo.json"}, s.URL)
d = &Data{}
_, err = d.DefineDatasource("data", "/otherdir/foo.json")
diff --git a/env/env.go b/env/env.go
index b8bbbfa5..e9ab3632 100644
--- a/env/env.go
+++ b/env/env.go
@@ -2,11 +2,12 @@
package env
import (
- "io"
+ "io/fs"
"os"
"strings"
- "github.com/spf13/afero"
+ osfs "github.com/hack-pad/hackpadfs/os"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
)
// Getenv - retrieves the value of the environment variable named by the key.
@@ -14,24 +15,26 @@ import (
// referenced file will be read into the value.
// Otherwise the provided default (or an emptry string) is returned.
func Getenv(key string, def ...string) string {
- return getenvVFS(afero.NewOsFs(), key, def...)
+ fsys := datafs.WrapWdFS(osfs.NewFS())
+ return getenvVFS(fsys, key, def...)
}
// ExpandEnv - like os.ExpandEnv, except supports `_FILE` vars as well
func ExpandEnv(s string) string {
- return expandEnvVFS(afero.NewOsFs(), s)
+ fsys := datafs.WrapWdFS(osfs.NewFS())
+ return expandEnvVFS(fsys, s)
}
// expandEnvVFS -
-func expandEnvVFS(fs afero.Fs, s string) string {
+func expandEnvVFS(fsys fs.FS, s string) string {
return os.Expand(s, func(s string) string {
- return getenvVFS(fs, s)
+ return getenvVFS(fsys, s)
})
}
// getenvVFS - a convenience function intended for internal use only!
-func getenvVFS(fs afero.Fs, key string, def ...string) string {
- val := getenvFile(fs, key)
+func getenvVFS(fsys fs.FS, key string, def ...string) string {
+ val := getenvFile(fsys, key)
if val == "" && len(def) > 0 {
return def[0]
}
@@ -39,7 +42,7 @@ func getenvVFS(fs afero.Fs, key string, def ...string) string {
return val
}
-func getenvFile(fs afero.Fs, key string) string {
+func getenvFile(fsys fs.FS, key string) string {
val := os.Getenv(key)
if val != "" {
return val
@@ -47,7 +50,7 @@ func getenvFile(fs afero.Fs, key string) string {
p := os.Getenv(key + "_FILE")
if p != "" {
- val, err := readFile(fs, p)
+ val, err := readFile(fsys, p)
if err != nil {
return ""
}
@@ -57,14 +60,11 @@ func getenvFile(fs afero.Fs, key string) string {
return ""
}
-func readFile(fs afero.Fs, p string) (string, error) {
- f, err := fs.OpenFile(p, os.O_RDONLY, 0)
- if err != nil {
- return "", err
- }
- b, err := io.ReadAll(f)
+func readFile(fsys fs.FS, p string) (string, error) {
+ b, err := fs.ReadFile(fsys, p)
if err != nil {
return "", err
}
+
return string(b), nil
}
diff --git a/env/env_test.go b/env/env_test.go
index 59bf4048..faaaa603 100644
--- a/env/env_test.go
+++ b/env/env_test.go
@@ -2,10 +2,13 @@ package env
import (
"errors"
+ "io/fs"
"os"
"testing"
+ "testing/fstest"
- "github.com/spf13/afero"
+ "github.com/hack-pad/hackpadfs"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
)
@@ -17,22 +20,22 @@ func TestGetenv(t *testing.T) {
}
func TestGetenvFile(t *testing.T) {
- fs := afero.NewMemMapFs()
- _ = fs.Mkdir("/tmp", 0777)
- f, _ := fs.Create("/tmp/foo")
- _, _ = f.Write([]byte("foo"))
-
- defer os.Unsetenv("FOO_FILE")
- os.Setenv("FOO_FILE", "/tmp/foo")
- assert.Equal(t, "foo", getenvVFS(fs, "FOO", "bar"))
-
- os.Setenv("FOO_FILE", "/tmp/missing")
- assert.Equal(t, "bar", getenvVFS(fs, "FOO", "bar"))
-
- _, _ = fs.Create("/tmp/unreadable")
- fs = writeOnly(fs)
- os.Setenv("FOO_FILE", "/tmp/unreadable")
- assert.Equal(t, "bar", getenvVFS(fs, "FOO", "bar"))
+ fsys := fs.FS(fstest.MapFS{
+ "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/foo": &fstest.MapFile{Data: []byte("foo")},
+ "tmp/unreadable": &fstest.MapFile{Data: []byte("foo"), Mode: 0o000},
+ })
+ fsys = datafs.WrapWdFS(fsys)
+
+ t.Setenv("FOO_FILE", "/tmp/foo")
+ assert.Equal(t, "foo", getenvVFS(fsys, "FOO", "bar"))
+
+ t.Setenv("FOO_FILE", "/tmp/missing")
+ assert.Equal(t, "bar", getenvVFS(fsys, "FOO", "bar"))
+
+ fsys = writeOnly(fsys)
+ t.Setenv("FOO_FILE", "/tmp/unreadable")
+ assert.Equal(t, "bar", getenvVFS(fsys, "FOO", "bar"))
}
func TestExpandEnv(t *testing.T) {
@@ -44,73 +47,62 @@ func TestExpandEnv(t *testing.T) {
}
func TestExpandEnvFile(t *testing.T) {
- fs := afero.NewMemMapFs()
- _ = fs.Mkdir("/tmp", 0777)
- f, _ := fs.Create("/tmp/foo")
- _, _ = f.Write([]byte("foo"))
-
- defer os.Unsetenv("FOO_FILE")
- os.Setenv("FOO_FILE", "/tmp/foo")
- assert.Equal(t, "foo is foo", expandEnvVFS(fs, "foo is $FOO"))
-
- os.Setenv("FOO_FILE", "/tmp/missing")
- assert.Equal(t, "empty", expandEnvVFS(fs, "${FOO}empty"))
-
- _, _ = fs.Create("/tmp/unreadable")
- fs = writeOnly(fs)
- os.Setenv("FOO_FILE", "/tmp/unreadable")
- assert.Equal(t, "", expandEnvVFS(fs, "${FOO}"))
-}
+ fsys := fs.FS(fstest.MapFS{
+ "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/foo": &fstest.MapFile{Data: []byte("foo")},
+ "tmp/unreadable": &fstest.MapFile{Data: []byte("foo"), Mode: 0o000},
+ })
+ fsys = datafs.WrapWdFS(fsys)
-// Maybe extract this into a separate package sometime...
-// writeOnly - represents a filesystem that's writeable, but read operations fail
-func writeOnly(fs afero.Fs) afero.Fs {
- return &woFS{fs}
-}
+ t.Setenv("FOO_FILE", "/tmp/foo")
+ assert.Equal(t, "foo is foo", expandEnvVFS(fsys, "foo is $FOO"))
-type woFS struct {
- afero.Fs
-}
+ t.Setenv("FOO_FILE", "/tmp/missing")
+ assert.Equal(t, "empty", expandEnvVFS(fsys, "${FOO}empty"))
-func (fs woFS) Remove(name string) error {
- return fs.Fs.Remove(name)
+ fsys = writeOnly(fsys)
+ t.Setenv("FOO_FILE", "/tmp/unreadable")
+ assert.Equal(t, "", expandEnvVFS(fsys, "${FOO}"))
}
-func (fs woFS) Rename(oldpath, newpath string) error {
- return fs.Fs.Rename(oldpath, newpath)
+// Maybe extract this into a separate package sometime...
+// writeOnly - represents a filesystem that's writeable, but read operations fail
+func writeOnly(fsys fs.FS) fs.FS {
+ return &woFS{fsys}
}
-func (fs woFS) Mkdir(name string, perm os.FileMode) error {
- return fs.Fs.Mkdir(name, perm)
+type woFS struct {
+ fsys fs.FS
}
-func (fs woFS) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
- f, err := fs.Fs.OpenFile(name, flag, perm)
- if err != nil {
- return writeOnlyFile(f), err
- }
- return writeOnlyFile(f), nil
+func (fsys woFS) Open(name string) (fs.File, error) {
+ f, err := fsys.fsys.Open(name)
+ return writeOnlyFile(f), err
}
-func (fs woFS) ReadDir(_ string) ([]os.FileInfo, error) {
+func (fsys woFS) ReadDir(_ string) ([]fs.DirEntry, error) {
return nil, ErrWriteOnly
}
-func (fs woFS) Stat(_ string) (os.FileInfo, error) {
+func (fsys woFS) Stat(_ string) (fs.FileInfo, error) {
return nil, ErrWriteOnly
}
-func writeOnlyFile(f afero.File) afero.File {
+func writeOnlyFile(f fs.File) fs.File {
+ if f == nil {
+ return nil
+ }
+
return &woFile{f}
}
type woFile struct {
- afero.File
+ fs.File
}
-// Write is disabled and returns ErrWriteOnly
+// Write -
func (f woFile) Write(p []byte) (n int, err error) {
- return f.File.Write(p)
+ return hackpadfs.WriteFile(f.File, p)
}
// Read is disabled and returns ErrWriteOnly
diff --git a/file/file.go b/file/file.go
index 11857fff..6b7a8e8a 100644
--- a/file/file.go
+++ b/file/file.go
@@ -3,28 +3,22 @@ package file
import (
"fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
+ "io/fs"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/hairyhenderson/gomplate/v4/internal/iohelpers"
- "github.com/spf13/afero"
+ osfs "github.com/hack-pad/hackpadfs/os"
)
-// for overriding in tests
-var fs = afero.NewOsFs()
+// fsys for legacy functions - see deprecation notices
+var fsys = datafs.WrapWdFS(osfs.NewFS())
// Read the contents of the referenced file, as a string.
+//
+// Deprecated: (as of 4.0.0) use [io/fs#ReadFile] instead
func Read(filename string) (string, error) {
- inFile, err := fs.OpenFile(filename, os.O_RDONLY, 0)
- if err != nil {
- return "", fmt.Errorf("failed to open %s: %w", filename, err)
- }
- // nolint: errcheck
- defer inFile.Close()
- bytes, err := io.ReadAll(inFile)
+ bytes, err := fs.ReadFile(fsys, filename)
if err != nil {
err = fmt.Errorf("read failed for %s: %w", filename, err)
return "", err
@@ -33,73 +27,26 @@ func Read(filename string) (string, error) {
}
// ReadDir gets a directory listing.
+//
+// Deprecated: (as of 4.0.0) use [io/fs#ReadDir] instead
func ReadDir(path string) ([]string, error) {
- f, err := fs.Open(path)
- if err != nil {
- return nil, err
- }
- i, err := f.Stat()
+ des, err := fs.ReadDir(fsys, path)
if err != nil {
return nil, err
}
- if i.IsDir() {
- return f.Readdirnames(0)
+
+ names := make([]string, len(des))
+ for i, de := range des {
+ names[i] = de.Name()
}
- return nil, fmt.Errorf("file is not a directory")
+
+ return names, nil
}
// Write the given content to the file, truncating any existing file, and
// creating the directory structure leading up to it if necessary.
+//
+// Deprecated: (as of 4.0.0) use [os#WriteFile] instead
func Write(filename string, content []byte) error {
- err := assertPathInWD(filename)
- if err != nil {
- return fmt.Errorf("failed to open %s: %w", filename, err)
- }
-
- fi, err := os.Stat(filename)
- if err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("failed to stat %s: %w", filename, err)
- }
- mode := iohelpers.NormalizeFileMode(0o644)
- if fi != nil {
- mode = fi.Mode()
- }
- err = fs.MkdirAll(filepath.Dir(filename), 0o755)
- if err != nil {
- return fmt.Errorf("failed to make dirs for %s: %w", filename, err)
- }
- inFile, err := fs.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode)
- if err != nil {
- return fmt.Errorf("failed to open %s: %w", filename, err)
- }
-
- defer inFile.Close()
-
- n, err := inFile.Write(content)
- if err != nil {
- return fmt.Errorf("failed to write %s: %w", filename, err)
- }
- if n != len(content) {
- return fmt.Errorf("short write on %s (%d bytes): %w", filename, n, err)
- }
- return nil
-}
-
-func assertPathInWD(filename string) error {
- wd, err := os.Getwd()
- if err != nil {
- return err
- }
- f, err := filepath.Abs(filename)
- if err != nil {
- return err
- }
- r, err := filepath.Rel(wd, f)
- if err != nil {
- return err
- }
- if strings.HasPrefix(r, "..") {
- return fmt.Errorf("path %s not contained by working directory %s (rel: %s)", filename, wd, r)
- }
- return nil
+ return iohelpers.WriteFile(fsys, filename, content)
}
diff --git a/file/file_test.go b/file/file_test.go
index 73821f50..1c185951 100644
--- a/file/file_test.go
+++ b/file/file_test.go
@@ -1,23 +1,22 @@
package file
import (
- "os"
- "path/filepath"
+ "io/fs"
"testing"
+ "testing/fstest"
- "github.com/spf13/afero"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- tfs "gotest.tools/v3/fs"
)
func TestRead(t *testing.T) {
- origfs := fs
- defer func() { fs = origfs }()
- fs = afero.NewMemMapFs()
- _ = fs.Mkdir("/tmp", 0777)
- f, _ := fs.Create("/tmp/foo")
- _, _ = f.Write([]byte("foo"))
+ oldFS := fsys
+ defer func() { fsys = oldFS }()
+ fsys = datafs.WrapWdFS(fstest.MapFS{
+ "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/foo": &fstest.MapFile{Data: []byte("foo")},
+ })
actual, err := Read("/tmp/foo")
require.NoError(t, err)
@@ -28,15 +27,16 @@ func TestRead(t *testing.T) {
}
func TestReadDir(t *testing.T) {
- origfs := fs
- defer func() { fs = origfs }()
- fs = afero.NewMemMapFs()
- fs.Mkdir("/tmp", 0777)
- fs.Create("/tmp/foo")
- fs.Create("/tmp/bar")
- fs.Create("/tmp/baz")
- fs.Mkdir("/tmp/qux", 0777)
- fs.Create("/tmp/qux/quux")
+ oldFS := fsys
+ defer func() { fsys = oldFS }()
+ fsys = datafs.WrapWdFS(fstest.MapFS{
+ "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/foo": &fstest.MapFile{Data: []byte("foo")},
+ "tmp/bar": &fstest.MapFile{Data: []byte("bar")},
+ "tmp/baz": &fstest.MapFile{Data: []byte("baz")},
+ "tmp/qux": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/qux/quux": &fstest.MapFile{Data: []byte("quux")},
+ })
actual, err := ReadDir("/tmp")
require.NoError(t, err)
@@ -45,79 +45,3 @@ func TestReadDir(t *testing.T) {
_, err = ReadDir("/tmp/foo")
assert.Error(t, err)
}
-
-func TestWrite(t *testing.T) {
- oldwd, _ := os.Getwd()
- defer os.Chdir(oldwd)
-
- rootDir := tfs.NewDir(t, "gomplate-test")
- defer rootDir.Remove()
-
- newwd := rootDir.Join("the", "path", "we", "want")
- badwd := rootDir.Join("some", "other", "dir")
- fs.MkdirAll(newwd, 0755)
- fs.MkdirAll(badwd, 0755)
- newwd, _ = filepath.EvalSymlinks(newwd)
- badwd, _ = filepath.EvalSymlinks(badwd)
-
- err := os.Chdir(newwd)
- require.NoError(t, err)
-
- err = Write("/foo", []byte("Hello world"))
- assert.Error(t, err)
-
- rel, err := filepath.Rel(newwd, badwd)
- require.NoError(t, err)
- err = Write(rel, []byte("Hello world"))
- assert.Error(t, err)
-
- foopath := filepath.Join(newwd, "foo")
- err = Write(foopath, []byte("Hello world"))
- require.NoError(t, err)
-
- out, err := os.ReadFile(foopath)
- require.NoError(t, err)
- assert.Equal(t, "Hello world", string(out))
-
- err = Write(foopath, []byte("truncate"))
- require.NoError(t, err)
-
- out, err = os.ReadFile(foopath)
- require.NoError(t, err)
- assert.Equal(t, "truncate", string(out))
-
- foopath = filepath.Join(newwd, "nonexistant", "subdir", "foo")
- err = Write(foopath, []byte("Hello subdirranean world!"))
- require.NoError(t, err)
-
- out, err = os.ReadFile(foopath)
- require.NoError(t, err)
- assert.Equal(t, "Hello subdirranean world!", string(out))
-}
-
-func TestAssertPathInWD(t *testing.T) {
- oldwd, _ := os.Getwd()
- defer os.Chdir(oldwd)
-
- err := assertPathInWD("/tmp")
- assert.Error(t, err)
-
- err = assertPathInWD(filepath.Join(oldwd, "subpath"))
- require.NoError(t, err)
-
- err = assertPathInWD("subpath")
- require.NoError(t, err)
-
- err = assertPathInWD("./subpath")
- require.NoError(t, err)
-
- err = assertPathInWD(filepath.Join("..", "bogus"))
- assert.Error(t, err)
-
- err = assertPathInWD(filepath.Join("..", "..", "bogus"))
- assert.Error(t, err)
-
- base := filepath.Base(oldwd)
- err = assertPathInWD(filepath.Join("..", base))
- require.NoError(t, err)
-}
diff --git a/funcs/file.go b/funcs/file.go
index 05fecf77..32af47cf 100644
--- a/funcs/file.go
+++ b/funcs/file.go
@@ -2,11 +2,13 @@ package funcs
import (
"context"
- "os"
+ "io/fs"
+ "path/filepath"
+ osfs "github.com/hack-pad/hackpadfs/os"
"github.com/hairyhenderson/gomplate/v4/conv"
- "github.com/hairyhenderson/gomplate/v4/file"
- "github.com/spf13/afero"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
+ "github.com/hairyhenderson/gomplate/v4/internal/iohelpers"
)
// FileNS - the File namespace
@@ -27,10 +29,16 @@ func AddFileFuncs(f map[string]interface{}) {
// CreateFileFuncs -
func CreateFileFuncs(ctx context.Context) map[string]interface{} {
+ fsys, err := datafs.FSysForPath(ctx, "/")
+ if err != nil {
+ fsys = datafs.WrapWdFS(osfs.NewFS())
+ }
+
ns := &FileFuncs{
ctx: ctx,
- fs: afero.NewOsFs(),
+ fs: fsys,
}
+
return map[string]interface{}{
"file": func() interface{} { return ns },
}
@@ -39,17 +47,18 @@ func CreateFileFuncs(ctx context.Context) map[string]interface{} {
// FileFuncs -
type FileFuncs struct {
ctx context.Context
- fs afero.Fs
+ fs fs.FS
}
// Read -
func (f *FileFuncs) Read(path interface{}) (string, error) {
- return file.Read(conv.ToString(path))
+ b, err := fs.ReadFile(f.fs, conv.ToString(path))
+ return string(b), err
}
// Stat -
-func (f *FileFuncs) Stat(path interface{}) (os.FileInfo, error) {
- return f.fs.Stat(conv.ToString(path))
+func (f *FileFuncs) Stat(path interface{}) (fs.FileInfo, error) {
+ return fs.Stat(f.fs, conv.ToString(path))
}
// Exists -
@@ -66,16 +75,32 @@ func (f *FileFuncs) IsDir(path interface{}) bool {
// ReadDir -
func (f *FileFuncs) ReadDir(path interface{}) ([]string, error) {
- return file.ReadDir(conv.ToString(path))
+ des, err := fs.ReadDir(f.fs, conv.ToString(path))
+ if err != nil {
+ return nil, err
+ }
+
+ names := make([]string, len(des))
+ for i, de := range des {
+ names[i] = de.Name()
+ }
+
+ return names, nil
}
// Walk -
func (f *FileFuncs) Walk(path interface{}) ([]string, error) {
files := make([]string, 0)
- err := afero.Walk(f.fs, conv.ToString(path), func(subpath string, finfo os.FileInfo, err error) error {
+ err := fs.WalkDir(f.fs, conv.ToString(path), func(subpath string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
+
+ // fs.WalkDir always uses slash-separated paths, even on Windows. We
+ // need to convert them to the OS-specific separator as that was the
+ // previous behavior.
+ subpath = filepath.FromSlash(subpath)
+
files = append(files, subpath)
return nil
})
@@ -84,10 +109,20 @@ func (f *FileFuncs) Walk(path interface{}) ([]string, error) {
// Write -
func (f *FileFuncs) Write(path interface{}, data interface{}) (s string, err error) {
+ type byteser interface{ Bytes() []byte }
+
+ var content []byte
+ fname := conv.ToString(path)
+
if b, ok := data.([]byte); ok {
- err = file.Write(conv.ToString(path), b)
+ content = b
+ } else if b, ok := data.(byteser); ok {
+ content = b.Bytes()
} else {
- err = file.Write(conv.ToString(path), []byte(conv.ToString(data)))
+ content = []byte(conv.ToString(data))
}
+
+ err = iohelpers.WriteFile(f.fs, fname, content)
+
return "", err
}
diff --git a/funcs/file_test.go b/funcs/file_test.go
index d36f7f65..e70c178a 100644
--- a/funcs/file_test.go
+++ b/funcs/file_test.go
@@ -1,14 +1,21 @@
package funcs
import (
+ "bytes"
"context"
+ "io/fs"
+ "os"
"path/filepath"
"strconv"
"testing"
+ "testing/fstest"
- "github.com/spf13/afero"
+ "github.com/hack-pad/hackpadfs"
+ osfs "github.com/hack-pad/hackpadfs/os"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ tfs "gotest.tools/v3/fs"
)
func TestCreateFileFuncs(t *testing.T) {
@@ -31,12 +38,11 @@ func TestCreateFileFuncs(t *testing.T) {
func TestFileExists(t *testing.T) {
t.Parallel()
- fs := afero.NewMemMapFs()
- ff := &FileFuncs{fs: fs}
-
- _ = fs.Mkdir("/tmp", 0777)
- f, _ := fs.Create("/tmp/foo")
- _, _ = f.Write([]byte("foo"))
+ fsys := fstest.MapFS{
+ "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/foo": &fstest.MapFile{Data: []byte("foo")},
+ }
+ ff := &FileFuncs{fs: datafs.WrapWdFS(fsys)}
assert.True(t, ff.Exists("/tmp/foo"))
assert.False(t, ff.Exists("/tmp/bar"))
@@ -45,12 +51,12 @@ func TestFileExists(t *testing.T) {
func TestFileIsDir(t *testing.T) {
t.Parallel()
- fs := afero.NewMemMapFs()
- ff := &FileFuncs{fs: fs}
+ fsys := fstest.MapFS{
+ "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/foo": &fstest.MapFile{Data: []byte("foo")},
+ }
- _ = fs.Mkdir("/tmp", 0777)
- f, _ := fs.Create("/tmp/foo")
- _, _ = f.Write([]byte("foo"))
+ ff := &FileFuncs{fs: datafs.WrapWdFS(fsys)}
assert.True(t, ff.IsDir("/tmp"))
assert.False(t, ff.IsDir("/tmp/foo"))
@@ -59,14 +65,14 @@ func TestFileIsDir(t *testing.T) {
func TestFileWalk(t *testing.T) {
t.Parallel()
- fs := afero.NewMemMapFs()
- ff := &FileFuncs{fs: fs}
+ fsys := fstest.MapFS{
+ "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/bar": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/bar/baz": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/bar/baz/foo": &fstest.MapFile{Data: []byte("foo")},
+ }
- _ = fs.Mkdir("/tmp", 0777)
- _ = fs.Mkdir("/tmp/bar", 0777)
- _ = fs.Mkdir("/tmp/bar/baz", 0777)
- f, _ := fs.Create("/tmp/bar/baz/foo")
- _, _ = f.Write([]byte("foo"))
+ ff := &FileFuncs{fs: datafs.WrapWdFS(fsys)}
expectedLists := [][]string{{"tmp"}, {"tmp", "bar"}, {"tmp", "bar", "baz"}, {"tmp", "bar", "baz", "foo"}}
expectedPaths := make([]string, 0)
@@ -79,3 +85,93 @@ func TestFileWalk(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, expectedPaths, actualPaths)
}
+
+func TestReadDir(t *testing.T) {
+ fsys := fs.FS(fstest.MapFS{
+ "tmp": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/foo": &fstest.MapFile{Data: []byte("foo")},
+ "tmp/bar": &fstest.MapFile{Data: []byte("bar")},
+ "tmp/baz": &fstest.MapFile{Data: []byte("baz")},
+ "tmp/qux": &fstest.MapFile{Mode: fs.ModeDir | 0o777},
+ "tmp/qux/quux": &fstest.MapFile{Data: []byte("quux")},
+ })
+
+ fsys = datafs.WrapWdFS(fsys)
+
+ ff := &FileFuncs{
+ ctx: context.Background(),
+ fs: fsys,
+ }
+
+ actual, err := ff.ReadDir("/tmp")
+ require.NoError(t, err)
+ assert.Equal(t, []string{"bar", "baz", "foo", "qux"}, actual)
+
+ _, err = ff.ReadDir("/tmp/foo")
+ assert.Error(t, err)
+}
+
+func TestWrite(t *testing.T) {
+ oldwd, _ := os.Getwd()
+ defer os.Chdir(oldwd)
+
+ rootDir := tfs.NewDir(t, "gomplate-test")
+ t.Cleanup(rootDir.Remove)
+
+ // we want to use a real filesystem here, so we can test interactions with
+ // the current working directory
+ fsys := datafs.WrapWdFS(osfs.NewFS())
+
+ f := &FileFuncs{
+ ctx: context.Background(),
+ fs: fsys,
+ }
+
+ newwd := rootDir.Join("the", "path", "we", "want")
+ badwd := rootDir.Join("some", "other", "dir")
+ hackpadfs.MkdirAll(fsys, newwd, 0755)
+ hackpadfs.MkdirAll(fsys, badwd, 0755)
+ newwd, _ = filepath.EvalSymlinks(newwd)
+ badwd, _ = filepath.EvalSymlinks(badwd)
+
+ err := os.Chdir(newwd)
+ require.NoError(t, err)
+
+ _, err = f.Write("/foo", []byte("Hello world"))
+ assert.Error(t, err)
+
+ rel, err := filepath.Rel(newwd, badwd)
+ require.NoError(t, err)
+ _, err = f.Write(rel, []byte("Hello world"))
+ assert.Error(t, err)
+
+ foopath := filepath.Join(newwd, "foo")
+ _, err = f.Write(foopath, []byte("Hello world"))
+ require.NoError(t, err)
+
+ out, err := fs.ReadFile(fsys, foopath)
+ require.NoError(t, err)
+ assert.Equal(t, "Hello world", string(out))
+
+ _, err = f.Write(foopath, []byte("truncate"))
+ require.NoError(t, err)
+
+ out, err = fs.ReadFile(fsys, foopath)
+ require.NoError(t, err)
+ assert.Equal(t, "truncate", string(out))
+
+ foopath = filepath.Join(newwd, "nonexistant", "subdir", "foo")
+ _, err = f.Write(foopath, "Hello subdirranean world!")
+ require.NoError(t, err)
+
+ out, err = fs.ReadFile(fsys, foopath)
+ require.NoError(t, err)
+ assert.Equal(t, "Hello subdirranean world!", string(out))
+
+ _, err = f.Write(foopath, bytes.NewBufferString("Hello from a byte buffer!"))
+ require.NoError(t, err)
+
+ out, err = fs.ReadFile(fsys, foopath)
+ require.NoError(t, err)
+ assert.Equal(t, "Hello from a byte buffer!", string(out))
+}
diff --git a/go.mod b/go.mod
index 136967ca..dcbdaab6 100644
--- a/go.mod
+++ b/go.mod
@@ -13,8 +13,10 @@ require (
github.com/go-git/go-git/v5 v5.6.1
github.com/google/uuid v1.3.0
github.com/gosimple/slug v1.13.1
+ github.com/hack-pad/hackpadfs v0.2.0
github.com/hairyhenderson/go-fsimpl v0.0.0-20230121155226-8aa24800449d
github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf
+ github.com/hairyhenderson/xignore v0.3.3-0.20230403012150-95fe86932830 // iofs-port branch
github.com/hashicorp/consul/api v1.20.0
github.com/hashicorp/go-sockaddr v1.0.2
github.com/hashicorp/vault/api v1.9.0
@@ -22,11 +24,9 @@ require (
github.com/johannesboyne/gofakes3 v0.0.0-20220627085814-c3ac35da23b2
github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.29.1
- github.com/spf13/afero v1.9.5
github.com/spf13/cobra v1.7.0
github.com/stretchr/testify v1.8.2
github.com/ugorji/go/codec v1.2.11
- github.com/zealic/xignore v0.3.3
go4.org/netipx v0.0.0-20230125063823-8449b0a6169f
gocloud.dev v0.29.0
golang.org/x/crypto v0.8.0
diff --git a/go.sum b/go.sum
index 1fc61deb..fc897deb 100644
--- a/go.sum
+++ b/go.sum
@@ -1212,10 +1212,14 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFb
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.1/go.mod h1:G+WkljZi4mflcqVxYSgvt8MNctRQHjEH8ubKtt1Ka3w=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
+github.com/hack-pad/hackpadfs v0.2.0 h1:biRa6fvmuwwdbmODi2lnA+WlNkCStvmj3jr6DndMqKY=
+github.com/hack-pad/hackpadfs v0.2.0/go.mod h1:8Pz+ynD4SBpYltFauQHxSvCL35CCaqfTJBAs9Zbs38k=
github.com/hairyhenderson/go-fsimpl v0.0.0-20230121155226-8aa24800449d h1:RqyRRWUi3ftiPrEuO9ZbaILf6C8TDRw90fLM8pqbzGs=
github.com/hairyhenderson/go-fsimpl v0.0.0-20230121155226-8aa24800449d/go.mod h1:2k9HLXToBp8dOXrbgZUTaZfm03JZlkCGkrgvp4ip/JQ=
github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf h1:I1sbT4ZbIt9i+hB1zfKw2mE8C12TuGxPiW7YmtLbPa4=
github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf/go.mod h1:jDHmWDKZY6MIIYltYYfW4Rs7hQ50oS4qf/6spSiZAxY=
+github.com/hairyhenderson/xignore v0.3.3-0.20230403012150-95fe86932830 h1:f+VnmDFJqYgkq1PRraUsYEzJ7bFr36CmzOb/xfV5Q9s=
+github.com/hairyhenderson/xignore v0.3.3-0.20230403012150-95fe86932830/go.mod h1:UqUZ8CHnVcV2/rb26Ydn+PQO7bAI8kFONU/vaK1Q/WU=
github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce h1:cVkYhlWAxwuS2/Yp6qPtcl0fGpcWxuZNonywHZ6/I+s=
github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce/go.mod h1:7TyiGlHI+IO+iJbqRZ82QbFtvgj/AIcFm5qc9DLn7Kc=
github.com/hanwen/go-fuse/v2 v2.2.0/go.mod h1:B1nGE/6RBFyBRC1RRnf23UpwCdyJ31eukw34oAKukAc=
@@ -1795,15 +1799,12 @@ github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/afero v1.2.0/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
-github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
-github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
@@ -1906,8 +1907,6 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
-github.com/zealic/xignore v0.3.3 h1:EpLXUgZY/JEzFkTc+Y/VYypzXtNz+MSOMVCGW5Q4CKQ=
-github.com/zealic/xignore v0.3.3/go.mod h1:lhS8V7fuSOtJOKsvKI7WfsZE276/7AYEqokv3UiqEAU=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
diff --git a/internal/cmd/config.go b/internal/cmd/config.go
index cfba90f7..409bfa28 100644
--- a/internal/cmd/config.go
+++ b/internal/cmd/config.go
@@ -8,10 +8,10 @@ import (
"github.com/hairyhenderson/gomplate/v4/conv"
"github.com/hairyhenderson/gomplate/v4/env"
"github.com/hairyhenderson/gomplate/v4/internal/config"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/rs/zerolog"
- "github.com/spf13/afero"
"github.com/spf13/cobra"
)
@@ -19,20 +19,17 @@ const (
defaultConfigFile = ".gomplate.yaml"
)
-var fs = afero.NewOsFs()
-
// loadConfig is intended to be called before command execution. It:
// - creates a config.Config from the cobra flags
// - creates a config.Config from the config file (if present)
// - merges the two (flags take precedence)
-func loadConfig(cmd *cobra.Command, args []string) (*config.Config, error) {
- ctx := cmd.Context()
+func loadConfig(ctx context.Context, cmd *cobra.Command, args []string) (*config.Config, error) {
flagConfig, err := cobraConfig(cmd, args)
if err != nil {
return nil, err
}
- cfg, err := readConfigFile(cmd)
+ cfg, err := readConfigFile(ctx, cmd)
if err != nil {
return nil, err
}
@@ -68,16 +65,18 @@ func pickConfigFile(cmd *cobra.Command) (cfgFile string, required bool) {
return cfgFile, required
}
-func readConfigFile(cmd *cobra.Command) (cfg *config.Config, err error) {
- ctx := cmd.Context()
- if ctx == nil {
- ctx = context.Background()
- }
+func readConfigFile(ctx context.Context, cmd *cobra.Command) (cfg *config.Config, err error) {
log := zerolog.Ctx(ctx)
cfgFile, configRequired := pickConfigFile(cmd)
- f, err := fs.Open(cfgFile)
+ // we only support loading configs from the local filesystem for now
+ fsys, err := datafs.FSysForPath(ctx, cfgFile)
+ if err != nil {
+ return nil, err
+ }
+
+ f, err := fsys.Open(cfgFile)
if err != nil {
if configRequired {
return cfg, fmt.Errorf("config file requested, but couldn't be opened: %w", err)
diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go
index 5203952e..f4c032c8 100644
--- a/internal/cmd/config_test.go
+++ b/internal/cmd/config_test.go
@@ -4,66 +4,72 @@ import (
"bytes"
"context"
"fmt"
+ "io/fs"
+ "net/url"
"os"
"testing"
+ "testing/fstest"
"time"
+ "github.com/hairyhenderson/go-fsimpl"
"github.com/hairyhenderson/gomplate/v4/internal/config"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
- "github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadConfigFile(t *testing.T) {
- fs = afero.NewMemMapFs()
- defer func() { fs = afero.NewOsFs() }()
+ ctx := context.Background()
+ fsys := fstest.MapFS{}
+ ctx = datafs.ContextWithFSProvider(ctx, fsimpl.FSProviderFunc(func(_ *url.URL) (fs.FS, error) {
+ return fsys, nil
+ }))
cmd := &cobra.Command{}
- _, err := readConfigFile(cmd)
+ _, err := readConfigFile(ctx, cmd)
require.NoError(t, err)
cmd.Flags().String("config", defaultConfigFile, "foo")
- _, err = readConfigFile(cmd)
+ _, err = readConfigFile(ctx, cmd)
require.NoError(t, err)
cmd.ParseFlags([]string{"--config", "config.file"})
- _, err = readConfigFile(cmd)
+ _, err = readConfigFile(ctx, cmd)
assert.Error(t, err)
cmd = &cobra.Command{}
cmd.Flags().String("config", defaultConfigFile, "foo")
- f, err := fs.Create(defaultConfigFile)
- require.NoError(t, err)
- f.WriteString("")
+ fsys[defaultConfigFile] = &fstest.MapFile{}
- cfg, err := readConfigFile(cmd)
+ cfg, err := readConfigFile(ctx, cmd)
require.NoError(t, err)
assert.EqualValues(t, &config.Config{}, cfg)
cmd.ParseFlags([]string{"--config", "config.yaml"})
- f, err = fs.Create("config.yaml")
- require.NoError(t, err)
- f.WriteString("in: hello world\n")
+ fsys["config.yaml"] = &fstest.MapFile{Data: []byte("in: hello world\n")}
- cfg, err = readConfigFile(cmd)
+ cfg, err = readConfigFile(ctx, cmd)
require.NoError(t, err)
assert.EqualValues(t, &config.Config{Input: "hello world"}, cfg)
- f.WriteString("in: ")
+ fsys["config.yaml"] = &fstest.MapFile{Data: []byte("in: hello world\nin: \n")}
- _, err = readConfigFile(cmd)
+ _, err = readConfigFile(ctx, cmd)
assert.Error(t, err)
}
func TestLoadConfig(t *testing.T) {
- fs = afero.NewMemMapFs()
- defer func() { fs = afero.NewOsFs() }()
+ ctx := context.Background()
+ fsys := fstest.MapFS{}
+ ctx = datafs.ContextWithFSProvider(ctx, fsimpl.FSProviderFunc(func(_ *url.URL) (fs.FS, error) {
+ return fsys, nil
+ }))
stdin, stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}
cmd := &cobra.Command{}
@@ -81,7 +87,7 @@ func TestLoadConfig(t *testing.T) {
cmd.Flags().Bool("exec-pipe", false, "...")
cmd.ParseFlags(nil)
- out, err := loadConfig(cmd, cmd.Flags().Args())
+ out, err := loadConfig(ctx, cmd, cmd.Flags().Args())
expected := &config.Config{
Stdin: stdin,
Stdout: stdout,
@@ -91,7 +97,7 @@ func TestLoadConfig(t *testing.T) {
assert.EqualValues(t, expected, out)
cmd.ParseFlags([]string{"--in", "foo"})
- out, err = loadConfig(cmd, cmd.Flags().Args())
+ out, err = loadConfig(ctx, cmd, cmd.Flags().Args())
expected = &config.Config{
Input: "foo",
Stdin: stdin,
@@ -102,7 +108,7 @@ func TestLoadConfig(t *testing.T) {
assert.EqualValues(t, expected, out)
cmd.ParseFlags([]string{"--in", "foo", "--exec-pipe", "--", "tr", "[a-z]", "[A-Z]"})
- out, err = loadConfig(cmd, cmd.Flags().Args())
+ out, err = loadConfig(ctx, cmd, cmd.Flags().Args())
expected = &config.Config{
Input: "foo",
ExecPipe: true,
diff --git a/internal/cmd/main.go b/internal/cmd/main.go
index 1a1d9b3a..e09ee863 100644
--- a/internal/cmd/main.go
+++ b/internal/cmd/main.go
@@ -7,9 +7,10 @@ import (
"os/exec"
"os/signal"
- "github.com/hairyhenderson/go-fsimpl/filefs"
+ "github.com/hairyhenderson/go-fsimpl"
"github.com/hairyhenderson/gomplate/v4"
"github.com/hairyhenderson/gomplate/v4/env"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/hairyhenderson/gomplate/v4/version"
"github.com/rs/zerolog"
@@ -82,7 +83,7 @@ func NewGomplateCmd() *cobra.Command {
ctx := cmd.Context()
log := zerolog.Ctx(ctx)
- cfg, err := loadConfig(cmd, args)
+ cfg, err := loadConfig(ctx, cmd, args)
if err != nil {
return err
}
@@ -168,10 +169,12 @@ func Main(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io
ctx = initLogger(ctx, stderr)
// inject a default filesystem provider for file:// URLs
- // TODO: expand this to support other schemes!
- if gomplate.FSProviderFromContext(ctx) == nil {
- // allow this to be overridden by tests
- ctx = gomplate.ContextWithFSProvider(ctx, filefs.FS)
+ if datafs.FSProviderFromContext(ctx) == nil {
+ // TODO: expand this to support other schemes!
+ mux := fsimpl.NewMux()
+ mux.Add(datafs.WdFS)
+
+ ctx = datafs.ContextWithFSProvider(ctx, mux)
}
command := NewGomplateCmd()
diff --git a/internal/config/configfile.go b/internal/config/configfile.go
index e9b17671..763520de 100644
--- a/internal/config/configfile.go
+++ b/internal/config/configfile.go
@@ -9,11 +9,11 @@ import (
"net/url"
"os"
"path"
- "path/filepath"
"strconv"
"strings"
"time"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/hairyhenderson/gomplate/v4/internal/iohelpers"
"github.com/hairyhenderson/yaml"
)
@@ -69,14 +69,14 @@ type Config struct {
Experimental bool `yaml:"experimental,omitempty"`
}
-var experimentalCtxKey = struct{}{}
+type experimentalCtxKey struct{}
func SetExperimental(ctx context.Context) context.Context {
- return context.WithValue(ctx, experimentalCtxKey, true)
+ return context.WithValue(ctx, experimentalCtxKey{}, true)
}
func ExperimentalEnabled(ctx context.Context) bool {
- v, ok := ctx.Value(experimentalCtxKey).(bool)
+ v, ok := ctx.Value(experimentalCtxKey{}).(bool)
return ok && v
}
@@ -111,7 +111,7 @@ func (d *DataSource) UnmarshalYAML(value *yaml.Node) error {
if err != nil {
return err
}
- u, err := ParseSourceURL(r.URL)
+ u, err := datafs.ParseSourceURL(r.URL)
if err != nil {
return fmt.Errorf("could not parse datasource URL %q: %w", r.URL, err)
}
@@ -374,7 +374,7 @@ func parseDatasourceArg(value string) (alias string, ds DataSource, err error) {
}
}
- ds.URL, err = ParseSourceURL(u)
+ ds.URL, err = datafs.ParseSourceURL(u)
return alias, ds, err
}
@@ -583,64 +583,3 @@ func (c *Config) String() string {
}
return out.String()
}
-
-// ParseSourceURL parses a datasource URL value, which may be '-' (for stdin://),
-// or it may be a Windows path (with driver letter and back-slack separators) or
-// UNC, or it may be relative. It also might just be a regular absolute URL...
-// In all cases it returns a correct URL for the value.
-func ParseSourceURL(value string) (*url.URL, error) {
- if value == "-" {
- value = "stdin://"
- }
- value = filepath.ToSlash(value)
- // handle absolute Windows paths
- volName := ""
- if volName = filepath.VolumeName(value); volName != "" {
- // handle UNCs
- if len(volName) > 2 {
- value = "file:" + value
- } else {
- value = "file:///" + value
- }
- }
- srcURL, err := url.Parse(value)
- if err != nil {
- return nil, err
- }
-
- if volName != "" && len(srcURL.Path) >= 3 {
- if srcURL.Path[0] == '/' && srcURL.Path[2] == ':' {
- srcURL.Path = srcURL.Path[1:]
- }
- }
-
- if !srcURL.IsAbs() {
- srcURL, err = absFileURL(value)
- if err != nil {
- return nil, err
- }
- }
- return srcURL, nil
-}
-
-func absFileURL(value string) (*url.URL, error) {
- wd, err := os.Getwd()
- if err != nil {
- return nil, fmt.Errorf("can't get working directory: %w", err)
- }
- wd = filepath.ToSlash(wd)
- baseURL := &url.URL{
- Scheme: "file",
- Path: wd + "/",
- }
- relURL, err := url.Parse(value)
- if err != nil {
- return nil, fmt.Errorf("can't parse value %s as URL: %w", value, err)
- }
- resolved := baseURL.ResolveReference(relURL)
- // deal with Windows drive letters
- if !strings.HasPrefix(wd, "/") && resolved.Path[2] == ':' {
- resolved.Path = resolved.Path[1:]
- }
- return resolved, nil
-}
diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go
index 46aaf89c..8fe17693 100644
--- a/internal/config/configfile_test.go
+++ b/internal/config/configfile_test.go
@@ -3,9 +3,6 @@ package config
import (
"net/http"
"net/url"
- "os"
- "path"
- "path/filepath"
"runtime"
"strings"
"testing"
@@ -91,13 +88,7 @@ func mustURL(s string) *url.URL {
if err != nil {
panic(err)
}
- // handle the case where it's a relative URL - just like in parseSourceURL.
- if !u.IsAbs() {
- u, err = absFileURL(s)
- if err != nil {
- panic(err)
- }
- }
+
return u
}
@@ -232,6 +223,7 @@ func TestMergeFrom(t *testing.T) {
"bar": {URL: mustURL("stdin:///")},
},
}
+
expected := &Config{
Input: "hello world",
OutputFiles: []string{"out.txt"},
@@ -534,30 +526,33 @@ func TestParsePluginFlags(t *testing.T) {
assert.EqualValues(t, &Config{Plugins: map[string]PluginConfig{"foo": {Cmd: "bar"}}}, cfg)
}
-func TestConfigString(t *testing.T) {
- c := &Config{}
- c.ApplyDefaults()
+func TestConfig_String(t *testing.T) {
+ t.Run("defaults", func(t *testing.T) {
+ c := &Config{}
+ c.ApplyDefaults()
- expected := `---
+ expected := `---
inputFiles: ['-']
outputFiles: ['-']
leftDelim: '{{'
rightDelim: '}}'
pluginTimeout: 5s
`
- assert.Equal(t, expected, c.String())
-
- c = &Config{
- LDelim: "L",
- RDelim: "R",
- Input: "foo",
- OutputFiles: []string{"-"},
- Templates: Templates{
- "foo": {URL: mustURL("https://www.example.com/foo.tmpl")},
- "bar": {URL: mustURL("/tmp/bar.t")},
- },
- }
- expected = `---
+ assert.Equal(t, expected, c.String())
+ })
+
+ t.Run("overridden values", func(t *testing.T) {
+ c := &Config{
+ LDelim: "L",
+ RDelim: "R",
+ Input: "foo",
+ OutputFiles: []string{"-"},
+ Templates: Templates{
+ "foo": {URL: mustURL("https://www.example.com/foo.tmpl")},
+ "bar": {URL: mustURL("file:///tmp/bar.t")},
+ },
+ }
+ expected := `---
in: foo
outputFiles: ['-']
leftDelim: L
@@ -568,19 +563,21 @@ templates:
bar:
url: file:///tmp/bar.t
`
- assert.YAMLEq(t, expected, c.String())
-
- c = &Config{
- LDelim: "L",
- RDelim: "R",
- Input: "long input that should be truncated",
- OutputFiles: []string{"-"},
- Templates: Templates{
- "foo": {URL: mustURL("https://www.example.com/foo.tmpl")},
- "bar": {URL: mustURL("/tmp/bar.t")},
- },
- }
- expected = `---
+ assert.YAMLEq(t, expected, c.String())
+ })
+
+ t.Run("long input", func(t *testing.T) {
+ c := &Config{
+ LDelim: "L",
+ RDelim: "R",
+ Input: "long input that should be truncated",
+ OutputFiles: []string{"-"},
+ Templates: Templates{
+ "foo": {URL: mustURL("https://www.example.com/foo.tmpl")},
+ "bar": {URL: mustURL("file:///tmp/bar.t")},
+ },
+ }
+ expected := `---
in: long inp...
outputFiles: ['-']
leftDelim: L
@@ -591,49 +588,56 @@ templates:
bar:
url: file:///tmp/bar.t
`
- assert.YAMLEq(t, expected, c.String())
+ assert.YAMLEq(t, expected, c.String())
+ })
- c = &Config{
- InputDir: "in/",
- OutputDir: "out/",
- }
- expected = `---
+ t.Run("relative dirs", func(t *testing.T) {
+ c := &Config{
+ InputDir: "in/",
+ OutputDir: "out/",
+ }
+ expected := `---
inputDir: in/
outputDir: out/
`
+ assert.YAMLEq(t, expected, c.String())
+ })
- assert.Equal(t, expected, c.String())
-
- c = &Config{
- InputDir: "in/",
- OutputMap: "{{ .in }}",
- }
- expected = `---
+ t.Run("outputmap", func(t *testing.T) {
+ c := &Config{
+ InputDir: "in/",
+ OutputMap: "{{ .in }}",
+ }
+ expected := `---
inputDir: in/
outputMap: '{{ .in }}'
`
- assert.Equal(t, expected, c.String())
+ assert.YAMLEq(t, expected, c.String())
+ })
- c = &Config{
- PluginTimeout: 500 * time.Millisecond,
- }
- expected = `---
+ t.Run("pluginTimeout", func(t *testing.T) {
+ c := &Config{
+ PluginTimeout: 500 * time.Millisecond,
+ }
+ expected := `---
pluginTimeout: 500ms
`
- assert.Equal(t, expected, c.String())
+ assert.YAMLEq(t, expected, c.String())
+ })
- c = &Config{
- Plugins: map[string]PluginConfig{
- "foo": {
- Cmd: "bar",
- Timeout: 1 * time.Second,
- Pipe: true,
+ t.Run("plugins", func(t *testing.T) {
+ c := &Config{
+ Plugins: map[string]PluginConfig{
+ "foo": {
+ Cmd: "bar",
+ Timeout: 1 * time.Second,
+ Pipe: true,
+ },
},
- },
- }
- expected = `---
+ }
+ expected := `---
plugins:
foo:
cmd: bar
@@ -641,7 +645,8 @@ plugins:
pipe: true
`
- assert.Equal(t, expected, c.String())
+ assert.YAMLEq(t, expected, c.String())
+ })
}
func TestApplyDefaults(t *testing.T) {
@@ -772,71 +777,11 @@ func TestParseHeaderArgs(t *testing.T) {
assert.Equal(t, expected, parsed)
}
-func TestParseSourceURL(t *testing.T) {
- expected := &url.URL{
- Scheme: "http",
- Host: "example.com",
- Path: "/foo.json",
- RawQuery: "bar",
- }
- u, err := ParseSourceURL("http://example.com/foo.json?bar")
- require.NoError(t, err)
- assert.EqualValues(t, expected, u)
-
- expected = &url.URL{Scheme: "stdin"}
- u, err = ParseSourceURL("-")
- require.NoError(t, err)
- assert.EqualValues(t, expected, u)
-
- wd, err := os.Getwd()
- require.NoError(t, err)
- expected = &url.URL{
- Scheme: "file",
- Path: path.Join(filepath.ToSlash(wd), "foo/bar.json"),
- }
- u, err = ParseSourceURL("./foo/bar.json")
- require.NoError(t, err)
- assert.EqualValues(t, expected, u)
-}
-
-func TestAbsFileURL(t *testing.T) {
- cwd, _ := os.Getwd()
- // make this pass on Windows
- cwd = filepath.ToSlash(cwd)
- expected := &url.URL{
- Scheme: "file",
- Host: "",
- Path: "/tmp/foo",
- }
- u, err := absFileURL("/tmp/foo")
- require.NoError(t, err)
- assert.EqualValues(t, expected, u)
-
- expected = &url.URL{
- Scheme: "file",
- Host: "",
- Path: cwd + "/tmp/foo",
- }
- u, err = absFileURL("tmp/foo")
- require.NoError(t, err)
- assert.EqualValues(t, expected, u)
-
- expected = &url.URL{
- Scheme: "file",
- Host: "",
- Path: cwd + "/tmp/foo",
- RawQuery: "q=p",
- }
- u, err = absFileURL("tmp/foo?q=p")
- require.NoError(t, err)
- assert.EqualValues(t, expected, u)
-}
-
func TestParseDatasourceArgNoAlias(t *testing.T) {
alias, ds, err := parseDatasourceArg("foo.json")
require.NoError(t, err)
assert.Equal(t, "foo", alias)
- assert.Equal(t, "file", ds.URL.Scheme)
+ assert.Empty(t, ds.URL.Scheme)
_, _, err = parseDatasourceArg("../foo.json")
assert.Error(t, err)
@@ -849,8 +794,7 @@ func TestParseDatasourceArgWithAlias(t *testing.T) {
alias, ds, err := parseDatasourceArg("data=foo.json")
require.NoError(t, err)
assert.Equal(t, "data", alias)
- assert.Equal(t, "file", ds.URL.Scheme)
- assert.True(t, ds.URL.IsAbs())
+ assert.EqualValues(t, &url.URL{Path: "foo.json"}, ds.URL)
alias, ds, err = parseDatasourceArg("data=/otherdir/foo.json")
require.NoError(t, err)
@@ -863,45 +807,33 @@ func TestParseDatasourceArgWithAlias(t *testing.T) {
alias, ds, err = parseDatasourceArg("data=foo.json")
require.NoError(t, err)
assert.Equal(t, "data", alias)
- assert.Equal(t, "file", ds.URL.Scheme)
- assert.True(t, ds.URL.IsAbs())
- assert.Equalf(t, byte(':'), ds.URL.Path[1], "Path was %s", ds.URL.Path)
+ assert.EqualValues(t, &url.URL{Path: "foo.json"}, ds.URL)
alias, ds, err = parseDatasourceArg(`data=\otherdir\foo.json`)
require.NoError(t, err)
assert.Equal(t, "data", alias)
- assert.Equal(t, "file", ds.URL.Scheme)
- assert.True(t, ds.URL.IsAbs())
- assert.Equal(t, `/otherdir/foo.json`, ds.URL.Path)
+ assert.EqualValues(t, &url.URL{Scheme: "file", Path: "/otherdir/foo.json"}, ds.URL)
alias, ds, err = parseDatasourceArg("data=C:\\windowsdir\\foo.json")
require.NoError(t, err)
assert.Equal(t, "data", alias)
- assert.Equal(t, "file", ds.URL.Scheme)
- assert.True(t, ds.URL.IsAbs())
- assert.Equal(t, "C:/windowsdir/foo.json", ds.URL.Path)
+ assert.EqualValues(t, &url.URL{Scheme: "file", Path: "C:/windowsdir/foo.json"}, ds.URL)
alias, ds, err = parseDatasourceArg("data=\\\\somehost\\share\\foo.json")
require.NoError(t, err)
assert.Equal(t, "data", alias)
- assert.Equal(t, "file", ds.URL.Scheme)
- assert.Equal(t, "somehost", ds.URL.Host)
- assert.True(t, ds.URL.IsAbs())
- assert.Equal(t, "/share/foo.json", ds.URL.Path)
+ assert.EqualValues(t, &url.URL{Scheme: "file", Host: "somehost", Path: "/share/foo.json"}, ds.URL)
}
alias, ds, err = parseDatasourceArg("data=sftp://example.com/blahblah/foo.json")
require.NoError(t, err)
assert.Equal(t, "data", alias)
- assert.Equal(t, "sftp", ds.URL.Scheme)
- assert.True(t, ds.URL.IsAbs())
- assert.Equal(t, "/blahblah/foo.json", ds.URL.Path)
+ assert.EqualValues(t, &url.URL{Scheme: "sftp", Host: "example.com", Path: "/blahblah/foo.json"}, ds.URL)
alias, ds, err = parseDatasourceArg("merged=merge:./foo.yaml|http://example.com/bar.json%3Ffoo=bar")
require.NoError(t, err)
assert.Equal(t, "merged", alias)
- assert.Equal(t, "merge", ds.URL.Scheme)
- assert.Equal(t, "./foo.yaml|http://example.com/bar.json%3Ffoo=bar", ds.URL.Opaque)
+ assert.EqualValues(t, &url.URL{Scheme: "merge", Opaque: "./foo.yaml|http://example.com/bar.json%3Ffoo=bar"}, ds.URL)
}
func TestPluginConfig_UnmarshalYAML(t *testing.T) {
diff --git a/internal/config/types.go b/internal/config/types.go
index d058d5a5..57901f0d 100644
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/hairyhenderson/yaml"
)
@@ -52,7 +53,7 @@ func (t *Templates) unmarshalYAMLArray(value *yaml.Node) error {
pth = alias
}
- u, err := ParseSourceURL(pth)
+ u, err := datafs.ParseSourceURL(pth)
if err != nil {
return fmt.Errorf("could not parse template URL %q: %w", pth, err)
}
@@ -89,7 +90,7 @@ func parseTemplateArg(value string) (alias string, ds DataSource, err error) {
u = alias
}
- ds.URL, err = ParseSourceURL(u)
+ ds.URL, err = datafs.ParseSourceURL(u)
return alias, ds, err
}
diff --git a/internal/datafs/fsys.go b/internal/datafs/fsys.go
new file mode 100644
index 00000000..ac4c22c5
--- /dev/null
+++ b/internal/datafs/fsys.go
@@ -0,0 +1,111 @@
+package datafs
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "net/url"
+ "path"
+ "path/filepath"
+
+ "github.com/hairyhenderson/go-fsimpl"
+)
+
+type fsProviderCtxKey struct{}
+
+// ContextWithFSProvider returns a context with the given FSProvider. Should
+// only be used in tests.
+func ContextWithFSProvider(ctx context.Context, fsp fsimpl.FSProvider) context.Context {
+ return context.WithValue(ctx, fsProviderCtxKey{}, fsp)
+}
+
+// FSProviderFromContext returns the FSProvider from the context, if any
+func FSProviderFromContext(ctx context.Context) fsimpl.FSProvider {
+ if fsp, ok := ctx.Value(fsProviderCtxKey{}).(fsimpl.FSProvider); ok {
+ return fsp
+ }
+
+ return nil
+}
+
+// ParseSourceURL parses a datasource URL value, which may be '-' (for stdin://),
+// or it may be a Windows path (with driver letter and back-slash separators) or
+// UNC, or it may be relative. It also might just be a regular absolute URL...
+// In all cases it returns a correct URL for the value. It may be a relative URL
+// in which case the scheme should be assumed to be 'file'
+func ParseSourceURL(value string) (*url.URL, error) {
+ if value == "-" {
+ value = "stdin://"
+ }
+ value = filepath.ToSlash(value)
+ // handle absolute Windows paths
+ volName := ""
+ if volName = filepath.VolumeName(value); volName != "" {
+ // handle UNCs
+ if len(volName) > 2 {
+ value = "file:" + value
+ } else {
+ value = "file:///" + value
+ }
+ }
+ srcURL, err := url.Parse(value)
+ if err != nil {
+ return nil, err
+ }
+
+ if volName != "" && len(srcURL.Path) >= 3 {
+ if srcURL.Path[0] == '/' && srcURL.Path[2] == ':' {
+ srcURL.Path = srcURL.Path[1:]
+ }
+ }
+
+ // if it's an absolute path with no scheme, assume it's a file
+ if srcURL.Scheme == "" && path.IsAbs(srcURL.Path) {
+ srcURL.Scheme = "file"
+ }
+
+ return srcURL, nil
+}
+
+// FSysForPath returns an [io/fs.FS] for the given path (which may be an URL),
+// rooted at /. A [fsimpl.FSProvider] is required to be present in ctx,
+// otherwise an error is returned.
+func FSysForPath(ctx context.Context, path string) (fs.FS, error) {
+ u, err := ParseSourceURL(path)
+ if err != nil {
+ return nil, err
+ }
+
+ fsp := FSProviderFromContext(ctx)
+ if fsp == nil {
+ return nil, fmt.Errorf("no filesystem provider in context")
+ }
+
+ fsys, err := fsp.New(&url.URL{Scheme: u.Scheme, Path: "/"})
+ if err != nil {
+ return nil, fmt.Errorf("filesystem provider for %q unavailable: %w", path, err)
+ }
+
+ return fsys, nil
+}
+
+type fsp struct {
+ newFunc func(*url.URL) (fs.FS, error)
+ schemes []string
+}
+
+func (p fsp) Schemes() []string {
+ return p.schemes
+}
+
+func (p fsp) New(u *url.URL) (fs.FS, error) {
+ return p.newFunc(u)
+}
+
+// WrappedFSProvider is an FSProvider that returns the given fs.FS
+func WrappedFSProvider(fsys fs.FS, schemes ...string) fsimpl.FSProvider {
+ return fsp{
+ newFunc: func(u *url.URL) (fs.FS, error) { return fsys, nil },
+ schemes: schemes,
+ }
+}
diff --git a/internal/datafs/fsys_test.go b/internal/datafs/fsys_test.go
new file mode 100644
index 00000000..80ff5ca1
--- /dev/null
+++ b/internal/datafs/fsys_test.go
@@ -0,0 +1,42 @@
+package datafs
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseSourceURL(t *testing.T) {
+ expected := &url.URL{
+ Scheme: "http",
+ Host: "example.com",
+ Path: "/foo.json",
+ RawQuery: "bar",
+ }
+ u, err := ParseSourceURL("http://example.com/foo.json?bar")
+ require.NoError(t, err)
+ assert.EqualValues(t, expected, u)
+
+ expected = &url.URL{Scheme: "", Path: ""}
+ u, err = ParseSourceURL("")
+ require.NoError(t, err)
+ assert.EqualValues(t, expected, u)
+
+ expected = &url.URL{Scheme: "stdin"}
+ u, err = ParseSourceURL("-")
+ require.NoError(t, err)
+ assert.EqualValues(t, expected, u)
+
+ // behviour change in v4 - return relative if it's relative
+ expected = &url.URL{Path: "./foo/bar.json"}
+ u, err = ParseSourceURL("./foo/bar.json")
+ require.NoError(t, err)
+ assert.EqualValues(t, expected, u)
+
+ expected = &url.URL{Scheme: "file", Path: "/absolute/bar.json"}
+ u, err = ParseSourceURL("/absolute/bar.json")
+ require.NoError(t, err)
+ assert.EqualValues(t, expected, u)
+}
diff --git a/internal/datafs/wdfs.go b/internal/datafs/wdfs.go
new file mode 100644
index 00000000..79c2b0ef
--- /dev/null
+++ b/internal/datafs/wdfs.go
@@ -0,0 +1,275 @@
+package datafs
+
+import (
+ "fmt"
+ "io/fs"
+ "net/url"
+ "os"
+ "path/filepath"
+
+ "github.com/hack-pad/hackpadfs"
+ osfs "github.com/hack-pad/hackpadfs/os"
+ "github.com/hairyhenderson/go-fsimpl"
+)
+
+// ResolveLocalPath resolves a path on the local filesystem, relative to the
+// current working directory, and returns both the root (/ or a volume name on
+// Windows) and the resolved path. If the path is absolute (e.g. starts with a `/` or
+// volume name on Windows), it is split and returned as-is.
+// The output is suitable for use with [io/fs] functions.
+//
+// TODO: maybe take fsys as an argument, and if it's a wdFS, use its vol instead
+// of calling os.Getwd?
+func ResolveLocalPath(name string) (root, resolved string) {
+ // ignore empty names
+ if len(name) == 0 {
+ return "", ""
+ }
+
+ wd, err := os.Getwd()
+ if err != nil {
+ panic(err)
+ }
+
+ vol := filepath.VolumeName(wd)
+ if vol == "" {
+ vol = "/"
+ }
+
+ f := &wdFS{vol: vol}
+ return f.resolveLocalPath(name)
+}
+
+func (w *wdFS) resolveLocalPath(name string) (root, resolved string) {
+ // ignore empty names
+ if len(name) == 0 {
+ return "", ""
+ }
+
+ // we want to assume a / separator regardless of the OS
+ name = filepath.ToSlash(name)
+
+ // special-case for (Windows) paths that start with '/' but have no volume
+ // name (e.g. "/foo/bar"). UNC paths (beginning with "//") are ignored.
+ if name[0] == '/' && (len(name) == 1 || name[1] != '/') {
+ name = filepath.Join(w.vol, name)
+ } else if name[0] != '/' && !filepath.IsAbs(name) {
+ wd, err := os.Getwd()
+ if err != nil {
+ panic(err)
+ }
+ name = filepath.Join(wd, name)
+ }
+
+ name = filepath.ToSlash(name)
+
+ vol := filepath.VolumeName(name)
+ if vol != "" && name != vol {
+ root = vol
+ name = name[len(vol)+1:]
+ } else if name[0] == '/' {
+ root = "/"
+ name = name[1:]
+ }
+
+ // there may still be backslashes in the root
+ root = filepath.ToSlash(root)
+
+ // we might've emptied name, so return "." instead
+ if name == "" {
+ name = "."
+ }
+
+ return root, name
+}
+
+// WdFS is a filesystem provider that creates local filesystems which support
+// absolute paths beginning with '/', and interpret relative paths as relative
+// to the current working directory (as reported by [os.Getwd])
+var WdFS = fsimpl.FSProviderFunc(
+ func(u *url.URL) (fs.FS, error) {
+ vol, _ := ResolveLocalPath(u.Path)
+
+ var fsys fs.FS
+ if vol == "" || vol == "/" {
+ fsys = osfs.NewFS()
+ } else {
+ var err error
+ fsys, err = osfs.NewFS().SubVolume(vol)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return &wdFS{vol: vol, fsys: fsys}, nil
+ },
+ // register for both file and empty scheme (empty when path is relative)
+ "file", "",
+)
+
+// WrapWdFS is a filesystem wrapper that assumes non-absolute paths are relative
+// to the current working directory (as reported by [os.Getwd]). It only works
+// in a meaningful way when used with a local filesystem (e.g. [os.DirFS] or
+// [hackpadfs/os.FS]).
+func WrapWdFS(fsys fs.FS) fs.FS {
+ // if fsys is a wdFS, just return it
+ if _, ok := fsys.(*wdFS); ok {
+ return fsys
+ }
+
+ return &wdFS{fsys: fsys}
+}
+
+// wdFS is a filesystem wrapper that assumes non-absolute paths are relative to
+// the current working directory (as reported by [os.Getwd]).
+// It only works in a meaningful way when used with a local filesystem (e.g.
+// [os.DirFS] or [hackpadfs/os.FS]).
+type wdFS struct {
+ fsys fs.FS
+ vol string
+}
+
+var (
+ _ fs.FS = &wdFS{}
+ _ fs.StatFS = &wdFS{}
+ _ fs.ReadFileFS = &wdFS{}
+ _ fs.ReadDirFS = &wdFS{}
+ _ fs.SubFS = &wdFS{}
+ _ fs.GlobFS = &wdFS{}
+ _ hackpadfs.CreateFS = &wdFS{}
+ _ hackpadfs.OpenFileFS = &wdFS{}
+ _ hackpadfs.MkdirFS = &wdFS{}
+ _ hackpadfs.MkdirAllFS = &wdFS{}
+ _ hackpadfs.RemoveFS = &wdFS{}
+ _ hackpadfs.ChmodFS = &wdFS{}
+)
+
+func (w *wdFS) fsysFor(vol string) (fs.FS, error) {
+ if vol == "" || vol == "/" || vol == w.vol {
+ return w.fsys, nil
+ }
+
+ // create a new osfs.FS here, because we can't modify the original if
+ // SubVolume was already called on it.
+ if _, ok := w.fsys.(*osfs.FS); ok {
+ fsys, err := osfs.NewFS().SubVolume(vol)
+ if err != nil {
+ return nil, fmt.Errorf("fsysFor %q: %w", vol, err)
+ }
+
+ return fsys, nil
+ }
+
+ // just return the original filesystem if we're not wrapping an osfs.FS
+ return w.fsys, nil
+}
+
+func (w *wdFS) Open(name string) (fs.File, error) {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return nil, err
+ }
+ return fsys.Open(resolved)
+}
+
+func (w *wdFS) Stat(name string) (fs.FileInfo, error) {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return nil, err
+ }
+ return fs.Stat(fsys, resolved)
+}
+
+func (w *wdFS) ReadFile(name string) ([]byte, error) {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return nil, err
+ }
+ return fs.ReadFile(fsys, resolved)
+}
+
+func (w *wdFS) ReadDir(name string) ([]fs.DirEntry, error) {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return nil, err
+ }
+ return fs.ReadDir(fsys, resolved)
+}
+
+func (w *wdFS) Sub(name string) (fs.FS, error) {
+ // we don't resolve the name here, because this name must necessarily be
+ // a path relative to the wrapped filesystem's root
+ if fsys, ok := w.fsys.(fs.SubFS); ok {
+ return fsys.Sub(name)
+ }
+
+ return fs.Sub(w.fsys, name)
+}
+
+func (w *wdFS) Glob(_ string) ([]string, error) {
+ // I'm not sure how to handle this, so I'm just going to error for now -
+ // I have no need of Glob anyway.
+ return nil, fmt.Errorf("glob not supported by wdFS: %w", fs.ErrInvalid)
+}
+
+func (w *wdFS) Create(name string) (fs.File, error) {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return nil, err
+ }
+ return hackpadfs.Create(fsys, resolved)
+}
+
+func (w *wdFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return nil, err
+ }
+ return hackpadfs.OpenFile(fsys, resolved, flag, perm)
+}
+
+func (w *wdFS) Mkdir(name string, perm fs.FileMode) error {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return err
+ }
+ err = hackpadfs.Mkdir(fsys, resolved, perm)
+ if err != nil {
+ return fmt.Errorf("mkdir %q (resolved as %q): %w", name, resolved, err)
+ }
+ return nil
+}
+
+func (w *wdFS) MkdirAll(name string, perm fs.FileMode) error {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return err
+ }
+ return hackpadfs.MkdirAll(fsys, resolved, perm)
+}
+
+func (w *wdFS) Remove(name string) error {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return err
+ }
+ return hackpadfs.Remove(fsys, resolved)
+}
+
+func (w *wdFS) Chmod(name string, mode fs.FileMode) error {
+ root, resolved := w.resolveLocalPath(name)
+ fsys, err := w.fsysFor(root)
+ if err != nil {
+ return err
+ }
+ return hackpadfs.Chmod(fsys, resolved, mode)
+}
diff --git a/internal/datafs/wdfs_test.go b/internal/datafs/wdfs_test.go
new file mode 100644
index 00000000..610920af
--- /dev/null
+++ b/internal/datafs/wdfs_test.go
@@ -0,0 +1,273 @@
+package datafs
+
+import (
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/hack-pad/hackpadfs"
+ "github.com/hack-pad/hackpadfs/mem"
+ osfs "github.com/hack-pad/hackpadfs/os"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ tfs "gotest.tools/v3/fs"
+)
+
+func TestWDFS_ReadOps(t *testing.T) {
+ wd, _ := os.Getwd()
+ t.Cleanup(func() {
+ _ = os.Chdir(wd)
+ })
+ _ = os.Chdir("/")
+
+ memfs, _ := mem.NewFS()
+
+ _ = memfs.Mkdir("tmp", 0o777)
+ _ = hackpadfs.WriteFullFile(memfs, "tmp/foo", []byte("hello world"), 0o777)
+ _ = hackpadfs.WriteFullFile(memfs, "tmp/one.txt", []byte("one"), 0o644)
+ _ = hackpadfs.WriteFullFile(memfs, "tmp/two.txt", []byte("two"), 0o644)
+ _ = hackpadfs.WriteFullFile(memfs, "tmp/three.txt", []byte("three"), 0o644)
+ _ = memfs.Mkdir("tmp/sub", 0o777)
+ _ = hackpadfs.WriteFullFile(memfs, "tmp/sub/bar", []byte("goodnight moon"), 0o777)
+
+ fsys := WrapWdFS(memfs).(*wdFS)
+
+ f, err := fsys.Open("/tmp/foo")
+ require.NoError(t, err)
+
+ b, err := io.ReadAll(f)
+ require.NoError(t, err)
+ assert.Equal(t, "hello world", string(b))
+
+ fi, err := fs.Stat(fsys, "/tmp/sub/bar")
+ require.NoError(t, err)
+ assert.True(t, fi.Mode().IsRegular())
+
+ b, err = fs.ReadFile(fsys, "/tmp/sub/bar")
+ require.NoError(t, err)
+ assert.Equal(t, "goodnight moon", string(b))
+
+ des, err := fs.ReadDir(fsys, "/tmp")
+ require.NoError(t, err)
+ assert.Len(t, des, 5)
+
+ // note the relative path here, a requirement of fsys.Sub
+ subfs, err := fs.Sub(fsys, "tmp/sub")
+ require.NoError(t, err)
+
+ b, err = fs.ReadFile(subfs, "bar")
+ require.NoError(t, err)
+ assert.Equal(t, "goodnight moon", string(b))
+}
+
+func TestWDFS_WriteOps(t *testing.T) {
+ // this test is backed by the real filesystem so we can test permissions
+ // and have some confidence it'll run on Windows
+ tmpDir := tfs.NewDir(t, "gomplate-wdfs-test")
+ tmpPath := tmpDir.Path()
+ vol := filepath.VolumeName(tmpPath)
+ if vol != "" {
+ tmpPath = tmpPath[len(vol):]
+ } else if tmpPath[0] == '/' {
+ vol = "/"
+ tmpPath = tmpPath[1:]
+ }
+
+ var osfsys fs.FS
+ var err error
+ if vol != "/" {
+ osfsys, err = osfs.NewFS().SubVolume(vol)
+ require.NoError(t, err)
+ } else {
+ osfsys = osfs.NewFS()
+ }
+
+ osfsys, err = hackpadfs.Sub(osfsys, tmpPath)
+ require.NoError(t, err)
+
+ fsys := &wdFS{
+ vol: vol,
+ fsys: osfsys,
+ }
+
+ err = fsys.Mkdir("/tmp", 0o700)
+ require.NoError(t, err, "failed to create /tmp: %q", tmpDir.Path())
+
+ // use os.Stat to make sure the directory was created in the right place
+ fi, err := os.Stat(filepath.Join(vol, tmpPath, "tmp"))
+ require.NoError(t, err)
+ assert.True(t, fi.Mode().IsDir())
+
+ err = hackpadfs.WriteFullFile(fsys, "/tmp/foo", []byte("hello world"), 0o600)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, "/tmp/one.txt", []byte("one"), 0o644)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, "/tmp/two.txt", []byte("two"), 0o644)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, "/tmp/three.txt", []byte("three"), 0o644)
+ require.NoError(t, err)
+
+ err = fsys.MkdirAll("/tmp/sub", 0o777)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, "/tmp/sub/bar", []byte("goodnight moon"), 0o777)
+ require.NoError(t, err)
+
+ b, err := fs.ReadFile(fsys, "/tmp/foo")
+ require.NoError(t, err)
+ assert.Equal(t, "hello world", string(b))
+
+ b, err = fs.ReadFile(fsys, "/tmp/sub/bar")
+ require.NoError(t, err)
+ assert.Equal(t, "goodnight moon", string(b))
+
+ err = fsys.Chmod("/tmp/foo", 0o444)
+ require.NoError(t, err)
+
+ // check permissions
+ fi, err = fsys.Stat("/tmp/foo")
+ require.NoError(t, err)
+ assert.True(t, fi.Mode().IsRegular())
+ assert.Equal(t, "0444", fmt.Sprintf("%#o", fi.Mode().Perm()))
+
+ // now delete it
+ err = fsys.Remove("/tmp/foo")
+ require.NoError(t, err)
+
+ // and check that it's gone
+ _, err = fsys.Stat("/tmp/foo")
+ assert.ErrorIs(t, err, fs.ErrNotExist)
+}
+
+func skipWindows(t *testing.T) {
+ t.Helper()
+ if runtime.GOOS == "windows" {
+ t.Skip("skipping non-Windows test")
+ }
+}
+
+func skipNonWindows(t *testing.T) {
+ t.Helper()
+ if runtime.GOOS != "windows" {
+ t.Skip("skipping Windows test")
+ }
+}
+
+func TestResolveLocalPath_NonWindows(t *testing.T) {
+ skipWindows(t)
+
+ wd, _ := os.Getwd()
+ wd = wd[1:]
+
+ testdata := []struct {
+ path string
+ expected string
+ }{
+ {"/tmp/foo", "tmp/foo"},
+ {"tmp/foo", wd + "/tmp/foo"},
+ {"./tmp/foo", wd + "/tmp/foo"},
+ {"tmp/../foo", wd + "/foo"},
+ }
+
+ for _, td := range testdata {
+ root, path := ResolveLocalPath(td.path)
+ assert.Equal(t, "/", root)
+ assert.Equal(t, td.expected, path)
+ }
+}
+
+func TestResolveLocalPath_Windows(t *testing.T) {
+ skipNonWindows(t)
+
+ wd, _ := os.Getwd()
+ volname := filepath.VolumeName(wd)
+ wd = wd[len(volname)+1:]
+ wd = filepath.ToSlash(wd)
+
+ testdata := []struct {
+ path string
+ expRoot string
+ expected string
+ }{
+ {"C:/tmp/foo", "C:", "tmp/foo"},
+ {"D:\\tmp\\foo", "D:", "tmp/foo"},
+ {"/tmp/foo", volname, "tmp/foo"},
+ {"tmp/foo", volname, wd + "/tmp/foo"},
+ {"./tmp/foo", volname, wd + "/tmp/foo"},
+ {"tmp/../foo", volname, wd + "/foo"},
+ }
+
+ for _, td := range testdata {
+ td := td
+ t.Run(td.path, func(t *testing.T) {
+ root, path := ResolveLocalPath(td.path)
+ assert.Equal(t, td.expRoot, root)
+ assert.Equal(t, td.expected, path)
+ })
+ }
+}
+
+func TestWdFS_ResolveLocalPath_NonWindows(t *testing.T) {
+ skipWindows(t)
+
+ wd, _ := os.Getwd()
+ wd = wd[1:]
+
+ testdata := []struct {
+ path string
+ expected string
+ }{
+ {"/tmp/foo", "tmp/foo"},
+ {"tmp/foo", wd + "/tmp/foo"},
+ {"./tmp/foo", wd + "/tmp/foo"},
+ {"tmp/../foo", wd + "/foo"},
+ }
+
+ fsys := &wdFS{}
+
+ for _, td := range testdata {
+ root, path := fsys.resolveLocalPath(td.path)
+ assert.Equal(t, "/", root)
+ assert.Equal(t, td.expected, path)
+ }
+}
+
+func TestWdFS_ResolveLocalPath_Windows(t *testing.T) {
+ skipNonWindows(t)
+
+ wd, _ := os.Getwd()
+ volname := filepath.VolumeName(wd)
+ wd = wd[len(volname)+1:]
+ wd = filepath.ToSlash(wd)
+
+ testdata := []struct {
+ path string
+ expRoot string
+ expected string
+ }{
+ {"C:/tmp/foo", "C:", "tmp/foo"},
+ {"D:\\tmp\\foo", "D:", "tmp/foo"},
+ {"/tmp/foo", volname, "tmp/foo"},
+ {"tmp/foo", volname, wd + "/tmp/foo"},
+ {"./tmp/foo", volname, wd + "/tmp/foo"},
+ {"tmp/../foo", volname, wd + "/foo"},
+ {`\\?\C:\tmp\foo`, "//?/C:", "tmp/foo"},
+ {`\\somehost\share\foo\bar`, "//somehost/share", "foo/bar"},
+ {`//?/C:/tmp/foo`, "//?/C:", "tmp/foo"},
+ {`//somehost/share/foo/bar`, "//somehost/share", "foo/bar"},
+ }
+
+ fsys := &wdFS{vol: volname}
+
+ for _, td := range testdata {
+ td := td
+ t.Run(td.path, func(t *testing.T) {
+ root, path := fsys.resolveLocalPath(td.path)
+ assert.Equal(t, td.expRoot, root)
+ assert.Equal(t, td.expected, path)
+ })
+ }
+}
diff --git a/internal/iohelpers/writers.go b/internal/iohelpers/writers.go
index 5030d275..870fa4cb 100644
--- a/internal/iohelpers/writers.go
+++ b/internal/iohelpers/writers.go
@@ -6,7 +6,13 @@ import (
"errors"
"fmt"
"io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
"sync"
+
+ "github.com/hack-pad/hackpadfs"
)
type emptySkipper struct {
@@ -212,3 +218,52 @@ func (l *lazyWriteCloser) Write(p []byte) (n int, err error) {
}
return w.Write(p)
}
+
+// WriteFile writes the given content to the file, truncating any existing file,
+// and creating the directory structure leading up to it if necessary.
+func WriteFile(fsys fs.FS, filename string, content []byte) error {
+ err := assertPathInWD(filename)
+ if err != nil {
+ return fmt.Errorf("failed to open %s: %w", filename, err)
+ }
+
+ fi, err := fs.Stat(fsys, filename)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("failed to stat %s: %w", filename, err)
+ }
+ mode := NormalizeFileMode(0o644)
+ if fi != nil {
+ mode = fi.Mode()
+ }
+
+ err = hackpadfs.MkdirAll(fsys, filepath.Dir(filename), 0o755)
+ if err != nil {
+ return fmt.Errorf("failed to make dirs for %s: %w", filename, err)
+ }
+
+ err = hackpadfs.WriteFullFile(fsys, filename, content, mode)
+ if err != nil {
+ return fmt.Errorf("failed to write %s: %w", filename, err)
+ }
+
+ return nil
+}
+
+func assertPathInWD(filename string) error {
+ wd, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ f, err := filepath.Abs(filename)
+ if err != nil {
+ return err
+ }
+ r, err := filepath.Rel(wd, f)
+ if err != nil {
+ return err
+ }
+ if strings.HasPrefix(r, "..") {
+ return fmt.Errorf("path %s not contained by working directory %s (rel: %s)", filename, wd, r)
+ }
+ return nil
+}
diff --git a/internal/iohelpers/writers_test.go b/internal/iohelpers/writers_test.go
index f2451737..415634d5 100644
--- a/internal/iohelpers/writers_test.go
+++ b/internal/iohelpers/writers_test.go
@@ -4,11 +4,17 @@ import (
"bytes"
"fmt"
"io"
+ "io/fs"
"os"
+ "path/filepath"
"testing"
+ "github.com/hack-pad/hackpadfs"
+ osfs "github.com/hack-pad/hackpadfs/os"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ tfs "gotest.tools/v3/fs"
)
func TestAllWhitespace(t *testing.T) {
@@ -159,3 +165,83 @@ func TestLazyWriteCloser(t *testing.T) {
err = l.Close()
assert.Error(t, err)
}
+
+func TestWrite(t *testing.T) {
+ oldwd, _ := os.Getwd()
+ defer os.Chdir(oldwd)
+
+ rootDir := tfs.NewDir(t, "gomplate-test")
+ t.Cleanup(rootDir.Remove)
+
+ // we want to use a real filesystem here, so we can test interactions with
+ // the current working directory
+ fsys := datafs.WrapWdFS(osfs.NewFS())
+
+ newwd := rootDir.Join("the", "path", "we", "want")
+ badwd := rootDir.Join("some", "other", "dir")
+ hackpadfs.MkdirAll(fsys, newwd, 0755)
+ hackpadfs.MkdirAll(fsys, badwd, 0755)
+ newwd, _ = filepath.EvalSymlinks(newwd)
+ badwd, _ = filepath.EvalSymlinks(badwd)
+
+ err := os.Chdir(newwd)
+ require.NoError(t, err)
+
+ err = WriteFile(fsys, "/foo", []byte("Hello world"))
+ assert.Error(t, err)
+
+ rel, err := filepath.Rel(newwd, badwd)
+ require.NoError(t, err)
+ err = WriteFile(fsys, rel, []byte("Hello world"))
+ assert.Error(t, err)
+
+ foopath := filepath.Join(newwd, "foo")
+ err = WriteFile(fsys, foopath, []byte("Hello world"))
+ require.NoError(t, err)
+
+ out, err := fs.ReadFile(fsys, foopath)
+ require.NoError(t, err)
+ assert.Equal(t, "Hello world", string(out))
+
+ err = WriteFile(fsys, foopath, []byte("truncate"))
+ require.NoError(t, err)
+
+ out, err = fs.ReadFile(fsys, foopath)
+ require.NoError(t, err)
+ assert.Equal(t, "truncate", string(out))
+
+ foopath = filepath.Join(newwd, "nonexistant", "subdir", "foo")
+ err = WriteFile(fsys, foopath, []byte("Hello subdirranean world!"))
+ require.NoError(t, err)
+
+ out, err = fs.ReadFile(fsys, foopath)
+ require.NoError(t, err)
+ assert.Equal(t, "Hello subdirranean world!", string(out))
+}
+
+func TestAssertPathInWD(t *testing.T) {
+ oldwd, _ := os.Getwd()
+ defer os.Chdir(oldwd)
+
+ err := assertPathInWD("/tmp")
+ assert.Error(t, err)
+
+ err = assertPathInWD(filepath.Join(oldwd, "subpath"))
+ require.NoError(t, err)
+
+ err = assertPathInWD("subpath")
+ require.NoError(t, err)
+
+ err = assertPathInWD("./subpath")
+ require.NoError(t, err)
+
+ err = assertPathInWD(filepath.Join("..", "bogus"))
+ assert.Error(t, err)
+
+ err = assertPathInWD(filepath.Join("..", "..", "bogus"))
+ assert.Error(t, err)
+
+ base := filepath.Base(oldwd)
+ err = assertPathInWD(filepath.Join("..", base))
+ require.NoError(t, err)
+}
diff --git a/internal/tests/integration/basic_test.go b/internal/tests/integration/basic_test.go
index d3a1d369..e4cb046e 100644
--- a/internal/tests/integration/basic_test.go
+++ b/internal/tests/integration/basic_test.go
@@ -8,17 +8,19 @@ import (
"github.com/hairyhenderson/gomplate/v4/internal/iohelpers"
"gotest.tools/v3/assert"
"gotest.tools/v3/assert/cmp"
- testfs "gotest.tools/v3/fs"
+ tfs "gotest.tools/v3/fs"
)
-func setupBasicTest(t *testing.T) *testfs.Dir {
- tmpDir := testfs.NewDir(t, "gomplate-inttests",
- testfs.WithFile("one", "hi\n", testfs.WithMode(0640)),
- testfs.WithFile("two", "hello\n"),
- testfs.WithFile("broken", "", testfs.WithMode(0000)),
- testfs.WithDir("subdir",
- testfs.WithFile("f1", "first\n", testfs.WithMode(0640)),
- testfs.WithFile("f2", "second\n"),
+func setupBasicTest(t *testing.T) *tfs.Dir {
+ t.Helper()
+
+ tmpDir := tfs.NewDir(t, "gomplate-inttests",
+ tfs.WithFile("one", "hi\n", tfs.WithMode(0o640)),
+ tfs.WithFile("two", "hello\n"),
+ tfs.WithFile("broken", "", tfs.WithMode(0o000)),
+ tfs.WithDir("subdir",
+ tfs.WithFile("f1", "first\n", tfs.WithMode(0o640)),
+ tfs.WithFile("f2", "second\n"),
),
)
t.Cleanup(tmpDir.Remove)
@@ -180,7 +182,7 @@ func TestBasic_EmptyOutputSuppression(t *testing.T) {
assertSuccess(t, o, e, err, "")
_, err = os.Stat(out)
- assert.Equal(t, true, os.IsNotExist(err))
+ assert.ErrorIs(t, err, fs.ErrNotExist)
}
func TestBasic_RoutesInputsToProperOutputsWithChmod(t *testing.T) {
diff --git a/internal/tests/integration/config_test.go b/internal/tests/integration/config_test.go
index 7b05048b..cccc0d97 100644
--- a/internal/tests/integration/config_test.go
+++ b/internal/tests/integration/config_test.go
@@ -1,25 +1,30 @@
package integration
import (
+ "io/fs"
"os"
"testing"
"gotest.tools/v3/assert"
- "gotest.tools/v3/fs"
+ tfs "gotest.tools/v3/fs"
)
-func setupConfigTest(t *testing.T) *fs.Dir {
- tmpDir := fs.NewDir(t, "gomplate-inttests",
- fs.WithDir("indir"),
- fs.WithDir("outdir"),
- fs.WithFile(".gomplate.yaml", "in: hello world\n"),
- fs.WithFile("sleep.sh", "#!/bin/sh\n\nexec sleep $1\n", fs.WithMode(0755)),
+func setupConfigTest(t *testing.T) *tfs.Dir {
+ t.Helper()
+
+ tmpDir := tfs.NewDir(t, "gomplate-inttests",
+ tfs.WithDir("indir"),
+ tfs.WithDir("outdir"),
+ tfs.WithFile(".gomplate.yaml", "in: hello world\n"),
+ tfs.WithFile("sleep.sh", "#!/bin/sh\n\nexec sleep $1\n", tfs.WithMode(0755)),
)
t.Cleanup(tmpDir.Remove)
return tmpDir
}
-func writeFile(t *testing.T, dir *fs.Dir, f, content string) {
+func writeFile(t *testing.T, dir *tfs.Dir, f, content string) {
+ t.Helper()
+
f = dir.Join(f)
err := os.WriteFile(f, []byte(content), 0600)
if err != nil {
@@ -27,7 +32,7 @@ func writeFile(t *testing.T, dir *fs.Dir, f, content string) {
}
}
-func writeConfig(t *testing.T, dir *fs.Dir, content string) {
+func writeConfig(t *testing.T, dir *tfs.Dir, content string) {
t.Helper()
writeFile(t, dir, ".gomplate.yaml", content)
@@ -230,7 +235,7 @@ suppressEmpty: true
assert.NilError(t, err)
_, err = os.Stat(tmpDir.Join("missing"))
- assert.Equal(t, true, os.IsNotExist(err))
+ assert.ErrorIs(t, err, fs.ErrNotExist)
}
func TestConfig_ConfigTemplatesSupportsMap(t *testing.T) {
diff --git a/internal/tests/integration/gomplateignore_test.go b/internal/tests/integration/gomplateignore_test.go
index 17a6c817..c3220d97 100644
--- a/internal/tests/integration/gomplateignore_test.go
+++ b/internal/tests/integration/gomplateignore_test.go
@@ -1,24 +1,24 @@
package integration
import (
+ "io/fs"
"os"
"path/filepath"
"sort"
"testing"
- "github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "gotest.tools/v3/fs"
+ tfs "gotest.tools/v3/fs"
)
-func setupGomplateignoreTest(t *testing.T) func(inFileOps ...fs.PathOp) *fs.Dir {
+func setupGomplateignoreTest(t *testing.T) func(inFileOps ...tfs.PathOp) *tfs.Dir {
basedir := "gomplate-gomplateignore-tests"
- inBuilder := func(inFileOps ...fs.PathOp) *fs.Dir {
- tmpDir := fs.NewDir(t, basedir,
- fs.WithDir("in", inFileOps...),
- fs.WithDir("out"),
+ inBuilder := func(inFileOps ...tfs.PathOp) *tfs.Dir {
+ tmpDir := tfs.NewDir(t, basedir,
+ tfs.WithDir("in", inFileOps...),
+ tfs.WithDir("out"),
)
t.Cleanup(tmpDir.Remove)
return tmpDir
@@ -27,14 +27,14 @@ func setupGomplateignoreTest(t *testing.T) func(inFileOps ...fs.PathOp) *fs.Dir
return inBuilder
}
-func execute(t *testing.T, ignoreContent string, inFileOps ...fs.PathOp) ([]string, error) {
+func execute(t *testing.T, ignoreContent string, inFileOps ...tfs.PathOp) ([]string, error) {
return executeOpts(t, ignoreContent, []string{}, inFileOps...)
}
-func executeOpts(t *testing.T, ignoreContent string, opts []string, inFileOps ...fs.PathOp) ([]string, error) {
+func executeOpts(t *testing.T, ignoreContent string, opts []string, inFileOps ...tfs.PathOp) ([]string, error) {
inBuilder := setupGomplateignoreTest(t)
- inFileOps = append(inFileOps, fs.WithFile(".gomplateignore", ignoreContent))
+ inFileOps = append(inFileOps, tfs.WithFile(".gomplateignore", ignoreContent))
tmpDir := inBuilder(inFileOps...)
argv := []string{}
@@ -48,13 +48,14 @@ func executeOpts(t *testing.T, ignoreContent string, opts []string, inFileOps ..
files := []string{}
- fs := afero.NewBasePathFs(afero.NewOsFs(), tmpDir.Join("out"))
- afero.Walk(fs, "", func(path string, info os.FileInfo, werr error) error {
- if werr != nil {
- err = werr
- return nil
+ fsys := os.DirFS(tmpDir.Join("out") + "/")
+ err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
}
- if path != "" && !info.IsDir() {
+
+ if path != "" && !d.IsDir() {
+ path = filepath.FromSlash(path)
files = append(files, path)
}
return nil
@@ -69,8 +70,8 @@ func TestGomplateignore_Simple(t *testing.T) {
files, err := execute(t, `# all dot files
.*
*.log`,
- fs.WithFile("empty.log", ""),
- fs.WithFile("rain.txt", ""))
+ tfs.WithFile("empty.log", ""),
+ tfs.WithFile("rain.txt", ""))
require.NoError(t, err)
assert.Equal(t, []string{"rain.txt"}, files)
@@ -87,15 +88,15 @@ func TestGomplateignore_Folder(t *testing.T) {
files, err := execute(t, `.gomplateignore
f[o]o/bar
!foo/bar/tool`,
- fs.WithDir("foo",
- fs.WithDir("bar",
- fs.WithDir("tool",
- fs.WithFile("lex.txt", ""),
+ tfs.WithDir("foo",
+ tfs.WithDir("bar",
+ tfs.WithDir("tool",
+ tfs.WithFile("lex.txt", ""),
),
- fs.WithFile("1.txt", ""),
+ tfs.WithFile("1.txt", ""),
),
- fs.WithDir("tar",
- fs.WithFile("2.txt", ""),
+ tfs.WithDir("tar",
+ tfs.WithFile("2.txt", ""),
),
),
)
@@ -108,11 +109,11 @@ f[o]o/bar
func TestGomplateignore_Root(t *testing.T) {
files, err := execute(t, `.gomplateignore
/1.txt`,
- fs.WithDir("sub",
- fs.WithFile("1.txt", ""),
- fs.WithFile("2.txt", ""),
+ tfs.WithDir("sub",
+ tfs.WithFile("1.txt", ""),
+ tfs.WithFile("2.txt", ""),
),
- fs.WithFile("1.txt", ""),
+ tfs.WithFile("1.txt", ""),
)
require.NoError(t, err)
@@ -126,14 +127,14 @@ func TestGomplateignore_Exclusion(t *testing.T) {
!/e2.txt
en/e3.txt
!`,
- fs.WithFile("!", ""),
- fs.WithFile("e1.txt", ""),
- fs.WithFile("e2.txt", ""),
- fs.WithFile("e3.txt", ""),
- fs.WithDir("en",
- fs.WithFile("e1.txt", ""),
- fs.WithFile("e2.txt", ""),
- fs.WithFile("e3.txt", ""),
+ tfs.WithFile("!", ""),
+ tfs.WithFile("e1.txt", ""),
+ tfs.WithFile("e2.txt", ""),
+ tfs.WithFile("e3.txt", ""),
+ tfs.WithDir("en",
+ tfs.WithFile("e1.txt", ""),
+ tfs.WithFile("e2.txt", ""),
+ tfs.WithFile("e3.txt", ""),
),
)
@@ -144,16 +145,16 @@ en/e3.txt
func TestGomplateignore_Nested(t *testing.T) {
files, err := execute(t, `inner/foo.md`,
- fs.WithDir("inner",
- fs.WithDir("inner2",
- fs.WithFile(".gomplateignore", "moss.ini\n!/jess.ini"),
- fs.WithFile("jess.ini", ""),
- fs.WithFile("moss.ini", "")),
- fs.WithFile(".gomplateignore", "*.lst\njess.ini"),
- fs.WithFile("2.lst", ""),
- fs.WithFile("foo.md", ""),
+ tfs.WithDir("inner",
+ tfs.WithDir("inner2",
+ tfs.WithFile(".gomplateignore", "moss.ini\n!/jess.ini"),
+ tfs.WithFile("jess.ini", ""),
+ tfs.WithFile("moss.ini", "")),
+ tfs.WithFile(".gomplateignore", "*.lst\njess.ini"),
+ tfs.WithFile("2.lst", ""),
+ tfs.WithFile("foo.md", ""),
),
- fs.WithFile("1.txt", ""),
+ tfs.WithFile("1.txt", ""),
)
require.NoError(t, err)
@@ -166,20 +167,20 @@ func TestGomplateignore_Nested(t *testing.T) {
func TestGomplateignore_ByName(t *testing.T) {
files, err := execute(t, `.gomplateignore
world.txt`,
- fs.WithDir("aa",
- fs.WithDir("a1",
- fs.WithDir("a2",
- fs.WithFile("hello.txt", ""),
- fs.WithFile("world.txt", "")),
- fs.WithFile("hello.txt", ""),
- fs.WithFile("world.txt", "")),
- fs.WithFile("hello.txt", ""),
- fs.WithFile("world.txt", "")),
- fs.WithDir("bb",
- fs.WithFile("hello.txt", ""),
- fs.WithFile("world.txt", "")),
- fs.WithFile("hello.txt", ""),
- fs.WithFile("world.txt", ""),
+ tfs.WithDir("aa",
+ tfs.WithDir("a1",
+ tfs.WithDir("a2",
+ tfs.WithFile("hello.txt", ""),
+ tfs.WithFile("world.txt", "")),
+ tfs.WithFile("hello.txt", ""),
+ tfs.WithFile("world.txt", "")),
+ tfs.WithFile("hello.txt", ""),
+ tfs.WithFile("world.txt", "")),
+ tfs.WithDir("bb",
+ tfs.WithFile("hello.txt", ""),
+ tfs.WithFile("world.txt", "")),
+ tfs.WithFile("hello.txt", ""),
+ tfs.WithFile("world.txt", ""),
)
require.NoError(t, err)
@@ -193,12 +194,12 @@ func TestGomplateignore_BothName(t *testing.T) {
loss.txt
!2.log
`,
- fs.WithDir("loss.txt",
- fs.WithFile("1.log", ""),
- fs.WithFile("2.log", "")),
- fs.WithDir("foo",
- fs.WithFile("loss.txt", ""),
- fs.WithFile("bare.txt", "")),
+ tfs.WithDir("loss.txt",
+ tfs.WithFile("1.log", ""),
+ tfs.WithFile("2.log", "")),
+ tfs.WithDir("foo",
+ tfs.WithFile("loss.txt", ""),
+ tfs.WithFile("bare.txt", "")),
)
require.NoError(t, err)
@@ -213,12 +214,12 @@ func TestGomplateignore_LeadingSpace(t *testing.T) {
*.log
! dart.log
`,
- fs.WithDir("inner",
- fs.WithFile(" what.txt", ""),
- fs.WithFile(" dart.log", "")),
- fs.WithDir("inner2",
- fs.WithFile(" what.txt", "")),
- fs.WithFile(" what.txt", ""),
+ tfs.WithDir("inner",
+ tfs.WithFile(" what.txt", ""),
+ tfs.WithFile(" dart.log", "")),
+ tfs.WithDir("inner2",
+ tfs.WithFile(" what.txt", "")),
+ tfs.WithFile(" what.txt", ""),
)
require.NoError(t, err)
@@ -234,20 +235,20 @@ func TestGomplateignore_WithExcludes(t *testing.T) {
"--exclude", "rules/*.txt",
"--exclude", "sprites/*.ini",
},
- fs.WithDir("logs",
- fs.WithFile("archive.zip", ""),
- fs.WithFile("engine.log", ""),
- fs.WithFile("skills.log", "")),
- fs.WithDir("rules",
- fs.WithFile("index.csv", ""),
- fs.WithFile("fire.txt", ""),
- fs.WithFile("earth.txt", "")),
- fs.WithDir("sprites",
- fs.WithFile("human.csv", ""),
- fs.WithFile("demon.xml", ""),
- fs.WithFile("alien.ini", "")),
- fs.WithFile("manifest.json", ""),
- fs.WithFile("crash.bin", ""),
+ tfs.WithDir("logs",
+ tfs.WithFile("archive.zip", ""),
+ tfs.WithFile("engine.log", ""),
+ tfs.WithFile("skills.log", "")),
+ tfs.WithDir("rules",
+ tfs.WithFile("index.csv", ""),
+ tfs.WithFile("fire.txt", ""),
+ tfs.WithFile("earth.txt", "")),
+ tfs.WithDir("sprites",
+ tfs.WithFile("human.csv", ""),
+ tfs.WithFile("demon.xml", ""),
+ tfs.WithFile("alien.ini", "")),
+ tfs.WithFile("manifest.json", ""),
+ tfs.WithFile("crash.bin", ""),
)
require.NoError(t, err)
@@ -263,16 +264,16 @@ func TestGomplateignore_WithIncludes(t *testing.T) {
"--include", "rules/*",
"--exclude", "rules/*.txt",
},
- fs.WithDir("logs",
- fs.WithFile("archive.zip", ""),
- fs.WithFile("engine.log", ""),
- fs.WithFile("skills.log", "")),
- fs.WithDir("rules",
- fs.WithFile("index.csv", ""),
- fs.WithFile("fire.txt", ""),
- fs.WithFile("earth.txt", "")),
- fs.WithFile("manifest.json", ""),
- fs.WithFile("crash.bin", ""),
+ tfs.WithDir("logs",
+ tfs.WithFile("archive.zip", ""),
+ tfs.WithFile("engine.log", ""),
+ tfs.WithFile("skills.log", "")),
+ tfs.WithDir("rules",
+ tfs.WithFile("index.csv", ""),
+ tfs.WithFile("fire.txt", ""),
+ tfs.WithFile("earth.txt", "")),
+ tfs.WithFile("manifest.json", ""),
+ tfs.WithFile("crash.bin", ""),
)
require.NoError(t, err)
diff --git a/internal/tests/integration/integration_test.go b/internal/tests/integration/integration_test.go
index 95479407..1e56e009 100644
--- a/internal/tests/integration/integration_test.go
+++ b/internal/tests/integration/integration_test.go
@@ -190,6 +190,8 @@ func (c *command) withEnv(k, v string) *command {
var GomplateBinPath = ""
func (c *command) run() (o, e string, err error) {
+ c.t.Helper()
+
if GomplateBinPath != "" {
return c.runCompiled(GomplateBinPath)
}
@@ -197,6 +199,8 @@ func (c *command) run() (o, e string, err error) {
}
func (c *command) runInProcess() (o, e string, err error) {
+ c.t.Helper()
+
// iterate env vars by order of insertion
for _, k := range c.envK {
k := k
@@ -221,6 +225,8 @@ func (c *command) runInProcess() (o, e string, err error) {
if err != nil {
c.t.Fatal(err)
}
+
+ c.t.Logf("running in dir %q", c.dir)
}
stdin := strings.NewReader(c.stdin)
diff --git a/render_test.go b/render_test.go
index e7f03ac0..261a2591 100644
--- a/render_test.go
+++ b/render_test.go
@@ -10,16 +10,21 @@ import (
"testing"
"testing/fstest"
- "github.com/hairyhenderson/go-fsimpl"
"github.com/hairyhenderson/gomplate/v4/data"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRenderTemplate(t *testing.T) {
+ wd, _ := os.Getwd()
+ t.Cleanup(func() {
+ _ = os.Chdir(wd)
+ })
+ _ = os.Chdir("/")
+
fsys := fstest.MapFS{}
- ctx := ContextWithFSProvider(context.Background(),
- fsimpl.WrappedFSProvider(fsys, "mem"))
+ ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "mem"))
// no options - built-in function
tr := NewRenderer(Options{})
diff --git a/template.go b/template.go
index f9679b47..54095fdd 100644
--- a/template.go
+++ b/template.go
@@ -2,6 +2,7 @@ package gomplate
import (
"context"
+ "errors"
"fmt"
"io"
"io/fs"
@@ -11,21 +12,20 @@ import (
"strings"
"text/template"
+ "github.com/hack-pad/hackpadfs"
"github.com/hairyhenderson/go-fsimpl"
"github.com/hairyhenderson/gomplate/v4/internal/config"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/hairyhenderson/gomplate/v4/internal/iohelpers"
"github.com/hairyhenderson/gomplate/v4/tmpl"
- "github.com/spf13/afero"
- "github.com/zealic/xignore"
+ // TODO: switch back if/when fs.FS support gets merged upstream
+ "github.com/hairyhenderson/xignore"
)
// ignorefile name, like .gitignore
const gomplateignore = ".gomplateignore"
-// for overriding in tests
-var aferoFS = afero.NewOsFs()
-
func addTmplFuncs(f template.FuncMap, root *template.Template, tctx interface{}, path string) {
t := tmpl.New(root, tctx, path)
tns := func() *tmpl.Template { return t }
@@ -47,23 +47,6 @@ func copyFuncMap(funcMap template.FuncMap) template.FuncMap {
return newFuncMap
}
-var fsProviderCtxKey = struct{}{}
-
-// ContextWithFSProvider returns a context with the given FSProvider. Should
-// only be used in tests.
-func ContextWithFSProvider(ctx context.Context, fsp fsimpl.FSProvider) context.Context {
- return context.WithValue(ctx, fsProviderCtxKey, fsp)
-}
-
-// FSProviderFromContext returns the FSProvider from the context, if any
-func FSProviderFromContext(ctx context.Context) fsimpl.FSProvider {
- if fsp, ok := ctx.Value(fsProviderCtxKey).(fsimpl.FSProvider); ok {
- return fsp
- }
-
- return nil
-}
-
// parseTemplate - parses text as a Go template with the given name and options
func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMap, tmplctx interface{}, nested config.Templates, leftDelim, rightDelim string) (tmpl *template.Template, err error) {
tmpl = template.New(name)
@@ -89,7 +72,7 @@ func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMa
}
func parseNestedTemplates(ctx context.Context, nested config.Templates, tmpl *template.Template) error {
- fsp := FSProviderFromContext(ctx)
+ fsp := datafs.FSProviderFromContext(ctx)
for alias, n := range nested {
u := *n.URL
@@ -106,6 +89,14 @@ func parseNestedTemplates(ctx context.Context, nested config.Templates, tmpl *te
return fmt.Errorf("filesystem provider for %q unavailable: %w", &u, err)
}
+ // TODO: maybe need to do something with root here?
+ if _, reldir := datafs.ResolveLocalPath(u.Path); reldir != "" && reldir != "." {
+ fsys, err = fs.Sub(fsys, reldir)
+ if err != nil {
+ return fmt.Errorf("sub filesystem for %q unavailable: %w", &u, err)
+ }
+ }
+
// inject context & header in case they're useful...
fsys = fsimpl.WithContextFS(ctx, fsys)
fsys = fsimpl.WithHeaderFS(n.Header, fsys)
@@ -172,20 +163,22 @@ func parseNestedTemplate(_ context.Context, fsys fs.FS, alias, fname string, tmp
// gatherTemplates - gather and prepare templates for rendering
// nolint: gocyclo
-func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(context.Context, string) (string, error)) (templates []Template, err error) {
+func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(context.Context, string) (string, error)) ([]Template, error) {
mode, modeOverride, err := cfg.GetMode()
if err != nil {
return nil, err
}
+ var templates []Template
+
switch {
// the arg-provided input string gets a special name
case cfg.Input != "":
// open the output file - no need to close it, as it will be closed by the
// caller later
- target, oerr := openOutFile(cfg.OutputFiles[0], 0755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty)
+ target, oerr := openOutFile(ctx, cfg.OutputFiles[0], 0o755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty)
if oerr != nil {
- return nil, oerr
+ return nil, fmt.Errorf("openOutFile: %w", oerr)
}
templates = []Template{{
@@ -197,14 +190,14 @@ func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(
// input dirs presume output dirs are set too
templates, err = walkDir(ctx, cfg, cfg.InputDir, outFileNamer, cfg.ExcludeGlob, mode, modeOverride)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("walkDir: %w", err)
}
case cfg.Input == "":
templates = make([]Template, len(cfg.InputFiles))
- for i := range cfg.InputFiles {
- templates[i], err = fileToTemplate(cfg, cfg.InputFiles[i], cfg.OutputFiles[i], mode, modeOverride)
+ for i, f := range cfg.InputFiles {
+ templates[i], err = fileToTemplate(ctx, cfg, f, cfg.OutputFiles[i], mode, modeOverride)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("fileToTemplate: %w", err)
}
}
}
@@ -216,48 +209,66 @@ func gatherTemplates(ctx context.Context, cfg *config.Config, outFileNamer func(
// of .gomplateignore and exclude globs (if any), walk the input directory and create a list of
// tplate objects, and an error, if any.
func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer func(context.Context, string) (string, error), excludeGlob []string, mode os.FileMode, modeOverride bool) ([]Template, error) {
- dir = filepath.Clean(dir)
+ dir = filepath.ToSlash(filepath.Clean(dir))
+
+ // we want a filesystem rooted at dir, for relative matching
+ fsys, err := datafs.FSysForPath(ctx, dir)
+ if err != nil {
+ return nil, fmt.Errorf("filesystem provider for %q unavailable: %w", dir, err)
+ }
+
+ // we need dir to be relative to the root of fsys
+ // TODO: maybe need to do something with root here?
+ _, reldir := datafs.ResolveLocalPath(dir)
- dirStat, err := aferoFS.Stat(dir)
+ subfsys, err := fs.Sub(fsys, reldir)
if err != nil {
- return nil, fmt.Errorf("couldn't stat %s: %w", dir, err)
+ return nil, fmt.Errorf("sub: %w", err)
+ }
+
+ // just check . because fsys is subbed to dir already
+ dirStat, err := fs.Stat(subfsys, ".")
+ if err != nil {
+ return nil, fmt.Errorf("stat %q (%q): %w", dir, reldir, err)
}
dirMode := dirStat.Mode()
templates := make([]Template, 0)
- matcher := xignore.NewMatcher(aferoFS)
+ matcher := xignore.NewMatcher(subfsys)
- // work around bug in xignore - a basedir of '.' doesn't work
- basedir := dir
- if basedir == "." {
- basedir, _ = os.Getwd()
- }
- matches, err := matcher.Matches(basedir, &xignore.MatchesOptions{
+ matches, err := matcher.Matches(".", &xignore.MatchesOptions{
Ignorefile: gomplateignore,
Nested: true, // allow nested ignorefile
AfterPatterns: excludeGlob,
})
if err != nil {
- return nil, fmt.Errorf("ignore matching failed for %s: %w", basedir, err)
+ return nil, fmt.Errorf("ignore matching failed for %s: %w", dir, err)
}
// Unmatched ignorefile rules's files
- files := matches.UnmatchedFiles
- for _, file := range files {
- inFile := filepath.Join(dir, file)
+ for _, file := range matches.UnmatchedFiles {
+ // we want to pass an absolute (as much as possible) path to fileToTemplate
+ inPath := filepath.Join(dir, file)
+ inPath = filepath.ToSlash(inPath)
+
+ // but outFileNamer expects only the filename itself
outFile, err := outFileNamer(ctx, file)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("outFileNamer: %w", err)
}
- tpl, err := fileToTemplate(cfg, inFile, outFile, mode, modeOverride)
+ tpl, err := fileToTemplate(ctx, cfg, inPath, outFile, mode, modeOverride)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("fileToTemplate: %w", err)
}
- // Ensure file parent dirs
- if err = aferoFS.MkdirAll(filepath.Dir(outFile), dirMode); err != nil {
- return nil, err
+ // Ensure file parent dirs - use separate fsys for output file
+ outfsys, err := datafs.FSysForPath(ctx, outFile)
+ if err != nil {
+ return nil, fmt.Errorf("fsysForPath: %w", err)
+ }
+ if err = hackpadfs.MkdirAll(outfsys, filepath.Dir(outFile), dirMode); err != nil {
+ return nil, fmt.Errorf("mkdirAll %q: %w", outFile, err)
}
templates = append(templates, tpl)
@@ -266,21 +277,26 @@ func walkDir(ctx context.Context, cfg *config.Config, dir string, outFileNamer f
return templates, nil
}
-func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode, modeOverride bool) (Template, error) {
+func fileToTemplate(ctx context.Context, cfg *config.Config, inFile, outFile string, mode os.FileMode, modeOverride bool) (Template, error) {
source := ""
//nolint:nestif
if inFile == "-" {
b, err := io.ReadAll(cfg.Stdin)
if err != nil {
- return Template{}, fmt.Errorf("failed to read from stdin: %w", err)
+ return Template{}, fmt.Errorf("read from stdin: %w", err)
}
source = string(b)
} else {
- si, err := aferoFS.Stat(inFile)
+ fsys, err := datafs.FSysForPath(ctx, inFile)
+ if err != nil {
+ return Template{}, fmt.Errorf("fsysForPath: %w", err)
+ }
+
+ si, err := fs.Stat(fsys, inFile)
if err != nil {
- return Template{}, err
+ return Template{}, fmt.Errorf("stat %q: %w", inFile, err)
}
if mode == 0 {
mode = si.Mode()
@@ -288,17 +304,9 @@ func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode
// we read the file and store in memory immediately, to prevent leaking
// file descriptors.
- f, err := aferoFS.OpenFile(inFile, os.O_RDONLY, 0)
+ b, err := fs.ReadFile(fsys, inFile)
if err != nil {
- return Template{}, fmt.Errorf("failed to open %s: %w", inFile, err)
- }
-
- //nolint: errcheck
- defer f.Close()
-
- b, err := io.ReadAll(f)
- if err != nil {
- return Template{}, fmt.Errorf("failed to read %s: %w", inFile, err)
+ return Template{}, fmt.Errorf("readAll %q: %w", inFile, err)
}
source = string(b)
@@ -306,9 +314,9 @@ func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode
// open the output file - no need to close it, as it will be closed by the
// caller later
- target, err := openOutFile(outFile, 0755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty)
+ target, err := openOutFile(ctx, outFile, 0755, mode, modeOverride, cfg.Stdout, cfg.SuppressEmpty)
if err != nil {
- return Template{}, err
+ return Template{}, fmt.Errorf("openOutFile: %w", err)
}
tmpl := Template{
@@ -328,13 +336,13 @@ func fileToTemplate(cfg *config.Config, inFile, outFile string, mode os.FileMode
//
// TODO: the 'suppressEmpty' behaviour should be always enabled, in the next
// major release (v4.x).
-func openOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool, stdout io.Writer, suppressEmpty bool) (out io.Writer, err error) {
+func openOutFile(ctx context.Context, filename string, dirMode, mode os.FileMode, modeOverride bool, stdout io.Writer, suppressEmpty bool) (out io.Writer, err error) {
if suppressEmpty {
out = iohelpers.NewEmptySkipper(func() (io.Writer, error) {
if filename == "-" {
return stdout, nil
}
- return createOutFile(filename, dirMode, mode, modeOverride)
+ return createOutFile(ctx, filename, dirMode, mode, modeOverride)
})
return out, nil
}
@@ -342,34 +350,41 @@ func openOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool,
if filename == "-" {
return stdout, nil
}
- return createOutFile(filename, dirMode, mode, modeOverride)
+ return createOutFile(ctx, filename, dirMode, mode, modeOverride)
}
-func createOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool) (out io.WriteCloser, err error) {
+func createOutFile(ctx context.Context, filename string, dirMode, mode os.FileMode, modeOverride bool) (out io.WriteCloser, err error) {
+ // we only support writing out to local files for now
+ fsys, err := datafs.FSysForPath(ctx, filename)
+ if err != nil {
+ return nil, fmt.Errorf("fsysForPath: %w", err)
+ }
+
mode = iohelpers.NormalizeFileMode(mode.Perm())
if modeOverride {
- err = aferoFS.Chmod(filename, mode)
- if err != nil && !os.IsNotExist(err) {
- return nil, fmt.Errorf("failed to chmod output file '%s' with mode %q: %w", filename, mode, err)
+ err = hackpadfs.Chmod(fsys, filename, mode)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return nil, fmt.Errorf("failed to chmod output file %q with mode %q: %w", filename, mode, err)
}
}
open := func() (out io.WriteCloser, err error) {
// Ensure file parent dirs
- if err = aferoFS.MkdirAll(filepath.Dir(filename), dirMode); err != nil {
- return nil, err
+ if err = hackpadfs.MkdirAll(fsys, filepath.Dir(filename), dirMode); err != nil {
+ return nil, fmt.Errorf("mkdirAll %q: %w", filename, err)
}
- out, err = aferoFS.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode)
+ f, err := hackpadfs.OpenFile(fsys, filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return out, fmt.Errorf("failed to open output file '%s' for writing: %w", filename, err)
}
+ out = f.(io.WriteCloser)
return out, err
}
// if the output file already exists, we'll use a SameSkipper
- fi, err := aferoFS.Stat(filename)
+ fi, err := hackpadfs.Stat(fsys, filename)
if err != nil {
// likely means the file just doesn't exist - further errors will be more useful
return iohelpers.LazyWriteCloser(open), nil
@@ -380,7 +395,7 @@ func createOutFile(filename string, dirMode, mode os.FileMode, modeOverride bool
}
out = iohelpers.SameSkipper(iohelpers.LazyReadCloser(func() (io.ReadCloser, error) {
- return aferoFS.OpenFile(filename, os.O_RDONLY, mode)
+ return hackpadfs.OpenFile(fsys, filename, os.O_RDONLY, mode)
}), open)
return out, err
diff --git a/template_test.go b/template_test.go
index 85a8ae90..fd6a387a 100644
--- a/template_test.go
+++ b/template_test.go
@@ -4,29 +4,33 @@ import (
"bytes"
"context"
"io"
+ "io/fs"
"net/url"
"os"
"testing"
"testing/fstest"
"text/template"
- "github.com/hairyhenderson/go-fsimpl"
+ "github.com/hack-pad/hackpadfs"
+ "github.com/hack-pad/hackpadfs/mem"
"github.com/hairyhenderson/gomplate/v4/internal/config"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/hairyhenderson/gomplate/v4/internal/iohelpers"
- "github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOpenOutFile(t *testing.T) {
- origfs := aferoFS
- defer func() { aferoFS = origfs }()
- aferoFS = afero.NewMemMapFs()
- _ = aferoFS.Mkdir("/tmp", 0777)
+ memfs, _ := mem.NewFS()
+ fsys := datafs.WrapWdFS(memfs)
+
+ _ = hackpadfs.Mkdir(fsys, "/tmp", 0o777)
+
+ ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file"))
cfg := &config.Config{Stdout: &bytes.Buffer{}}
- f, err := openOutFile("/tmp/foo", 0755, 0644, false, nil, false)
+ f, err := openOutFile(ctx, "/tmp/foo", 0o755, 0o644, false, nil, false)
require.NoError(t, err)
wc, ok := f.(io.WriteCloser)
@@ -34,28 +38,34 @@ func TestOpenOutFile(t *testing.T) {
err = wc.Close()
require.NoError(t, err)
- i, err := aferoFS.Stat("/tmp/foo")
+ i, err := hackpadfs.Stat(fsys, "/tmp/foo")
require.NoError(t, err)
assert.Equal(t, iohelpers.NormalizeFileMode(0644), i.Mode())
out := &bytes.Buffer{}
- f, err = openOutFile("-", 0755, 0644, false, out, false)
+ f, err = openOutFile(ctx, "-", 0755, 0644, false, out, false)
require.NoError(t, err)
assert.Equal(t, cfg.Stdout, f)
}
func TestGatherTemplates(t *testing.T) {
- ctx := context.Background()
+ // chdir to root so we can use relative paths
+ wd, _ := os.Getwd()
+ t.Cleanup(func() {
+ _ = os.Chdir(wd)
+ })
+ _ = os.Chdir("/")
+
+ fsys, _ := mem.NewFS()
- origfs := aferoFS
- defer func() { aferoFS = origfs }()
- aferoFS = afero.NewMemMapFs()
- afero.WriteFile(aferoFS, "foo", []byte("bar"), 0600)
+ _ = hackpadfs.WriteFullFile(fsys, "foo", []byte("bar"), 0o600)
+ _ = hackpadfs.Mkdir(fsys, "in", 0o777)
+ _ = hackpadfs.WriteFullFile(fsys, "in/1", []byte("foo"), 0o644)
+ _ = hackpadfs.WriteFullFile(fsys, "in/2", []byte("bar"), 0o644)
+ _ = hackpadfs.WriteFullFile(fsys, "in/3", []byte("baz"), 0o644)
- afero.WriteFile(aferoFS, "in/1", []byte("foo"), 0644)
- afero.WriteFile(aferoFS, "in/2", []byte("bar"), 0644)
- afero.WriteFile(aferoFS, "in/3", []byte("baz"), 0644)
+ ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file"))
cfg := &config.Config{
Stdin: &bytes.Buffer{},
@@ -83,20 +93,18 @@ func TestGatherTemplates(t *testing.T) {
}, nil)
require.NoError(t, err)
assert.Len(t, templates, 1)
- // assert.Equal(t, iohelpers.NormalizeFileMode(0644), templates[0].mode)
// out file is created only on demand
- _, err = aferoFS.Stat("out")
- assert.Error(t, err)
- assert.True(t, os.IsNotExist(err))
+ _, err = hackpadfs.Stat(fsys, "out")
+ assert.ErrorIs(t, err, fs.ErrNotExist)
_, err = templates[0].Writer.Write([]byte("hello world"))
require.NoError(t, err)
- info, err := aferoFS.Stat("out")
+ info, err := hackpadfs.Stat(fsys, "out")
require.NoError(t, err)
- assert.Equal(t, iohelpers.NormalizeFileMode(0644), info.Mode())
- aferoFS.Remove("out")
+ assert.Equal(t, iohelpers.NormalizeFileMode(0o644), info.Mode())
+ _ = hackpadfs.Remove(fsys, "out")
cfg = &config.Config{
InputFiles: []string{"foo"},
@@ -113,10 +121,10 @@ func TestGatherTemplates(t *testing.T) {
_, err = templates[0].Writer.Write([]byte("hello world"))
require.NoError(t, err)
- info, err = aferoFS.Stat("out")
+ info, err = hackpadfs.Stat(fsys, "out")
require.NoError(t, err)
assert.Equal(t, iohelpers.NormalizeFileMode(0600), info.Mode())
- aferoFS.Remove("out")
+ hackpadfs.Remove(fsys, "out")
cfg = &config.Config{
InputFiles: []string{"foo"},
@@ -134,44 +142,48 @@ func TestGatherTemplates(t *testing.T) {
_, err = templates[0].Writer.Write([]byte("hello world"))
require.NoError(t, err)
- info, err = aferoFS.Stat("out")
+ info, err = hackpadfs.Stat(fsys, "out")
require.NoError(t, err)
assert.Equal(t, iohelpers.NormalizeFileMode(0755), info.Mode())
- aferoFS.Remove("out")
+ hackpadfs.Remove(fsys, "out")
templates, err = gatherTemplates(ctx, &config.Config{
InputDir: "in",
OutputDir: "out",
}, simpleNamer("out"))
require.NoError(t, err)
- assert.Len(t, templates, 3)
+ require.Len(t, templates, 3)
assert.Equal(t, "foo", templates[0].Text)
- aferoFS.Remove("out")
+ hackpadfs.Remove(fsys, "out")
}
func TestCreateOutFile(t *testing.T) {
- origfs := aferoFS
- defer func() { aferoFS = origfs }()
- aferoFS = afero.NewMemMapFs()
- _ = aferoFS.Mkdir("in", 0755)
+ fsys, _ := mem.NewFS()
+ _ = hackpadfs.Mkdir(fsys, "in", 0755)
+
+ ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file"))
- _, err := createOutFile("in", 0755, 0644, false)
+ _, err := createOutFile(ctx, "in", 0755, 0644, false)
assert.Error(t, err)
- assert.IsType(t, &os.PathError{}, err)
+ assert.IsType(t, &fs.PathError{}, err)
}
func TestParseNestedTemplates(t *testing.T) {
- ctx := context.Background()
+ wd, _ := os.Getwd()
+ t.Cleanup(func() {
+ _ = os.Chdir(wd)
+ })
+ _ = os.Chdir("/")
// in-memory test filesystem
fsys := fstest.MapFS{
"foo.t": {Data: []byte("hello world"), Mode: 0o600},
}
- fsp := fsimpl.WrappedFSProvider(fsys, "file")
- ctx = ContextWithFSProvider(ctx, fsp)
+
+ ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file"))
// simple test with single template
- u, _ := url.Parse("file:///foo.t")
+ u, _ := url.Parse("foo.t")
nested := config.Templates{"foo": {URL: u}}
tmpl, _ := template.New("root").Parse(`{{ template "foo" }}`)
@@ -185,10 +197,11 @@ func TestParseNestedTemplates(t *testing.T) {
assert.Equal(t, "hello world", out.String())
// test with directory of templates
+ fsys["dir/"] = &fstest.MapFile{Mode: 0o777 | os.ModeDir}
fsys["dir/foo.t"] = &fstest.MapFile{Data: []byte("foo"), Mode: 0o600}
fsys["dir/bar.t"] = &fstest.MapFile{Data: []byte("bar"), Mode: 0o600}
- u, _ = url.Parse("file:///dir/")
+ u, _ = url.Parse("dir/")
nested["dir"] = config.DataSource{URL: u}
tmpl, _ = template.New("root").Parse(`{{ template "dir/foo.t" }} {{ template "dir/bar.t" }}`)
diff --git a/template_unix_test.go b/template_unix_test.go
index fb7f6fef..9cf8729b 100644
--- a/template_unix_test.go
+++ b/template_unix_test.go
@@ -6,33 +6,40 @@ import (
"context"
"testing"
+ "github.com/hack-pad/hackpadfs"
+ "github.com/hack-pad/hackpadfs/mem"
"github.com/hairyhenderson/gomplate/v4/internal/config"
- "github.com/spf13/afero"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestWalkDir(t *testing.T) {
- ctx := context.Background()
- origfs := aferoFS
- defer func() { aferoFS = origfs }()
- aferoFS = afero.NewMemMapFs()
+func TestWalkDir_UNIX(t *testing.T) {
+ memfs, _ := mem.NewFS()
+ fsys := datafs.WrapWdFS(memfs)
+
+ ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file"))
cfg := &config.Config{}
_, err := walkDir(ctx, cfg, "/indir", simpleNamer("/outdir"), nil, 0, false)
assert.Error(t, err)
- _ = aferoFS.MkdirAll("/indir/one", 0777)
- _ = aferoFS.MkdirAll("/indir/two", 0777)
- afero.WriteFile(aferoFS, "/indir/one/foo", []byte("foo"), 0644)
- afero.WriteFile(aferoFS, "/indir/one/bar", []byte("bar"), 0664)
- afero.WriteFile(aferoFS, "/indir/two/baz", []byte("baz"), 0644)
+ err = hackpadfs.MkdirAll(fsys, "/indir/one", 0o777)
+ require.NoError(t, err)
+ err = hackpadfs.MkdirAll(fsys, "/indir/two", 0o777)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, "/indir/one/foo", []byte("foo"), 0o644)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, "/indir/one/bar", []byte("bar"), 0o644)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, "/indir/two/baz", []byte("baz"), 0o644)
+ require.NoError(t, err)
templates, err := walkDir(ctx, cfg, "/indir", simpleNamer("/outdir"), []string{"*/two"}, 0, false)
-
require.NoError(t, err)
+
expected := []Template{
{
Name: "/indir/one/bar",
diff --git a/template_windows_test.go b/template_windows_test.go
index 587b8b1c..c5c977aa 100644
--- a/template_windows_test.go
+++ b/template_windows_test.go
@@ -1,49 +1,69 @@
//go:build windows
+// +build windows
package gomplate
import (
"context"
+ "io/fs"
+ "os"
"testing"
+ "github.com/hack-pad/hackpadfs"
+ "github.com/hack-pad/hackpadfs/mem"
"github.com/hairyhenderson/gomplate/v4/internal/config"
- "github.com/spf13/afero"
+ "github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestWalkDir(t *testing.T) {
- ctx := context.Background()
- origfs := aferoFS
- defer func() { aferoFS = origfs }()
- aferoFS = afero.NewMemMapFs()
+func TestWalkDir_Windows(t *testing.T) {
+ wd, _ := os.Getwd()
+ t.Cleanup(func() {
+ _ = os.Chdir(wd)
+ })
+ _ = os.Chdir("C:/")
+
+ memfs, _ := mem.NewFS()
+ fsys := datafs.WrapWdFS(memfs)
+
+ ctx := datafs.ContextWithFSProvider(context.Background(), datafs.WrappedFSProvider(fsys, "file"))
cfg := &config.Config{}
- _, err := walkDir(ctx, cfg, `C:\indir`, simpleNamer(`C:\outdir`), nil, 0, false)
+ _, err := walkDir(ctx, cfg, `C:\indir`, simpleNamer(`C:/outdir`), nil, 0, false)
assert.Error(t, err)
- _ = aferoFS.MkdirAll(`C:\indir\one`, 0777)
- _ = aferoFS.MkdirAll(`C:\indir\two`, 0777)
- afero.WriteFile(aferoFS, `C:\indir\one\foo`, []byte("foo"), 0644)
- afero.WriteFile(aferoFS, `C:\indir\one\bar`, []byte("bar"), 0644)
- afero.WriteFile(aferoFS, `C:\indir\two\baz`, []byte("baz"), 0644)
+ err = hackpadfs.MkdirAll(fsys, `C:\indir\one`, 0o777)
+ require.NoError(t, err)
+ err = hackpadfs.MkdirAll(fsys, `C:\indir\two`, 0o777)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, `C:\indir\one\foo`, []byte("foo"), 0o644)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, `C:\indir\one\bar`, []byte("bar"), 0o644)
+ require.NoError(t, err)
+ err = hackpadfs.WriteFullFile(fsys, `C:\indir\two\baz`, []byte("baz"), 0o644)
+ require.NoError(t, err)
- templates, err := walkDir(ctx, cfg, `C:\indir`, simpleNamer(`C:\outdir`), []string{`*\two`}, 0, false)
+ fi, err := fs.Stat(fsys, `C:\indir\two\baz`)
+ require.NoError(t, err)
+ assert.Equal(t, "baz", fi.Name())
+ templates, err := walkDir(ctx, cfg, `C:\indir`, simpleNamer(`C:/outdir`), []string{`*\two`}, 0, false)
require.NoError(t, err)
+
expected := []Template{
{
- Name: `C:\indir\one\bar`,
+ Name: `C:/indir/one/bar`,
Text: "bar",
},
{
- Name: `C:\indir\one\foo`,
+ Name: `C:/indir/one/foo`,
Text: "foo",
},
}
- assert.Len(t, templates, 2)
+ require.Len(t, templates, 2)
for i, tmpl := range templates {
assert.Equal(t, expected[i].Name, tmpl.Name)
assert.Equal(t, expected[i].Text, tmpl.Text)